diff --git a/app/play/page.tsx b/app/play/page.tsx index 64ec63f..22afe18 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -81,6 +81,12 @@ const useIsomorphicLayoutEffect = // 20s + the aspect-video fallback together remove that failure mode. const IMAGE_PRELOAD_TIMEOUT_MS = 20000; +// After blob/preload resolves the still needs to decode the bitmap. +// This gate keeps the "transitioning" overlay visible until decode fires, +// so the user never sees progressive paint or a blank flash. 3s is generous +// (decode is typically <100ms for a locally-held blob). +const IMAGE_READY_TIMEOUT_MS = 3000; + // ────────────────────────────────────────────────────────────────────── // Two ways an gets its pixels, picked per-URL by shouldProxy(): // @@ -596,6 +602,27 @@ function PlayInner() { // not the blob URL, because blobUrlCache is keyed by original URL. const lastImageOriginalUrlRef = useRef(null); + // Image-ready gate: keeps the "transitioning" overlay visible until the + // actual element has decoded its bitmap, so the user never sees + // progressive paint or a blank flash between scenes. + const imageReadyResolverRef = useRef<(() => void) | null>(null); + function waitForImageReady(): Promise { + return new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) return; + settled = true; + imageReadyResolverRef.current = null; + resolve(); + }; + imageReadyResolverRef.current = done; + setTimeout(done, IMAGE_READY_TIMEOUT_MS); + }); + } + const handleImageReady = useCallback(() => { + imageReadyResolverRef.current?.(); + }, []); + const currentBeat = useMemo(() => { if (!currentScene || !currentBeatId) return null; return currentScene.beats.find((b) => b.id === currentBeatId) ?? null; @@ -1211,7 +1238,9 @@ function PlayInner() { setSession(initial); setCurrentScene(first.scene); setCurrentBeatId(first.scene.entryBeatId); + const ready = waitForImageReady(); setImageUrl(blobUrl); + await ready; setPhase("ready"); track("scene_reached", { scene_index: 1 }); } catch (e) { @@ -1346,9 +1375,9 @@ function PlayInner() { setSession(initial); setCurrentScene(data.scene); setCurrentBeatId(data.scene.entryBeatId); + const ready = waitForImageReady(); setImageUrl(blobUrl); - // beatAudioMap is populated lazily by the per-beat fetch effect once - // currentScene becomes non-null (see fetchBeatAudio). + await ready; setPhase("ready"); track("scene_reached", { scene_index: initial.history.length }); }) @@ -1467,9 +1496,10 @@ function PlayInner() { setSession(newSession); setCurrentScene(result.scene); setCurrentBeatId(result.scene.entryBeatId); + const ready = waitForImageReady(); setImageUrl(blobUrl); - // beatAudioMap reset + per-beat fetches kicked off by the scene effect. setLastExitLabel(exitLabel); + await ready; setPhase("ready"); track("scene_reached", { scene_index: newSession.history.length }); } catch (e) { @@ -1545,8 +1575,10 @@ function PlayInner() { setSession(nextSession); setCurrentScene(next.scene); setCurrentBeatId(next.scene.entryBeatId); + const ready = waitForImageReady(); setImageUrl(blobUrl); setLastExitLabel(choice.label); + await ready; setPhase("ready"); track("scene_reached", { scene_index: nextSession.history.length }); } catch (e) { @@ -1958,6 +1990,7 @@ function PlayInner() { playerName={session?.playerName} visionClickEnabled={visionClickEnabled} onOpenSettings={() => setSettingsOpen(true)} + onImageReady={handleImageReady} fullViewport dialogueHistory={dialogueHistory} disabledChoiceIds={disabledReplayChoiceIds} @@ -2050,6 +2083,7 @@ function PlayInner() { playerName={session?.playerName} visionClickEnabled={visionClickEnabled} onOpenSettings={() => setSettingsOpen(true)} + onImageReady={handleImageReady} dialogueHistory={dialogueHistory} disabledChoiceIds={disabledReplayChoiceIds} freeformDisabled={replayLocked} diff --git a/components/PlayCanvas.tsx b/components/PlayCanvas.tsx index 0235f59..9c2ee86 100644 --- a/components/PlayCanvas.tsx +++ b/components/PlayCanvas.tsx @@ -183,6 +183,7 @@ export function PlayCanvas({ playerName, visionClickEnabled = true, onOpenSettings, + onImageReady, aboveCanvas, aboveCanvasLeft, belowCanvas, @@ -207,6 +208,7 @@ export function PlayCanvas({ // 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。 visionClickEnabled?: boolean; onOpenSettings?: () => void; + onImageReady?: () => void; // 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。 aboveCanvas?: ReactNode; // 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。 @@ -407,6 +409,13 @@ export function PlayCanvas({ alt="Generated scene" onClick={handleImageClick} draggable={false} + onLoad={() => { + if (!onImageReady) return; + const el = imgRef.current; + if (!el) { onImageReady(); return; } + const notify = () => { if (imgRef.current === el) onImageReady(); }; + el.decode().then(notify, notify); + }} className={`block select-none animate-fade-in transition-opacity duration-700 ease-out ${ imageClickable ? "cursor-pointer" : interactive ? "cursor-default" : "cursor-wait" } ${dimmed ? "opacity-40" : "opacity-100"}`}