From e3ee3547e5b224c5b05206e215929176e151639c Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 11:43:35 +0800 Subject: [PATCH 1/4] fix(play): gate scene transition on image decode Keep the "transitioning" overlay visible until the element's bitmap is fully decoded, so the user never sees progressive paint or a blank flash between scenes. - Add onImageReady callback to PlayCanvas ( + decode()) - Delay setPhase("ready") until decode resolves (3s timeout fallback) - Applied to all 4 scene entry paths: prebaked card, live /api/start, performSceneTransition, and recorded replay transition Co-Authored-By: Claude Opus 4.6 --- app/play/page.tsx | 40 ++++++++++++++++++++++++++++++++++++--- components/PlayCanvas.tsx | 8 ++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) 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..a5bc764 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,12 @@ export function PlayCanvas({ alt="Generated scene" onClick={handleImageClick} draggable={false} + onLoad={() => { + if (!onImageReady) return; + const el = imgRef.current; + if (!el) { onImageReady(); return; } + el.decode().then(onImageReady, onImageReady); + }} 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"}`} From a1b6848688cfdf7894ce8a4b3276cdc027fc4869 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 11:51:15 +0800 Subject: [PATCH 2/4] fix(play): guard decode callback against stale img ref Verify imgRef.current === el before firing onImageReady, so a late-resolving decode from a prior element cannot trigger the gate prematurely. Co-Authored-By: Claude Opus 4.6 --- components/PlayCanvas.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/PlayCanvas.tsx b/components/PlayCanvas.tsx index a5bc764..9c2ee86 100644 --- a/components/PlayCanvas.tsx +++ b/components/PlayCanvas.tsx @@ -413,7 +413,8 @@ export function PlayCanvas({ if (!onImageReady) return; const el = imgRef.current; if (!el) { onImageReady(); return; } - el.decode().then(onImageReady, onImageReady); + 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" From 0998f7c46a904e20c810150f595663c51a59062c Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 18:57:38 +0800 Subject: [PATCH 3/4] feat(play): add error observability analytics for mobile diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track play_error and play_visibility_lost events via Umami to distinguish mobile vs desktop failure modes. Each error event captures orientation, connection type, visibility state, elapsed time bucket, and error classification — all categorical, no free text. Includes postJson "HTTP \d+" status parsing for the new engineClient dual-path architecture. Co-Authored-By: Claude Fable 5 --- app/play/page.tsx | 109 +++++++++++++++++++++++++++++++++++++++++++++- lib/analytics.ts | 14 ++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/app/play/page.tsx b/app/play/page.tsx index 22afe18..fa13a57 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -372,6 +372,7 @@ function prefetchScenePath( const specSession = buildSpeculativeSession(baseSession, steps); const abort = new AbortController(); + const prefetchT0 = Date.now(); const promise = (async () => { const data = await requestScene({ session: specSession, clientTts }); if (abort.signal.aborted) throw new Error("aborted"); @@ -430,7 +431,20 @@ function prefetchScenePath( return data; })(); - promise.catch(() => {}); + promise.catch((e) => { + if ((e as { name?: string }).name === "AbortError") return; + const { kind, http_status } = classifyError(e); + track("play_error", { + source: "prefetch" as const, + kind, + http_status, + orientation: baseSession.orientation ?? "landscape", + connection: getConnectionType(), + was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden", + scene_index: baseSession.history.length, + elapsed_bucket: elapsedBucket(prefetchT0), + }); + }); pool.set(key, { promise, abort }); } @@ -496,6 +510,51 @@ async function resolveByoVoice( } } +// ── Error observability helpers ──────────────────────────────────────── + +type ErrorSource = "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch"; + +function classifyError( + e: unknown, + res?: Response, +): { kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown"; http_status: number } { + if (res) { + const s = res.status; + if (s >= 500) return { kind: "http_5xx", http_status: s }; + if (s >= 400) return { kind: "http_4xx", http_status: s }; + } + if (e instanceof Error) { + if (e.name === "AbortError") return { kind: "abort", http_status: 0 }; + if (e instanceof TypeError && /fetch|network/i.test(e.message)) + return { kind: "network", http_status: 0 }; + if (/timeout/i.test(e.message)) return { kind: "timeout", http_status: 0 }; + const httpMatch = e.message.match(/^HTTP (\d+)$/); + if (httpMatch) { + const s = Number(httpMatch[1]); + if (s >= 500) return { kind: "http_5xx", http_status: s }; + if (s >= 400) return { kind: "http_4xx", http_status: s }; + } + } + return { kind: "unknown", http_status: 0 }; +} + +function elapsedBucket(startMs: number): "<5s" | "5-30s" | "30-60s" | "60-120s" | "120s+" { + const s = (Date.now() - startMs) / 1000; + if (s < 5) return "<5s"; + if (s < 30) return "5-30s"; + if (s < 60) return "30-60s"; + if (s < 120) return "60-120s"; + return "120s+"; +} + +function getConnectionType(): "4g" | "3g" | "2g" | "slow-2g" | "unknown" { + const nav = typeof navigator !== "undefined" ? navigator : undefined; + const conn = (nav as { connection?: { effectiveType?: string } } | undefined)?.connection; + const et = conn?.effectiveType; + if (et === "4g" || et === "3g" || et === "2g" || et === "slow-2g") return et; + return "unknown"; +} + // ────────────────────────────────────────────────────────────────────── // Component // ────────────────────────────────────────────────────────────────────── @@ -566,6 +625,7 @@ function PlayInner() { // 首页「语音配音 关闭」会把 muted 初值置为 true(见上方 useState 初始化), // 不再单独维护 audioEnabledRef —— 单一来源避免两个 flag 漂移。 const mutedRef = useRef(muted); + const phaseRef = useRef(phase); // Resolved bring-your-own Xiaomi TTS config (region preset + key), read once // from localStorage. When non-null, the browser provisions + synths voices @@ -647,10 +707,27 @@ function PlayInner() { useEffect(() => { mutedRef.current = muted; }, [muted]); + useEffect(() => { + phaseRef.current = phase; + }, [phase]); useEffect(() => { setVisionClickEnabled(readStoredVisionClick()); }, []); + function trackPlayError(source: ErrorSource, e: unknown, startMs: number, res?: Response) { + const { kind, http_status } = classifyError(e, res); + track("play_error", { + source, + kind, + http_status, + orientation, + connection: getConnectionType(), + was_hidden: document.visibilityState === "hidden", + scene_index: session?.history.length ?? 0, + elapsed_bucket: elapsedBucket(startMs), + }); + } + // Coarse liveness ping for active-time analytics. /play is a single SPA // route, so page views alone read as ~0 duration; a 30s heartbeat (only // while the tab is visible) gives Umami the timestamps to derive real @@ -666,6 +743,20 @@ function PlayInner() { return () => window.clearInterval(id); }, []); + useEffect(() => { + function onVisChange() { + if (document.visibilityState === "hidden") { + const p = phaseRef.current; + track("play_visibility_lost", { + phase: p, + had_pending_fetch: p !== "ready", + }); + } + } + document.addEventListener("visibilitychange", onVisChange); + return () => document.removeEventListener("visibilitychange", onVisChange); + }, []); + // Whenever currentBeatId changes, append it to visited (skip consecutive dups) useEffect(() => { if (!currentBeatId) return; @@ -1186,6 +1277,7 @@ function PlayInner() { if (isShare) { (async () => { + const t0 = Date.now(); try { const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY); if (!raw) throw new Error("没有找到要载入的剧情文件。"); @@ -1244,6 +1336,7 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: 1 }); } catch (e) { + trackPlayError("start", e, t0); setError(e instanceof Error ? e.message : String(e)); } })(); @@ -1314,6 +1407,7 @@ function PlayInner() { ? "firstact-portrait" : "firstact"; + const startT0 = Date.now(); const fetchStart: Promise = cardName ? fetch(`/home/${firstactDir}/${encodeURIComponent(cardName)}.json`).then( async (r) => { @@ -1381,7 +1475,10 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: initial.history.length }); }) - .catch((e) => setError(String(e))); + .catch((e) => { + trackPlayError("start", e, startT0); + setError(String(e)); + }); }, [params, router]); // ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ────── @@ -1450,6 +1547,7 @@ function PlayInner() { visitedForCurrent: string[], exitLabel: string, ) { + const sceneT0 = Date.now(); setPhase("transitioning"); setPendingClick(null); try { @@ -1507,6 +1605,7 @@ function PlayInner() { setPhase("ready"); return; } + trackPlayError("scene", e, sceneT0); setError(String(e)); setPhase("ready"); } @@ -1536,6 +1635,7 @@ function PlayInner() { } void (async () => { + const replayT0 = Date.now(); setPhase("transitioning"); setPendingClick(null); try { @@ -1582,6 +1682,7 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: nextSession.history.length }); } catch (e) { + trackPlayError("scene", e, replayT0); setError(e instanceof Error ? e.message : String(e)); setPhase("ready"); } @@ -1734,6 +1835,7 @@ function PlayInner() { text_length: text.length, }); + const freeformT0 = Date.now(); setPhase("vision-thinking"); try { @@ -1822,6 +1924,7 @@ function PlayInner() { setPendingClick(null); void performSceneTransition(promise, exit, visited, decision.freeformAction); } catch (e) { + trackPlayError("freeform", e, freeformT0); setError(String(e)); setPhase("ready"); } @@ -1830,6 +1933,7 @@ function PlayInner() { async function onBackgroundClick(click: { x: number; y: number }) { if (phase !== "ready" || !session || !currentScene || !imageUrl) return; if (replayActiveRef.current) detachRecordedReplay(); + const visionT0 = Date.now(); setPhase("vision-thinking"); setPendingClick(click); @@ -1927,6 +2031,7 @@ function PlayInner() { ); } } catch (e) { + trackPlayError("vision", e, visionT0); setError(String(e)); setPendingClick(null); setPhase("ready"); diff --git a/lib/analytics.ts b/lib/analytics.ts index fb89fb3..e527aa0 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -54,6 +54,20 @@ type AnalyticsEventData = { fullscreen_toggle: { on: boolean }; play_heartbeat: never; gallery_export: { scene_count: number; audio_count: number }; + play_error: { + source: "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch"; + kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown"; + http_status: number; + orientation: "portrait" | "landscape"; + connection: "4g" | "3g" | "2g" | "slow-2g" | "unknown"; + was_hidden: boolean; + scene_index: number; + elapsed_bucket: "<5s" | "5-30s" | "30-60s" | "60-120s" | "120s+"; + }; + play_visibility_lost: { + phase: "loading-first" | "ready" | "transitioning" | "vision-thinking" | "inserting-beat"; + had_pending_fetch: boolean; + }; }; export type AnalyticsEvent = keyof AnalyticsEventData; From ccdb4780d62d0e9f143e9e6f1b1a0d2e2ca90b9c Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 19:09:04 +0800 Subject: [PATCH 4/4] fix(play): throw AbortError on cancelled prefetch to avoid false analytics Co-Authored-By: Claude Fable 5 --- app/play/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/play/page.tsx b/app/play/page.tsx index fa13a57..f3d11f1 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -375,7 +375,7 @@ function prefetchScenePath( const prefetchT0 = Date.now(); const promise = (async () => { const data = await requestScene({ session: specSession, clientTts }); - if (abort.signal.aborted) throw new Error("aborted"); + if (abort.signal.aborted) throw new DOMException("aborted", "AbortError"); // Record this resolved alternate for the gallery export. Key is // (parent scene id at the choice point) : (choice id). Includes the