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 1978a7fc1ab..043551efdf3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -19,7 +19,7 @@ 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_REVEAL_DURATION_MS, useImageViewerContext } from './context'; import { NoContentForViewer } from './NoContentForViewer'; import { ProgressImage } from './ProgressImage2'; import { ProgressImageTiles } from './ProgressImageTiles'; @@ -38,6 +38,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu $activeProgressData, $isProgressImageResolving, $isTemporarilyShowingSelectedImage, + lastRenderedItemNameRef, } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); @@ -45,7 +46,6 @@ 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); useEffect(() => { @@ -93,8 +93,16 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu useEffect(() => { const renderedImageName = imageToRender?.image_name ?? null; - const previousRenderedImageName = previousRenderedImageNameRef.current; - previousRenderedImageNameRef.current = renderedImageName; + // The previous-item ref is shared with CurrentVideoPreview (via the viewer context) so a click + // that switches media type still reads as a selection change here. While a selection exists + // but its preload hasn't landed yet (renderedImageName still null on the mount run after a + // video -> image swap), the ref must NOT be overwritten — nulling it here would erase the + // "previous item was the video" fact and swallow the reveal this run's successor would fire. + // A genuinely empty selection does reset it, preserving the no-reveal-on-first-selection rule. + const previousRenderedItemName = lastRenderedItemNameRef.current; + if (renderedImageName !== null || !selectedImageName) { + lastRenderedItemNameRef.current = renderedImageName; + } window.clearTimeout(selectedImageRevealTimeoutId.current); @@ -109,14 +117,14 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu return; } - if (previousRenderedImageName === null || previousRenderedImageName === renderedImageName) { + if (previousRenderedItemName === null || previousRenderedItemName === renderedImageName) { return; } $isTemporarilyShowingSelectedImage.set(true); selectedImageRevealTimeoutId.current = window.setTimeout(() => { $isTemporarilyShowingSelectedImage.set(false); - }, SELECTED_IMAGE_REVEAL_DURATION_MS); + }, SELECTED_ITEM_REVEAL_DURATION_MS); return () => { window.clearTimeout(selectedImageRevealTimeoutId.current); @@ -126,6 +134,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu hasProgressImage, imageToRender?.image_name, isProgressImageResolving, + lastRenderedItemNameRef, selectedImageName, shouldShowProgressInViewer, ]); @@ -271,5 +280,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..945fd1702c1 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,32 @@ 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'); + // The previous-item ref must be the shared one from the viewer context, so image -> video + // clicks still read as a selection change after the preview component swaps. + expect(source).toContain('lastRenderedItemNameRef.current = videoName'); + }); + + 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('clears a pending post-render overlay when the video element errors', () => { + // onLoadedMetadata normally clears the resolve state; an errored element never fires it. + const errorHandler = source.slice(source.indexOf('const handleVideoError'), source.indexOf('const handlePlay')); + expect(errorHandler).toContain('onLoadImage()'); + }); +}); 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 9329a7173c7..35369b91613 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx @@ -30,9 +30,10 @@ 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_REVEAL_DURATION_MS, useImageViewerContext } from './context'; import { NoContentForViewer } from './NoContentForViewer'; import { ProgressImage } from './ProgressImage2'; +import { ProgressImageTiles } from './ProgressImageTiles'; import { ProgressIndicator } from './ProgressIndicator2'; import { VideoPlayButtonOverlay } from './VideoPlayButtonOverlay'; @@ -57,6 +58,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); @@ -71,17 +74,82 @@ export const CurrentVideoPreview = memo(({ videoDTO }: Props) => { 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 the user closes the player. + 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 selectedVideoRevealTimeoutId = useRef(0); // 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. Unlike the image path there is no + // preload step: the