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.
|
// 20s + the <img> aspect-video fallback together remove that failure mode.
|
||||||
const IMAGE_PRELOAD_TIMEOUT_MS = 20000;
|
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():
|
// 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.
|
// not the blob URL, because blobUrlCache is keyed by original URL.
|
||||||
const lastImageOriginalUrlRef = useRef<string | null>(null);
|
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>(() => {
|
const currentBeat = useMemo<Beat | null>(() => {
|
||||||
if (!currentScene || !currentBeatId) return null;
|
if (!currentScene || !currentBeatId) return null;
|
||||||
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
|
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
|
||||||
@@ -1211,7 +1238,9 @@ function PlayInner() {
|
|||||||
setSession(initial);
|
setSession(initial);
|
||||||
setCurrentScene(first.scene);
|
setCurrentScene(first.scene);
|
||||||
setCurrentBeatId(first.scene.entryBeatId);
|
setCurrentBeatId(first.scene.entryBeatId);
|
||||||
|
const ready = waitForImageReady();
|
||||||
setImageUrl(blobUrl);
|
setImageUrl(blobUrl);
|
||||||
|
await ready;
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
track("scene_reached", { scene_index: 1 });
|
track("scene_reached", { scene_index: 1 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1346,9 +1375,9 @@ function PlayInner() {
|
|||||||
setSession(initial);
|
setSession(initial);
|
||||||
setCurrentScene(data.scene);
|
setCurrentScene(data.scene);
|
||||||
setCurrentBeatId(data.scene.entryBeatId);
|
setCurrentBeatId(data.scene.entryBeatId);
|
||||||
|
const ready = waitForImageReady();
|
||||||
setImageUrl(blobUrl);
|
setImageUrl(blobUrl);
|
||||||
// beatAudioMap is populated lazily by the per-beat fetch effect once
|
await ready;
|
||||||
// currentScene becomes non-null (see fetchBeatAudio).
|
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
track("scene_reached", { scene_index: initial.history.length });
|
track("scene_reached", { scene_index: initial.history.length });
|
||||||
})
|
})
|
||||||
@@ -1467,9 +1496,10 @@ function PlayInner() {
|
|||||||
setSession(newSession);
|
setSession(newSession);
|
||||||
setCurrentScene(result.scene);
|
setCurrentScene(result.scene);
|
||||||
setCurrentBeatId(result.scene.entryBeatId);
|
setCurrentBeatId(result.scene.entryBeatId);
|
||||||
|
const ready = waitForImageReady();
|
||||||
setImageUrl(blobUrl);
|
setImageUrl(blobUrl);
|
||||||
// beatAudioMap reset + per-beat fetches kicked off by the scene effect.
|
|
||||||
setLastExitLabel(exitLabel);
|
setLastExitLabel(exitLabel);
|
||||||
|
await ready;
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
track("scene_reached", { scene_index: newSession.history.length });
|
track("scene_reached", { scene_index: newSession.history.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1545,8 +1575,10 @@ function PlayInner() {
|
|||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
setCurrentScene(next.scene);
|
setCurrentScene(next.scene);
|
||||||
setCurrentBeatId(next.scene.entryBeatId);
|
setCurrentBeatId(next.scene.entryBeatId);
|
||||||
|
const ready = waitForImageReady();
|
||||||
setImageUrl(blobUrl);
|
setImageUrl(blobUrl);
|
||||||
setLastExitLabel(choice.label);
|
setLastExitLabel(choice.label);
|
||||||
|
await ready;
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
track("scene_reached", { scene_index: nextSession.history.length });
|
track("scene_reached", { scene_index: nextSession.history.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1958,6 +1990,7 @@ function PlayInner() {
|
|||||||
playerName={session?.playerName}
|
playerName={session?.playerName}
|
||||||
visionClickEnabled={visionClickEnabled}
|
visionClickEnabled={visionClickEnabled}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
onImageReady={handleImageReady}
|
||||||
fullViewport
|
fullViewport
|
||||||
dialogueHistory={dialogueHistory}
|
dialogueHistory={dialogueHistory}
|
||||||
disabledChoiceIds={disabledReplayChoiceIds}
|
disabledChoiceIds={disabledReplayChoiceIds}
|
||||||
@@ -2050,6 +2083,7 @@ function PlayInner() {
|
|||||||
playerName={session?.playerName}
|
playerName={session?.playerName}
|
||||||
visionClickEnabled={visionClickEnabled}
|
visionClickEnabled={visionClickEnabled}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
onImageReady={handleImageReady}
|
||||||
dialogueHistory={dialogueHistory}
|
dialogueHistory={dialogueHistory}
|
||||||
disabledChoiceIds={disabledReplayChoiceIds}
|
disabledChoiceIds={disabledReplayChoiceIds}
|
||||||
freeformDisabled={replayLocked}
|
freeformDisabled={replayLocked}
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export function PlayCanvas({
|
|||||||
playerName,
|
playerName,
|
||||||
visionClickEnabled = true,
|
visionClickEnabled = true,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
onImageReady,
|
||||||
aboveCanvas,
|
aboveCanvas,
|
||||||
aboveCanvasLeft,
|
aboveCanvasLeft,
|
||||||
belowCanvas,
|
belowCanvas,
|
||||||
@@ -207,6 +208,7 @@ export function PlayCanvas({
|
|||||||
// 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。
|
// 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。
|
||||||
visionClickEnabled?: boolean;
|
visionClickEnabled?: boolean;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
|
onImageReady?: () => void;
|
||||||
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
||||||
aboveCanvas?: ReactNode;
|
aboveCanvas?: ReactNode;
|
||||||
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||||||
@@ -407,6 +409,13 @@ export function PlayCanvas({
|
|||||||
alt="Generated scene"
|
alt="Generated scene"
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
draggable={false}
|
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 ${
|
className={`block select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
||||||
imageClickable ? "cursor-pointer" : interactive ? "cursor-default" : "cursor-wait"
|
imageClickable ? "cursor-pointer" : interactive ? "cursor-default" : "cursor-wait"
|
||||||
} ${dimmed ? "opacity-40" : "opacity-100"}`}
|
} ${dimmed ? "opacity-40" : "opacity-100"}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user