Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,14 +38,14 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
$activeProgressData,
$isProgressImageResolving,
$isTemporarilyShowingSelectedImage,
lastRenderedItemNameRef,
} = useImageViewerContext();
const progressEvent = useStore($progressEvent);
const progressImage = useStore($progressImage);
const activeProgressData = useStore($activeProgressData);
const isProgressImageResolving = useStore($isProgressImageResolving);
const isTemporarilyShowingSelectedImage = useStore($isTemporarilyShowingSelectedImage);
const [imageToRender, setImageToRender] = useState<ImageDTO | null>(null);
const previousRenderedImageNameRef = useRef<string | null>(null);
const selectedImageRevealTimeoutId = useRef(0);

useEffect(() => {
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand All @@ -126,6 +134,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
hasProgressImage,
imageToRender?.image_name,
isProgressImageResolving,
lastRenderedItemNameRef,
selectedImageName,
shouldShowProgressInViewer,
]);
Expand Down Expand Up @@ -271,5 +280,3 @@ const exit: AnimationProps['exit'] = {
opacity: 0,
transition: { duration: 0.07 },
};

const SELECTED_IMAGE_REVEAL_DURATION_MS = 2000;
Original file line number Diff line number Diff line change
Expand Up @@ -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('<ProgressImageTiles data={activeProgressData} />');
});

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()');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand All @@ -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 <video> renders immediately (first frame paints when metadata arrives), so
// the reveal is keyed directly off the selected video name. The previous-item ref is shared with
// CurrentImagePreview so an image -> video click still reads as a selection change.
useEffect(() => {
const previousRenderedItemName = lastRenderedItemNameRef.current;
lastRenderedItemNameRef.current = videoName;

window.clearTimeout(selectedVideoRevealTimeoutId.current);

if (!shouldShowProgressInViewer || !hasProgressImage || isProgressImageResolving || !videoName) {
$isTemporarilyShowingSelectedImage.set(false);
return;
}

if (previousRenderedItemName === null || previousRenderedItemName === videoName) {
return;
}

$isTemporarilyShowingSelectedImage.set(true);
selectedVideoRevealTimeoutId.current = window.setTimeout(() => {
$isTemporarilyShowingSelectedImage.set(false);
}, SELECTED_ITEM_REVEAL_DURATION_MS);

return () => {
window.clearTimeout(selectedVideoRevealTimeoutId.current);
};
}, [
$isTemporarilyShowingSelectedImage,
hasProgressImage,
isProgressImageResolving,
lastRenderedItemNameRef,
shouldShowProgressInViewer,
videoName,
]);

useEffect(() => {
return () => {
$isTemporarilyShowingSelectedImage.set(false);
};
}, [$isTemporarilyShowingSelectedImage]);

// Register the viewer's <video> as a drag source so users can drag the currently-displayed
// video onto node fields (e.g. a Video Primitive's "Starting Video" input) directly from
// the viewer, just like they can from the gallery thumbnail. Mirrors GalleryVideoItem's
Expand Down Expand Up @@ -125,13 +193,17 @@ export const CurrentVideoPreview = memo(({ videoDTO }: Props) => {
if (isMediaCookieSelfHealPending()) {
return;
}
// A genuinely errored element will never fire onLoadedMetadata, which is what normally clears
// a pending post-render progress overlay — clear it here or it strands over the viewer.
// (onLoadImage is a no-op unless a resolve is actually pending.)
onLoadImage();
toast({
id: 'VIDEO_PLAYBACK_FAILED',
status: 'error',
title: t('toast.videoPlaybackFailed'),
description: t('toast.videoPlaybackFailedDesc'),
});
}, [t]);
}, [onLoadImage, t]);

