Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
eebcefb
fix(ui): resolve the viewer preview on the thumbnail and stop it stic…
lstein Aug 1, 2026
d86b6f6
fix(ui): stop auto-switch flashing the previous image over the next p…
lstein Aug 3, 2026
edbe017
Merge branch 'main' into fix/viewer-progress-image-handoff
JPPhoto Aug 6, 2026
54c76c3
fix(ui): don't strand the viewer under the video progress overlay
lstein Aug 6, 2026
dd524d2
fix(ui): tile concurrent session previews in the video viewer (multi-…
lstein Aug 7, 2026
f282f0f
Merge branch 'main' into fix/viewer-progress-image-handoff
lstein Aug 7, 2026
e854d32
Merge branch 'main' into fix/viewer-progress-image-handoff
lstein Aug 8, 2026
fb8d80c
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 8, 2026
f54d2dd
Merge branch 'main' into fix/viewer-progress-image-handoff
JPPhoto Aug 8, 2026
978092a
chore(ui): resolve knip warnings
JPPhoto Aug 8, 2026
f17a07b
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 9, 2026
c0da00a
Merge branch 'main' into fix/viewer-progress-image-handoff
JPPhoto Aug 9, 2026
68f9c00
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 10, 2026
bb90d5e
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 13, 2026
031a8a9
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 13, 2026
99220bd
Merge branch 'main' into fix/viewer-progress-image-handoff
JPPhoto Aug 18, 2026
b536b7b
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 18, 2026
87ee5e0
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 18, 2026
421fa0d
Merge branch 'main' into fix/viewer-progress-image-handoff
JPPhoto Aug 18, 2026
71217f4
fix(ui): don't treat an auto-switch to a finished video as a user reveal
lstein Aug 18, 2026
dbdf632
Merge branch 'main' into fix/viewer-video-progress-overlay
lstein Aug 18, 2026
fafcdf8
test(ui): pin the promoted session's preview against a stale resolve …
lstein Aug 18, 2026
450897d
fix(ui): address review — deadline ownership handoff + duplicate-comp…
lstein Aug 18, 2026
0452cfd
docs(ui): drop a comment reference to a symbol this tree doesn't have
lstein Aug 18, 2026
ab79d80
fix(ui): close four holes an adversarial review found in the video re…
lstein Aug 18, 2026
c2b6fab
Merge branch 'main' into fix/viewer-progress-image-handoff
lstein Aug 19, 2026
6e263ff
fix(ui): address review round 2 — selection-scoped auto-switch marker…
lstein Aug 19, 2026
2f56661
Merge branch 'main' into fix/viewer-video-progress-overlay
JPPhoto Aug 19, 2026
5e8e3a0
Merge branch 'main' into fix/viewer-progress-image-handoff
JPPhoto Aug 19, 2026
f407cdc
test(ui): cover the reveal suppression, and narrow the retry to galle…
lstein Aug 19, 2026
ff9ff8f
Merge remote-tracking branch 'fork/fix/viewer-progress-image-handoff'…
lstein Aug 19, 2026
f8e6dc9
fix(ui): stop exporting a type nothing imports
lstein Aug 19, 2026
baaa895
fix(ui): address review round 2 — selection-scoped marker, completion…
lstein Aug 19, 2026
7d110c6
fix(ui): carry #9434's per-output retry, and unblock knip
lstein Aug 19, 2026
6e66c73
fix(ui): repair five defects an adversarial review found in the retry…
lstein Aug 19, 2026
c52bfb0
fix(ui): don't put the overlay back over a reveal the user already ea…
lstein Aug 19, 2026
b46cb1b
fix(ui): repair five defects an adversarial review found in the retry…
lstein Aug 19, 2026
75bc243
fix(ui): cancel the board probe on any selection, not just imageSelected
lstein Aug 20, 2026
52174b4
fix(ui): cancel the board probe on any selection, not just imageSelected
lstein Aug 20, 2026
7a17be0
fix(ui): cancel the probe on any selection write, and stop losing out…
lstein Aug 20, 2026
c9c62d3
fix(ui): cancel the probe on any selection write, and stop losing out…
lstein Aug 20, 2026
cef16e0
Merge #9434 (viewer progress-image handoff) into the video overlay fix
lstein Aug 20, 2026
f44c07a
test(ui): pin the reveal's connection to the overlay atom
lstein Aug 20, 2026
b874b90
fix(ui): don't let retries outlive their session, or swallow the firs…
lstein Aug 21, 2026
042350c
fix(ui): end a completion handler's session properly, and restore the…
lstein Aug 21, 2026
cd09bab
fix(ui): close the disposal escapes a self-review found before the ne…
lstein Aug 22, 2026
f2853a6
test(ui): mount the reveal wiring in a real DOM instead of grepping f…
lstein Aug 22, 2026
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