"use client"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { PlayCanvas, type Phase, } from "@/components/PlayCanvas"; import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal"; import type { GalleryDoc, GalleryScene } from "@/app/gallery/page"; import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; import { annotateClick } from "@/lib/annotateClient"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { collectBeatAudioForExport } from "@/lib/exportAudio"; import { PRESETS } from "@/lib/presets"; import { STORY_SHARE_STORAGE_KEY, createStoryShareDoc, parseStoryShareDoc, storyShareFilename, } from "@/lib/storyShare"; import { provisionVoice, synthesize } from "@infiplot/tts-client"; import { startSession, requestScene, visionDecide, classifyFreeform, requestInsertBeat, AuthRequiredError, } from "@/lib/engineClient"; import type { Beat, BeatChoice, Character, CharacterVoice, Orientation, Scene, SceneExit, SceneResponse, Session, StartResponse, TtsConfig, } from "@infiplot/types"; import { track } from "@/lib/analytics"; import { AUTH_ENABLED } from "@/lib/supabase/config"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; const MUTED_STORAGE_KEY = "infiplot:muted"; // Mobile-portrait users get a 9:16 scene image painted for them; everyone else // (desktop, tablet, mobile-landscape) keeps the 16:9 landscape image. Only a // touch device (coarse pointer) held upright counts as "portrait" — a mouse // device is always landscape. Detected once and locked for the whole session. function detectOrientation(): Orientation { if (typeof window === "undefined") return "landscape"; const portrait = window.matchMedia("(orientation: portrait)").matches; const coarse = window.matchMedia("(pointer: coarse)").matches; return portrait && coarse ? "portrait" : "landscape"; } // Runs before the browser paints (so it can correct first-frame state without a // visible flash), but useLayoutEffect warns when called during SSR. PlayInner // only ever renders on the client (/play prerenders the Suspense fallback), yet // fall back to useEffect on the server anyway to keep the warning out. const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; // Cap how long we wait for the browser to download + decode a scene image // before giving up and rendering anyway. Runware's CDN is usually <2s for a // 1792×1024 PNG, but over slow links / VPN / strict corp networks the same // download can stretch to 10-20s. The previous 8s ceiling fired in that // window, and because the rendered has no aspect-ratio occupation, the // layout collapsed to a one-pixel-tall sliver until the bytes actually // finished arriving — "等了很久 → 一根线 → 突然出图" of the original report. // 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(): // // 1. DIRECT (default — no proxy configured): preload the URL with an // Image() + decode() so the HTTP cache is warm and the bitmap decoded // before React commits, then hand the ORIGINAL URL to . This is the // long-standing behavior; deployers who set no env var get exactly this // and are completely unaffected by the proxy machinery below. // // 2. PROXY (opt-in — NEXT_PUBLIC_IMAGE_PROXY_URL set, host allow-listed): // fetch the bytes through the Cloudflare Worker (which adds CORS and // serves over stable HTTP/2), await the FULL body via .blob(), materialize // a blob: URL over that local copy, and hand THAT to . The // never sees a network-backed src, so there's no "字节还在路上" middle // state and no progressive paint. // Why it matters: Chrome's direct fetch of im.runware.ai sometimes hits // ERR_QUIC_PROTOCOL_ERROR mid-stream, leaving partial PNG bytes that // paint row-by-row. The Worker re-fetches server-to-server (no QUIC // fragility) and serves over HTTP/2 — atomic and reliable. Trade-off: // callers MUST revoke the blob URL when swapping it out (revokeBlobUrlFor) // or the bytes leak in the JS heap. // // Data URIs (MOCK_IMAGE mode) are already local; passed through unchanged // on both paths. blobUrlCache is keyed by the ORIGINAL URL either way. // ────────────────────────────────────────────────────────────────────── // Direct-path preload: decode the URL in memory before committing to React // state, so when the mounts the cache is warm and first paint is // instant. Errors / timeouts resolve quietly — better a broken than a // hung play loop. (im.runware.ai sends no CORS header, so we can't fetch() // its bytes here; warming + decoding is the most the direct path can do.) function preloadImage(url: string): Promise { return new Promise((resolve) => { const img = new Image(); let timer: ReturnType; // Single exit: clear the timeout and resolve. resolve() is idempotent, so // whichever path fires first (load+decode, error, timeout) wins. const done = () => { clearTimeout(timer); resolve(); }; // Armed across BOTH network load and decode, so a hung decode still // resolves quietly — better a broken than a stuck play loop. timer = setTimeout(done, IMAGE_PRELOAD_TIMEOUT_MS); img.onload = () => { // .decode() forces the bitmap to be fully decoded before we proceed — // without it, a slow decode could still cause a flash on first paint. img.decode().then(done, done); }; img.onerror = done; img.src = url; }); } // Opt-in Cloudflare Workers proxy (deploy your own — see the link in README). // Inlined by Next.js at build time. Empty / unset → no proxy → every URL takes // the direct path above, exactly as if this feature didn't exist. const IMAGE_PROXY_BASE = ( process.env.NEXT_PUBLIC_IMAGE_PROXY_URL ?? "" ).replace(/\/$/, ""); // Hostnames eligible for the proxy. Default: Runware's CDN only. Deployers who // point IMAGE_BASE_URL at another provider can opt that provider's image host // in via NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS (comma-separated). Inlined at // build time. Anything not on this list stays on the direct path. const IMAGE_PROXY_ALLOWED_HOSTS = ( process.env.NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS ?? "im.runware.ai" ) .split(",") .map((h) => h.trim().toLowerCase()) .filter(Boolean); // Route a URL through the proxy only when a proxy is configured AND it's a // remote http(s) image on an allow-listed host. data: URIs (MOCK_IMAGE) are // already local; malformed URLs and any other origin fall through to direct. function shouldProxy(originalUrl: string): boolean { if (!IMAGE_PROXY_BASE) return false; if (originalUrl.startsWith("data:")) return false; try { const { protocol, hostname } = new URL(originalUrl); if (protocol !== "https:" && protocol !== "http:") return false; return IMAGE_PROXY_ALLOWED_HOSTS.includes(hostname.toLowerCase()); } catch { return false; } } function proxiedImageUrl(originalUrl: string): string { return `${IMAGE_PROXY_BASE}/?url=${encodeURIComponent(originalUrl)}`; } async function fetchImageAsBlobUrl(url: string): Promise { if (url.startsWith("data:")) return url; // Direct path (default): warm the cache + decode, hand back the original // URL. No fetch() — im.runware.ai has no CORS, so fetch().blob() would throw. if (!shouldProxy(url)) { await preloadImage(url); return url; } // Proxy path (opt-in): fetch through the Worker and materialize a blob: URL. // On error / timeout fall back to the original URL so still tries // (possible progressive paint — same as the direct path, never worse). const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), IMAGE_PRELOAD_TIMEOUT_MS); try { const r = await fetch(proxiedImageUrl(url), { signal: ctrl.signal }); if (!r.ok) return url; const blob = await r.blob(); return URL.createObjectURL(blob); } catch { return url; } finally { clearTimeout(timer); } } // Module-level cache so speculative prefetches and the eventual commit share // the same in-flight fetch — no double-download per scene. Keyed by the // ORIGINAL CDN URL (the blob: URL it resolves to is the value). Persists for // the page's lifetime; entries are explicitly revoked when the scene swaps. const blobUrlCache = new Map>(); function getOrCreateBlobUrl(originalUrl: string): Promise { let p = blobUrlCache.get(originalUrl); if (!p) { p = fetchImageAsBlobUrl(originalUrl); blobUrlCache.set(originalUrl, p); } return p; } function revokeBlobUrlFor(originalUrl: string): void { const p = blobUrlCache.get(originalUrl); if (!p) return; blobUrlCache.delete(originalUrl); p.then((u) => { if (u.startsWith("blob:")) URL.revokeObjectURL(u); }).catch(() => {}); } // ────────────────────────────────────────────────────────────────────── // Prefetch pool — speculative SceneResponses keyed by choice path. // // Key format: "C1" → reached by choosing C1 from current scene. // "C1/C2" → after C1, then C2 (recursive must-pass prefetch). // // When the player picks a change-scene choice, we keep that key's // descendants (re-rooted) and abort the rest. // ────────────────────────────────────────────────────────────────────── const PREFETCH_MAX_DEPTH = 3; type PrefetchEntry = { promise: Promise; abort: AbortController; }; type ScenePathStep = { fromScene: Scene; fromVisitedBeats: string[]; exit: { choiceId: string; label: string; nextSceneSeed: string }; }; function buildDialogueHistory( session: Session | null, ): DialogueHistoryItem[] { if (!session) return []; return session.history.flatMap((entry, sceneIndex) => { const beatsById = new Map(entry.scene.beats.map((b) => [b.id, b])); const visitedBeatIds = entry.visitedBeatIds; return visitedBeatIds.flatMap((beatId, beatIndex) => { const beat = beatsById.get(beatId); if (!beat) return []; const nextVisitedBeatId = visitedBeatIds[beatIndex + 1]; const choice = beat.next.type === "choice" ? beat.next.choices.find((c) => { if (c.effect.kind === "advance-beat") { return c.effect.targetBeatId === nextVisitedBeatId; } return ( beatIndex === visitedBeatIds.length - 1 && entry.exit?.kind === "choice" && c.id === entry.exit.choiceId ); }) : undefined; const freeformAction = beatIndex === visitedBeatIds.length - 1 && entry.exit?.kind === "freeform" ? entry.exit.action : undefined; const body = beat.speaker ? beat.line : beat.narration; const narration = beat.speaker ? beat.narration : undefined; if (!body && !narration && !choice && !freeformAction) return []; return [ { id: `${sceneIndex}:${beatId}:${beatIndex}`, sceneIndex: sceneIndex + 1, speaker: beat.speaker, body, narration, selectedChoice: choice?.label, freeformAction, }, ]; }); }); } function pathKey(steps: ScenePathStep[]): string { return steps.map((s) => s.exit.choiceId).join("/"); } function buildSpeculativeSession( base: Session, steps: ScenePathStep[], ): Session { // Drop base's current (last) entry and re-add each step's `fromScene` with // its exit set. Final result has `history.length = base.length - 1 + steps.length`. const newHistory = [...base.history.slice(0, -1)]; for (const step of steps) { newHistory.push({ scene: step.fromScene, visitedBeatIds: step.fromVisitedBeats, exit: { kind: "choice", choiceId: step.exit.choiceId, label: step.exit.label, nextSceneSeed: step.exit.nextSceneSeed, }, }); } return { ...base, history: newHistory }; } function findAllChangeSceneChoices(scene: Scene): BeatChoice[] { const result: BeatChoice[] = []; const seen = new Set(); for (const b of scene.beats) { if (b.next.type === "choice") { for (const c of b.next.choices) { if (c.effect.kind === "change-scene" && !seen.has(c.id)) { seen.add(c.id); result.push(c); } } } } return result; } function findSoleChangeSceneChoice(scene: Scene): BeatChoice | null { const all = findAllChangeSceneChoices(scene); return all.length === 1 ? all[0]! : null; } function prefetchScenePath( pool: Map, // Resolved-prefetch sink for the gallery export. Every successful resolve // is recorded here keyed by `${parentSceneId}:${choiceId}` so the gallery // can let the player click any choice whose alternate the AI already paid // to generate — even ones that were later abandoned mid-play because the // player took a different branch. Survives `consumeChoice`'s abort sweep: // a prefetch that's already resolved when its parent choice is abandoned // still leaves the result here. resolvedSink: Map, baseSession: Session, steps: ScenePathStep[], depth: number, clientTts: boolean, ): void { if (depth >= PREFETCH_MAX_DEPTH) return; const key = pathKey(steps); if (pool.has(key)) return; 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 DOMException("aborted", "AbortError"); // Record this resolved alternate for the gallery export. Key is // (parent scene id at the choice point) : (choice id). Includes the // CDN imageUrl on the Scene so the gallery has everything it needs to // render without any further info from the engine. const lastStep = steps[steps.length - 1]!; resolvedSink.set(`${lastStep.fromScene.id}:${lastStep.exit.choiceId}`, { ...data.scene, imageUrl: data.imageUrl, }); // Kick off the blob fetch for this URL so when the player eventually // picks this choice, transitioning is a no-op cache lookup instead of a // fresh CDN download. Don't await — let it run in the background; the // transition path awaits the same cached promise via getOrCreateBlobUrl. void getOrCreateBlobUrl(data.imageUrl); // Recursive: if the resulting scene has exactly one change-scene exit, // it is a must-pass node — prefetch its child too. if (depth + 1 < PREFETCH_MAX_DEPTH) { const sole = findSoleChangeSceneChoice(data.scene); if (sole && sole.effect.kind === "change-scene") { const nextStep: ScenePathStep = { fromScene: data.scene, fromVisitedBeats: [data.scene.entryBeatId], exit: { choiceId: sole.id, label: sole.label, nextSceneSeed: sole.effect.nextSceneSeed, }, }; // Carry forward the registry that the parent prefetch result already // settled (it may include characters introduced by the intermediate // scene). Without this, the L2+ prefetch starts from the original // base.characters and a later transition through this survivor would // silently drop voices the player has already heard. const carriedBase: Session = { ...baseSession, characters: data.characters, storyState: data.storyState, }; prefetchScenePath( pool, resolvedSink, carriedBase, [...steps, nextStep], depth + 1, clientTts, ); } } return data; })(); 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 }); } function consumeChoice( pool: Map, choiceId: string, ): PrefetchEntry | undefined { const my = pool.get(choiceId); const survivors = new Map(); for (const [key, entry] of pool) { if (key === choiceId) continue; if (key.startsWith(choiceId + "/")) { survivors.set(key.slice(choiceId.length + 1), entry); } else { entry.abort.abort(); } } pool.clear(); for (const [k, e] of survivors) pool.set(k, e); return my; } function clearPool(pool: Map): void { for (const e of pool.values()) e.abort.abort(); pool.clear(); } // ────────────────────────────────────────────────────────────────────── // BYO voice resolution (client-direct Xiaomi TTS). // // In BYO mode the server skips all TTS (clientTts:true), so the browser must // obtain each speaker's reference audio itself. `cache` is keyed by character // NAME and persists for the whole session, so a voice locked in on a // character's first speaking beat stays identical across every later scene — // even though /api/scene returns its characters without `.voice`. Storing the // in-flight Promise (not the resolved value) dedupes the burst of concurrent // beats by the same speaker into ONE voicedesign call, which matters because // Xiaomi rate-limits voicedesign hard. // ────────────────────────────────────────────────────────────────────── async function resolveByoVoice( cache: Map>, cfg: TtsConfig, speaker: Character, ): Promise { const cached = cache.get(speaker.name); if (cached) return cached; // Prebaked cards ship baked reference audio — reuse it directly (cross-key // synth with the user's key works), keeping the prebaked voice identical. if (speaker.voice) { const ready = Promise.resolve(speaker.voice); cache.set(speaker.name, ready); return ready; } if (!speaker.voiceDescription) return null; const p = provisionVoice(cfg, speaker.voiceDescription, speaker.name); cache.set(speaker.name, p); try { return await p; } catch (e) { cache.delete(speaker.name); // failed provision — let a later beat retry throw e; } } // ── 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 // ────────────────────────────────────────────────────────────────────── function PlayInner() { const router = useRouter(); const params = useSearchParams(); const [phase, setPhase] = useState("loading-first"); const [session, setSession] = useState(null); const [currentScene, setCurrentScene] = useState(null); const [currentBeatId, setCurrentBeatId] = useState(null); const [imageUrl, setImageUrl] = useState(null); const [beatAudioMap, setBeatAudioMap] = useState>({}); // Lazy-initialize 优先级:本局选择(homepage 的「语音配音」存到 sessionStorage:infiplot:custom) // > 上次会话的粘性偏好(localStorage:infiplot:muted) > 默认非静音。 // 这样首页选了「关闭」开始游戏,进来就是静音;选「开启」就不是静音;进入 play 页后用户自己 // 切换 静音/有声 时再用 localStorage 持久化,下一局开新游戏 sessionStorage 选择会再覆盖。 const [muted, setMuted] = useState(() => { if (typeof window === "undefined") return false; try { const stored = window.sessionStorage.getItem("infiplot:custom"); if (stored) { const parsed = JSON.parse(stored) as { audioEnabled?: boolean }; if (typeof parsed.audioEnabled === "boolean") { return !parsed.audioEnabled; } } return window.localStorage.getItem(MUTED_STORAGE_KEY) === "1"; } catch { return false; } }); const [pendingClick, setPendingClick] = useState<{ x: number; y: number; } | null>(null); const [error, setError] = useState(null); const [presentation, setPresentation] = useState(false); // Session-locked image orientation (see detectOrientation). "portrait" makes // the whole play surface render full-bleed vertical on phones. const [orientation, setOrientation] = useState("landscape"); const [lastExitLabel, setLastExitLabel] = useState(null); // Consecutive server-side TTS misses (null audio / failed /api/beat-audio). const [settingsOpen, setSettingsOpen] = useState(false); const [visionClickEnabled, setVisionClickEnabled] = useState(true); const [authModalOpen, setAuthModalOpen] = useState(false); const authResolveRef = useRef<(() => void) | null>(null); // Top-of-screen progress toast for the gallery / story export pipeline. // null when idle; { done, total, label } while collecting beat audio. const [exportProgress, setExportProgress] = useState< { done: number; total: number; label: string } | null >(null); // `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess // after the user signs in. Omitted by callers whose path can't actually 401 // (initial load already gated on the homepage, recorded replay is local). const handleAuthError = useCallback( (e: unknown, retry?: () => void): boolean => { if (e instanceof AuthRequiredError) { authResolveRef.current = retry ?? null; setAuthModalOpen(true); return true; } return false; }, [], ); const startedRef = useRef(false); const poolRef = useRef>(new Map()); // Accumulator for resolved prefetches across the whole session — every // `prefetchScenePath` resolution writes here, keyed by parent-scene + choice. // Survives `consumeChoice`'s pool sweep (an already-resolved promise is not // un-resolved by aborting its controller), so abandoned alternates remain // available for the gallery export. Cleared only on unmount. const resolvedPrefetchesRef = useRef>(new Map()); // Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene // changes so stale in-flight requests can't poison the new scene's map // (beat ids like "b1" are scene-local and would collide across scenes). const beatAudioAbortRef = useRef>(new Map()); // Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on // it. Muting stops TTS *synthesis*, not just playback — TTS is the only sound // source, so synthesizing audio the user can't hear just burns quota. // 首页「语音配音 关闭」会把 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 // directly against Xiaomi — the key never touches our server — and every // start/scene/insert-beat request carries clientTts:true so the engine skips // server-side TTS. null = user hasn't opted in (server default / silent). const [byoTtsConfig, setByoTtsConfig] = useState(() => loadClientTtsConfig(), ); const byoTtsRef = useRef(byoTtsConfig); // BYO voice cache (see resolveByoVoice). Keyed by character name; persists // across scenes so each speaker is provisioned at most once per session. const provisionedVoicesRef = useRef>>( new Map(), ); // Mirrors for use inside async handlers (closure-stable) const sessionRef = useRef(null); const currentSceneRef = useRef(null); const currentBeatRef = useRef(null); const visitedBeatsRef = useRef([]); const replaySourceRef = useRef(null); const replayIndexRef = useRef(-1); const replayActiveRef = useRef(false); const exportingStoryRef = useRef(false); const exportingGalleryRef = useRef(false); // Audio carried in from a `.infiplot` share file, keyed by `${sceneId}:${beatId}`. // Survives scene swaps so a player who re-exports a replayed game keeps the // baked voices that the original creator already paid to synth — they're // free to embed back into the new gallery / share file. const prebakedAudioRef = useRef>({}); // Original (CDN) URL of the currently-rendered scene image. Used as the key // to revoke its blob: URL when the scene swaps. We track the ORIGINAL URL, // 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; }, [currentScene, currentBeatId]); const dialogueHistory = useMemo( () => buildDialogueHistory(session), [session], ); const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null; useEffect(() => { sessionRef.current = session; }, [session]); useEffect(() => { currentSceneRef.current = currentScene; }, [currentScene]); useEffect(() => { currentBeatRef.current = currentBeat; }, [currentBeat]); 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 // engaged time. Content-free — no payload. The interval is never even // scheduled unless the tracker is configured, so it's zero work when off. useEffect(() => { if (!process.env.NEXT_PUBLIC_UMAMI_SRC || !process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) { return; } const id = window.setInterval(() => { if (document.visibilityState === "visible") track("play_heartbeat"); }, 30_000); 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; if (visitedBeatsRef.current.at(-1) === currentBeatId) return; visitedBeatsRef.current = [...visitedBeatsRef.current, currentBeatId]; setSession((s) => { if (!s) return s; return { ...s, history: s.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: [...visitedBeatsRef.current] } : h, ), }; }); }, [currentBeatId]); // ── Lazy per-beat audio fetch ──────────────────────────────────────── // Returns silently on any failure — the UI never waits for audio, so a // null result just means that beat plays without voice. // Sends only the speaker's voice + the line to speak — NOT the whole // session — so the per-beat payload stays small even with many characters // (each voice.referenceAudioBase64 is ~160KB). const fetchBeatAudio = useCallback( async ( sess: Session, beat: { id: string; speaker?: string; line?: string; lineDelivery?: string }, ): Promise => { if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)。 // 「首页选关闭」也走这条路:bootstrap 时 muted 已被初始化为 true。 if (!beat.speaker || !beat.line) return; // Reuse pre-baked audio from a `.infiplot` import before any synth — // free, instant, and identical to what the original player heard. const curSceneId = currentSceneRef.current?.id; if (curSceneId) { const baked = prebakedAudioRef.current[`${curSceneId}:${beat.id}`]; if (baked) { setBeatAudioMap((m) => (m[beat.id] === baked ? m : { ...m, [beat.id]: baked })); return; } } const speaker = sess.characters.find((c) => c.name === beat.speaker); if (!speaker) return; const byo = byoTtsRef.current; // Non-BYO relies on the server having provisioned speaker.voice. BYO // skipped server TTS, so it needs a baked voice (prebaked card) or a // voiceDescription to provision from in the browser. if (!byo && !speaker.voice) return; if (byo && !speaker.voice && !speaker.voiceDescription) return; if (beatAudioAbortRef.current.has(beat.id)) return; const abort = new AbortController(); beatAudioAbortRef.current.set(beat.id, abort); try { let audioUrl: string | null = null; if (byo) { // Client-direct: provision (once per speaker, cached) + synth against // Xiaomi with the user's own key — the key never touches our server. const voice = await resolveByoVoice( provisionedVoicesRef.current, byo, speaker, ); if (!voice || abort.signal.aborted) return; const out = await synthesize( byo, voice, beat.line, beat.lineDelivery, abort.signal, ); audioUrl = `data:${out.mimeType};base64,${out.audioBase64}`; } else { // No TTS configured — silent. return; } // Skip the state write if we've been aborted between the await and // here — beat ids are scene-local, so a late arrival from a prior // scene would otherwise overwrite the current scene's audio under the // same id. if (audioUrl && !abort.signal.aborted) { setBeatAudioMap((m) => ({ ...m, [beat.id]: audioUrl })); } } catch { // aborted / network / Xiaomi rate-limit — silent fallback (no audio) } finally { // Only clear the slot if it's still ours. An aborted prior fetch // running its finally late could otherwise delete the controller of a // new fetch that took the same beat id, leaving the new one // unabortable on the next scene change. if (beatAudioAbortRef.current.get(beat.id) === abort) { beatAudioAbortRef.current.delete(beat.id); } } }, [], ); function cancelBeatAudioFetches(): void { for (const c of beatAudioAbortRef.current.values()) c.abort(); beatAudioAbortRef.current.clear(); } // Fire one /api/beat-audio request per speaking beat in the current scene. // Reads refs (not props) so it stays closure-stable and can be re-run on // un-mute as well as on scene change. const prefetchSceneAudio = useCallback(() => { const scene = currentSceneRef.current; const sess = sessionRef.current; if (!scene || !sess) return; for (const b of scene.beats) { if (b.speaker && b.line) void fetchBeatAudio(sess, b); } }, [fetchBeatAudio]); // (Re)synthesize each time the scene changes. Cancel any in-flight requests // from the prior scene first — beat ids are scene-local ("b1" repeats across // scenes) so a late arrival would land under the wrong beat otherwise. useEffect(() => { cancelBeatAudioFetches(); setBeatAudioMap((prev) => { for (const url of Object.values(prev)) { if (url.startsWith("blob:")) URL.revokeObjectURL(url); } return {}; }); prefetchSceneAudio(); }, [currentScene?.id, prefetchSceneAudio]); // ── Mute persistence (read is via the useState lazy initializer above) ─ const toggleMuted = useCallback(() => { track("tts_toggle", { muted: !mutedRef.current }); setMuted((prev) => { const next = !prev; try { window.localStorage.setItem(MUTED_STORAGE_KEY, next ? "1" : "0"); } catch { // ignore } return next; }); }, []); // Muting stops synthesis, not just playback: abort in-flight requests when // muting. When un-muting, re-synthesize the current scene — fetchBeatAudio // skips synthesis while muted, so a scene entered muted has no audio to play // back otherwise. (Clearing the map re-synthesizes already-fetched beats on a // mid-scene un-mute, but that's bounded to one scene and a rare toggle.) // // Gate on actual mute *transitions*: on mount this effect would otherwise // fire alongside the scene effect above (both call prefetchSceneAudio), // doubling the initial /api/beat-audio batch — the first set is dispatched // only to be aborted mid-flight, burning TTS quota. const prevMutedRef = useRef(muted); useEffect(() => { const prev = prevMutedRef.current; prevMutedRef.current = muted; if (prev === muted) return; cancelBeatAudioFetches(); if (muted) return; setBeatAudioMap((prev) => { for (const url of Object.values(prev)) { if (url.startsWith("blob:")) URL.revokeObjectURL(url); } return {}; }); prefetchSceneAudio(); }, [muted, prefetchSceneAudio]); const handleSettingsSaved = useCallback( (settings: { playerName: string; visionClickEnabled: boolean; ttsConfigured: boolean }) => { setVisionClickEnabled(settings.visionClickEnabled); const nextPlayerName = settings.playerName || undefined; setSession((prev) => prev ? { ...prev, playerName: nextPlayerName } : prev); }, [], ); function detachRecordedReplay(): void { replayActiveRef.current = false; replaySourceRef.current = null; replayIndexRef.current = -1; clearPool(poolRef.current); } // ── Export to interactive gallery (PPT-style replay) ───────────────── // Drop all but the (keepCount) most-recent gallery exports from localStorage, // ordered by their stored createdAt. Called right before writing a new // export so the cap is enforced strictly (≤ keepCount + 1 transiently → ≤ N // once write completes). Corrupt entries (un-parseable / no createdAt) sort // last and get evicted first. // // Audio lives in a sidecar key `infiplot:gallery::audio` so the main // doc JSON.parse on gallery load doesn't block the main thread with several // MB of base64. The sidecar key inherits its doc's age — paired by id, not // its own createdAt (it never has one) — and is evicted alongside its doc. const trimGalleryExports = useCallback((keepCount: number) => { try { const prefix = "infiplot:gallery:"; const audioSuffix = ":audio"; const docs: Map = new Map(); const sidecars: Map = new Map(); for (let i = 0; i < window.localStorage.length; i++) { const k = window.localStorage.key(i); if (!k || !k.startsWith(prefix)) continue; if (k.endsWith(audioSuffix)) { const id = k.slice(prefix.length, -audioSuffix.length); sidecars.set(id, k); continue; } const id = k.slice(prefix.length); let createdAt = 0; try { const raw = window.localStorage.getItem(k); if (raw) { const parsed = JSON.parse(raw) as { createdAt?: number }; createdAt = parsed.createdAt ?? 0; } } catch { createdAt = 0; } docs.set(id, { key: k, createdAt }); } const ordered = [...docs.entries()].sort( (a, b) => b[1].createdAt - a[1].createdAt, ); for (const [id, { key }] of ordered.slice(keepCount)) { window.localStorage.removeItem(key); const sc = sidecars.get(id); if (sc) window.localStorage.removeItem(sc); sidecars.delete(id); } // Orphan sidecars (their doc was already gone) get cleaned up too. for (const sc of sidecars.values()) { if (!docs.has(sc.slice(prefix.length, -audioSuffix.length))) { window.localStorage.removeItem(sc); } } } catch { // best-effort — quota or disabled storage shouldn't block the export } }, []); // Strips the live Session to a small GalleryDoc — only scene images + // dialogue text + recorded choices, no voice base64 / portraits / style // reference (those are tens-to-hundreds of KB each). Writes it to // localStorage under a one-shot id and opens /gallery# in a new tab // so the play session keeps running. // // Beat audio is collected synchronously here (reusing the per-scene // beatAudioMap when possible, BYO / server TTS for the rest) and stashed // in a sidecar localStorage key so the gallery's first paint isn't // bottlenecked on JSON.parse-ing several MB of base64. const handleExportGallery = useCallback(async () => { const s = sessionRef.current; if (!s || exportingGalleryRef.current) return; exportingGalleryRef.current = true; const scenes: GalleryScene[] = s.history .map((h) => ({ id: h.scene.id, imageUrl: h.scene.imageUrl ?? "", sceneKey: h.scene.sceneKey, orientation: h.scene.orientation, beats: h.scene.beats, entryBeatId: h.scene.entryBeatId, visitedBeatIds: h.visitedBeatIds, exit: h.exit, })) .filter((sc) => sc.imageUrl); if (scenes.length === 0) { exportingGalleryRef.current = false; return; } // Alternates: ${parentSceneId}:${choiceId} → reachable scene. Two sources, // merged with main-path winning ties (it always agrees with prefetch when // prefetch was actually used, so the override is a no-op in the common case; // it differs only when the player took a cold path and the prefetch had // resolved to something the engine later regenerated): // 1. Every resolved prefetch (including alternates the player never took) // 2. Main path: every history step's choice exit → the next visited scene const alternates: Record = {}; for (const [key, scene] of resolvedPrefetchesRef.current) { if (!scene.imageUrl) continue; alternates[key] = { id: scene.id, imageUrl: scene.imageUrl, sceneKey: scene.sceneKey, orientation: scene.orientation, beats: scene.beats, entryBeatId: scene.entryBeatId, }; } for (let i = 0; i < s.history.length - 1; i++) { const h = s.history[i]!; const nextH = s.history[i + 1]!; if ( h.exit?.kind === "choice" && h.scene.id && nextH.scene.imageUrl ) { alternates[`${h.scene.id}:${h.exit.choiceId}`] = { id: nextH.scene.id, imageUrl: nextH.scene.imageUrl, sceneKey: nextH.scene.sceneKey, orientation: nextH.scene.orientation, beats: nextH.scene.beats, entryBeatId: nextH.scene.entryBeatId, }; } } // Character portraits — names + CDN URLs only. The big voice base64s are // intentionally dropped (the gallery only needs the portraits for download). const characters = s.characters .filter((c) => c.basePortraitUrl) .map((c) => ({ name: c.name, basePortraitUrl: c.basePortraitUrl as string, })); const id = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; let audioByBeatId: Record = {}; try { setExportProgress({ done: 0, total: 0, label: "正在准备配音" }); audioByBeatId = await collectBeatAudioForExport({ session: s, beatAudioMap, currentSceneId: currentSceneRef.current?.id ?? null, byoTts: byoTtsRef.current, byoVoiceCache: provisionedVoicesRef.current, prebakedAudio: prebakedAudioRef.current, onProgress: (done, total) => setExportProgress({ done, total, label: "正在准备配音" }), }); } catch { // best-effort — even if the collector throws, the gallery without audio // is still usable; we keep going rather than block the export. } finally { setExportProgress(null); } const doc: GalleryDoc = { v: audioByBeatId && Object.keys(audioByBeatId).length > 0 ? 3 : 2, id, createdAt: Date.now(), orientation: s.orientation ?? "landscape", scenes, alternates, characters, }; // Cap retained gallery exports at the most recent 2. Drop everything // older BEFORE writing the new doc so we never transiently exceed the cap // (and so a near-quota localStorage has headroom for the new entry). trimGalleryExports(1); const docStr = JSON.stringify(doc); try { window.localStorage.setItem(`infiplot:gallery:${id}`, docStr); } catch { // localStorage full or disabled — silently bail; the player keeps playing. exportingGalleryRef.current = false; return; } const audioCount = Object.keys(audioByBeatId).length; if (audioCount > 0) { try { window.localStorage.setItem( `infiplot:gallery:${id}:audio`, JSON.stringify(audioByBeatId), ); } catch { // Sidecar too big for quota — gallery still opens without sound. } } track("gallery_export", { scene_count: scenes.length, audio_count: audioCount }); window.open(`/gallery#id=${id}`, "_blank", "noopener"); exportingGalleryRef.current = false; }, [beatAudioMap, trimGalleryExports]); const handleExportStory = useCallback(async () => { const s = sessionRef.current; if (!s || s.history.length === 0 || exportingStoryRef.current) return; exportingStoryRef.current = true; const sceneIndex = Math.max(0, s.history.length - 1); let audioByBeatId: Record = {}; try { setExportProgress({ done: 0, total: 0, label: "正在准备配音" }); audioByBeatId = await collectBeatAudioForExport({ session: s, beatAudioMap, currentSceneId: currentSceneRef.current?.id ?? null, byoTts: byoTtsRef.current, byoVoiceCache: provisionedVoicesRef.current, prebakedAudio: prebakedAudioRef.current, onProgress: (done, total) => setExportProgress({ done, total, label: "正在准备配音" }), }); } catch { // best-effort — share the doc silent if collecting audio failed } finally { setExportProgress(null); } const doc = createStoryShareDoc( s, { sceneIndex, beatId: currentBeatRef.current?.id ?? s.history[sceneIndex]?.scene.entryBeatId, }, Object.keys(audioByBeatId).length > 0 ? audioByBeatId : undefined, ); try { const r = await fetch("/api/story-pack", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ docStr: JSON.stringify(doc) }), }); if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; window.alert(j.error ?? "剧情分享打包失败"); return; } const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = storyShareFilename(doc); a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); } catch { window.alert("剧情分享打包失败"); } finally { exportingStoryRef.current = false; } }, [beatAudioMap]); // ── Presentation mode toggle ───────────────────────────────────────── const togglePresentation = useCallback(async () => { const entering = !presentation; track("fullscreen_toggle", { on: entering }); if (entering) { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen(); } } catch { // ignore — fall through to chrome-less mode anyway } setPresentation(true); } else { try { if (document.fullscreenElement) await document.exitFullscreen(); } catch { // ignore } setPresentation(false); } }, [presentation]); useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "f" || e.key === "F") { if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); void togglePresentation(); } else if (e.key === "Escape" && presentation) { setPresentation(false); } } function onFullscreenChange() { if (!document.fullscreenElement && presentation) setPresentation(false); } window.addEventListener("keydown", onKey); document.addEventListener("fullscreenchange", onFullscreenChange); return () => { window.removeEventListener("keydown", onKey); document.removeEventListener("fullscreenchange", onFullscreenChange); }; }, [togglePresentation, presentation]); // Lock the visible orientation BEFORE the first paint, so portrait phones // never flash the landscape loading chrome. The state inits to "landscape" // for SSR-safety; this corrects it pre-paint (no-op re-render on landscape // devices). The bootstrap effect below re-derives the same value for the // /api/start payload. useIsomorphicLayoutEffect(() => { setOrientation(detectOrientation()); }, [params]); // ── Bootstrap: start session ───────────────────────────────────────── useEffect(() => { if (startedRef.current) return; startedRef.current = true; // 三条进入路径: // ?card= → 首页精选卡,直接从 /home/firstact/{name}.json // 静态文件加载(已在构建期 prebake,免一切引擎调用) // ?preset= → 内置 PRESETS(仍走 /api/start 现场生成) // ?custom=1 → 用户自定义 prompt,sessionStorage 取 ws/sg // 后走 /api/start 现场生成 // ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放 const cardName = params.get("card"); const presetId = params.get("preset"); const isCustom = params.get("custom") === "1"; const isShare = params.get("share") === "1"; if (isShare) { (async () => { const t0 = Date.now(); try { const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY); if (!raw) throw new Error("没有找到要载入的剧情文件。"); const doc = parseStoryShareDoc(JSON.parse(raw)); const imported = doc.session; const first = imported.history[0]; if (!first) throw new Error("剧情分享文件没有可载入的剧情。"); if (!first.scene.imageUrl) throw new Error("剧情分享文件缺少第一幕图片。"); const sessionOrientation = first.scene.orientation ?? imported.orientation ?? detectOrientation(); setOrientation(sessionOrientation); const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl); lastImageOriginalUrlRef.current = first.scene.imageUrl; const initialStoryState = first.storyStateAfter ?? imported.storyState; if (!initialStoryState) throw new Error("剧情分享文件缺少初始剧情记忆,无法载入。"); const initial: Session = { ...imported, history: [ { ...first, visitedBeatIds: [first.scene.entryBeatId], exit: undefined, }, ], storyState: initialStoryState, orientation: sessionOrientation, }; replaySourceRef.current = imported; replayIndexRef.current = 0; replayActiveRef.current = imported.history.length > 1; visitedBeatsRef.current = [first.scene.entryBeatId]; // Stash pre-baked audio (from doc.audioByBeatId) so it survives scene // swaps and re-exports. Keyed by `${sceneId}:${beatId}`. Also seed the // current beatAudioMap for the first scene so audio plays right away // — the scene-change effect normally clears the map on transition, // and bare beat ids "b1/b2/..." would otherwise miss prebaked entries. if (doc.audioByBeatId) { prebakedAudioRef.current = { ...doc.audioByBeatId }; const seed: Record = {}; for (const beat of first.scene.beats) { const k = `${first.scene.id}:${beat.id}`; const v = doc.audioByBeatId[k]; if (v) seed[beat.id] = v; } if (Object.keys(seed).length > 0) setBeatAudioMap(seed); } 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) { if (!handleAuthError(e)) { trackPlayError("start", e, t0); setError(e instanceof Error ? e.message : String(e)); } } })(); return; } let livePayload: { worldSetting: string; styleGuide: string; styleReferenceImage?: string; orientation?: Orientation; playerName?: string; } | null = null; if (!cardName) { if (presetId) { const p = PRESETS.find((x) => x.id === presetId); if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide, playerName: readStoredPlayerName() || undefined }; } else if (isCustom) { const stored = sessionStorage.getItem("infiplot:custom"); if (stored) { try { const parsed = JSON.parse(stored) as { worldSetting: string; styleGuide: string; audioEnabled?: boolean; styleReferenceImage?: string; playerName?: string; }; livePayload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide, styleReferenceImage: parsed.styleReferenceImage || undefined, playerName: parsed.playerName || undefined, }; // audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。 } catch { livePayload = null; } } } } // Lock orientation for the whole session. Both prebaked-card and live paths // now respect device orientation — portrait prebaked assets live under // firstact-portrait/ and firstscene-portrait/. const sessionOrientation: Orientation = detectOrientation(); if (livePayload) livePayload.orientation = sessionOrientation; if (!cardName && !livePayload) { router.replace("/"); return; } type PrebakedFirstAct = StartResponse & { worldSetting: string; styleGuide: string; // Live /api/start path tags this on after the response (prebaked card // JSONs never have one — they were rendered at build time without any // user-uploaded reference). Carried into Session so /api/scene's painter // anchors the same style image on every subsequent scene. styleReferenceImage?: string; cardName?: string; cardTitle?: string; cardGender?: string; }; const firstactDir = sessionOrientation === "portrait" ? "firstact-portrait" : "firstact"; const startT0 = Date.now(); const fetchStart: Promise = cardName ? fetch(`/home/${firstactDir}/${encodeURIComponent(cardName)}.json`).then( async (r) => { if (r.ok) return (await r.json()) as PrebakedFirstAct; if (sessionOrientation === "portrait") { console.warn(`[play] portrait firstact missing for ${cardName} (HTTP ${r.status}), falling back to landscape`); const fb = await fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`); if (fb.ok) { const fallback = (await fb.json()) as PrebakedFirstAct; return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } }; } } throw new Error(`找不到精选剧情:${cardName}`); }, ) : (async () => { const data = await startSession({ ...livePayload!, clientTts: !!byoTtsRef.current, }); // startSession doesn't echo ws/sg back — splice in what we sent. // styleReferenceImage is similarly not in StartResponse; tag it on so // the session we build below carries it for every scene call. return { ...data, worldSetting: livePayload!.worldSetting, styleGuide: livePayload!.styleGuide, styleReferenceImage: livePayload!.styleReferenceImage, }; })(); fetchStart .then(async (data) => { // Resolve to a paintable src before committing to state. Proxy path: // a fully-local blob: URL the browser paints atomically (no row-by-row // "层层加载"). Direct path (default): the preloaded original URL. const blobUrl = await getOrCreateBlobUrl(data.imageUrl); lastImageOriginalUrlRef.current = data.imageUrl; const initial: Session = { id: data.sessionId, createdAt: Date.now(), worldSetting: data.worldSetting, styleGuide: data.styleGuide, history: [ { scene: data.scene, visitedBeatIds: [data.scene.entryBeatId], storyStateAfter: data.storyState, }, ], characters: data.characters, storyState: data.storyState, styleReferenceImage: data.styleReferenceImage, orientation: data.scene.orientation ?? sessionOrientation, playerName: livePayload?.playerName || readStoredPlayerName() || undefined, }; visitedBeatsRef.current = [data.scene.entryBeatId]; setSession(initial); setCurrentScene(data.scene); setCurrentBeatId(data.scene.entryBeatId); const ready = waitForImageReady(); setImageUrl(blobUrl); await ready; setPhase("ready"); track("scene_reached", { scene_index: initial.history.length }); }) .catch((e) => { if (!handleAuthError(e)) { trackPlayError("start", e, startT0); setError(String(e)); } }); }, [params, router]); // ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ────── useEffect(() => { const s = session; const scene = currentScene; if (!s || !scene) return; if (isRecordedReplayLockedAt(currentBeat)) return; const exits = findAllChangeSceneChoices(scene); for (const choice of exits) { if (choice.effect.kind !== "change-scene") continue; const step: ScenePathStep = { fromScene: scene, // Snapshot of visited beats at prefetch start. Slight drift is OK. fromVisitedBeats: [...visitedBeatsRef.current], exit: { choiceId: choice.id, label: choice.label, nextSceneSeed: choice.effect.nextSceneSeed, }, }; prefetchScenePath( poolRef.current, resolvedPrefetchesRef.current, s, [step], 0, !!byoTtsRef.current, ); } }, [currentScene?.id, session?.id]); // Abort all in-flight speculative prefetches when the page unmounts, so we // stop paying for background scene/image generation. Empty deps → fires only // on unmount; it must NOT run on scene transitions, which rely on // consumeChoice keeping the re-rooted survivor prefetches alive. // Also revoke any surviving blob: URLs so their bytes can be GC'd — the // module-level blobUrlCache outlives the component but its entries should // not survive the page navigation that unmounts us. useEffect(() => { const pool = poolRef.current; const beatAborts = beatAudioAbortRef.current; return () => { clearPool(pool); for (const c of beatAborts.values()) c.abort(); beatAborts.clear(); for (const [originalUrl] of blobUrlCache) { revokeBlobUrlFor(originalUrl); } }; }, []); // ── Handlers ────────────────────────────────────────────────────────── function onAdvance() { if (phase !== "ready") return; const beat = currentBeatRef.current; if (!beat || beat.next.type !== "continue") return; setCurrentBeatId(beat.next.nextBeatId); } async function performSceneTransition( source: PrefetchEntry | Promise, exit: SceneExit, visitedForCurrent: string[], exitLabel: string, retry?: () => void, ) { const sceneT0 = Date.now(); setPhase("transitioning"); setPendingClick(null); try { const result = await ("promise" in source ? source.promise : source); const base = sessionRef.current; if (!base) throw new Error("Session lost mid-transition"); // Pull full image bytes into a local blob: URL before committing. For // prefetched scenes the speculative getOrCreateBlobUrl in // prefetchScenePath already has this in flight (often resolved), so // this is a near-instant cache lookup. For cold transitions we eat the // CDN download / preload time under the "transitioning" overlay. Proxy // path: the then gets a fully-local blob (no progressive paint); // direct path (default): the preloaded original URL. const blobUrl = await getOrCreateBlobUrl(result.imageUrl); // Revoke the previous scene's blob (no longer rendered) to release JS // heap. New scene's original URL takes its place as "current". const priorOriginal = lastImageOriginalUrlRef.current; if (priorOriginal && priorOriginal !== result.imageUrl) { revokeBlobUrlFor(priorOriginal); } lastImageOriginalUrlRef.current = result.imageUrl; const closedHistory = base.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visitedForCurrent, exit } : h, ); const newSession: Session = { ...base, history: [ ...closedHistory, { scene: result.scene, visitedBeatIds: [result.scene.entryBeatId], storyStateAfter: result.storyState, }, ], characters: result.characters, storyState: result.storyState, }; visitedBeatsRef.current = [result.scene.entryBeatId]; setSession(newSession); setCurrentScene(result.scene); setCurrentBeatId(result.scene.entryBeatId); const ready = waitForImageReady(); setImageUrl(blobUrl); setLastExitLabel(exitLabel); await ready; setPhase("ready"); track("scene_reached", { scene_index: newSession.history.length }); } catch (e) { if ((e as { name?: string }).name === "AbortError") { setPhase("ready"); return; } if (!handleAuthError(e, retry)) { trackPlayError("scene", e, sceneT0); setError(String(e)); } setPhase("ready"); } } function tryRecordedSceneTransition( choice: BeatChoice, exit: SceneExit, visitedForCurrent: string[], ): boolean { const source = replaySourceRef.current; const idx = replayIndexRef.current; if (!source || idx < 0 || !isRecordedReplayLockedAt(currentBeatRef.current)) { return false; } const recorded = source.history[idx]; const next = source.history[idx + 1]; if ( !recorded || !next || recorded.exit?.kind !== "choice" || recorded.exit.choiceId !== choice.id ) { detachRecordedReplay(); return false; } void (async () => { const replayT0 = Date.now(); setPhase("transitioning"); setPendingClick(null); try { if (!next.scene.imageUrl) throw new Error("剧情分享文件缺少下一幕图片。"); const blobUrl = await getOrCreateBlobUrl(next.scene.imageUrl); const priorOriginal = lastImageOriginalUrlRef.current; if (priorOriginal && priorOriginal !== next.scene.imageUrl) { revokeBlobUrlFor(priorOriginal); } lastImageOriginalUrlRef.current = next.scene.imageUrl; const base = sessionRef.current; if (!base) throw new Error("Session lost mid-replay"); const closedHistory = base.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visitedForCurrent, exit } : h, ); const nextIndex = idx + 1; const nextSession: Session = { ...base, history: [ ...closedHistory, { ...next, visitedBeatIds: [next.scene.entryBeatId], exit: undefined, }, ], characters: source.characters, storyState: next.storyStateAfter ?? base.storyState, orientation: next.scene.orientation ?? base.orientation, }; replayIndexRef.current = nextIndex; replayActiveRef.current = true; visitedBeatsRef.current = [next.scene.entryBeatId]; 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) { if (!handleAuthError(e)) { trackPlayError("scene", e, replayT0); setError(e instanceof Error ? e.message : String(e)); } setPhase("ready"); } })(); return true; } function recordedAllowedChoiceIds(beat: Beat | null): Set | null { if (!replaySourceRef.current || !beat || beat.next.type !== "choice") return null; const source = replaySourceRef.current; const recorded = source?.history[replayIndexRef.current]; if (!recorded) return new Set(); const visited = recorded.visitedBeatIds; const beatIdx = visited.indexOf(beat.id); if (beatIdx < 0) return null; const nextVisited = beatIdx >= 0 ? visited[beatIdx + 1] : undefined; const allowed = new Set(); if (nextVisited) { for (const choice of beat.next.choices) { if ( choice.effect.kind === "advance-beat" && choice.effect.targetBeatId === nextVisited ) { allowed.add(choice.id); } } return allowed; } if ( beatIdx === visited.length - 1 && recorded.exit?.kind === "choice" && source.history[replayIndexRef.current + 1] ) { allowed.add(recorded.exit.choiceId); return allowed; } return null; } function isRecordedReplayLockedAt(beat: Beat | null): boolean { if (!replaySourceRef.current || !beat) return false; const recorded = replaySourceRef.current.history[replayIndexRef.current]; if (!recorded) return false; const beatIdx = recorded.visitedBeatIds.indexOf(beat.id); if (beatIdx < 0) return false; return Boolean( recorded.visitedBeatIds[beatIdx + 1] || ( beatIdx === recorded.visitedBeatIds.length - 1 && recorded.exit?.kind === "choice" && replaySourceRef.current.history[replayIndexRef.current + 1] ), ); } function isDisabledByRecordedReplay(choice: BeatChoice): boolean { const allowed = recordedAllowedChoiceIds(currentBeatRef.current); return allowed !== null && !allowed.has(choice.id); } function onSelectChoice(choice: BeatChoice) { if (phase !== "ready" || !session || !currentScene) return; if (isDisabledByRecordedReplay(choice)) return; const beatNext = currentBeatRef.current?.next; const choiceIndex = beatNext?.type === "choice" ? beatNext.choices.findIndex((c) => c.id === choice.id) : -1; if (choiceIndex >= 0) { track("choice_select", { scene_index: session.history.length, choice_index: choiceIndex, kind: choice.effect.kind, }); } if (choice.effect.kind === "advance-beat") { if (replayActiveRef.current && currentBeatRef.current) { const source = replaySourceRef.current; const idx = replayIndexRef.current; const recorded = source?.history[idx]; const recordedVisited = recorded?.visitedBeatIds ?? []; const beatIdx = recordedVisited.indexOf(currentBeatRef.current.id); const recordedNext = beatIdx >= 0 ? recordedVisited[beatIdx + 1] : undefined; if (recordedNext && recordedNext !== choice.effect.targetBeatId) { detachRecordedReplay(); } } else if ( replaySourceRef.current && !isRecordedReplayLockedAt(currentBeatRef.current) ) { detachRecordedReplay(); } // Pure local jump. No network. No pool changes. setCurrentBeatId(choice.effect.targetBeatId); return; } const visited = [...visitedBeatsRef.current]; const exit: SceneExit = { kind: "choice", choiceId: choice.id, label: choice.label, nextSceneSeed: choice.effect.nextSceneSeed, }; if (tryRecordedSceneTransition(choice, exit, visited)) return; if (replaySourceRef.current) detachRecordedReplay(); const cached = consumeChoice(poolRef.current, choice.id); if (cached) { void performSceneTransition(cached, exit, visited, choice.label, () => onSelectChoice(choice), ); return; } // Cold path — start a fresh fetch const step: ScenePathStep = { fromScene: currentScene, fromVisitedBeats: visited, exit: { choiceId: choice.id, label: choice.label, nextSceneSeed: choice.effect.nextSceneSeed, }, }; const specSession = buildSpeculativeSession(session, [step]); clearPool(poolRef.current); const promise = (async () => { const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); return data; })(); void performSceneTransition(promise, exit, visited, choice.label, () => onSelectChoice(choice), ); } async function onFreeformInput(text: string) { if (phase !== "ready" || !session || !currentScene) return; if (replayActiveRef.current) detachRecordedReplay(); track("freeform_input", { scene_index: session.history.length, text_length: text.length, }); const freeformT0 = Date.now(); setPhase("vision-thinking"); try { const decision = await classifyFreeform({ session, freeformText: text, }); if (decision.classify === "insert-beat") { // Interactive beat: NPC responds to the player's action, scene stays setPhase("inserting-beat"); const { partial, characters: insertChars } = await requestInsertBeat({ session, freeformAction: decision.freeformAction, clientTts: !!byoTtsRef.current, }); const fromBeatId = currentBeatRef.current?.id ?? currentScene.entryBeatId; const newBeatId = `b_ins_${Date.now()}_${Math.random() .toString(36) .slice(2, 6)}`; const newBeat: Beat = { id: newBeatId, narration: partial.narration, speaker: partial.speaker, line: partial.line, lineDelivery: partial.lineDelivery, next: { type: "continue", nextBeatId: fromBeatId }, }; const patched: Scene = { ...currentScene, beats: [...currentScene.beats, newBeat], }; const nextVisited = [...visitedBeatsRef.current, newBeatId]; visitedBeatsRef.current = nextVisited; const nextSession: Session = { ...session, history: session.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, scene: patched, visitedBeatIds: nextVisited } : h, ), characters: insertChars, }; setSession(nextSession); setCurrentScene(patched); setCurrentBeatId(newBeatId); if (newBeat.speaker && newBeat.line) { void fetchBeatAudio(nextSession, { id: newBeatId, speaker: newBeat.speaker, line: newBeat.line, lineDelivery: newBeat.lineDelivery, }); } setLastExitLabel(decision.freeformAction); setPhase("ready"); return; } // change-scene path const visited = [...visitedBeatsRef.current]; const exit: SceneExit = { kind: "freeform", action: decision.freeformAction, }; clearPool(poolRef.current); const specSession: Session = { ...session, history: session.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visited, exit } : h, ), }; const promise = (async () => { const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); return data; })(); setPendingClick(null); void performSceneTransition( promise, exit, visited, decision.freeformAction, () => onFreeformInput(text), ); } catch (e) { if (!handleAuthError(e, () => onFreeformInput(text))) { trackPlayError("freeform", e, freeformT0); setError(String(e)); } setPhase("ready"); } } 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); try { const annotatedImageBase64 = await annotateClick(imageUrl, click); const decision = await visionDecide({ session, annotatedImageBase64, }); track("vision_click", { result: decision.classify }); if (decision.classify === "insert-beat") { setPhase("inserting-beat"); const { partial, characters: insertChars } = await requestInsertBeat({ session, freeformAction: decision.intent.freeformAction, clientTts: !!byoTtsRef.current, }); const fromBeatId = currentBeatRef.current?.id ?? currentScene.entryBeatId; const newBeatId = `b_ins_${Date.now()}_${Math.random() .toString(36) .slice(2, 6)}`; const newBeat: Beat = { id: newBeatId, narration: partial.narration, speaker: partial.speaker, line: partial.line, lineDelivery: partial.lineDelivery, next: { type: "continue", nextBeatId: fromBeatId }, }; const patched: Scene = { ...currentScene, beats: [...currentScene.beats, newBeat], }; const nextSession: Session = { ...session, history: session.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, scene: patched } : h, ), characters: insertChars, }; setSession(nextSession); setCurrentScene(patched); setCurrentBeatId(newBeatId); // Insert-beat doesn't change scene.id, so the scene effect won't // re-fire — manually kick off the audio fetch for the new beat. if (newBeat.speaker && newBeat.line) { void fetchBeatAudio(nextSession, { id: newBeatId, speaker: newBeat.speaker, line: newBeat.line, lineDelivery: newBeat.lineDelivery, }); } setLastExitLabel(decision.intent.freeformAction); setPhase("ready"); setPendingClick(null); } else { const exit: SceneExit = { kind: "freeform", action: decision.intent.freeformAction, }; const visited = [...visitedBeatsRef.current]; const base = sessionRef.current; if (!base) { setPhase("ready"); setPendingClick(null); return; } const specSession: Session = { ...base, history: base.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visited, exit } : h, ), }; clearPool(poolRef.current); const promise = (async () => { const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); return data; })(); await performSceneTransition( promise, exit, visited, decision.intent.freeformAction, () => onBackgroundClick(click), ); } } catch (e) { if (!handleAuthError(e, () => onBackgroundClick(click))) { trackPlayError("vision", e, visionT0); setError(String(e)); } setPendingClick(null); setPhase("ready"); } } // ── Render ──────────────────────────────────────────────────────────── const replayAllowedChoiceIds = recordedAllowedChoiceIds(currentBeat); const disabledReplayChoiceIds = replayAllowedChoiceIds && currentBeat?.next.type === "choice" ? currentBeat.next.choices .filter((choice) => !replayAllowedChoiceIds.has(choice.id)) .map((choice) => choice.id) : []; const replayLocked = isRecordedReplayLockedAt(currentBeat); if (error) { return (

