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"}`}