Merge pull request #67 from zonghaoyuan/fix/image-ready-gate
fix(play): gate scene transition on image decode
This commit is contained in:
+37
-3
@@ -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}
|
||||
|
||||
@@ -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"}`}
|
||||
|
||||
Reference in New Issue
Block a user