Merge pull request #67 from zonghaoyuan/fix/image-ready-gate

fix(play): gate scene transition on image decode
This commit is contained in:
Zonghao Yuan
2026-06-13 17:32:01 +08:00
committed by GitHub
2 changed files with 46 additions and 3 deletions
+37 -3
View File
@@ -81,6 +81,12 @@ const useIsomorphicLayoutEffect =
// 20s + the <img> aspect-video fallback together remove that failure mode.
const IMAGE_PRELOAD_TIMEOUT_MS = 20000;
// After blob/preload resolves the <img> 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 <img> 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<string | null>(null);
// Image-ready gate: keeps the "transitioning" overlay visible until the
// actual <img> 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<void> {
return new Promise<void>((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<Beat | null>(() => {
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}
+9
View File
@@ -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"}`}