From 0f36ead67ebefc82f4dc563626781f30d64d172a Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 6 Sep 2026 18:02:39 -0400 Subject: [PATCH] fix(preview): follow the slot's own frame, not the store-wide latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two slots live — a long video render next to a quick image batch — the single-frame preview read the store-wide "latest" frame and matched it to the followed placeholder. The latest belonged to whichever slot stepped last, and releasing that slot when its batch finished nulled it outright. The video slot's own frame was still stored per slot (the gallery cell kept showing it), but the preview rendered an empty card until the video's next step, minutes away. Reloading fixed it only because reconcile re-fed the frame. LivePreview now reads its followed slot's frame directly, and the store's latest falls back to the most recently updated remaining slot instead of null so the header toggle and the editor's Current Image node do not blank either. The store-wide matcher is gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke --- .../queue/data/progressImageStore.test.ts | 31 +++++++++++++++++++ .../features/queue/data/progressImageStore.ts | 31 +++++++++++++++++-- .../PreviewNavigation.browser.test.tsx | 27 ++++++++++++++++ .../widgets/preview/PreviewWidgetView.test.ts | 30 +----------------- .../widgets/preview/PreviewWidgetView.tsx | 27 +++------------- 5 files changed, 92 insertions(+), 54 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts index 07ff7389ca0..724101b83a4 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { consumeQueueItemSwapProgressImage, + getLatestProgressImage, getQueueItemBridgeProgressImage, getQueueItemSwapProgressImage, progressImageStore, @@ -133,3 +134,33 @@ describe('progressImageStore held frames', () => { expect(vi.getTimerCount()).toBe(0); }); }); + +describe('progressImageStore latest frame', () => { + beforeEach(() => { + progressImageStore.clear(); + }); + + it('falls back to the most recently updated live slot when the latest slot is released', () => { + progressImageStore.set(frame('video-1'), target('video')); + progressImageStore.set(frame('anima-1'), target('anima')); + progressImageStore.set(frame('video-2'), target('video')); + progressImageStore.set(frame('anima-2'), target('anima')); + + progressImageStore.clear(target('anima')); + + expect(getLatestProgressImage()).toEqual({ ...frame('video-2'), target: target('video') }); + + progressImageStore.clear(target('video')); + + expect(getLatestProgressImage()).toBeNull(); + }); + + it('keeps the latest frame when a slot that is not the latest is released', () => { + progressImageStore.set(frame('anima-1'), target('anima')); + progressImageStore.set(frame('video-1'), target('video')); + + progressImageStore.clear(target('anima')); + + expect(getLatestProgressImage()).toEqual({ ...frame('video-1'), target: target('video') }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts index 5c7cdd48359..dd1d68915de 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/progressImageStore.ts @@ -54,6 +54,8 @@ interface SwapFrame { } const snapshotsByTarget = createKeyedTransientStore(); +/** Insertion order is recency: `set` re-inserts, so the last entry is the most recently updated slot. */ +const targetsByKey = new Map(); const bridgeFrames = createKeyedTransientStore(); const swapFrames = createKeyedTransientStore(); const swapExpiryTimers = new Map>(); @@ -116,6 +118,7 @@ export const progressImageStore = { if (!target) { latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); snapshotsByTarget.clear(); + targetsByKey.clear(); bridgeFrames.clear(); for (const timer of swapExpiryTimers.values()) { @@ -132,9 +135,14 @@ export const progressImageStore = { const didClearLatest = isLatestTarget(target); snapshotsByTarget.delete(targetKey); + targetsByKey.delete(targetKey); if (didClearLatest) { - latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); + // Another slot may still be live: a video rendering for minutes next to a + // quick image batch. Falling to null here left the single-frame preview + // blank until the video's next step, while the gallery cell — which reads + // its own slot — kept showing the frame. + latestSnapshotStore.patchSnapshot({ latestSnapshot: getMostRecentSnapshot() }); } }, /** Routing landed: these are the images the held swap frame may be painted over. */ @@ -169,11 +177,27 @@ export const progressImageStore = { latestSnapshotStore.patchSnapshot({ latestSnapshot: target ? { ...image, target } : image }); if (target) { - snapshotsByTarget.set(getTargetKey(target), image); + const targetKey = getTargetKey(target); + + snapshotsByTarget.set(targetKey, image); + targetsByKey.delete(targetKey); + targetsByKey.set(targetKey, target); } }, }; +function getMostRecentSnapshot(): LatestProgressImageSnapshot | null { + for (const [targetKey, target] of [...targetsByKey.entries()].reverse()) { + const image = snapshotsByTarget.get(targetKey); + + if (image) { + return { ...image, target }; + } + } + + return null; +} + registerAccountOwnedResource({ clear: () => progressImageStore.clear(), name: 'queue-progress-images', @@ -186,6 +210,9 @@ export const consumeQueueItemSwapProgressImage = (queueItemId: string): void => dropSwap(queueItemId); }; +export const getLatestProgressImage = (): LatestProgressImageSnapshot | null => + latestSnapshotStore.getSnapshot().latestSnapshot; + export const getQueueItemBridgeProgressImage = (queueItemId: string): ProgressImageSnapshot | null => bridgeFrames.get(queueItemId) ?? 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 ed30e128d68..4a011493520 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 @@ -122,6 +122,7 @@ const mocks = vi.hoisted(() => { recentImages, bridgeProgressImage: null as unknown, runningProgressTargets: undefined as unknown[] | undefined, + slotProgressImage: undefined as unknown, useActiveProgressTarget: vi.fn(() => null as unknown), useProgressImage: vi.fn(() => null as unknown), }; @@ -150,6 +151,16 @@ vi.mock('@features/queue/react', async (importOriginal) => ({ useFollowedProgressTargets: () => mockProgressTargets(), useProgressImage: () => mocks.useProgressImage(), useQueueItemBridgeProgressImage: () => mocks.bridgeProgressImage, + // The slot's own frame: derived from the "latest" mock by target unless a test overrides it. + useQueueItemProgressImage: (queueItemId: string, itemIndex: number) => { + if (mocks.slotProgressImage !== undefined) { + return mocks.slotProgressImage; + } + + const latest = mocks.useProgressImage() as { target?: { itemIndex: number; queueItemId: string } } | null; + + return latest?.target?.queueItemId === queueItemId && latest.target.itemIndex === itemIndex ? latest : null; + }, })); vi.mock('@features/gallery/queries', () => ({ @@ -474,6 +485,7 @@ beforeEach(() => { mocks.useProgressImage.mockReturnValue(null); mocks.bridgeProgressImage = null; mocks.runningProgressTargets = undefined; + mocks.slotProgressImage = undefined; }); afterEach(async () => { @@ -1444,6 +1456,21 @@ describe('preview keyboard navigation boundary', () => { expect(host?.textContent).toContain('64 × 64'); }); + it("shows the followed slot's own frame even when the store-wide latest frame is gone", async () => { + // A quick image batch finished next to a long video render while the tab was + // hidden: releasing the batch's slot cleared the latest frame. The video slot + // still has its frame and must not render an empty card until its next step. + mocks.project.queue.items = [queueItem]; + mocks.project.settings.showProgressImagesInViewer = true; + mocks.useActiveProgressTarget.mockReturnValue({ itemIndex: 1, queueItemId: 'queue-item-live' }); + mocks.useProgressImage.mockReturnValue(null); + mocks.slotProgressImage = { dataUrl: 'data:image/png;base64,video-step', height: 64, width: 64 }; + + await render(); + + expect(host?.querySelector('img[src="data:image/png;base64,video-step"]')).not.toBeNull(); + }); + 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. diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts index 9abaf7ff5d1..4ab8032ed22 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts @@ -3,37 +3,9 @@ import type { GalleryItem } from '@features/gallery'; import { GALLERY_MAX_ROWS } from '@features/gallery/queries'; import { describe, expect, it } from 'vitest'; -import { getMatchingProgressImage, getVideoFrameCopyNotice } from './PreviewWidgetView'; +import { getVideoFrameCopyNotice } from './PreviewWidgetView'; import { mergePreviewBoardItems } from './usePreviewNavigation'; -describe('getMatchingProgressImage', () => { - const placeholder = { - backendItemId: null, - boardId: 'none', - height: 768, - id: 'queue-1:1', - itemIndex: 2, - queueItemId: 'queue-1', - width: 512, - }; - const progressImage = { - dataUrl: 'data:image/png;base64,abc', - height: 768, - target: { itemIndex: 2, queueItemId: 'queue-1' }, - width: 512, - }; - - it('returns progress only when it belongs to the current placeholder', () => { - expect(getMatchingProgressImage(progressImage, placeholder)).toBe(progressImage); - expect( - getMatchingProgressImage({ ...progressImage, target: { itemIndex: 1, queueItemId: 'queue-1' } }, placeholder) - ).toBeNull(); - expect( - getMatchingProgressImage({ ...progressImage, target: { itemIndex: 2, queueItemId: 'queue-2' } }, placeholder) - ).toBeNull(); - }); -}); - describe('mergePreviewBoardItems', () => { const item = (kind: GalleryItem['kind'], name: string, createdAt: string, starred = false): GalleryItem => { const base = { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx index b4324b95cf0..9cf4c62ca72 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx @@ -41,11 +41,9 @@ import { useActiveProgressTargets, useFollowedProgressTargets, useItemProgress, - useProgressImage, useQueueItemBridgeProgressImage, useQueueItemProgressImage, useQueueItemSwapProgressImage, - type LatestProgressImageSnapshot, } from '@features/queue/react'; import { imageUrlToStreamingSource, @@ -156,22 +154,6 @@ const getBoardName = ( ): string => boardId === 'none' ? uncategorizedLabel : (boards.find((board) => board.id === boardId)?.name ?? unknownBoardLabel); -export const getMatchingProgressImage = ( - progressImage: LatestProgressImageSnapshot | null, - placeholder: GalleryQueuePlaceholder | null -): LatestProgressImageSnapshot | null => { - if ( - !progressImage?.target || - !placeholder || - progressImage.target.queueItemId !== placeholder.queueItemId || - progressImage.target.itemIndex !== placeholder.itemIndex - ) { - return null; - } - - return progressImage; -}; - const selectGenerateRecallValues = createGenerateFormValuesSelector(); /** @@ -212,7 +194,6 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { const { antialiasProgressImages, showProgressImagesInViewer } = useActiveProjectSelector( (project) => project.settings ); - const progressImage = useProgressImage(); const runningProgressTargets = useActiveProgressTargets(); const followedProgressTargets = useFollowedProgressTargets(); const { account, gallery, notifications, widgets } = useWorkbenchCommands(); @@ -265,7 +246,6 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { null, [followedProgressTargets, generationSequence.chronologicalSlots, liveGalleryPlaceholders] ); - const matchingProgressImage = getMatchingProgressImage(progressImage, activeGalleryPlaceholder); // Not while a similarity search is active: the grid hides pending items // there entirely, so following the generation would put Preview on a tile // the grid is not showing and, worse, hand the arrows the board listing @@ -654,7 +634,6 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { filmstripItems={isFilmstripVisible && density !== 'minimal' ? boardItems : null} isLoadingBoard={isLoadingBoard} placeholder={activeGalleryPlaceholder} - progressImage={matchingProgressImage} selectedIndex={navigationCursor} shouldAntialiasProgressImage={antialiasProgressImages} onNext={selectNextItem} @@ -950,7 +929,6 @@ const LivePreview = ({ filmstripItems, isLoadingBoard, placeholder, - progressImage, selectedIndex, shouldAntialiasProgressImage, onNext, @@ -962,13 +940,16 @@ const LivePreview = ({ filmstripItems: GalleryItem[] | null; isLoadingBoard: boolean; placeholder: GalleryQueuePlaceholder; - progressImage: LatestProgressImageSnapshot | null; selectedIndex: number; shouldAntialiasProgressImage: boolean; onNext: () => void; onPrevious: () => void; onSelectItem: (item: GalleryItem) => void; }) => { + // The followed slot's own frame, not the store-wide latest: with two slots + // live (a long video next to a quick image batch) the latest belongs to + // whichever stepped last, and releasing that slot must not blank this one. + const progressImage = useQueueItemProgressImage(placeholder.queueItemId, placeholder.itemIndex); // 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.