Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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