diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml index 331d1e1..5ba4cd4 100644 --- a/.github/workflows/pr-agent.yml +++ b/.github/workflows/pr-agent.yml @@ -34,7 +34,7 @@ jobs: config.reasoning_effort: "high" openai.api_base: ${{ secrets.PR_REVIEW_BASE_URL }} github_action_config.auto_review: "true" - github_action_config.auto_describe: "true" + github_action_config.auto_describe: "false" github_action_config.auto_improve: "true" github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "synchronize"]' pr_reviewer.extra_instructions: | diff --git a/.pr_agent.toml b/.pr_agent.toml index 0e3ee39..a8c20e1 100644 --- a/.pr_agent.toml +++ b/.pr_agent.toml @@ -19,7 +19,7 @@ require_todo_scan = true persistent_comment = false # two model jobs would otherwise overwrite each other [pr_description] -generate_ai_title = true +generate_ai_title = false publish_labels = true use_bullet_points = true enable_pr_diagram = true diff --git a/AGENTS.md b/AGENTS.md index 3d75bb1..0eb8262 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,8 @@ At session start, `startSession()` runs Architect first to create `storyState`; `Scene` is an image plus a graph of `Beat` nodes. `Beat.next` is either `continue` or `choice`. A scene should have at least one meaningful `change-scene` exit toward a new scene. Beat ids are graph keys; keep them unique and repair references when coercing LLM output. +`SceneHistoryEntry.storyStateAfter` snapshots the story memory after each scene is generated. Keep it when exporting/importing playable story JSON or replaying shared sessions so continuing from a replayed prefix uses the right narrative context. + `StoryState` has stable and volatile zones. Stable fields are set by Architect and must not be patched by Writer: `logline`, `genreTags`, `protagonist`, `castNotes`. Volatile fields may be rewritten every scene: `synopsis`, `openThreads`, `relationships`, `nextHook`. If adding a field, classify it and update `applyStoryStatePatch()` plus Writer coercion. Characters are identified by `name`. `mergeCharacters()` preserves existing portrait and voice fields when a later design omits them. Do not casually change character matching without checking Writer, Director, and Painter reference handling. @@ -91,6 +93,7 @@ Common routes live under `app/api/`: - `POST /api/insert-beat`: creates a transient beat without image generation. - `POST /api/beat-audio`: lazy TTS for a displayed beat; returns binary audio, or `204` when silent. - `POST /api/parse-style-image`: extracts a style prompt from uploaded reference art. +- `POST /api/story-pack` / `POST /api/story-unpack`: stateless AES-GCM packing/unpacking for playable story share `.infiplot` files; uses `GALLERY_SECRET`. When changing public types or route payloads, update all route callers and client consumers in the same change. @@ -139,6 +142,7 @@ Use `.env.example` as the source of truth. Never commit `.env.local`, API keys, - `MOCK_IMAGE=true` skips image generation and returns a placeholder for cheap local iteration. - `NEXT_PUBLIC_IMAGE_PROXY_URL` and `NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS` opt into browser-side image proxying for allowed hosts. - Analytics uses optional Umami `NEXT_PUBLIC_UMAMI_*` values and must stay content-free/privacy-preserving. +- `GALLERY_SECRET` enables encrypted `.infiplot` share files for gallery and playable story export/import. - `NEXT_PUBLIC_*` values are inlined at build time. ## File Dependency Map diff --git a/README.en.md b/README.en.md index 761ba52..9354b03 100644 --- a/README.en.md +++ b/README.en.md @@ -51,10 +51,16 @@ After deploy, fill in the environment variables — see the [Configuration guide ### Docker (self-hosted) -For VPS, home servers, or local machines. Supports x86 and ARM (including Apple Silicon Macs). +For VPS, home servers, or local machines. Supports x86 and ARM (including Apple Silicon Macs). No need to clone the repo — just download two files: -1. Copy `.env.example` to `.env.local` and fill in your API keys (see [Configuration guide](#configuration-guide)) -2. Start: +```bash +mkdir -p infiplot && cd infiplot +curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/docker-compose.yml -o docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/.env.example -o .env.example +[ -f .env.local ] || cp .env.example .env.local +``` + +Edit `.env.local` with your API keys (see [Configuration guide](#configuration-guide)), then start: ```bash docker compose up -d diff --git a/README.ja.md b/README.ja.md index 2a015af..77cbcd1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -51,10 +51,16 @@ Cloudflare へのデプロイはシーンパイプラインがより長い CPU ### Docker デプロイ(セルフホスト) -VPS、ホームサーバー、ローカルマシンに対応。x86 と ARM(Apple Silicon Mac を含む)をサポート。 +VPS、ホームサーバー、ローカルマシンに対応。x86 と ARM(Apple Silicon Mac を含む)をサポート。リポジトリのクローンは不要です。2 つのファイルをダウンロードするだけで始められます: -1. `.env.example` を `.env.local` にコピーし、API キーを設定([設定ガイド](#設定ガイド)を参照) -2. 起動: +```bash +mkdir -p infiplot && cd infiplot +curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/docker-compose.yml -o docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/.env.example -o .env.example +[ -f .env.local ] || cp .env.example .env.local +``` + +`.env.local` を編集して API キーを設定し([設定ガイド](#設定ガイド)を参照)、起動します: ```bash docker compose up -d diff --git a/README.md b/README.md index 0b086b6..cea0126 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,16 @@ Cloudflare 部署因场景流水线需要更长 CPU 时间,需要 Workers Paid ### Docker 部署(自托管) -适用于 VPS、家庭服务器或本地电脑。支持 x86 和 ARM(含 Apple Silicon Mac)。 +适用于 VPS、家庭服务器或本地电脑。支持 x86 和 ARM(含 Apple Silicon Mac)。无需克隆仓库,只需下载两个文件: -1. 复制 `.env.example` 为 `.env.local`,填入你的 API Key(详见[配置教程](#配置教程)) -2. 启动: +```bash +mkdir -p infiplot && cd infiplot +curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/docker-compose.yml -o docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/.env.example -o .env.example +[ -f .env.local ] || cp .env.example .env.local +``` + +编辑 `.env.local` 填入你的 API Key(详见[配置教程](#配置教程)),然后启动: ```bash docker compose up -d diff --git a/app/api/story-pack/route.ts b/app/api/story-pack/route.ts new file mode 100644 index 0000000..29bb9de --- /dev/null +++ b/app/api/story-pack/route.ts @@ -0,0 +1,52 @@ +import { packDoc } from "@/lib/galleryCrypto"; + +export const runtime = "nodejs"; + +const MAX_DOC_BYTES = 12_000_000; + +export async function POST(req: Request): Promise { + const secret = process.env.GALLERY_SECRET; + if (!secret) { + return Response.json( + { error: "剧情分享未启用 (GALLERY_SECRET 未配置)" }, + { status: 503 }, + ); + } + + const contentLength = req.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) { + return Response.json( + { error: "剧情数据太大,无法打包分享" }, + { status: 413 }, + ); + } + + let docStr: string; + try { + const body = (await req.json()) as { docStr?: unknown }; + if (typeof body.docStr !== "string") { + return Response.json({ error: "Missing docStr" }, { status: 400 }); + } + docStr = body.docStr; + } catch { + return Response.json({ error: "Bad JSON" }, { status: 400 }); + } + + if (new TextEncoder().encode(docStr).byteLength > MAX_DOC_BYTES) { + return Response.json( + { error: "剧情数据太大,无法打包分享" }, + { status: 413 }, + ); + } + + const bytes = await packDoc(docStr, secret); + const ab = new ArrayBuffer(bytes.byteLength); + new Uint8Array(ab).set(bytes); + return new Response(ab, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/story-unpack/route.ts b/app/api/story-unpack/route.ts new file mode 100644 index 0000000..c0c2e68 --- /dev/null +++ b/app/api/story-unpack/route.ts @@ -0,0 +1,43 @@ +import { unpackDoc } from "@/lib/galleryCrypto"; + +export const runtime = "nodejs"; + +const MAX_FILE_BYTES = 13_000_000; + +export async function POST(req: Request): Promise { + const secret = process.env.GALLERY_SECRET; + if (!secret) { + return Response.json( + { error: "剧情分享未启用 (GALLERY_SECRET 未配置)" }, + { status: 503 }, + ); + } + + const contentLength = req.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_FILE_BYTES) { + return Response.json({ error: "文件太大" }, { status: 413 }); + } + + let ab: ArrayBuffer; + try { + ab = await req.arrayBuffer(); + } catch { + return Response.json({ error: "Bad request body" }, { status: 400 }); + } + if (ab.byteLength > MAX_FILE_BYTES) { + return Response.json({ error: "文件太大" }, { status: 413 }); + } + if (ab.byteLength === 0) { + return Response.json({ error: "文件为空" }, { status: 400 }); + } + + try { + const docStr = await unpackDoc(new Uint8Array(ab), secret); + return Response.json({ docStr }); + } catch { + return Response.json( + { error: "剧情文件解包失败" }, + { status: 400 }, + ); + } +} diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index 164f12e..d79361f 100644 --- a/app/gallery/page.tsx +++ b/app/gallery/page.tsx @@ -15,6 +15,11 @@ import type { Orientation, SceneExit, } from "@infiplot/types"; +import { + downloadImagesIndividually, + downloadImagesAsZip, + inferImageExtension, +} from "@/lib/imageZipDownload"; // ────────────────────────────────────────────────────────────────────── // Gallery — an offline-only replay of a played session. Entered from @@ -123,72 +128,6 @@ function pickedChoiceIdAt( return null; } -// ── Download a batch of image URLs as separate browser downloads. -// Runware CDN sends Access-Control-Allow-Origin (the annotate flow already -// relies on this) so fetch().blob() works cross-origin without a proxy. -// -// Each fetch has its own AbortController + per-file timeout — without that -// a single slow/hung CDN response strands the whole loop, the caller's busy -// flag never clears, and the button looks "stuck" (the original "下载完按钮就没了" -// report). Fetches run in a small concurrency pool to keep total time -// reasonable for ~10-30 portraits; the actual clicks remain -// serial with a small gap so Chrome's "allow multiple downloads" prompt -// fires once instead of being coalesced or dropped. -async function downloadImages( - files: { url: string; name: string }[], -): Promise { - const CONCURRENT_FETCH = 4; - const FETCH_TIMEOUT_MS = 20_000; - - async function fetchOne( - file: { url: string; name: string }, - ): Promise<{ blobUrl: string; name: string } | null> { - const { url, name } = file; - if (!url) return null; - if (url.startsWith("data:")) return { blobUrl: url, name }; - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); - try { - const r = await fetch(url, { mode: "cors", signal: ctrl.signal }); - if (!r.ok) return null; - const blob = await r.blob(); - return { blobUrl: URL.createObjectURL(blob), name }; - } catch { - return null; - } finally { - clearTimeout(timer); - } - } - - const queue = [...files]; - const ready: ({ blobUrl: string; name: string } | null)[] = []; - await Promise.all( - Array.from({ length: CONCURRENT_FETCH }, async () => { - while (queue.length > 0) { - const f = queue.shift(); - if (!f) break; - ready.push(await fetchOne(f)); - } - }), - ); - - for (const item of ready) { - if (!item) continue; - const { blobUrl, name } = item; - const a = document.createElement("a"); - a.href = blobUrl; - a.download = name; - a.rel = "noopener"; - document.body.appendChild(a); - a.click(); - a.remove(); - if (blobUrl.startsWith("blob:")) { - setTimeout(() => URL.revokeObjectURL(blobUrl), 1500); - } - await new Promise((r) => setTimeout(r, 250)); - } -} - // ────────────────────────────────────────────────────────────────────── // Dialogue panel — full beat trail of the current scene // ────────────────────────────────────────────────────────────────────── @@ -892,13 +831,6 @@ function GalleryInner() { if (!doc || downloadingScenes) return; setDownloadingScenes(true); try { - function extOf(url: string): string { - if (url.startsWith("data:image/svg")) return "svg"; - if (url.startsWith("data:image/")) { - return url.slice(11, url.indexOf(";")) || "png"; - } - return "jpg"; - } // Main path + every unique alternate (AI-prefetched branches the player // didn't take). Dedupe by URL — the picked choice's alternate IS the // next main scene, so they overlap, and we never want the same image @@ -913,7 +845,7 @@ function GalleryInner() { sceneN++; files.push({ url: sc.imageUrl, - name: `infiplot-scene-${String(sceneN).padStart(3, "0")}.${extOf(sc.imageUrl)}`, + name: `infiplot-scene-${String(sceneN).padStart(3, "0")}.${inferImageExtension(sc.imageUrl)}`, }); } let branchN = 0; @@ -923,10 +855,17 @@ function GalleryInner() { branchN++; files.push({ url: alt.imageUrl, - name: `infiplot-branch-${String(branchN).padStart(3, "0")}.${extOf(alt.imageUrl)}`, + name: `infiplot-branch-${String(branchN).padStart(3, "0")}.${inferImageExtension(alt.imageUrl)}`, }); } - await downloadImages(files); + const result = await downloadImagesAsZip(files, `infiplot-gallery-${doc.id}.zip`); + if (result.downloaded === 0) { + alert("所有图片抓取失败,请检查网络后重试"); + } else if (result.failed.length > 0) { + alert(`已打包 ${result.downloaded} 张,${result.failed.length} 张抓取失败`); + } + } catch { + alert("打包下载失败,请重试"); } finally { setDownloadingScenes(false); } @@ -1000,7 +939,7 @@ function GalleryInner() { if (files.length === 0) return; setDownloadingPortraits(true); try { - await downloadImages(files); + await downloadImagesIndividually(files); } finally { setDownloadingPortraits(false); } @@ -1173,27 +1112,28 @@ function GalleryInner() { disabled={downloadingScenes} className="flex h-9 items-center gap-2 rounded-full bg-black/40 px-3 text-[11px] smallcaps text-white/80 backdrop-blur-sm transition-colors hover:text-white disabled:opacity-50" aria-label="批量下载图集到本地" - title="把本局所有场景图(含未选中的分支预生成图)下载到本机(浏览器若弹「允许多个下载」请点允许)" + title="把本局所有场景图(含未选中的分支预生成图)打包成 zip 下载到本机" > - {downloadingScenes ? "下载中" : "下载图集"} + {downloadingScenes ? "打包中" : "下载图集"} - {/* Download-in-progress hint — Chrome/Edge/Firefox throw a "允许此网站 - 下载多个文件" prompt after the first .click(); without - this banner most users miss it and only the first file lands. */} {(downloadingScenes || downloadingPortraits) && (
- - 浏览器顶部如弹出「允许此网站下载多个文件」,请点「允许」,否则只能下到第一张 + + {downloadingScenes + ? "正在抓取图片并打包 zip,完成后会自动开始下载" + : "浏览器顶部如弹出「允许此网站下载多个文件」,请点「允许」,否则只能下到第一张"}
)} diff --git a/app/page.tsx b/app/page.tsx index 729fbdc..66b52c7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,7 @@ import { } from "@/lib/options"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; +import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -1249,6 +1250,8 @@ export default function HomePage() { const [customStyleGuide, setCustomStyleGuide] = useState(""); const [customStyleRefImage, setCustomStyleRefImage] = useState(""); const inputRef = useRef(null); + const storyImportRef = useRef(null); + const [storyImportError, setStoryImportError] = useState(null); // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。 const [hintClosed, setHintClosed] = useState(false); @@ -1396,6 +1399,46 @@ export default function HomePage() { router.push("/play?custom=1"); }; + const handleStoryImport = async (file: File | undefined) => { + setStoryImportError(null); + if (!file) return; + if (file.size <= 0) { + setStoryImportError("这个剧情文件是空的。"); + return; + } + const isJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json"; + const maxImportBytes = isJson ? 12_000_000 : 13_000_000; + if (file.size > maxImportBytes) { + setStoryImportError("剧情文件太大,无法载入。"); + return; + } + try { + let text: string; + if (isJson) { + text = await file.text(); + } else { + const r = await fetch("/api/story-unpack", { + method: "POST", + body: await file.arrayBuffer(), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + throw new Error(j.error ?? "剧情文件解包失败。"); + } + const j = (await r.json()) as { docStr?: unknown }; + if (typeof j.docStr !== "string") throw new Error("剧情文件解包失败。"); + text = j.docStr; + } + const doc = parseStoryShareDoc(JSON.parse(text)); + window.sessionStorage.setItem(STORY_SHARE_STORAGE_KEY, JSON.stringify(doc)); + router.push("/play?share=1"); + } catch (e) { + setStoryImportError(e instanceof Error ? e.message : "剧情文件解析失败。"); + } finally { + if (storyImportRef.current) storyImportRef.current.value = ""; + } + }; + const stories = STORIES[galleryGender]; const imgPrefix = galleryGender === "女性向" ? "f" : "m"; const analyticsOn = Boolean( @@ -1510,7 +1553,29 @@ export default function HomePage() { 开始 + void handleStoryImport(e.target.files?.[0])} + /> + + {storyImportError && ( +

+ {storyImportError} +

+ )} {prompt && (

Enter 发送 · Shift+Enter 换行 diff --git a/app/play/page.tsx b/app/play/page.tsx index c6bf723..c88cfb5 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -21,6 +21,12 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co import { annotateClick } from "@/lib/annotateClient"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { PRESETS } from "@/lib/presets"; +import { + STORY_SHARE_STORAGE_KEY, + createStoryShareDoc, + parseStoryShareDoc, + storyShareFilename, +} from "@/lib/storyShare"; import { provisionVoice, synthesize } from "@infiplot/tts-client"; import type { Beat, @@ -621,6 +627,10 @@ function PlayInner() { 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); // 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. @@ -876,6 +886,13 @@ function PlayInner() { [prefetchSceneAudio], ); + 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 @@ -1034,6 +1051,45 @@ function PlayInner() { })(); }, [trimGalleryExports]); + const handleExportStory = useCallback(() => { + 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); + const doc = createStoryShareDoc(s, { + sceneIndex, + beatId: currentBeatRef.current?.id ?? s.history[sceneIndex]?.scene.entryBeatId, + }); + void (async () => { + 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; + } + })(); + }, []); + // ── Presentation mode toggle ───────────────────────────────────────── const togglePresentation = useCallback(async () => { const entering = !presentation; @@ -1098,9 +1154,60 @@ function PlayInner() { // ?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 () => { + 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]; + setSession(initial); + setCurrentScene(first.scene); + setCurrentBeatId(first.scene.entryBeatId); + setImageUrl(blobUrl); + setPhase("ready"); + track("scene_reached", { scene_index: 1 }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + })(); + return; + } let livePayload: { worldSetting: string; @@ -1224,6 +1331,7 @@ function PlayInner() { { scene: data.scene, visitedBeatIds: [data.scene.entryBeatId], + storyStateAfter: data.storyState, }, ], characters: data.characters, @@ -1250,6 +1358,7 @@ function PlayInner() { const s = session; const scene = currentScene; if (!s || !scene) return; + if (isRecordedReplayLockedAt(currentBeat)) return; const exits = findAllChangeSceneChoices(scene); for (const choice of exits) { @@ -1346,6 +1455,7 @@ function PlayInner() { { scene: result.scene, visitedBeatIds: [result.scene.entryBeatId], + storyStateAfter: result.storyState, }, ], characters: mergeCharactersPreserveVoice( @@ -1373,8 +1483,140 @@ function PlayInner() { } } + 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 () => { + 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); + setImageUrl(blobUrl); + setLastExitLabel(choice.label); + setPhase("ready"); + track("scene_reached", { scene_index: nextSession.history.length }); + } catch (e) { + 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 = @@ -1390,6 +1632,22 @@ function PlayInner() { } 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; @@ -1403,6 +1661,9 @@ function PlayInner() { 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); @@ -1445,6 +1706,7 @@ function PlayInner() { async function onFreeformInput(text: string) { if (phase !== "ready" || !session || !currentScene) return; + if (replayActiveRef.current) detachRecordedReplay(); track("freeform_input", { scene_index: session.history.length, @@ -1576,6 +1838,7 @@ function PlayInner() { async function onBackgroundClick(click: { x: number; y: number }) { if (phase !== "ready" || !session || !currentScene || !imageUrl) return; + if (replayActiveRef.current) detachRecordedReplay(); setPhase("vision-thinking"); setPendingClick(click); @@ -1720,6 +1983,15 @@ function PlayInner() { // ── 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 (

@@ -1768,6 +2040,8 @@ function PlayInner() { onOpenSettings={() => setSettingsOpen(true)} fullViewport dialogueHistory={dialogueHistory} + disabledChoiceIds={disabledReplayChoiceIds} + freeformDisabled={replayLocked} /> {orientation === "portrait" && (
setSettingsOpen(true)} dialogueHistory={dialogueHistory} + disabledChoiceIds={disabledReplayChoiceIds} + freeformDisabled={replayLocked} aboveCanvas={ + <> + + + ) : null } aboveCanvasLeft={ diff --git a/components/PlayCanvas.tsx b/components/PlayCanvas.tsx index 807f4b1..9a03e9f 100644 --- a/components/PlayCanvas.tsx +++ b/components/PlayCanvas.tsx @@ -113,12 +113,14 @@ function ChoiceButton({ index, label, disabled, + disabledTitle, vertical, onClick, }: { index: number; label: string; disabled: boolean; + disabledTitle?: string; vertical: boolean; onClick: () => void; }) { @@ -126,9 +128,10 @@ function ChoiceButton({