const handlePlay = useCallback(() => {
setIsPlaying(true);
Expand Down Expand Up @@ -355,9 +427,15 @@ export const CurrentVideoPreview = memo(({ videoDTO }: Props) => {
)}
{withProgress && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center" bg="base.900">
<ProgressImage progressImage={progressImage} />
{progressEvent && (
<ProgressIndicator progressEvent={progressEvent} position="absolute" top={6} right={6} size={8} />
{withTiledProgress ? (
<ProgressImageTiles data={activeProgressData} />
) : (
<>
<ProgressImage progressImage={progressImage} />
{progressEvent && (
<ProgressIndicator progressEvent={progressEvent} position="absolute" top={6} right={6} size={8} />
)}
</>
)}
</Flex>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { selectAutoSwitch } from 'features/gallery/store/gallerySelectors';
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
import { LRUCache } from 'lru-cache';
import { type Atom, atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
import type { PropsWithChildren } from 'react';
import type { MutableRefObject, PropsWithChildren } from 'react';
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { S } from 'services/api/types';
import { getEventScope } from 'services/events/eventScope';
Expand Down Expand Up @@ -33,9 +33,23 @@ type ImageViewerContextValue = {
$activeProgressData: Atom<ViewerProgressDatum[]>;
$isProgressImageResolving: Atom<boolean>;
$isTemporarilyShowingSelectedImage: WritableAtom<boolean>;
/** Name of the item most recently rendered by either preview component (image or video). Shared
* across the two components so a click that switches media type (image -> video or back) still
* reads as a selection change to the temporary-reveal logic — a per-component ref resets on the
* swap and would silently swallow the first reveal after every type switch. */
lastRenderedItemNameRef: MutableRefObject<string | null>;
onLoadImage: () => void;
};

/** How long a mid-generation gallery click shows the clicked item before the live preview returns. */
export const SELECTED_ITEM_REVEAL_DURATION_MS = 2000;

/** Upper bound on the post-completion "progress preview resolves into the final media" illusion.
* The clear normally fires from the final image's onLoad / the final video's onLoadedMetadata, but
* on a slow connection that load can lag far behind completion — and if the media element errors,
* it never fires at all. Past this deadline we drop the illusion rather than strand the overlay. */
const RESOLVE_FAILSAFE_MS = 10_000;

const ImageViewerContext = createContext<ImageViewerContextValue | null>(null);

const log = logger('events');
Expand All @@ -59,6 +73,8 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
const $isProgressImageResolving = useState(() => atom(false))[0];
const $isTemporarilyShowingSelectedImage = useState(() => atom(false))[0];
const shouldClearProgressImageOnLoadRef = useRef(false);
const lastRenderedItemNameRef = useRef<string | null>(null);
const resolveFailsafeTimeoutRef = useRef(0);
// We can have race conditions where we receive a progress event for a queue item that has already finished. Easiest
// way to handle this is to keep track of finished queue items in a cache and ignore progress events for those.
const [finishedQueueItemIds] = useState(() => new LRUCache<number, boolean>({ max: 200 }));
Expand All @@ -81,6 +97,8 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
);
return;
}
// A new render owns the display now; a pending resolve (and its failsafe) is moot.
window.clearTimeout(resolveFailsafeTimeoutRef.current);
shouldClearProgressImageOnLoadRef.current = false;
$isProgressImageResolving.set(false);
$progressEvent.set(data);
Expand Down Expand Up @@ -153,13 +171,28 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
// will be stuck on the viewer.
(data.origin === 'canvas' && data.destination !== 'canvas')
) {
window.clearTimeout(resolveFailsafeTimeoutRef.current);
shouldClearProgressImageOnLoadRef.current = false;
$isProgressImageResolving.set(false);
$progressEvent.set(null);
$progressImage.set(null);
} else {
shouldClearProgressImageOnLoadRef.current = true;
$isProgressImageResolving.set(true);
// Failsafe: onLoadImage normally performs this clear when the final media loads, but on
// a slow connection that can lag far behind completion, and an errored media element
// never fires it — stranding the opaque overlay until a tab switch remounts this
// provider. Past the deadline, drop the resolve illusion and clear directly.
window.clearTimeout(resolveFailsafeTimeoutRef.current);
resolveFailsafeTimeoutRef.current = window.setTimeout(() => {
if (!shouldClearProgressImageOnLoadRef.current) {
return;
}
shouldClearProgressImageOnLoadRef.current = false;
$isProgressImageResolving.set(false);
$progressEvent.set(null);
$progressImage.set(null);
}, RESOLVE_FAILSAFE_MS);
}
}
};
Expand All @@ -185,12 +218,20 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
return;
}

window.clearTimeout(resolveFailsafeTimeoutRef.current);
shouldClearProgressImageOnLoadRef.current = false;
$isProgressImageResolving.set(false);
$progressEvent.set(null);
$progressImage.set(null);
}, [$isProgressImageResolving, $progressEvent, $progressImage]);

useEffect(() => {
const timeoutRef = resolveFailsafeTimeoutRef;
return () => {
window.clearTimeout(timeoutRef.current);
};
}, []);

const value = useMemo(
() => ({
$progressEvent,
Expand All @@ -200,6 +241,7 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
$activeProgressData,
$isProgressImageResolving,
$isTemporarilyShowingSelectedImage,
lastRenderedItemNameRef,
onLoadImage,
}),
[
Expand Down
Loading