From 1bf1fc6fbfc110aa7e7cd6414d4dc5d1557b63b1 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 6 Sep 2026 12:32:21 -0400 Subject: [PATCH] fix(preview): keep the last denoise frame through completion, reconnect and tab hiding The generation preview lost its frame at exactly the moments it mattered: - On the terminal socket event the coordinator released the followed slot at once, but the finished image is two HTTP round trips away (queue item read, then per-image read). Preview dropped out of live-follow and rendered the previous selection until the new image landed. - On any socket drop the coordinator wiped the frame and target, and nothing re-requested them. A run that finished while the tab was hidden left a blank card until the next event, or for good. - Between two items of a batch the next slot became live before it produced a frame, so the panel rendered an empty card. - One manual gallery click permanently disabled auto-select of new results, so later completions never reached the preview. Client-side preview state machine (Phase 1 of the preview restructuring): - Completed slots move to a "settling" phase and stay followed until result routing lands; the last frame is held for a bridge to the batch's next slot and for a swap that is painted over the finished image until it has decoded (bound to the image names routing delivered, consumed on first decode, 10 s TTL). Running slots win over settling ones so multi-GPU streams are never hidden, and the tile grid counts running slots only. - Frames and targets survive a socket drop; a `visibilitychange` listener runs the reconcile sweep on the visibility edge, and sweep requests coalesce instead of being dropped while one is in flight. - Progress frames carry an optional `revision`; a frame at or below the last accepted revision for the same item+session is dropped (ready for the server snapshot in Phase 2; no-op for today's ordered socket stream). - A deliberate gallery selection stamps `liveFollowPausedAt`; submitting new work resumes live-follow, and a result is auto-selected only when its generation was submitted after the pick. An explicit toggle lifts the stamp. - Result routing returns a per-route promise instead of the shared flush, so a settling slot is released when its own route lands. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke --- .../data/activeProgressTargetStore.test.ts | 52 +++++- .../queue/data/activeProgressTargetStore.ts | 84 ++++++++-- .../webv2/src/features/queue/data/events.ts | 6 + .../queue/data/progressImageStore.test.ts | 135 ++++++++++++++++ .../features/queue/data/progressImageStore.ts | 148 ++++++++++++++++++ .../webv2/src/features/queue/react.ts | 9 +- .../webv2/src/features/queue/runtime.ts | 54 +++++-- .../queue/runtime/coordinator.test.ts | 121 +++++++++++++- .../src/features/queue/runtime/coordinator.ts | 112 +++++++++++-- .../useStreamingImageSource.ts | 5 +- .../widgets/preview/PreviewFrame.tsx | 88 ++++++++++- .../preview/PreviewFrameHold.browser.test.tsx | 128 +++++++++++++++ .../PreviewMediaChrome.browser.test.tsx | 4 + .../PreviewNavigation.browser.test.tsx | 77 +++++++++ .../widgets/preview/PreviewWidgetView.tsx | 76 +++++++-- .../src/workbench/workbenchState.test.ts | 68 ++++++++ .../webv2/src/workbench/workbenchState.ts | 56 ++++++- 17 files changed, 1146 insertions(+), 77 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrameHold.browser.test.tsx diff --git a/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.test.ts b/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.test.ts index b347dac8c3c..4e6e851cac9 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { activeProgressTargetStore, getActiveProgressTargets } from './activeProgressTargetStore'; +import { + activeProgressTargetStore, + getActiveProgressTargets, + getFollowedProgressTargets, +} from './activeProgressTargetStore'; const target = (queueItemId: string, itemIndex: number) => ({ itemIndex, queueItemId }); @@ -65,4 +69,50 @@ describe('activeProgressTargetStore', () => { expect(getActiveProgressTargets()[0]).toEqual(target('queue-1', 1)); }); + + it('keeps a settling slot followable but out of the running set', () => { + // Completed, result not routed yet: the single-slot preview keeps following + // it, while the tile grid must not count it or a single-GPU batch would + // flash into two tiles at every item boundary. + activeProgressTargetStore.set(target('queue-1', 1)); + activeProgressTargetStore.set(target('queue-1', 2)); + + activeProgressTargetStore.settle(target('queue-1', 1)); + + expect(getActiveProgressTargets()).toEqual([target('queue-1', 2)]); + // Running first: the settling slot is followed only when nothing is running. + expect(getFollowedProgressTargets()).toEqual([target('queue-1', 2), target('queue-1', 1)]); + + activeProgressTargetStore.clear(target('queue-1', 1)); + + expect(getFollowedProgressTargets()).toEqual([target('queue-1', 2)]); + }); + + it('ignores settling a slot that never reported progress', () => { + activeProgressTargetStore.set(target('queue-1', 1)); + const first = getFollowedProgressTargets(); + + activeProgressTargetStore.settle(target('queue-1', 2)); + + expect(getFollowedProgressTargets()).toBe(first); + }); + + it('returns a settled slot to the running set when it reports progress again', () => { + activeProgressTargetStore.set(target('queue-1', 1)); + activeProgressTargetStore.settle(target('queue-1', 1)); + + activeProgressTargetStore.set(target('queue-1', 1)); + + expect(getActiveProgressTargets()).toEqual([target('queue-1', 1)]); + expect(getFollowedProgressTargets()).toEqual([target('queue-1', 1)]); + }); + + it('clears settling slots along with running ones', () => { + activeProgressTargetStore.set(target('queue-1', 1)); + activeProgressTargetStore.settle(target('queue-1', 1)); + + activeProgressTargetStore.clear(); + + expect(getFollowedProgressTargets()).toEqual([]); + }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.ts b/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.ts index 8d75b69491c..4c3e5a1930c 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/activeProgressTargetStore.ts @@ -4,7 +4,7 @@ import { registerAccountOwnedResource } from '@platform/state/accountLifecycle'; import { createExternalStore } from '@platform/state/externalStore'; /** - * The slots currently reporting progress. + * The slots currently reporting progress, plus the ones settling. * * A list rather than a single value because of multi-GPU: with `generation_devices` * (default `auto`) the backend runs one session per GPU, so a batch of four across @@ -14,46 +14,83 @@ import { createExternalStore } from '@platform/state/externalStore'; * * Order is the order sessions started, which keeps the single-target accessor below * stable for as long as that session runs. + * + * A *settling* slot is one whose backend item has completed but whose result has + * not landed in the gallery yet — two HTTP round trips away. Single-slot surfaces + * keep following it so the last denoise frame stays up until the finished image + * can take over; multi-slot surfaces (the tile grid) stop counting it, or a + * single-GPU batch would flash into a two-tile grid at every item boundary. */ export interface ActiveProgressTargetSink { clear(target?: QueueItemProgressTarget): void; set(target: QueueItemProgressTarget): void; + settle(target: QueueItemProgressTarget): void; +} + +interface ActiveProgressTargetsSnapshot { + settlingTargets: QueueItemProgressTarget[]; + targets: QueueItemProgressTarget[]; } -const store = createExternalStore<{ targets: QueueItemProgressTarget[] }>({ targets: [] }); +const store = createExternalStore({ settlingTargets: [], targets: [] }); const isSameTarget = (left: QueueItemProgressTarget, right: QueueItemProgressTarget): boolean => left.queueItemId === right.queueItemId && left.itemIndex === right.itemIndex; +const includes = (targets: QueueItemProgressTarget[], target: QueueItemProgressTarget): boolean => + targets.some((candidate) => isSameTarget(candidate, target)); + +const without = (targets: QueueItemProgressTarget[], target: QueueItemProgressTarget): QueueItemProgressTarget[] => + targets.filter((candidate) => !isSameTarget(candidate, target)); + export const activeProgressTargetStore: ActiveProgressTargetSink = { clear(target) { - const { targets } = store.getSnapshot(); + const { settlingTargets, targets } = store.getSnapshot(); if (!target) { - if (targets.length > 0) { - store.patchSnapshot({ targets: [] }); + if (targets.length > 0 || settlingTargets.length > 0) { + store.patchSnapshot({ settlingTargets: [], targets: [] }); } return; } - const remaining = targets.filter((candidate) => !isSameTarget(candidate, target)); + const remaining = without(targets, target); + const remainingSettling = without(settlingTargets, target); - if (remaining.length !== targets.length) { - store.patchSnapshot({ targets: remaining }); - } + store.patchSnapshot({ + ...(remaining.length !== targets.length ? { targets: remaining } : {}), + ...(remainingSettling.length !== settlingTargets.length ? { settlingTargets: remainingSettling } : {}), + }); }, set(target) { - const { targets } = store.getSnapshot(); + const { settlingTargets, targets } = store.getSnapshot(); // Progress frames arrive many times a second per session; re-appending an // already-tracked target would publish a fresh array identity every frame and // re-render every consumer. - if (targets.some((candidate) => isSameTarget(candidate, target))) { + if (includes(targets, target)) { + return; + } + + store.patchSnapshot({ + targets: [...targets, target], + // A settled slot reporting progress again is running again. + ...(includes(settlingTargets, target) ? { settlingTargets: without(settlingTargets, target) } : {}), + }); + }, + settle(target) { + const { settlingTargets, targets } = store.getSnapshot(); + + // A slot that never reported progress was never followed; nothing to keep up. + if (!includes(targets, target)) { return; } - store.patchSnapshot({ targets: [...targets, target] }); + store.patchSnapshot({ + settlingTargets: includes(settlingTargets, target) ? settlingTargets : [...settlingTargets, target], + targets: without(targets, target), + }); }, }; @@ -62,19 +99,36 @@ registerAccountOwnedResource({ name: 'queue-active-progress-target', }); +/** + * Running slots first: a settling slot is only worth following while nothing is + * running, or a concurrent session's live stream would sit unseen behind a + * static frame for the whole routing window. + */ +const selectFollowedTargets = ({ + settlingTargets, + targets, +}: ActiveProgressTargetsSnapshot): QueueItemProgressTarget[] => + settlingTargets.length === 0 ? targets : [...targets, ...settlingTargets]; + /** * The slot to follow where a surface can only show one. * * The oldest still-running slot rather than the most recent to report: following the * most recent is what made the preview flip between concurrent sessions. Behaviour is * identical to the previous single-value store whenever one session runs at a time, - * which is every single-GPU install. + * which is every single-GPU install — except that a completed slot stays followed + * until its result lands. */ export const useActiveProgressTarget = (): QueueItemProgressTarget | null => - store.useSelector((snapshot) => snapshot.targets[0] ?? null); + store.useSelector((snapshot) => selectFollowedTargets(snapshot)[0] ?? null); -/** Every slot reporting progress, in the order its session started. */ +/** Every slot currently running, in the order its session started. */ export const useActiveProgressTargets = (): QueueItemProgressTarget[] => store.useSelector((snapshot) => snapshot.targets); +/** Every followable slot — running ones first, then settling ones. */ +export const useFollowedProgressTargets = (): QueueItemProgressTarget[] => store.useSelector(selectFollowedTargets); + export const getActiveProgressTargets = (): QueueItemProgressTarget[] => store.getSnapshot().targets; + +export const getFollowedProgressTargets = (): QueueItemProgressTarget[] => selectFollowedTargets(store.getSnapshot()); diff --git a/invokeai/frontend/webv2/src/features/queue/data/events.ts b/invokeai/frontend/webv2/src/features/queue/data/events.ts index 9e5711d2513..cc81a6f9e4e 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/events.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/events.ts @@ -94,6 +94,12 @@ export interface InvocationProgressEvent extends InvocationEventBase { percentage: number | null; /** Intermittent denoising preview, when the invocation produces one. */ image?: { width: number; height: number; dataURL: string } | null; + /** + * Monotonic per queue item, when the backend sends it: a frame at or below a + * revision already shown is stale and dropped. Absent from today's socket + * events, which arrive in order; the reconnect snapshot carries it. + */ + revision?: number | null; /** * The accelerator running this session, e.g. `cuda:1` or `xpu:1` — null on CPU/MPS and in * single-device mode. With `generation_devices` set (default `auto`) several diff --git a/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts new file mode 100644 index 00000000000..07ff7389ca0 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + consumeQueueItemSwapProgressImage, + getQueueItemBridgeProgressImage, + getQueueItemSwapProgressImage, + progressImageStore, + SWAP_FRAME_TTL_MS, +} from './progressImageStore'; + +const frame = (label: string) => ({ dataUrl: `data:image/png;base64,${label}`, height: 32, width: 64 }); +const target = (queueItemId: string, itemIndex = 1) => ({ itemIndex, queueItemId }); + +describe('progressImageStore held frames', () => { + beforeEach(() => { + vi.useFakeTimers(); + progressImageStore.clear(); + }); + + afterEach(() => { + progressImageStore.clear(); + vi.useRealTimers(); + }); + + it('copies the slot frame into both held sets and keeps the live frame', () => { + progressImageStore.set(frame('last'), target('queue-1')); + + progressImageStore.hold(target('queue-1')); + progressImageStore.bindSwapImages('queue-1', ['result.png']); + + expect(getQueueItemBridgeProgressImage('queue-1')).toEqual(frame('last')); + expect(getQueueItemSwapProgressImage('queue-1', 'result.png')).toEqual(frame('last')); + // The single-slot preview keeps showing the live frame while routing runs. + progressImageStore.clear(target('queue-1')); + expect(getQueueItemBridgeProgressImage('queue-1')).toEqual(frame('last')); + }); + + it('holds nothing for a slot that never produced a frame', () => { + progressImageStore.hold(target('queue-1')); + progressImageStore.bindSwapImages('queue-1', ['result.png']); + + expect(getQueueItemBridgeProgressImage('queue-1')).toBeNull(); + expect(getQueueItemSwapProgressImage('queue-1', 'result.png')).toBeNull(); + }); + + it('paints the swap frame only over the images its backend item delivered', () => { + // Item 3 of a batch finishing must not put its denoise frame over item 1's + // image when the user clicks that one, nor over anything before routing + // has said which images the frame belongs to. + progressImageStore.set(frame('third'), target('queue-1', 3)); + progressImageStore.hold(target('queue-1', 3)); + + expect(getQueueItemSwapProgressImage('queue-1', 'image-3.png')).toBeNull(); + + progressImageStore.bindSwapImages('queue-1', ['image-3.png', 'image-3-control.png']); + + expect(getQueueItemSwapProgressImage('queue-1', 'image-3.png')).toEqual(frame('third')); + expect(getQueueItemSwapProgressImage('queue-1', 'image-3-control.png')).toEqual(frame('third')); + expect(getQueueItemSwapProgressImage('queue-1', 'image-1.png')).toBeNull(); + + // A later hold for the same queue item starts unbound again. + progressImageStore.set(frame('fourth'), target('queue-1', 4)); + progressImageStore.hold(target('queue-1', 4)); + + expect(getQueueItemSwapProgressImage('queue-1', 'image-3.png')).toBeNull(); + }); + + it('consumes the swap frame alone once the finished image has decoded', () => { + progressImageStore.set(frame('last'), target('queue-1')); + progressImageStore.hold(target('queue-1')); + progressImageStore.bindSwapImages('queue-1', ['result.png']); + + consumeQueueItemSwapProgressImage('queue-1'); + + expect(getQueueItemSwapProgressImage('queue-1', 'result.png')).toBeNull(); + // The bridge to the batch's next slot is still needed. + expect(getQueueItemBridgeProgressImage('queue-1')).toEqual(frame('last')); + }); + + it('expires the swap frame so browsing back never replays the low-resolution frame', () => { + progressImageStore.set(frame('last'), target('queue-1')); + progressImageStore.hold(target('queue-1')); + progressImageStore.bindSwapImages('queue-1', ['result.png']); + + vi.advanceTimersByTime(SWAP_FRAME_TTL_MS - 1); + expect(getQueueItemSwapProgressImage('queue-1', 'result.png')).toEqual(frame('last')); + + vi.advanceTimersByTime(1); + expect(getQueueItemSwapProgressImage('queue-1', 'result.png')).toBeNull(); + expect(getQueueItemBridgeProgressImage('queue-1')).toEqual(frame('last')); + }); + + it('restarts the expiry when a later slot of the same queue item is held', () => { + progressImageStore.set(frame('first'), target('queue-1', 1)); + progressImageStore.hold(target('queue-1', 1)); + vi.advanceTimersByTime(SWAP_FRAME_TTL_MS - 1); + + progressImageStore.set(frame('second'), target('queue-1', 2)); + progressImageStore.hold(target('queue-1', 2)); + progressImageStore.bindSwapImages('queue-1', ['second.png']); + vi.advanceTimersByTime(SWAP_FRAME_TTL_MS - 1); + + expect(getQueueItemSwapProgressImage('queue-1', 'second.png')).toEqual(frame('second')); + }); + + it('keeps only the most recent queue items', () => { + for (let index = 0; index < 9; index += 1) { + progressImageStore.set(frame(`frame-${index}`), target(`queue-${index}`)); + progressImageStore.hold(target(`queue-${index}`)); + progressImageStore.bindSwapImages(`queue-${index}`, [`image-${index}.png`]); + } + + expect(getQueueItemBridgeProgressImage('queue-0')).toBeNull(); + expect(getQueueItemSwapProgressImage('queue-0', 'image-0.png')).toBeNull(); + expect(getQueueItemBridgeProgressImage('queue-1')).toEqual(frame('frame-1')); + expect(getQueueItemSwapProgressImage('queue-8', 'image-8.png')).toEqual(frame('frame-8')); + }); + + it('forgets a queue item on clearHeld and everything on clear', () => { + progressImageStore.set(frame('one'), target('queue-1')); + progressImageStore.hold(target('queue-1')); + progressImageStore.set(frame('two'), target('queue-2')); + progressImageStore.hold(target('queue-2')); + progressImageStore.bindSwapImages('queue-2', ['two.png']); + + progressImageStore.clearHeld('queue-1'); + expect(getQueueItemBridgeProgressImage('queue-1')).toBeNull(); + expect(getQueueItemSwapProgressImage('queue-2', 'two.png')).toEqual(frame('two')); + + progressImageStore.clear(); + expect(getQueueItemBridgeProgressImage('queue-2')).toBeNull(); + expect(getQueueItemSwapProgressImage('queue-2', 'two.png')).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts index d606eee8340..5c7cdd48359 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts @@ -8,6 +8,21 @@ import { createExternalStore, createKeyedTransientStore } from '@platform/state/ * The most recent denoising preview image from `invocation_progress` events, * as a b64 data URL. Cleared when the run settles so consumers (the editor's * Current Image node, progress surfaces) fall back to the last real output. + * + * Next to the live frames, two small bounded sets of frames are *held* per + * local queue item when one of its backend items completes: + * + * - the bridge frame, shown while the batch's next slot is live but has not + * produced a frame of its own yet (model load, text encoding) — a sequential + * batch used to drop to an empty card between items; + * - the swap frame, shown in place of the finished image until the browser has + * decoded it, so the denoise→done boundary changes only the pixels inside + * the frame. Consumed on that first decode and expired shortly after, so + * browsing back to the image later never replays the low-resolution frame. + * + * Everything here survives a socket drop on purpose: the run continues on the + * backend and its durable outcome is reconciled over HTTP, so wiping the last + * frame on disconnect only ever produced a blank card until the next event. */ export type ProgressImageSnapshot = QueueProgressImage; @@ -16,10 +31,32 @@ export type ProgressImageTarget = QueueItemProgressTarget; export type LatestProgressImageSnapshot = ProgressImageSnapshot & { target?: ProgressImageTarget }; +/** Held frames are latent-grid JPEG data URLs, a few KB each. */ +const HELD_FRAME_LIMIT = 8; + +/** + * Long enough to cover the finished image's fetch and decode on a slow link, + * short enough that a later deliberate visit to the image never hits it. + */ +export const SWAP_FRAME_TTL_MS = 10_000; + const latestSnapshotStore = createExternalStore<{ latestSnapshot: LatestProgressImageSnapshot | null }>({ latestSnapshot: null, }); +/** + * A swap frame is bound to the image names its backend item delivered once + * routing lands (`bindSwapImages`); until then, and for any other image of the + * same batch, it must not be painted — item 3's denoise frame over item 1. + */ +interface SwapFrame { + image: ProgressImageSnapshot; + imageNames: readonly string[] | null; +} + const snapshotsByTarget = createKeyedTransientStore(); +const bridgeFrames = createKeyedTransientStore(); +const swapFrames = createKeyedTransientStore(); +const swapExpiryTimers = new Map>(); const getTargetKey = ({ itemIndex, queueItemId }: ProgressImageTarget): string => `${queueItemId}:${itemIndex}`; @@ -27,11 +64,66 @@ const isLatestTarget = (target: ProgressImageTarget): boolean => latestSnapshotStore.getSnapshot().latestSnapshot?.target?.queueItemId === target.queueItemId && latestSnapshotStore.getSnapshot().latestSnapshot?.target?.itemIndex === target.itemIndex; +/** Drop the oldest held entries past the cap. Insertion order is age: `hold` re-inserts. */ +const evictOldest = ( + store: { entries: () => Array<[string, Value]> }, + drop: (queueItemId: string) => void +): void => { + const entries = store.entries(); + + for (let index = 0; index < entries.length - HELD_FRAME_LIMIT; index += 1) { + const entry = entries[index]; + + if (entry) { + drop(entry[0]); + } + } +}; + +const dropBridge = (queueItemId: string): void => { + bridgeFrames.delete(queueItemId); +}; + +const dropSwap = (queueItemId: string): void => { + const timer = swapExpiryTimers.get(queueItemId); + + if (timer !== undefined) { + clearTimeout(timer); + swapExpiryTimers.delete(queueItemId); + } + + swapFrames.delete(queueItemId); +}; + +const holdBridge = (queueItemId: string, image: ProgressImageSnapshot): void => { + dropBridge(queueItemId); + bridgeFrames.set(queueItemId, image); + evictOldest(bridgeFrames, dropBridge); +}; + +const holdSwap = (queueItemId: string, image: ProgressImageSnapshot): void => { + dropSwap(queueItemId); + swapFrames.set(queueItemId, { image, imageNames: null }); + swapExpiryTimers.set( + queueItemId, + setTimeout(() => dropSwap(queueItemId), SWAP_FRAME_TTL_MS) + ); + evictOldest(swapFrames, dropSwap); +}; + export const progressImageStore = { clear(target?: ProgressImageTarget): void { if (!target) { latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); snapshotsByTarget.clear(); + bridgeFrames.clear(); + + for (const timer of swapExpiryTimers.values()) { + clearTimeout(timer); + } + + swapExpiryTimers.clear(); + swapFrames.clear(); return; } @@ -45,6 +137,34 @@ export const progressImageStore = { latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); } }, + /** Routing landed: these are the images the held swap frame may be painted over. */ + bindSwapImages(queueItemId: string, imageNames: readonly string[]): void { + const entry = swapFrames.get(queueItemId); + + if (entry) { + swapFrames.set(queueItemId, { image: entry.image, imageNames }); + } + }, + /** Forget a queue item's held frames: its run is gone (detached or canceled). */ + clearHeld(queueItemId: string): void { + dropBridge(queueItemId); + dropSwap(queueItemId); + }, + /** + * Copy the slot's current frame into both held sets. The live frame itself + * stays until the slot is cleared, so the single-slot preview keeps showing + * it while the finished image is fetched. + */ + hold(target: ProgressImageTarget): void { + const image = snapshotsByTarget.get(getTargetKey(target)); + + if (!image) { + return; + } + + holdBridge(target.queueItemId, image); + holdSwap(target.queueItemId, image); + }, set(image: ProgressImageSnapshot, target?: ProgressImageTarget): void { latestSnapshotStore.patchSnapshot({ latestSnapshot: target ? { ...image, target } : image }); @@ -61,8 +181,36 @@ registerAccountOwnedResource({ export type ProgressImageSink = typeof progressImageStore; +/** The finished image has decoded on screen; the swap frame has done its job. */ +export const consumeQueueItemSwapProgressImage = (queueItemId: string): void => { + dropSwap(queueItemId); +}; + +export const getQueueItemBridgeProgressImage = (queueItemId: string): ProgressImageSnapshot | null => + bridgeFrames.get(queueItemId) ?? null; + +const selectSwapProgressImage = (entry: SwapFrame | undefined, imageName: string): ProgressImageSnapshot | null => + entry?.imageNames?.includes(imageName) ? entry.image : null; + +export const getQueueItemSwapProgressImage = (queueItemId: string, imageName: string): ProgressImageSnapshot | null => + selectSwapProgressImage(swapFrames.get(queueItemId), imageName); + export const useProgressImage = (): LatestProgressImageSnapshot | null => latestSnapshotStore.useSelector((snapshot) => snapshot.latestSnapshot); export const useQueueItemProgressImage = (queueItemId: string, itemIndex: number): ProgressImageSnapshot | null => snapshotsByTarget.useValue(getTargetKey({ itemIndex, queueItemId })) ?? null; + +/** The frame to show for a slot of this queue item that has none of its own yet. */ +export const useQueueItemBridgeProgressImage = (queueItemId: string): ProgressImageSnapshot | null => + bridgeFrames.useValue(queueItemId) ?? null; + +/** + * The frame to show in place of this just-finished image until it has decoded; + * null once consumed or expired, for images the held frame did not produce, and + * for images with no local run. + */ +export const useQueueItemSwapProgressImage = ( + queueItemId: string | null | undefined, + imageName: string +): ProgressImageSnapshot | null => selectSwapProgressImage(swapFrames.useValue(queueItemId ?? ''), imageName); diff --git a/invokeai/frontend/webv2/src/features/queue/react.ts b/invokeai/frontend/webv2/src/features/queue/react.ts index 8edaae0bce3..98f6468ed4c 100644 --- a/invokeai/frontend/webv2/src/features/queue/react.ts +++ b/invokeai/frontend/webv2/src/features/queue/react.ts @@ -1,12 +1,19 @@ /** React-facing Queue read models, kept separate from widget registration. */ export type { QueueItemProgress } from './core/types'; export { QueueUiProvider, type QueueUiAdapter } from './ui/QueueUiContext'; -export { useActiveProgressTarget, useActiveProgressTargets } from './data/activeProgressTargetStore'; +export { + useActiveProgressTarget, + useActiveProgressTargets, + useFollowedProgressTargets, +} from './data/activeProgressTargetStore'; export { type ItemProgress, useActiveProgressItemIds, useItemProgress } from './data/itemProgressStore'; export { + consumeQueueItemSwapProgressImage, type LatestProgressImageSnapshot, useProgressImage, + useQueueItemBridgeProgressImage, useQueueItemProgressImage, + useQueueItemSwapProgressImage, } from './data/progressImageStore'; export { type QueueItemProgressSink, useQueueItemProgress } from './data/progressStore'; export { getQueueItemAccess } from './ui/queueOwnership'; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 2bf71b047a0..de983b23fc5 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -12,6 +12,7 @@ import type { BackendConnectionStatus } from '@platform/transport/types'; import { collectGraphInputMediaNames } from '@features/queue/core/graphInputMedia'; import { isQueuePromptSeedBehaviour, MAX_QUEUE_BATCH_ITEMS } from '@features/queue/core/promptBatch'; import { shouldSubmitPendingQueueItem } from '@features/queue/core/submissionRules'; +import { progressImageStore } from '@features/queue/data/progressImageStore'; import { createQueueCoordinator, QueueEnqueueNotAcceptedError, @@ -965,6 +966,12 @@ export const createQueueRuntime = ({ return; } + // The held denoise frame may be painted over exactly these images while + // they decode — not over an earlier image of the same batch. + progressImageStore.bindSwapImages( + queueItem.id, + visibleImages.map((image) => image.imageName) + ); commands.routePartialResults({ backendItemId, images: visibleImages, @@ -986,10 +993,16 @@ export const createQueueRuntime = ({ } }; - const pendingResultRoutes = new Map< - string, - { attempt: RunAttempt; backendItemId: number; projectId: string; queueItem: QueueItem } - >(); + interface PendingResultRoute { + attempt: RunAttempt; + backendItemId: number; + projectId: string; + queueItem: QueueItem; + /** Resolves when THIS route has run (or will never run), not when the shared flush drains. */ + settled: Promise; + settle: () => void; + } + const pendingResultRoutes = new Map(); let resultRoutingFlush: Promise | undefined; const scheduleResultRoute = ( projectId: string, @@ -998,25 +1011,44 @@ export const createQueueRuntime = ({ attempt: RunAttempt ): Promise => { const key = JSON.stringify([attempt.generation, projectId, queueItem.id, backendItemId]); - pendingResultRoutes.set(key, { attempt, backendItemId, projectId, queueItem }); + const existing = pendingResultRoutes.get(key); + let settle: () => void = () => undefined; + const settled = + existing?.settled ?? + new Promise((resolve) => { + settle = resolve; + }); + pendingResultRoutes.set(key, { + attempt, + backendItemId, + projectId, + queueItem, + settle: existing?.settle ?? settle, + settled, + }); if (!resultRoutingFlush) { const flush = Promise.resolve().then(async () => { while (isActive() && pendingResultRoutes.size > 0) { const batch = [...pendingResultRoutes.values()]; pendingResultRoutes.clear(); - await mapWithConcurrency( - batch, - QUEUE_RUNTIME_CONCURRENCY, - ({ attempt, backendItemId, projectId, queueItem }) => - routeBackendItemResults(projectId, queueItem, backendItemId, attempt) + await mapWithConcurrency(batch, QUEUE_RUNTIME_CONCURRENCY, (route) => + routeBackendItemResults(route.projectId, route.queueItem, route.backendItemId, route.attempt).finally( + route.settle + ) ); } + // Inactive: whatever is left will never run. Release its awaiters — a + // followed slot must not hang on a route that is never coming. + for (const route of pendingResultRoutes.values()) { + route.settle(); + } + pendingResultRoutes.clear(); }); resultRoutingFlush = flush.finally(() => { resultRoutingFlush = undefined; }); } - return resultRoutingFlush; + return settled; }; const coordinatorBackend: QueueBackendPort = { diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.test.ts index ec72e7a09e3..59e1a179f9c 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.test.ts @@ -154,7 +154,11 @@ const generateRequest: QueueEnqueueGenerateRequest = { }; interface Harness { - activeProgressTarget: { clear: ReturnType; set: ReturnType }; + activeProgressTarget: { + clear: ReturnType; + set: ReturnType; + settle: ReturnType; + }; api: { [Key in Exclude< keyof QueueCoordinatorBackendPort, @@ -166,7 +170,13 @@ interface Harness { hub: ReturnType; modelLoads: { [Key in keyof QueueModelLoadPort]: ReturnType }; nodeExecution: { [Key in keyof QueueNodeExecutionPort]: ReturnType }; - progressImage: { clear: ReturnType; set: ReturnType }; + progressImage: { + bindSwapImages: ReturnType; + clear: ReturnType; + clearHeld: ReturnType; + hold: ReturnType; + set: ReturnType; + }; progressEntries: Map; socket: FakeSocket; } @@ -215,8 +225,8 @@ const createHarness = (options: { galleryRefreshCoalesceMs?: number } = {}): Har settleRunning: vi.fn(), started: vi.fn(), }; - const progressImage = { clear: vi.fn(), set: vi.fn() }; - const activeProgressTarget = { clear: vi.fn(), set: vi.fn() } satisfies ActiveProgressTargetSink; + const progressImage = { bindSwapImages: vi.fn(), clear: vi.fn(), clearHeld: vi.fn(), hold: vi.fn(), set: vi.fn() }; + const activeProgressTarget = { clear: vi.fn(), set: vi.fn(), settle: vi.fn() } satisfies ActiveProgressTargetSink; const hub = createSocketHub({ createSocket: () => socket }); hub.connect(); @@ -571,14 +581,104 @@ describe('queueCoordinator', () => { expect(harness.progressImage.set).not.toHaveBeenCalled(); }); - it('clears active target and image state when the connection drops', async () => { + it('keeps the followed slot and its last frame across a connection drop', async () => { + // The run continues on the backend; the sweep reconciles its outcome. Wiping + // the frame here only ever produced a blank card until the next event. harness.coordinator.connect(); await harness.coordinator.submitGenerate('local-1', generateRequest); + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 1 }), + image: { dataURL: 'data:image/png;base64,frame', height: 32, width: 64 }, + message: 'Denoising', + percentage: 0.5, + }); harness.hub.disconnect(); - expect(harness.activeProgressTarget.clear).toHaveBeenCalledWith(); - expect(harness.progressImage.clear).toHaveBeenCalledWith(); + expect(harness.activeProgressTarget.clear).not.toHaveBeenCalled(); + expect(harness.progressImage.clear).not.toHaveBeenCalled(); + }); + + it('sweeps outstanding items when the tab becomes visible again', async () => { + // A hidden tab's socket is dropped by the server and socket.io reconnects on + // its own backoff; the visibility edge itself reconciles the outcome first. + const listeners = new Map void>(); + + vi.stubGlobal('document', { + addEventListener: (type: string, listener: () => void) => listeners.set(type, listener), + removeEventListener: (type: string) => listeners.delete(type), + visibilityState: 'visible', + }); + + try { + harness.coordinator.connect(); + await harness.coordinator.submitGenerate('local-1', generateRequest); + harness.api.getItem.mockClear(); + harness.api.getItem.mockResolvedValueOnce(createQueueBackendItem({ id: 1, status: 'completed' })); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + listeners.get('visibilitychange')?.(); + + await expect(resultsPromise).resolves.toEqual([expect.objectContaining({ imageName: 'image-1.png' })]); + expect(harness.api.getItem).toHaveBeenCalledWith(1); + + harness.coordinator.dispose(); + + expect(listeners.has('visibilitychange')).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('runs a sweep requested while one is in flight, instead of dropping it', async () => { + // The visibility sweep often fires while the network is still coming back; + // the reconnect sweep a second later is the one that can reach the backend. + const firstRead = deferred(); + + harness.coordinator.connect(); + await harness.coordinator.submitGenerate('local-1', generateRequest); + harness.api.getItem.mockClear(); + harness.api.getItem + .mockReturnValueOnce(firstRead.promise) + .mockResolvedValueOnce(createQueueBackendItem({ id: 1, status: 'completed' })); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.hub.disconnect(); + harness.hub.connect(); + await Promise.resolve(); + expect(harness.api.getItem).toHaveBeenCalledTimes(1); + + harness.hub.disconnect(); + harness.hub.connect(); + firstRead.resolve(createQueueBackendItem({ id: 1, status: 'in_progress' })); + + await expect(resultsPromise).resolves.toEqual([expect.objectContaining({ imageName: 'image-1.png' })]); + expect(harness.api.getItem).toHaveBeenCalledTimes(2); + }); + + it('drops a preview frame whose revision is not newer than the one already shown', async () => { + harness.coordinator.connect(); + await harness.coordinator.submitGenerate('local-1', generateRequest); + const frame = (revision: number, dataURL: string) => ({ + ...createStatusEvent({ item_id: 1 }), + image: { dataURL, height: 32, width: 64 }, + invocation_source_id: 'denoise', + message: 'Denoising', + percentage: 0.5, + revision, + }); + + harness.socket.fire('invocation_progress', frame(2, 'data:image/png;base64,second')); + harness.socket.fire('invocation_progress', frame(1, 'data:image/png;base64,first')); + harness.socket.fire('invocation_progress', frame(3, 'data:image/png;base64,third')); + // A new session on the same item starts over. + harness.socket.fire('invocation_progress', { ...frame(1, 'data:image/png;base64,retry'), session_id: 'session-2' }); + + expect(harness.progressImage.set.mock.calls.map(([image]) => (image as { dataUrl: string }).dataUrl)).toEqual([ + 'data:image/png;base64,second', + 'data:image/png;base64,third', + 'data:image/png;base64,retry', + ]); }); it('keeps the completed progress image until backend item result routing finishes', async () => { @@ -605,12 +705,19 @@ describe('queueCoordinator', () => { await resultsPromise; expect(harness.callbacks.onBackendItemComplete).toHaveBeenCalledWith('local-1', 1); + // Held before routing started, so the finished image can swap in over it. + expect(harness.progressImage.hold).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); + // The slot stays followed (settling) rather than dropping Preview back onto + // the previous selection while the finished image is still two round trips away. + expect(harness.activeProgressTarget.settle).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); + expect(harness.activeProgressTarget.clear).not.toHaveBeenCalled(); expect(harness.progressImage.clear).not.toHaveBeenCalled(); finishRouting(); await routingPromise; await Promise.resolve(); + expect(harness.activeProgressTarget.clear).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); expect(harness.progressImage.clear).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.ts b/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.ts index 5504f336662..d6ea4303d4c 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/coordinator.ts @@ -223,6 +223,12 @@ export const createQueueCoordinator = ( */ const recentTerminalOutcomes = new Map(); const latestStatusSequences = new Map(); + /** + * Per backend item, the session and revision of the last accepted preview + * frame. Socket delivery is ordered, so this only bites when a second source + * — the reconnect snapshot the backend is to grow — races the live stream. + */ + const latestFrameGates = new Map(); const detachers: Array<() => void> = []; let isAttached = false; @@ -230,6 +236,7 @@ export const createQueueCoordinator = ( let galleryRefreshTimer: ReturnType | null = null; let sweepTimer: ReturnType | null = null; let isSweeping = false; + let isSweepRequested = false; const isActive = (): boolean => !isDisposed && isAccountScopeCurrent(owner); const scheduleGalleryRefresh = (): void => { @@ -313,13 +320,14 @@ export const createQueueCoordinator = ( } waits.delete(backendItemId); + latestFrameGates.delete(backendItemId); const progressTarget = getProgressImageTarget(wait.localQueueItemId, backendItemId); - const clearProgressImage = (): void => { + const releaseProgressSlot = (): void => { if (isActive()) { + activeProgressTarget.clear(progressTarget); progressImage.clear(progressTarget); } }; - activeProgressTarget.clear(progressTarget); const state = runProgress.get(wait.localQueueItemId); if (state) { @@ -338,26 +346,32 @@ export const createQueueCoordinator = ( } if (outcome.status === 'completed') { + // Held before routing starts: the finished image swaps in over this frame + // once the browser has decoded it, and the batch's next slot shows it + // until a frame of its own arrives. + progressImage.hold(progressTarget); const routingPromise = callbacks.onBackendItemComplete?.(wait.localQueueItemId, backendItemId); if (routingPromise) { + // The slot stays followed until routing lands. Released on the terminal + // event, Preview fell out of live-follow two HTTP round trips before the + // finished image could be selected, and showed the previous selection + // in between. + activeProgressTarget.settle(progressTarget); void Promise.resolve(routingPromise) - .finally(clearProgressImage) + .finally(releaseProgressSlot) .catch(() => undefined); } else { - clearProgressImage(); + releaseProgressSlot(); } + } else { + releaseProgressSlot(); } if (outcome.status === 'canceled') { - clearProgressImage(); callbacks.onBackendItemCancelled?.(wait.localQueueItemId, backendItemId); } - if (outcome.status === 'failed') { - clearProgressImage(); - } - wait.settle(outcome); }; @@ -404,9 +418,20 @@ export const createQueueCoordinator = ( publishRunProgress(localQueueItemId); }; - /** Slow safety net for events lost to disconnects; runs on reconnect and on a long interval. */ + /** + * Slow safety net for events lost to disconnects; runs on reconnect, on the + * tab becoming visible, and on a long interval. A request made while one is in + * flight runs again afterwards rather than being dropped: the visibility sweep + * often fires while the network is still coming back and the reconnect sweep + * a second later is the one that can actually reach the backend. + */ const sweep = async (): Promise => { - if (!isActive() || isSweeping || waits.size === 0) { + if (!isActive() || waits.size === 0) { + return; + } + + if (isSweeping) { + isSweepRequested = true; return; } @@ -433,6 +458,11 @@ export const createQueueCoordinator = ( ); } finally { isSweeping = false; + + if (isSweepRequested) { + isSweepRequested = false; + void sweep(); + } } }; @@ -479,6 +509,30 @@ export const createQueueCoordinator = ( } }; + /** + * Whether a frame is older than one already shown for its backend item; + * records it as the newest when it is not. A new session on the same item + * starts over. + */ + const isStaleFrame = (event: InvocationProgressEvent): boolean => { + const revision = event.revision ?? null; + const gate = latestFrameGates.get(event.item_id); + + if ( + gate && + gate.sessionId === event.session_id && + revision !== null && + gate.revision !== null && + revision <= gate.revision + ) { + return true; + } + + latestFrameGates.set(event.item_id, { revision, sessionId: event.session_id }); + + return false; + }; + const handleProgress = (event: InvocationProgressEvent): void => { if (!isActive()) { return; @@ -490,6 +544,10 @@ export const createQueueCoordinator = ( return; } + if (event.image?.dataURL && isStaleFrame(event)) { + return; + } + nodeExecution.progress(event.invocation_source_id, event.percentage, event.message); const target = getProgressImageTarget(wait.localQueueItemId, event.item_id); @@ -513,18 +571,20 @@ export const createQueueCoordinator = ( /** * React to the shared socket's connection lifecycle. The Platform hub owns - * transport mechanics only; this Queue coordinator clears its domain stores - * and, on (re)connect, schedules a gallery refresh and missed-event sweep. + * transport mechanics only; this Queue coordinator clears its transient + * per-node and model-load state and, on (re)connect, schedules a gallery + * refresh and missed-event sweep. + * + * The followed slot and its last frame deliberately survive a drop: the run + * continues on the backend and the sweep reconciles its durable outcome, so + * wiping them only ever produced a blank card — until the next event if the + * run was still going, or for good if it finished while disconnected. */ const handleConnectionChange = (status: BackendConnectionStatus): void => { if (!isActive()) { return; } - if (status !== 'connected') { - activeProgressTarget.clear(); - progressImage.clear(); - } progress.clearAll?.(); nodeExecution.clearAll(); modelLoads.reset(); @@ -584,6 +644,21 @@ export const createQueueCoordinator = ( // has already connected still triggers the initial clear + sweep. detachers.push(backend.onConnectionChange(handleConnectionChange)); + // A hidden tab's socket is often dropped by the server (its pings are + // timer-throttled) and socket.io reconnects on its own backoff once the tab + // is back. The outcome is on the backend already, so sweep on the + // visibility edge itself rather than waiting for the reconnect edge. + if (typeof document !== 'undefined') { + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') { + void sweep(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + detachers.push(() => document.removeEventListener('visibilitychange', handleVisibilityChange)); + } + sweepTimer = setInterval(() => { void sweep(); }, sweepIntervalMs); @@ -627,6 +702,7 @@ export const createQueueCoordinator = ( runProgress.clear(); recentTerminalOutcomes.clear(); latestStatusSequences.clear(); + latestFrameGates.clear(); }; const reconcile = async (items: ReconcileInput[]): Promise> => { @@ -842,6 +918,7 @@ export const createQueueCoordinator = ( const wait = waits.get(backendItemId); if (wait?.localQueueItemId === localQueueItemId) { waits.delete(backendItemId); + latestFrameGates.delete(backendItemId); wait.settle({ status: 'canceled' }); } } @@ -852,6 +929,7 @@ export const createQueueCoordinator = ( activeProgressTarget.clear(target); progressImage.clear(target); } + progressImage.clearHeld(localQueueItemId); progress.clear(localQueueItemId); }; diff --git a/invokeai/frontend/webv2/src/platform/ui/streaming-image/useStreamingImageSource.ts b/invokeai/frontend/webv2/src/platform/ui/streaming-image/useStreamingImageSource.ts index 9c84119fc53..6919c97e523 100644 --- a/invokeai/frontend/webv2/src/platform/ui/streaming-image/useStreamingImageSource.ts +++ b/invokeai/frontend/webv2/src/platform/ui/streaming-image/useStreamingImageSource.ts @@ -3,9 +3,12 @@ import { resolveStreamingImageSource, type StreamingImageSource } from './stream export const useStreamingImageSource = ({ fallbackImage, finalImage, + heldLiveImage, liveImage, }: { fallbackImage?: StreamingImageSource | null; finalImage?: StreamingImageSource | null; + /** The last live frame kept up while no current live frame exists. */ + heldLiveImage?: StreamingImageSource | null; liveImage?: StreamingImageSource | null; -}): StreamingImageSource | null => resolveStreamingImageSource({ fallbackImage, finalImage, liveImage }); +}): StreamingImageSource | null => resolveStreamingImageSource({ fallbackImage, finalImage, heldLiveImage, liveImage }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx index da3b9821219..3d6bdaacfe1 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx @@ -11,6 +11,7 @@ import { Button } from '@platform/ui/Button'; import { GripHorizontalIcon } from 'lucide-react'; import { useCallback, + useEffect, useId, useImperativeHandle, useMemo, @@ -20,6 +21,7 @@ import { type MouseEvent, type ReactNode, type Ref, + type SyntheticEvent, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,6 +38,13 @@ interface PreviewFrameProps { dragItem?: GalleryItemRef; frameHeight: number; frameWidth: number; + /** + * Painted over `source` until the browser has decoded it — the last denoise + * frame, so a finished image swaps in over the pixels it grew from rather + * than over a blank card. `onSourceLoaded` fires once the swap has happened. + */ + holdSource?: StreamingImageSource | null; + onSourceLoaded?: (src: string) => void; isItemCurrent?: (itemKey: GalleryItemKey) => boolean; /** * No live frame carries a caption of any kind. The frame is styled exactly @@ -82,9 +91,11 @@ const PreviewImageFrame = ({ dragItem, frameHeight, frameWidth, + holdSource, isLive, loupeControlsRef, onContextMenu, + onSourceLoaded, padding, paddingBottom, shouldAntialiasLiveImage, @@ -145,15 +156,76 @@ const PreviewImageFrame = ({ }), [isLive, shouldAntialiasLiveImage] ); + const imageRef = useRef(null); + const [settledSrc, setSettledSrc] = useState(null); + const isHolding = Boolean(holdSource && source && settledSrc !== source.src); + const handleSourceSettled = useCallback( + (event: SyntheticEvent) => { + const src = event.currentTarget.getAttribute('src'); + + // Only tracked while a hold is up: a live frame is a new data URL every + // step, and settling each one would re-render the frame per step for + // nothing. + if (src === null || !holdSource) { + return; + } + + setSettledSrc(src); + onSourceLoaded?.(src); + }, + [holdSource, onSourceLoaded] + ); + // A hold arriving after the image already decoded (a cached image, or the + // element reused across a source swap) would never see a load event. + useEffect(() => { + const image = imageRef.current; + + if ( + !holdSource || + !source || + !image || + image.getAttribute('src') !== source.src || + !image.complete || + image.naturalWidth === 0 + ) { + return; + } + + setSettledSrc(source.src); + onSourceLoaded?.(source.src); + }, [holdSource, onSourceLoaded, source]); + const heldImageStyle = useMemo(() => ({ ...imageStyle, visibility: 'hidden' }), [imageStyle]); + const holdImageStyle = useMemo( + () => ({ + height: '100%', + imageRendering: shouldAntialiasLiveImage ? undefined : 'pixelated', + inset: 0, + objectFit: 'contain', + pointerEvents: 'none', + position: 'absolute', + width: '100%', + }), + [shouldAntialiasLiveImage] + ); const media = source ? ( - {source.alt} + <> + {source.alt} + {/* The hidden finished image keeps the frame's geometry; the held frame + only paints over it until the real pixels are ready. */} + {isHolding && holdSource ? ( + + ) : null} + ) : null; if (variant === 'inset') { return ( diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrameHold.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrameHold.browser.test.tsx new file mode 100644 index 00000000000..ccf33dbac9a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrameHold.browser.test.tsx @@ -0,0 +1,128 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-function-as-prop */ +import type { StreamingImageSource } from '@platform/ui/streaming-image/streamingImageSource'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { DndContext } from '@dnd-kit/core'; +import { system } from '@theme/system'; +import { createInstance } from 'i18next'; +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PreviewFrame } from './PreviewFrame'; + +const i18n = createInstance(); +void i18n.use(initReactI18next).init({ fallbackLng: 'en', initAsync: false, lng: 'en', resources: {} }); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +let uniqueSource = 0; + +/** A fresh URL each time: a data URL the page has already decoded loads synchronously. */ +const createSource = (alt: string, kind: StreamingImageSource['kind']): StreamingImageSource => { + uniqueSource += 1; + + return { + alt, + height: 8, + kind, + src: `data:image/svg+xml,`, + width: 8, + }; +}; + +const render = (element: ReactNode): void => { + act(() => { + root?.render( + + + {element} + + + ); + }); +}; + +const settle = (): Promise => + act(async () => { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 100); + }); + }); + +const frame = (source: StreamingImageSource, holdSource: StreamingImageSource | null, onSourceLoaded: () => void) => ( + +); + +beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + await Promise.resolve(); + }); + host?.remove(); + host = null; + root = null; +}); + +describe('PreviewFrame hold', () => { + it('paints the held frame over the finished image until it has decoded', async () => { + const onSourceLoaded = vi.fn(); + const finished = createSource('finished', 'fallback'); + const held = createSource('held', 'live'); + + render(frame(finished, held, onSourceLoaded)); + + // Synchronously after the commit the finished image has not loaded: the + // held frame is painted and the finished image only holds the geometry. + const hold = host?.querySelector('img[aria-hidden="true"]'); + expect(hold?.getAttribute('src')).toBe(held.src); + expect(host?.querySelector('img[alt="finished"]')?.style.visibility).toBe('hidden'); + expect(onSourceLoaded).not.toHaveBeenCalled(); + + await settle(); + + expect(host?.querySelector('img[aria-hidden="true"]')).toBeNull(); + expect(host?.querySelector('img[alt="finished"]')?.style.visibility).toBe(''); + expect(onSourceLoaded).toHaveBeenCalledWith(finished.src); + }); + + it('does not hold over an image that had already decoded', async () => { + const onSourceLoaded = vi.fn(); + const finished = createSource('finished', 'fallback'); + + render(frame(finished, null, onSourceLoaded)); + await settle(); + render(frame(finished, createSource('held', 'live'), onSourceLoaded)); + + expect(host?.querySelector('img[aria-hidden="true"]')).toBeNull(); + expect(onSourceLoaded).toHaveBeenCalledWith(finished.src); + }); + + it('drops the hold when the finished image fails to load', async () => { + const onSourceLoaded = vi.fn(); + const broken: StreamingImageSource = { alt: 'finished', height: 8, kind: 'fallback', src: 'data:,', width: 8 }; + + render(frame(broken, createSource('held', 'live'), onSourceLoaded)); + await settle(); + + expect(host?.querySelector('img[aria-hidden="true"]')).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewMediaChrome.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewMediaChrome.browser.test.tsx index 9c8d75887ac..8e492862ba7 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewMediaChrome.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewMediaChrome.browser.test.tsx @@ -54,10 +54,14 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@features/queue/react', () => ({ + consumeQueueItemSwapProgressImage: () => undefined, useItemProgress: () => mocks.itemProgress, + useQueueItemBridgeProgressImage: () => null, useQueueItemProgressImage: () => mocks.progressImage, + useQueueItemSwapProgressImage: () => null, useActiveProgressTargets: () => [], useActiveProgressTarget: () => null, + useFollowedProgressTargets: () => [], useActiveProgressItemIds: () => [], useProgressImage: () => null, })); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx index a9cd27c5e26..ed30e128d68 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx @@ -120,6 +120,8 @@ const mocks = vi.hoisted(() => { onImagesDeleted?: (imageNames: string[]) => void; }, recentImages, + bridgeProgressImage: null as unknown, + runningProgressTargets: undefined as unknown[] | undefined, useActiveProgressTarget: vi.fn(() => null as unknown), useProgressImage: vi.fn(() => null as unknown), }; @@ -135,10 +137,19 @@ vi.mock('@workbench/WorkbenchContext', () => ({ selector({ backendConnection: { status: 'connected' } }), })); +const mockProgressTargets = () => { + const target = mocks.useActiveProgressTarget(); + + return target ? [target] : []; +}; + vi.mock('@features/queue/react', async (importOriginal) => ({ ...(await importOriginal>()), useActiveProgressTarget: () => mocks.useActiveProgressTarget(), + useActiveProgressTargets: () => mocks.runningProgressTargets ?? mockProgressTargets(), + useFollowedProgressTargets: () => mockProgressTargets(), useProgressImage: () => mocks.useProgressImage(), + useQueueItemBridgeProgressImage: () => mocks.bridgeProgressImage, })); vi.mock('@features/gallery/queries', () => ({ @@ -461,6 +472,8 @@ beforeEach(() => { ]; mocks.useActiveProgressTarget.mockReturnValue(null); mocks.useProgressImage.mockReturnValue(null); + mocks.bridgeProgressImage = null; + mocks.runningProgressTargets = undefined; }); afterEach(async () => { @@ -1409,6 +1422,70 @@ describe('preview keyboard navigation boundary', () => { ); }); + it('keeps following a completed slot while its result is still routing', async () => { + // Completed on the backend, image not in the gallery yet: the slot is + // followed (settling) but no longer running. Preview must keep the live + // frame up in the single-frame branch rather than fall back onto the + // previous selection — and must not tile it. + mocks.project.queue.items = [queueItem]; + mocks.project.settings.showProgressImagesInViewer = true; + mocks.useActiveProgressTarget.mockReturnValue({ itemIndex: 1, queueItemId: 'queue-item-live' }); + mocks.runningProgressTargets = []; + mocks.useProgressImage.mockReturnValue({ + dataUrl: 'data:image/png;base64,', + height: 64, + target: { itemIndex: 1, queueItemId: 'queue-item-live' }, + width: 64, + }); + + await render(); + + expect(host?.querySelectorAll('img[src^="data:image/png"]')).toHaveLength(1); + expect(host?.textContent).toContain('64 × 64'); + }); + + it('follows a running slot over a settling one so a concurrent session is never hidden', async () => { + // Multi-GPU: slot 1 completed and is settling, slot 2 is still streaming. + // The single-frame preview must show slot 2 live, not slot 1's static frame. + mocks.project.queue.items = [{ ...queueItem, backendItemIds: [1, 2] }]; + mocks.project.settings.showProgressImagesInViewer = true; + mocks.useActiveProgressTarget.mockReturnValue({ itemIndex: 1, queueItemId: 'queue-item-live' }); + mocks.runningProgressTargets = [{ itemIndex: 2, queueItemId: 'queue-item-live' }]; + mocks.useProgressImage.mockReturnValue({ + dataUrl: 'data:image/png;base64,slot-two', + height: 64, + target: { itemIndex: 2, queueItemId: 'queue-item-live' }, + width: 64, + }); + mocks.bridgeProgressImage = { dataUrl: 'data:image/png;base64,slot-one', height: 64, width: 64 }; + + await render(); + + expect(host?.querySelector('img[src="data:image/png;base64,slot-two"]')).not.toBeNull(); + expect(host?.querySelector('img[src="data:image/png;base64,slot-one"]')).toBeNull(); + }); + + it("bridges to the next slot of a batch with the previous slot's last frame", async () => { + // Slot 2 is live but has produced no frame yet (model load, text encoding); + // the latest frame still belongs to slot 1. Without the bridge this was an + // empty card between every two items of a batch. + mocks.project.queue.items = [{ ...queueItem, backendItemIds: [1, 2], completedBackendItemIds: [1] }]; + mocks.project.settings.showProgressImagesInViewer = true; + mocks.useActiveProgressTarget.mockReturnValue({ itemIndex: 2, queueItemId: 'queue-item-live' }); + mocks.useProgressImage.mockReturnValue({ + dataUrl: 'data:image/png;base64,slot-one', + height: 64, + target: { itemIndex: 1, queueItemId: 'queue-item-live' }, + width: 64, + }); + mocks.bridgeProgressImage = { dataUrl: 'data:image/png;base64,bridge', height: 64, width: 64 }; + + await render(); + + expect(host?.querySelector('img[src="data:image/png;base64,bridge"]')).not.toBeNull(); + expect(host?.querySelector('img[src="data:image/png;base64,slot-one"]')).toBeNull(); + }); + it('orders local images oldest-first when the gallery is ascending', async () => { (mocks.project.widgetInstances.gallery.state.values as Record).imageOrderDir = 'ASC'; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx index b7a131dd2fc..b4324b95cf0 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx @@ -37,16 +37,20 @@ import { createGenerateFormValuesSelector } from '@features/generation/react'; import { getDeterminateProgressPercent } from '@features/queue/contracts'; import { useDeviceLabel } from '@features/queue/devices'; import { - useActiveProgressTarget, + consumeQueueItemSwapProgressImage, useActiveProgressTargets, + useFollowedProgressTargets, useItemProgress, useProgressImage, + useQueueItemBridgeProgressImage, useQueueItemProgressImage, + useQueueItemSwapProgressImage, type LatestProgressImageSnapshot, } from '@features/queue/react'; import { imageUrlToStreamingSource, progressImageToStreamingSource, + type StreamingImageSource, } from '@platform/ui/streaming-image/streamingImageSource'; import { useStreamingImageSource } from '@platform/ui/streaming-image/useStreamingImageSource'; import { useQuery } from '@tanstack/react-query'; @@ -209,8 +213,8 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { (project) => project.settings ); const progressImage = useProgressImage(); - const activeProgressTarget = useActiveProgressTarget(); - const activeProgressTargets = useActiveProgressTargets(); + const runningProgressTargets = useActiveProgressTargets(); + const followedProgressTargets = useFollowedProgressTargets(); const { account, gallery, notifications, widgets } = useWorkbenchCommands(); const queries = useWorkbenchQueries(); const { density, rootRef } = usePreviewDensity(region); @@ -237,16 +241,29 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { selectedItem?.kind === 'image' && compareImage !== null && toGalleryItemKey({ kind: 'image', name: compareImage.imageName }) !== selectedItemKey; - const generationSequence = useMemo( - () => getGalleryGenerationSequence(queueItems, activeProgressTarget), - [activeProgressTarget, queueItems] - ); - const activeGalleryPlaceholder = generationSequence.liveSlot; + const generationSequence = useMemo(() => getGalleryGenerationSequence(queueItems, null), [queueItems]); // Multi-GPU runs one session per GPU, so several slots can be live at once. One // live slot keeps the existing single-frame preview; two or more are tiled. + // Running slots only: a slot settling after completion must not turn a + // single-GPU batch into a two-tile grid at every item boundary. const liveGalleryPlaceholders = useMemo( - () => getGalleryLiveSlots(generationSequence.chronologicalSlots, activeProgressTargets), - [activeProgressTargets, generationSequence.chronologicalSlots] + () => getGalleryLiveSlots(generationSequence.chronologicalSlots, runningProgressTargets), + [generationSequence.chronologicalSlots, runningProgressTargets] + ); + // The slot to follow: the oldest running one, else the oldest settling one. A + // completed slot stays followed until its result routing lands, and routing + // removes its placeholder first — so the followed set is filtered against the + // placeholders that exist rather than trusting a single target that may have + // just vanished. That routing window is where Preview used to fall back onto + // the previous selection before the finished image was selected. A running + // slot wins over a settling one so a concurrent session's live stream is + // never hidden behind a static frame. + const activeGalleryPlaceholder = useMemo( + () => + liveGalleryPlaceholders[0] ?? + getGalleryLiveSlots(generationSequence.chronologicalSlots, followedProgressTargets)[0] ?? + null, + [followedProgressTargets, generationSequence.chronologicalSlots, liveGalleryPlaceholders] ); const matchingProgressImage = getMatchingProgressImage(progressImage, activeGalleryPlaceholder); // Not while a similarity search is active: the grid hides pending items @@ -669,6 +686,7 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { item={selectedItem} loupeControlsRef={loupeControlsRef} selectedIndex={navigationCursor} + shouldAntialiasProgressImage={antialiasProgressImages} onContextMenu={openItemContextMenu} onNext={selectNextItem} onPrevious={selectPreviousItem} @@ -713,7 +731,11 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { ); }; -const SelectedImagePreview = ({ item, ...props }: SelectedMediaPreviewProps & { item: GalleryImageItem }) => { +const SelectedImagePreview = ({ + item, + shouldAntialiasProgressImage, + ...props +}: SelectedMediaPreviewProps & { item: GalleryImageItem; shouldAntialiasProgressImage: boolean }) => { const previewImage = useStreamingImageSource({ fallbackImage: imageUrlToStreamingSource({ alt: item.name, @@ -727,6 +749,20 @@ const SelectedImagePreview = ({ item, ...props }: SelectedMediaPreviewProps & { () => (previewImage ? { itemKey: toGalleryItemKey(item), kind: 'image', source: previewImage } : null), [item, previewImage] ); + // The last denoise frame of the run that produced this image, when it finished + // moments ago: painted over the finished image until that has decoded, so the + // denoise→done boundary changes only the pixels inside the frame. + const swapProgressImage = useQueueItemSwapProgressImage(item.sourceQueueItemId, item.name); + const holdSource = useMemo( + () => progressImageToStreamingSource(swapProgressImage, item.name), + [item.name, swapProgressImage] + ); + const sourceQueueItemId = item.sourceQueueItemId; + const handleSourceLoaded = useCallback(() => { + if (sourceQueueItemId) { + consumeQueueItemSwapProgressImage(sourceQueueItemId); + } + }, [sourceQueueItemId]); return ( ); }; @@ -823,13 +862,16 @@ const SelectedMediaPreview = ({ filmstripItems, frameHeight, frameWidth, + holdSource, isItemCurrent, isLoadingBoard, isMetadataOpen, item, loupeControlsRef, onCopyAvailabilityChange, + onSourceLoaded, selectedIndex, + shouldAntialiasHoldImage, source, onContextMenu, onNext, @@ -841,6 +883,9 @@ const SelectedMediaPreview = ({ dragItem?: GalleryItemRef; frameHeight: number; frameWidth: number; + holdSource?: StreamingImageSource | null; + onSourceLoaded?: (src: string) => void; + shouldAntialiasHoldImage?: boolean; source: Parameters[0]['source']; }) => { const media = useMemo( @@ -854,13 +899,15 @@ const SelectedMediaPreview = ({ dragItem={dragItem} frameHeight={frameHeight} frameWidth={frameWidth} + holdSource={holdSource} isItemCurrent={isItemCurrent} isLive={false} loupeControlsRef={loupeControlsRef} + onSourceLoaded={onSourceLoaded} onVideoCopyAvailabilityChange={onCopyAvailabilityChange} padding={getMediaStagePadding(density)} paddingBottom={PREVIEW_OVERLAY_RESERVE} - shouldAntialiasLiveImage + shouldAntialiasLiveImage={shouldAntialiasHoldImage ?? true} source={source} variant="framed" videoControllerRef={videoControllerRef} @@ -922,7 +969,12 @@ const LivePreview = ({ onPrevious: () => void; onSelectItem: (item: GalleryItem) => void; }) => { + // The previous slot's last frame stands in until this slot produces one of + // its own (model load, text encoding) — otherwise a sequential batch drops + // to an empty card between items. + const bridgeProgressImage = useQueueItemBridgeProgressImage(placeholder.queueItemId); const previewImage = useStreamingImageSource({ + heldLiveImage: progressImageToStreamingSource(bridgeProgressImage), liveImage: progressImageToStreamingSource(progressImage), }); const source = useMemo( diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index ab5119a53c8..7fc64081e25 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -3926,6 +3926,74 @@ describe('workbenchReducer Phase 5 generation flow', () => { expect(getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageName).toBe('image:selected.png'); }); + it('selects a result whose generation was submitted after the manual selection', () => { + vi.useFakeTimers({ now: new Date('2026-06-10T00:00:00.000Z') }); + + try { + let state = primeGenerate(); + state = workbenchReducer(state, { destination: 'gallery', type: 'setInvocationDestination' }); + state = submitGenerate(state); + const earlierItem = getActiveProject(state).queue.items[0]; + + vi.setSystemTime(new Date('2026-06-10T00:00:01.000Z')); + state = workbenchReducer(state, { item: createGalleryImageItem('selected.png'), type: 'selectGalleryItem' }); + expect(getActiveProject(state).settings.showProgressImagesInViewer).toBe(false); + + // Invoking again is the counter-signal: the user wants to see what they just asked for. + vi.setSystemTime(new Date('2026-06-10T00:00:02.000Z')); + state = submitGenerate(state); + const laterItem = getActiveProject(state).queue.items[0]; + expect(laterItem.id).not.toBe(earlierItem.id); + expect(getActiveProject(state).settings.showProgressImagesInViewer).toBe(true); + + // The batch already running when the user picked stays out of the way… + state = workbenchReducer(state, { + images: [createImage('earlier.png', earlierItem.id)], + projectId: getActiveProject(state).id, + queueItemId: earlierItem.id, + type: 'routeQueueItemResults', + }); + expect(getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageName).toBe('image:selected.png'); + + // …while the one submitted after the pick takes the preview when it lands. + state = workbenchReducer(state, { + images: [createImage('later.png', laterItem.id)], + projectId: getActiveProject(state).id, + queueItemId: laterItem.id, + type: 'routeQueueItemResults', + }); + expect(getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageName).toBe('image:later.png'); + } finally { + vi.useRealTimers(); + } + }); + + it('leaves an explicit live-follow opt-out alone when submitting', () => { + let state = primeGenerate(); + state = workbenchReducer(state, { destination: 'gallery', type: 'setInvocationDestination' }); + state = workbenchReducer(state, { + settings: { showProgressImagesInViewer: false }, + type: 'setActiveProjectSettings', + }); + + state = submitGenerate(state); + + expect(getActiveProject(state).settings.showProgressImagesInViewer).toBe(false); + }); + + it('lifts the selection pause when live-follow is toggled explicitly', () => { + let state = createInitialWorkbenchState(); + state = workbenchReducer(state, { item: createGalleryImageItem('selected.png'), type: 'selectGalleryItem' }); + expect(typeof getProjectWidgetValues(getActiveProject(state), 'gallery').liveFollowPausedAt).toBe('string'); + + state = workbenchReducer(state, { + settings: { showProgressImagesInViewer: true }, + type: 'setActiveProjectSettings', + }); + + expect(getProjectWidgetValues(getActiveProject(state), 'gallery').liveFollowPausedAt).toBeUndefined(); + }); + it('stamps an explicit page into the navigation query already on a multi-selection', () => { // A host navigating its own window passes the page that keeps the primary // item in that window — the same contract as selectGalleryItem with diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index e9105a71c09..b04fe2dd177 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -2803,6 +2803,13 @@ const reconcileDeletedGalleryBoard = ( return didChangeQueue ? { ...withBoardReferencesCleared, projects } : withBoardReferencesCleared; }; +/** + * A deliberate selection pauses live-follow. It is also stamped, so that a + * generation submitted AFTER the pick can still take the preview when it lands + * while one that was already running when the user picked cannot; submitting + * resumes live-follow (`shouldResumeLiveFollowOnSubmit`). An explicit toggle of + * the setting speaks for every generation and lifts the stamp. + */ const updateGalleryValuesAndPauseLiveFollow = ( state: WorkbenchState, getValues: (values: Record) => Record, @@ -2815,10 +2822,41 @@ const updateGalleryValuesAndPauseLiveFollow = ( settings: { ...project.settings, showProgressImagesInViewer: false }, }, 'gallery', - getValues + (values) => ({ ...getValues(values), liveFollowPausedAt: now() }) ) ); +const getLiveFollowPausedAt = (project: Project): string | null => { + const pausedAt = getWidgetValues(project, 'gallery').liveFollowPausedAt; + + return typeof pausedAt === 'string' ? pausedAt : null; +}; + +/** + * Submitting new work is the counter-signal to a selection pause: the user + * wants to watch what they just asked for. An explicit opt-out of live-follow + * carries no pause stamp and is left alone. + */ +const shouldResumeLiveFollowOnSubmit = (project: Project): boolean => + !project.settings.showProgressImagesInViewer && getLiveFollowPausedAt(project) !== null; + +/** + * Whether a result may take the selection given the user's last deliberate + * pick: only if its generation was submitted after that pick. The batch that + * was running when the user picked stays out of the way. + */ +const isSubmittedAfterLiveFollowPause = (project: Project, image: GalleryImage | undefined): boolean => { + const pausedAt = getLiveFollowPausedAt(project); + + if (pausedAt === null || !image) { + return true; + } + + const submittedAt = project.queue.items.find((item) => item.id === image.sourceQueueItemId)?.snapshot.submittedAt; + + return submittedAt !== undefined && submittedAt > pausedAt; +}; + const updateQueueItem = (project: Project, queueItemId: string, getItem: (item: QueueItem) => QueueItem): Project => { let didChange = false; const items = project.queue.items.map((item) => { @@ -2896,7 +2934,8 @@ const updateGalleryWithResultImages = (project: Project, images: GeneratedImageC .filter((image) => !previousImageNames.has(image.imageName)) .map((image) => normalizeGalleryImage(image, queueBoardIds.get(image.sourceQueueItemId))); const shouldSelectIncomingImage = - project.settings.showProgressImagesInViewer || typeof galleryValues.selectedImageName !== 'string'; + typeof galleryValues.selectedImageName !== 'string' || + (project.settings.showProgressImagesInViewer && isSubmittedAfterLiveFollowPause(project, newImages[0])); const nextSelectedImage = shouldSelectIncomingImage ? newImages[0] : undefined; const nextSelectedItem = nextSelectedImage ? legacyGeneratedImageToGalleryItem(nextSelectedImage) : undefined; const nextSelectedItemKey = nextSelectedItem ? toGalleryItemKey(nextSelectedItem) : undefined; @@ -3217,6 +3256,9 @@ const enqueueCompiledSnapshot = ( sourceId: route.sourceId, }, queue: { items: [queueItem, ...project.queue.items] }, + ...(shouldResumeLiveFollowOnSubmit(project) + ? { settings: { ...project.settings, showProgressImagesInViewer: true } } + : {}), widgetGraphs: route.sourceId === 'generate' || route.sourceId === 'upscale' || route.sourceId === 'video' ? { ...project.widgetGraphs, [route.sourceId]: cloneGraph(graph) } @@ -4978,6 +5020,12 @@ export const __workbenchReducerInternal = ( case 'setActiveProjectSettings': { return updateActiveProject(state, (project) => { const settings = normalizeProjectSettings({ ...project.settings, ...action.settings }); + // An explicit live-follow choice speaks for every generation, so it also + // lifts the pause a deliberate selection stamped. + const withoutPause = + action.settings.showProgressImagesInViewer !== undefined && getLiveFollowPausedAt(project) !== null + ? updateProjectWidgetValues(project, 'gallery', ({ liveFollowPausedAt: _pausedAt, ...values }) => values) + : project; return Object.entries(settings).every(([key, value]) => { const settingKey = key as keyof ProjectSettings; @@ -4987,8 +5035,8 @@ export const __workbenchReducerInternal = ( value as ProjectSettings[typeof settingKey] ); }) - ? project - : { ...project, settings }; + ? withoutPause + : { ...withoutPause, settings }; }); } }