出 · 了 · 点 · 状 · 况

{error}

返 回
); } // Mobile portrait renders full-bleed by default — it sidesteps the iOS // Safari Fullscreen API (unsupported on iPhone) with a CSS full-viewport // layout instead. Desktop "presentation" mode shares the same immersive // canvas, toggled via the F key. const immersive = presentation || orientation === "portrait"; if (immersive) { return (
setSettingsOpen(true)} onImageReady={handleImageReady} fullViewport dialogueHistory={dialogueHistory} disabledChoiceIds={disabledReplayChoiceIds} freeformDisabled={replayLocked} /> {orientation === "portrait" && (
)} {settingsOpen && ( setSettingsOpen(false)} onSaved={handleSettingsSaved} footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。" /> )}
); } const sceneCount = session?.history.length ?? 0; const beatCount = visitedBeatsRef.current.length; return (
{exportProgress && (
{exportProgress.label} {exportProgress.total > 0 && ( {exportProgress.done}/{exportProgress.total} )}
)}
InfiPlot
第 · {String(sceneCount).padStart(3, "0")} · 幕 · {String(beatCount).padStart(3, "0")} · 拍
setAuthModalOpen(true)} />
setSettingsOpen(true)} onImageReady={handleImageReady} dialogueHistory={dialogueHistory} disabledChoiceIds={disabledReplayChoiceIds} freeformDisabled={replayLocked} aboveCanvas={ } belowCanvas={ session && session.history.length > 0 ? ( <> ) : null } aboveCanvasLeft={ <> } />
{phase === "loading-first" && (

正 · 在 · 唤 · 起 · 第 · 一 · 幕

)} {phase === "ready" && lastExitLabel && (

上 · 一 · 步 · {lastExitLabel}

)}
{settingsOpen && ( setSettingsOpen(false)} onSaved={handleSettingsSaved} footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。" /> )} {authModalOpen && ( { setAuthModalOpen(false); // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); const retry = authResolveRef.current; authResolveRef.current = null; retry?.(); }} /> )}
); } export default function PlayPage() { return ( 载入中 } > ); }