diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 6c7ea3f65ca..58050826024 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -142,6 +142,7 @@ "eslint-plugin-storybook": "^10.3.6", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^16.5.0", + "happy-dom": "^20.11.6", "knip": "^5.77.4", "magic-string": "^0.30.21", "openapi-types": "^12.1.3", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 4901ad00405..6488e7dd0ec 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -297,6 +297,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + happy-dom: + specifier: ^20.11.6 + version: 20.11.6 knip: specifier: ^5.77.4 version: 5.77.4(@types/node@22.19.3)(typescript@5.9.3) @@ -338,7 +341,7 @@ importers: version: 1.8.1(eslint@9.39.2(jiti@2.6.1))(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) + version: 4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.11.6)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) packages: @@ -1969,6 +1972,12 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.59.2': resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2331,6 +2340,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2660,6 +2673,10 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3070,6 +3087,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.11.6: + resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4854,6 +4875,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4919,6 +4944,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -6497,7 +6534,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -6635,6 +6672,12 @@ snapshots: '@types/uuid@10.0.0': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.3 + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6797,7 +6840,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) + vitest: 4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.11.6)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) '@vitest/expect@3.2.4': dependencies: @@ -6859,7 +6902,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) + vitest: 4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.11.6)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) '@vitest/utils@3.2.4': dependencies: @@ -7110,6 +7153,10 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-image-size@0.6.4: + dependencies: + '@types/node': 22.19.3 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -7445,6 +7492,8 @@ snapshots: engine.io-parser@5.2.3: {} + entities@7.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -7992,6 +8041,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.11.6: + dependencies: + '@types/node': 22.19.3 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -9744,7 +9806,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 - vitest@4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)): + vitest@4.1.5(@types/node@22.19.3)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.11.6)(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.11(@types/node@22.19.3)(esbuild@0.27.7)(jiti@2.6.1)) @@ -9770,6 +9832,7 @@ snapshots: '@types/node': 22.19.3 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) '@vitest/ui': 4.1.5(vitest@4.1.5) + happy-dom: 20.11.6 transitivePeerDependencies: - msw @@ -9785,6 +9848,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -9858,6 +9923,8 @@ snapshots: ws@8.20.0: {} + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.test.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.test.ts new file mode 100644 index 00000000000..54c1ff2bde2 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.test.ts @@ -0,0 +1,73 @@ +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/store'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; +import { + boardIdSelected, + gallerySliceConfig, + imageSelected, + selectionChanged, +} from 'features/gallery/store/gallerySlice'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { addAutoSwitchedSelectionListener } from './autoSwitchedSelection'; + +// A store with the real gallery reducer and the real listener, so the predicate is exercised +// against actual selection-writing actions rather than a hand-built state pair. +const buildStore = () => { + const listenerMiddleware = createListenerMiddleware(); + addAutoSwitchedSelectionListener(listenerMiddleware.startListening as unknown as AppStartListening); + return configureStore({ + reducer: { gallery: gallerySliceConfig.slice.reducer }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware.middleware), + }); +}; + +describe('addAutoSwitchedSelectionListener', () => { + beforeEach(() => { + // The marker is a module singleton; drop anything a previous test left on it. + autoSwitchedImages.settle(null); + }); + + it('keeps the marker when the auto-switch selection lands', () => { + const store = buildStore(); + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + expect(autoSwitchedImages.consume('a.png')).toBe(true); + }); + + it('drops the marker once the user selects something else', () => { + // The dead click this exists to prevent: the auto-switch to A never rendered because the user + // clicked B first, so their later click on A must still get its reveal. + const store = buildStore(); + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + store.dispatch(imageSelected('b.png')); + store.dispatch(imageSelected('a.png')); + expect(autoSwitchedImages.consume('a.png')).toBe(false); + }); + + it('settles on every action that writes the selection, not just imageSelected', () => { + const store = buildStore(); + + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + store.dispatch(selectionChanged(['b.png'])); + expect(autoSwitchedImages.consume('a.png')).toBe(false); + + autoSwitchedImages.record('c.png'); + store.dispatch(imageSelected('c.png')); + store.dispatch(boardIdSelected({ boardId: 'other', select: { selection: ['d.png'], galleryView: 'images' } })); + expect(autoSwitchedImages.consume('c.png')).toBe(false); + }); + + it('leaves the marker alone when an action does not move the selection', () => { + const store = buildStore(); + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + // Selecting the same item again, and a board switch that carries no selection, must not + // discard a marker whose image has not rendered yet. + store.dispatch(imageSelected('a.png')); + store.dispatch(boardIdSelected({ boardId: 'other' })); + expect(autoSwitchedImages.consume('a.png')).toBe(true); + }); +}); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.ts new file mode 100644 index 00000000000..0b14616f0b3 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.ts @@ -0,0 +1,27 @@ +import type { AppStartListening } from 'app/store/store'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; + +/** + * Keeps the auto-switch marker scoped to the selection it was recorded for. + * + * onInvocationComplete records the item it is about to auto-switch to, so the viewer's reveal + * effect can tell that handoff apart from a user's gallery click. The marker is only meaningful + * while that selection stands: once the selection moves on, the recorded auto-switch will never + * render, and leaving the marker behind would make the user's next click on that item read as an + * auto-switch and get no reveal. + * + * Matched by state rather than by action type on purpose — the selection is written by several + * reducers (imageSelected, selectionChanged, boardIdSelected, comparedImagesSwapped, + * showVirtualBoardsChanged, logout), and a new one added later would silently escape an + * action-type list, leaving exactly the stale marker this exists to prevent. + */ +export const addAutoSwitchedSelectionListener = (startAppListening: AppStartListening) => { + startAppListening({ + predicate: (_action, currentState, previousState) => + selectLastSelectedItem(currentState) !== selectLastSelectedItem(previousState), + effect: (_action, { getState }) => { + autoSwitchedImages.settle(selectLastSelectedItem(getState()) ?? null); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.test.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.test.ts new file mode 100644 index 00000000000..32d92de52d9 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.test.ts @@ -0,0 +1,117 @@ +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/store'; +import { + gallerySliceConfig, + galleryViewChanged, + imageSelected, + selectionChanged, +} from 'features/gallery/store/gallerySlice'; +import { api } from 'services/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { addBoardIdSelectedListener } from './boardIdSelected'; + +// The listener waits for the board's item list before auto-selecting, so the store needs the API +// slice present (the query is never fulfilled here — the point is what happens meanwhile). +const buildStore = () => { + const listenerMiddleware = createListenerMiddleware(); + addBoardIdSelectedListener(listenerMiddleware.startListening as unknown as AppStartListening); + return configureStore({ + reducer: { + gallery: gallerySliceConfig.slice.reducer, + [api.reducerPath]: api.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }).prepend(listenerMiddleware.middleware), + }); +}; + +describe('addBoardIdSelectedListener', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not overwrite a selection made while it was waiting for the board list', async () => { + // The gallery's auto-switch dispatches galleryViewChanged immediately before imageSelected. + // Without the cancel, the probe this starts wakes up on that very selection and re-selects + // from a stale (or empty) list, undoing the auto-switch — and the viewer then reveals the + // wrong item over the live preview, the flash the auto-switch marker exists to prevent. + const store = buildStore(); + + store.dispatch(galleryViewChanged('images')); + store.dispatch(imageSelected('new.png')); + + // Past the probe's 5s give-up, which would otherwise clear the selection outright. + await vi.advanceTimersByTimeAsync(6000); + + expect(store.getState().gallery.selection).toEqual(['new.png']); + }); + + it('still clears the selection when a board switch finds nothing to show', async () => { + // The auto-select probe itself must keep working: a board change with no items selects + // nothing rather than leaving the previous board's item highlighted. + const store = buildStore(); + store.dispatch(imageSelected('from-previous-board.png')); + + store.dispatch(galleryViewChanged('assets')); + await vi.advanceTimersByTimeAsync(6000); + + expect(store.getState().gallery.selection).toEqual([]); + }); + + it('does not overwrite a selection made through the gallery grid either', () => { + // Thumbnail clicks and keyboard navigation dispatch selectionChanged, not imageSelected, so + // matching on the action type alone leaves the ordinary path exposed. + const store = buildStore(); + + store.dispatch(galleryViewChanged('images')); + store.dispatch(selectionChanged(['picked.png'])); + + return vi.advanceTimersByTimeAsync(6000).then(() => { + expect(store.getState().gallery.selection).toEqual(['picked.png']); + }); + }); + + it('does not overwrite a multi-selection made while the probe was waiting', () => { + const store = buildStore(); + + store.dispatch(galleryViewChanged('images')); + store.dispatch(selectionChanged(['first.png', 'second.png'])); + + return vi.advanceTimersByTimeAsync(6000).then(() => { + expect(store.getState().gallery.selection).toEqual(['first.png', 'second.png']); + }); + }); + + it('does not overwrite a multi-selection narrowed while the probe was waiting', () => { + // Removing one of two selected thumbnails leaves the *last* selected item unchanged, so a + // predicate watching only the active item never fired and the probe survived to replace the + // whole selection when it woke. + const store = buildStore(); + store.dispatch(selectionChanged(['first.png', 'second.png'])); + + store.dispatch(galleryViewChanged('images')); + store.dispatch(selectionChanged(['second.png'])); + + return vi.advanceTimersByTimeAsync(6000).then(() => { + expect(store.getState().gallery.selection).toEqual(['second.png']); + }); + }); + + it('does not overwrite a re-selection of the item already active', () => { + // Same shape: the active item does not change, but the user has just said what they want. + const store = buildStore(); + store.dispatch(imageSelected('a.png')); + + store.dispatch(galleryViewChanged('images')); + store.dispatch(imageSelected('a.png')); + + return vi.advanceTimersByTimeAsync(6000).then(() => { + expect(store.getState().gallery.selection).toEqual(['a.png']); + }); + }); +}); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 05dd8e9f208..01072a81162 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,16 +1,44 @@ import { isAnyOf } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/store'; -import { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectGalleryItemNamesQueryArgs, selectSelection } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { galleryApi } from 'services/api/endpoints/gallery'; +/** The actions that ask this listener to pick an item for the user. */ +const startsProbe = isAnyOf(boardIdSelected, galleryViewChanged); + export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => { startAppListening({ - matcher: isAnyOf(boardIdSelected, galleryViewChanged), + // Two jobs, so this cannot be a plain action matcher. The probe below is started by a board or + // view change — but it must also be *cancelled* by any selection that lands while it waits, + // and a selection arrives through several actions: imageSelected from the gallery's auto-switch + // and keyboard navigation, selectionChanged from thumbnail clicks, boardIdSelected carrying a + // selection. Matching the resulting change of the selection covers all of them, including any + // writer added later — an action list would silently miss it. + // + // The whole selection, not just its active item: removing one of several selected thumbnails, + // or re-picking the one already active, leaves the last item unchanged while still being the + // user settling what they want. Comparing only that item left the probe running through those, + // to overwrite their selection when it woke. The state is immutable, so a new array reference + // is exactly "the selection was written", and cancelling a probe more often than strictly + // needed costs nothing. + predicate: (action, currentState, previousState) => + startsProbe(action) || selectSelection(currentState) !== selectSelection(previousState), effect: async (action, { getState, dispatch, condition, cancelActiveListeners }) => { // Cancel any in-progress instances of this listener, we don't want to select an item from a previous board cancelActiveListeners(); + if (!startsProbe(action)) { + // A selection landed. It settles what should be displayed, so a probe still waiting on a + // board's items must not overwrite it when it resolves. The gallery's auto-switch dispatches + // galleryViewChanged immediately before its selection: without this the probe that view + // change starts wakes on the selection that follows it, re-selects the first name in a + // possibly stale cached list, and undoes the switch — and the viewer then reveals that + // wrong image over the live preview, which is the flash the auto-switch marker exists to + // prevent. Cancelling above is the whole effect; there is nothing to auto-select here. + return; + } + if (boardIdSelected.match(action) && action.payload.select) { // This action already has a resource selection - skip the below auto-selection logic return; diff --git a/invokeai/frontend/web/src/app/store/store.test.ts b/invokeai/frontend/web/src/app/store/store.test.ts index 64dcdfe3af6..056bc639b48 100644 --- a/invokeai/frontend/web/src/app/store/store.test.ts +++ b/invokeai/frontend/web/src/app/store/store.test.ts @@ -4,7 +4,13 @@ import { externalTokenAdopted, logout, sessionExpiredLogout, setCredentials } fr import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; import { positivePromptChanged } from 'features/controlLayers/store/paramsSlice'; import { deleteVideosWithDialog } from 'features/deleteVideoModal/store/state'; -import { autoAddBoardIdChanged, boardIdSelected, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; +import { + autoAddBoardIdChanged, + boardIdSelected, + imageSelected, + selectionChanged, +} from 'features/gallery/store/gallerySlice'; import { appInfoApi } from 'services/api/endpoints/appInfo'; import type { S } from 'services/api/types'; import { describe, expect, it } from 'vitest'; @@ -116,3 +122,22 @@ describe('auth cache isolation', () => { await expect(pending).rejects.toBe('User canceled'); }); }); + +describe('gallery listener registration', () => { + it('settles the auto-switch marker through the real store wiring', () => { + // The per-listener tests build their own store, so nothing else fails if the registration in + // store.ts is deleted — and without it the marker never settles, every stale marker suppresses + // the user's next click on that item, and the exact dead click the marker exists to prevent + // comes back. This is the one test that dispatches through createStore()'s own listeners. + const store = createStore(); + autoSwitchedImages.settle(null); // module singleton; start from empty + + autoSwitchedImages.record('auto-switched.png'); + store.dispatch(imageSelected('auto-switched.png')); + store.dispatch(imageSelected('user-clicked-elsewhere.png')); + + expect(autoSwitchedImages.consume('auto-switched.png'), 'the marker must not survive the selection moving on').toBe( + false + ); + }); +}); diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 59551225314..7ea4ffbf45c 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -12,6 +12,7 @@ import { errorHandler } from 'app/store/enhancers/reduxRemember/errors'; import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener'; import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued'; import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted'; +import { addAutoSwitchedSelectionListener } from 'app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection'; import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/batchEnqueued'; import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted'; import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; @@ -326,6 +327,7 @@ addImageAddedToBoardFulfilledListener(startAppListening); addImageRemovedFromBoardFulfilledListener(startAppListening); addBoardIdSelectedListener(startAppListening); addArchivedOrDeletedBoardListener(startAppListening); +addAutoSwitchedSelectionListener(startAppListening); // Node schemas addGetOpenAPISchemaListener(startAppListening); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.test.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.test.ts new file mode 100644 index 00000000000..9f188f87e24 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const read = (file: string) => readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf8'); + +// Wiring checks only — this directory has no DOM test environment, so the component cannot be +// mounted. The lifecycle behavior behind onLoadImage is covered by real tests in +// viewerProgressLifecycle.test.ts, and the reveal-suppression registry in autoSwitchedImages.test.ts. +describe('CurrentImagePreview reveal wiring', () => { + const currentImagePreview = read('./CurrentImagePreview.tsx'); + + it('gates the viewer reveal on the thumbnail rather than the full-resolution image', () => { + // Gating on `/full` holds a stale latent preview on screen for the whole multi-megabyte + // download on a slow connection. + expect(currentImagePreview).toContain('useMediaUrl(imageDTO?.thumbnail_url)'); + expect(currentImagePreview).toContain('preloader.src = previewSrc'); + expect(currentImagePreview).not.toMatch(/preloader\.src\s*=\s*imageDTO\.image_url/); + }); + + it('clears the progress overlay when the preload settles, including on error', () => { + // Chakra reports a failed load as onError, not onLoad, so DndImage's onLoad alone is not + // enough to guarantee the overlay is ever cleared. + expect(currentImagePreview).toContain('preloader.onerror = onReady'); + const onReady = currentImagePreview.slice( + currentImagePreview.indexOf('const onReady ='), + currentImagePreview.indexOf('if (typeof window ===') + ); + expect(onReady).toContain('onLoadImage(imageDTO.session_id ?? null)'); + }); + + it('routes the reveal through the mounted-tested hook, gated on the settled preload', () => { + // The wiring is behaviorally covered in useSelectedItemReveal.test.tsx; this pins that the + // component uses it, and that readiness means "the preload settled" on the image path. + expect(currentImagePreview).toContain('useSelectedItemReveal({'); + expect(currentImagePreview).toContain('isMediaReady: imageToRender !== null,'); + }); +}); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index ba76ca299bb..ca0271d6f76 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -1,11 +1,13 @@ import { Box, Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; +import { useMediaUrl } from 'features/auth/store/mediaCookieRefresh'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons'; import { useNextPrevItemNavigation } from 'features/gallery/components/useNextPrevItemNavigation'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -19,11 +21,12 @@ import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; -import { useImageViewerContext } from './context'; +import { SELECTED_ITEM_MEDIA_GRACE_MS, SELECTED_ITEM_REVEAL_DURATION_MS, useImageViewerContext } from './context'; import { NoContentForViewer } from './NoContentForViewer'; import { ProgressImage } from './ProgressImage2'; import { ProgressImageTiles } from './ProgressImageTiles'; import { ProgressIndicator } from './ProgressIndicator2'; +import { useSelectedItemReveal } from './useSelectedItemReveal'; export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => { const activeTab = useAppSelector(selectActiveTab); @@ -38,6 +41,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu $activeProgressData, $isProgressImageResolving, $isTemporarilyShowingSelectedImage, + lastRenderedItemNameRef, } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); @@ -45,8 +49,22 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu const isProgressImageResolving = useStore($isProgressImageResolving); const isTemporarilyShowingSelectedImage = useStore($isTemporarilyShowingSelectedImage); const [imageToRender, setImageToRender] = useState(null); - const previousRenderedImageNameRef = useRef(null); - const selectedImageRevealTimeoutId = useRef(0); + // One controller per mounted preview component; the previous-item ref inside it is the shared + // one from the viewer context, so image <-> video clicks read as selection changes on both ends. + + // The reveal gate below deliberately preloads the *thumbnail*, not the full-resolution image. The + // progress overlay covers this element until onLoadImage fires, so gating on the multi-megabyte + // `/full` response would hold a stale latent preview on screen for that entire download on a slow + // connection. The 256px thumbnail is roughly 100x smaller and is typically higher resolution than + // the preview it replaces; DndImage renders it via Chakra's `fallbackSrc` and swaps the full image + // in, in place, once that finishes loading. + // + // The URL must go through useMediaUrl so it is byte-identical to the one DndImage requests. The + // media cookie version is a query parameter, so a mismatch is a different key and the bytes are + // fetched twice (measured: 2 requests mismatched vs 1 matched). Note the reuse here is the + // document's list of available images, which is keyed by URL and is not the HTTP cache — it still + // holds in multiuser mode, where images are served `Cache-Control: private, no-store`. + const previewSrc = useMediaUrl(imageDTO?.thumbnail_url); useEffect(() => { if (!selectedImageName) { @@ -65,9 +83,15 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu return; } setImageToRender(imageDTO); + // Resolve the progress overlay as soon as the thumbnail settles — on success *or* error. + // Relying on DndImage's onLoad alone leaves the overlay stuck whenever the image fails to + // load, because Chakra reports that as onError instead. The session id lets the lifecycle + // attribute the load, so a late-settling thumbnail from an earlier session cannot cut a + // different session's resolve illusion short. + onLoadImage(imageDTO.session_id ?? null); }; - if (typeof window === 'undefined') { + if (typeof window === 'undefined' || !previewSrc) { onReady(); return; } @@ -76,7 +100,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu preloader.onload = onReady; preloader.onerror = onReady; - preloader.src = imageDTO.image_url; + preloader.src = previewSrc; if (preloader.complete) { onReady(); @@ -87,54 +111,26 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu preloader.onload = null; preloader.onerror = null; }; - }, [imageDTO, imageToRender?.image_name, selectedImageName]); + }, [imageDTO, imageToRender?.image_name, onLoadImage, previewSrc, selectedImageName]); const hasProgressImage = progressImage !== null; - useEffect(() => { - const renderedImageName = imageToRender?.image_name ?? null; - const previousRenderedImageName = previousRenderedImageNameRef.current; - previousRenderedImageNameRef.current = renderedImageName; - - window.clearTimeout(selectedImageRevealTimeoutId.current); - - if ( - !shouldShowProgressInViewer || - !hasProgressImage || - isProgressImageResolving || - !renderedImageName || - renderedImageName !== selectedImageName - ) { - $isTemporarilyShowingSelectedImage.set(false); - return; - } - - if (previousRenderedImageName === null || previousRenderedImageName === renderedImageName) { - return; - } - - $isTemporarilyShowingSelectedImage.set(true); - selectedImageRevealTimeoutId.current = window.setTimeout(() => { - $isTemporarilyShowingSelectedImage.set(false); - }, SELECTED_IMAGE_REVEAL_DURATION_MS); - - return () => { - window.clearTimeout(selectedImageRevealTimeoutId.current); - }; - }, [ + // The reveal sequencing lives in the controller (selectedItemReveal.ts); the effect wiring + // around it lives in the hook, where it is mounted and tested with real lifecycles. The image + // path only renders an image once its preload has settled, so whatever is rendered has painted. + useSelectedItemReveal({ + lastRenderedItemNameRef, $isTemporarilyShowingSelectedImage, + marker: autoSwitchedImages, + durationMs: SELECTED_ITEM_REVEAL_DURATION_MS, + mediaGraceMs: SELECTED_ITEM_MEDIA_GRACE_MS, + renderedItemName: imageToRender?.image_name ?? null, + isMediaReady: imageToRender !== null, + selectedItemName: selectedImageName ?? null, + shouldShowProgressInViewer, hasProgressImage, - imageToRender?.image_name, isProgressImageResolving, - selectedImageName, - shouldShowProgressInViewer, - ]); - - useEffect(() => { - return () => { - $isTemporarilyShowingSelectedImage.set(false); - }; - }, [$isTemporarilyShowingSelectedImage]); + }); // Show and hide the next/prev buttons on mouse move const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); @@ -237,7 +233,11 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu - {shouldShowItemDetails && imageToRender && !withProgress && ( + {/* Gated on the reveal state itself, not only on !withProgress (which the reveal turns + off): the reveal exists to make a mid-render click visibly land, and the full-screen + metadata panel would drop exactly on top of the just-revealed image for the whole + window. Mirrors CurrentVideoPreview's gate. */} + {shouldShowItemDetails && imageToRender && !isTemporarilyShowingSelectedImage && !withProgress && ( @@ -277,5 +277,3 @@ const exit: AnimationProps['exit'] = { opacity: 0, transition: { duration: 0.07 }, }; - -const SELECTED_IMAGE_REVEAL_DURATION_MS = 2000; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts index 9a22e8cc321..1a79715f614 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts @@ -11,3 +11,65 @@ describe('CurrentVideoPreview playback errors', () => { expect(source).toContain('onError={handleVideoError}'); }); }); + +describe('CurrentVideoPreview progress overlay', () => { + const source = readFileSync(fileURLToPath(new URL('./CurrentVideoPreview.tsx', import.meta.url)), 'utf8'); + + it('lifts the overlay during the temporary reveal so mid-render thumbnail clicks visibly land', () => { + // The overlay must consult the shared reveal atom (and never re-cover an actively-playing + // video) — an unconditional overlay swallows every gallery click for the whole render. + expect(source).toMatch( + /withProgress =\s+shouldShowProgressInViewer && hasProgressImage && !isTemporarilyShowingSelectedImage && !isPlaying/ + ); + expect(source).toContain('SELECTED_ITEM_REVEAL_DURATION_MS'); + }); + + it('tiles concurrent sessions instead of letting them overwrite each other (multi-GPU)', () => { + // CurrentImagePreview tiles per-session previews when several renders run at once; the video + // overlay must do the same or the sessions fight over the single full-size preview slot. + expect(source).toMatch(/withTiledProgress = withProgress && activeProgressData\.length > 1/); + expect(source).toContain(''); + }); + + it('routes the reveal through the mounted-tested hook, fed by the painted-name readiness', () => { + // The wiring itself — effect order, cleanup, unmount, the component swap — is behaviorally + // covered in useSelectedItemReveal.test.tsx. What only this file can see is that this + // component actually uses that hook, with readiness from the real element's onLoadedData + // rather than from mount. + expect(source).toContain('useSelectedItemReveal({'); + expect(source).toContain('renderedItemName: videoName,'); + expect(source).toMatch(/const \{ isMediaReady, onPainted \} = usePaintedItemName\(videoName\);/); + expect(source).toContain('onLoadedData={onPainted}'); + }); + + it('does not cover playback or a temporary reveal with the metadata panel', () => { + // Playing and revealing both turn withProgress off, so gating the full-screen metadata panel + // on !withProgress alone drops it exactly on top of the native controls / the just-revealed + // video whenever item details are enabled. + expect(source).toMatch( + /shouldShowItemDetails && !isPlaying && !isTemporarilyShowingSelectedImage && !withProgress &&/ + ); + }); + + it('restores the overlay when playback ends on its own, not only when the player is closed', () => { + // isPlaying suppresses the overlay; without onEnded it never falls back, so the live preview + // stays hidden for the rest of the generation after a short video plays out. + expect(source).toContain('onEnded={handleClose}'); + }); + + it('does not end a pending resolve when play() is rejected', () => { + // A rejected play() is not a load failure — the element is intact and its metadata has usually + // already loaded — so it must not clear an overlay belonging to some other session's render. + const playHandler = source.slice(source.indexOf('const handlePlay'), source.indexOf('const handleClose')); + expect(playHandler).toContain('reportPlaybackFailure()'); + expect(playHandler).not.toContain('onLoadImage'); + }); + + it('ends a pending post-render resolve when the video element errors', () => { + // onLoadedMetadata normally ends the resolve illusion; an errored element never fires it. The + // call must carry this video's session id so the lifecycle can tell it apart from a late load + // belonging to another concurrently-completed session. + const errorHandler = source.slice(source.indexOf('const handleVideoError'), source.indexOf('const handlePlay')); + expect(errorHandler).toContain('onLoadImage(videoDTO?.session_id ?? null)'); + }); +}); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx index 19aacb1944b..87c991083e5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx @@ -13,7 +13,12 @@ import { dndInputFix } from 'features/dnd/util'; import VideoMetadataViewer from 'features/gallery/components/ImageMetadataViewer/VideoMetadataViewer'; import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons'; import { useNextPrevItemNavigation } from 'features/gallery/components/useNextPrevItemNavigation'; -import { selectSelectedBoardId, selectSelection } from 'features/gallery/store/gallerySelectors'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; +import { + selectLastSelectedItem, + selectSelectedBoardId, + selectSelection, +} from 'features/gallery/store/gallerySelectors'; import { isVideoName } from 'features/gallery/store/types'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { toast } from 'features/toast/toast'; @@ -30,10 +35,12 @@ import { useTranslation } from 'react-i18next'; import { PiArrowSquareOutBold, PiCopyBold, PiDownloadSimpleBold, PiTrashSimpleBold, PiXBold } from 'react-icons/pi'; import type { VideoDTO } from 'services/api/types'; -import { useImageViewerContext } from './context'; +import { SELECTED_ITEM_MEDIA_GRACE_MS, SELECTED_ITEM_REVEAL_DURATION_MS, useImageViewerContext } from './context'; import { NoContentForViewer } from './NoContentForViewer'; import { ProgressImage } from './ProgressImage2'; +import { ProgressImageTiles } from './ProgressImageTiles'; import { ProgressIndicator } from './ProgressIndicator2'; +import { usePaintedItemName, useSelectedItemReveal } from './useSelectedItemReveal'; import { VideoPlayButtonOverlay } from './VideoPlayButtonOverlay'; type Props = { @@ -57,6 +64,8 @@ type Props = { * appear on top of the previously-loaded video. Without this, a freshly generated render's * progress images had nowhere to display whenever a video was the last-selected gallery * item (and the user only saw the static first-frame still until the new video finished). + * Also mirrors its temporary reveal: clicking a gallery thumbnail mid-render lifts the + * overlay briefly so the click visibly lands, then the live preview returns. */ export const CurrentVideoPreview = memo(({ videoDTO }: Props) => { const videoUrl = useMediaUrl(videoDTO?.video_url); @@ -65,23 +74,67 @@ export const CurrentVideoPreview = memo(({ videoDTO }: Props) => { const videoName = videoDTO?.video_name ?? null; const videoRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); + const { isMediaReady, onPainted } = usePaintedItemName(videoName); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); const shouldShowItemDetails = useAppSelector(selectShouldShowItemDetails); const activeTab = useAppSelector(selectActiveTab); const deleteVideoModal = useDeleteVideoModalApi(); const { downloadItem } = useDownloadItem(); const clipboard = useClipboard(); - const { $progressEvent, $progressImage, onLoadImage } = useImageViewerContext(); + const { + $progressEvent, + $progressImage, + $activeProgressData, + $isProgressImageResolving, + $isTemporarilyShowingSelectedImage, + lastRenderedItemNameRef, + onLoadImage, + } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); - const withProgress = shouldShowProgressInViewer && progressImage !== null; + const activeProgressData = useStore($activeProgressData); + const isProgressImageResolving = useStore($isProgressImageResolving); + const isTemporarilyShowingSelectedImage = useStore($isTemporarilyShowingSelectedImage); + const hasProgressImage = progressImage !== null; + // `!isPlaying`: a reveal exposes the play button, and an explicit play is a stronger signal than + // the click that triggered the reveal — never re-cover an actively-playing video with the opaque + // overlay (its audio would keep running underneath, with the controls unreachable). The overlay + // returns when playback ends — whether the user closes the player or the video runs out. + const withProgress = + shouldShowProgressInViewer && hasProgressImage && !isTemporarilyShowingSelectedImage && !isPlaying; + // When more than one session is generating concurrently (multi-GPU), tile their previews instead + // of letting the sessions overwrite each other's full-size preview. Mirrors CurrentImagePreview. + const withTiledProgress = withProgress && activeProgressData.length > 1; const { goToPreviousImage, goToNextImage, isFetching } = useNextPrevItemNavigation(); + const selectedItemName = useAppSelector(selectLastSelectedItem); + // One controller per mounted preview component; the previous-item ref inside it is the shared + // one from the viewer context, so image <-> video clicks read as selection changes on both ends. // Whenever the selected video changes, drop back to the idle still + play overlay. useEffect(() => { setIsPlaying(false); }, [videoName]); + // Mid-generation gallery clicks: mirror CurrentImagePreview's temporary reveal. Without this, + // the opaque progress overlay swallows every video-thumbnail click for the whole render — the + // selection changes underneath, but nothing visibly happens. The sequencing lives in the + // controller (selectedItemReveal.ts); the effect wiring around it lives in the hook, where it is + // mounted and tested with real lifecycles. preload="metadata" plus the near-zero seek does not + // prove a frame exists, so readiness comes from usePaintedItemName fed by onLoadedData. + useSelectedItemReveal({ + lastRenderedItemNameRef, + $isTemporarilyShowingSelectedImage, + marker: autoSwitchedImages, + durationMs: SELECTED_ITEM_REVEAL_DURATION_MS, + mediaGraceMs: SELECTED_ITEM_MEDIA_GRACE_MS, + renderedItemName: videoName, + isMediaReady, + selectedItemName: selectedItemName ?? null, + shouldShowProgressInViewer, + hasProgressImage, + isProgressImageResolving, + }); + // Register the viewer's