From 7925e9c4593a1208b80552e0cac51e1480bf5c54 Mon Sep 17 00:00:00 2001 From: baizhi958216 <1475289190@qq.com> Date: Sun, 7 Jun 2026 15:45:46 +0800 Subject: [PATCH 1/7] feat(gallery): download scene gallery as zip Signed-off-by: baizhi958216 <1475289190@qq.com> --- app/gallery/page.tsx | 101 ++++------------------ lib/imageZipDownload.ts | 183 ++++++++++++++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 75 ++++++++++++++++ 4 files changed, 276 insertions(+), 84 deletions(-) create mode 100644 lib/imageZipDownload.ts diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index 164f12e..5dd2aa2 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,10 @@ 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); + await downloadImagesAsZip(files, `infiplot-gallery-${doc.id}.zip`); } finally { setDownloadingScenes(false); } @@ -1000,7 +932,7 @@ function GalleryInner() { if (files.length === 0) return; setDownloadingPortraits(true); try { - await downloadImages(files); + await downloadImagesIndividually(files); } finally { setDownloadingPortraits(false); } @@ -1173,27 +1105,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/lib/imageZipDownload.ts b/lib/imageZipDownload.ts new file mode 100644 index 0000000..f6acabf --- /dev/null +++ b/lib/imageZipDownload.ts @@ -0,0 +1,183 @@ +import JSZip from "jszip"; + +export type ImageZipFile = { + url: string; + name: string; +}; + +export type ImageZipDownloadResult = { + downloaded: number; + failed: ImageZipFile[]; +}; + +type DownloadOptions = { + concurrency?: number; + timeoutMs?: number; +}; + +const DEFAULT_CONCURRENCY = 4; +const DEFAULT_TIMEOUT_MS = 20_000; + +export function inferImageExtension(url: string): string { + if (url.startsWith("data:image/svg")) return "svg"; + if (url.startsWith("data:image/")) { + return url.slice(11, url.indexOf(";")).replace(/^jpeg$/, "jpg") || "png"; + } + + try { + const ext = new URL(url).pathname.split(".").pop()?.toLowerCase(); + if (ext && ["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext)) { + return ext === "jpeg" ? "jpg" : ext; + } + } catch { + // Fall through to the historical default used by gallery downloads. + } + + return "jpg"; +} + +export async function downloadImagesAsZip( + files: ImageZipFile[], + zipName: string, + options: DownloadOptions = {}, +): Promise { + const filtered = files.filter((file) => file.url && file.name); + if (filtered.length === 0) return { downloaded: 0, failed: [] }; + + const blobs = await fetchImageBlobs(filtered, options); + const zip = new JSZip(); + const usedPaths = new Set(); + const failed: ImageZipFile[] = []; + let downloaded = 0; + + for (let i = 0; i < filtered.length; i++) { + const file = filtered[i]!; + const blob = blobs[i]; + if (!blob) { + failed.push(file); + continue; + } + zip.file(uniqueZipPath(file.name, usedPaths), blob, { date: new Date() }); + downloaded++; + } + + if (downloaded === 0) return { downloaded, failed }; + + const blob = await zip.generateAsync({ type: "blob", compression: "STORE" }); + triggerBrowserDownload(blob, normalizeZipName(zipName)); + return { downloaded, failed }; +} + +export async function downloadImagesIndividually( + files: ImageZipFile[], + options: DownloadOptions = {}, +): Promise { + const filtered = files.filter((file) => file.url && file.name); + if (filtered.length === 0) return { downloaded: 0, failed: [] }; + + const blobs = await fetchImageBlobs(filtered, options); + const failed: ImageZipFile[] = []; + let downloaded = 0; + + for (let i = 0; i < filtered.length; i++) { + const file = filtered[i]!; + const blob = blobs[i]; + if (!blob) { + failed.push(file); + continue; + } + triggerBrowserDownload(blob, file.name); + downloaded++; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + return { downloaded, failed }; +} + +async function fetchImageBlobs( + files: ImageZipFile[], + options: DownloadOptions, +): Promise<(Blob | null)[]> { + const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY); + const timeoutMs = Math.max(1000, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const queue = files.map((file, index) => ({ file, index })); + const blobs = new Array(files.length).fill(null); + + await Promise.all( + Array.from({ length: Math.min(concurrency, files.length) }, async () => { + while (queue.length > 0) { + const next = queue.shift(); + if (!next) break; + blobs[next.index] = await fetchImageBlob(next.file.url, timeoutMs); + } + }), + ); + + return blobs; +} + +async function fetchImageBlob(url: string, timeoutMs: number): Promise { + if (!url) return null; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const init: RequestInit = { signal: ctrl.signal }; + if (!url.startsWith("data:")) init.mode = "cors"; + const response = await fetch(url, init); + if (!response.ok) return null; + const blob = await response.blob(); + return blob.size > 0 ? blob : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +function triggerBrowserDownload(blob: Blob, fileName: string): void { + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = fileName; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1500); +} + +function normalizeZipName(name: string): string { + const trimmed = name.trim() || "images.zip"; + return trimmed.toLowerCase().endsWith(".zip") ? trimmed : `${trimmed}.zip`; +} + +function uniqueZipPath(name: string, usedPaths: Set): string { + const clean = sanitizeZipPath(name); + if (!usedPaths.has(clean)) { + usedPaths.add(clean); + return clean; + } + + const dot = clean.lastIndexOf("."); + const base = dot > 0 ? clean.slice(0, dot) : clean; + const ext = dot > 0 ? clean.slice(dot) : ""; + let n = 2; + while (true) { + const candidate = `${base}-${n}${ext}`; + if (!usedPaths.has(candidate)) { + usedPaths.add(candidate); + return candidate; + } + n++; + } +} + +function sanitizeZipPath(name: string): string { + const parts = name + .replace(/\\/g, "/") + .split("/") + .map((part) => part.replace(/[^\w.\-\u4e00-\u9fff]/g, "_")) + .filter((part) => part && part !== "." && part !== ".."); + + return parts.join("/") || "image.jpg"; +} diff --git a/package.json b/package.json index f02121b..751430a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@ai-sdk/openai": "^3.0.67", "ai": "^6.0.196", "jsonrepair": "^3.14.0", + "jszip": "^3.10.1", "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e70280e..094b998 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: jsonrepair: specifier: ^3.14.0 version: 3.14.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 next: specifier: ^16.0.0 version: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1492,6 +1495,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1769,6 +1775,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1806,6 +1815,9 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1828,10 +1840,16 @@ packages: resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==} hasBin: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2013,6 +2031,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2115,6 +2136,9 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2146,6 +2170,9 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2166,6 +2193,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2185,6 +2215,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -2249,6 +2282,9 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -4037,6 +4073,8 @@ snapshots: cookie@1.1.1: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4382,6 +4420,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -4408,6 +4448,8 @@ snapshots: is-stream@2.0.1: {} + isarray@1.0.0: {} + isexe@2.0.0: {} isexe@3.1.5: {} @@ -4422,8 +4464,19 @@ snapshots: jsonrepair@3.14.0: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + kleur@4.1.5: {} + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4566,6 +4619,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parseurl@1.3.3: {} path-expression-matcher@1.5.0: {} @@ -4644,6 +4699,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + process-nextick-args@2.0.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -4675,6 +4732,16 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -4702,6 +4769,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + safer-buffer@2.1.2: {} scheduler@0.27.0: {} @@ -4733,6 +4802,8 @@ snapshots: transitivePeerDependencies: - supports-color + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sharp@0.33.5: @@ -4851,6 +4922,10 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 From 0abd5f15254eafa4b926869be77d8ca89d2b71c1 Mon Sep 17 00:00:00 2001 From: baizhi958216 <1475289190@qq.com> Date: Sun, 7 Jun 2026 17:13:27 +0800 Subject: [PATCH 2/7] feat(play): add encrypted story sharing --- AGENTS.md | 4 + app/api/story-pack/route.ts | 44 +++++ app/api/story-unpack/route.ts | 38 +++++ app/page.tsx | 63 +++++++ app/play/page.tsx | 304 ++++++++++++++++++++++++++++++++-- components/PlayCanvas.tsx | 21 ++- lib/storyShare.ts | 215 ++++++++++++++++++++++++ lib/types/index.ts | 4 + 8 files changed, 677 insertions(+), 16 deletions(-) create mode 100644 app/api/story-pack/route.ts create mode 100644 app/api/story-unpack/route.ts create mode 100644 lib/storyShare.ts 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/app/api/story-pack/route.ts b/app/api/story-pack/route.ts new file mode 100644 index 0000000..b73f7ce --- /dev/null +++ b/app/api/story-pack/route.ts @@ -0,0 +1,44 @@ +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 }, + ); + } + + 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..5f4a3ba --- /dev/null +++ b/app/api/story-unpack/route.ts @@ -0,0 +1,38 @@ +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 }, + ); + } + + 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 (e) { + return Response.json( + { error: e instanceof Error ? e.message : "解包失败" }, + { status: 400 }, + ); + } +} diff --git a/app/page.tsx b/app/page.tsx index 729fbdc..73a2c50 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,44 @@ 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; + } + if (file.size > 12_000_000) { + setStoryImportError("剧情文件太大,无法载入。"); + return; + } + try { + let text: string; + if (file.name.toLowerCase().endsWith(".json") || file.type === "application/json") { + 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( @@ -1511,6 +1552,28 @@ 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..2d52965 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,9 @@ 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); // 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 +885,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 +1050,42 @@ function PlayInner() { })(); }, [trimGalleryExports]); + const handleExportStory = useCallback(() => { + const s = sessionRef.current; + if (!s || s.history.length === 0) return; + 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("剧情分享打包失败"); + } + })(); + }, []); + // ── Presentation mode toggle ───────────────────────────────────────── const togglePresentation = useCallback(async () => { const entering = !presentation; @@ -1098,9 +1150,57 @@ 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 initial: Session = { + ...imported, + history: [ + { + ...first, + visitedBeatIds: [first.scene.entryBeatId], + exit: undefined, + }, + ], + storyState: first.storyStateAfter ?? imported.storyState, + 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 +1324,7 @@ function PlayInner() { { scene: data.scene, visitedBeatIds: [data.scene.entryBeatId], + storyStateAfter: data.storyState, }, ], characters: data.characters, @@ -1250,6 +1351,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) { @@ -1273,7 +1375,7 @@ function PlayInner() { !!byoTtsRef.current, ); } - }, [currentScene?.id, session?.id]); + }, [currentScene?.id, currentBeat?.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 @@ -1346,6 +1448,7 @@ function PlayInner() { { scene: result.scene, visitedBeatIds: [result.scene.entryBeatId], + storyStateAfter: result.storyState, }, ], characters: mergeCharactersPreserveVoice( @@ -1373,8 +1476,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 +1625,23 @@ 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(); + } + } + if ( + replaySourceRef.current && + !isRecordedReplayLockedAt(currentBeatRef.current) + ) { + detachRecordedReplay(); + } // Pure local jump. No network. No pool changes. setCurrentBeatId(choice.effect.targetBeatId); return; @@ -1403,6 +1655,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 +1700,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 +1832,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 +1977,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 +2034,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({ -
-
storyImportRef.current?.click()} - className="inline-flex items-center gap-2 text-[10px] smallcaps text-clay-500 transition-colors hover:text-ember-500" + className="group absolute right-[-2.25rem] bottom-2 md:bottom-3 inline-flex items-center justify-center rounded-sm border border-clay-900/20 px-2 py-2 md:py-2.5 text-clay-400 transition-colors hover:border-ember-500 hover:text-ember-500" > - - 载 · 入 · 剧 · 情 + + + 载入剧情 + - {storyImportError && ( -

- {storyImportError} -

- )}
+ {storyImportError && ( +

+ {storyImportError} +

+ )} {prompt && (

Enter 发送 · Shift+Enter 换行 diff --git a/app/play/page.tsx b/app/play/page.tsx index 2d52965..1ac9708 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -1173,6 +1173,9 @@ function PlayInner() { 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: [ @@ -1182,7 +1185,7 @@ function PlayInner() { exit: undefined, }, ], - storyState: first.storyStateAfter ?? imported.storyState, + storyState: initialStoryState, orientation: sessionOrientation, }; replaySourceRef.current = imported; @@ -1375,7 +1378,7 @@ function PlayInner() { !!byoTtsRef.current, ); } - }, [currentScene?.id, currentBeat?.id, session?.id]); + }, [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 @@ -1635,8 +1638,7 @@ function PlayInner() { if (recordedNext && recordedNext !== choice.effect.targetBeatId) { detachRecordedReplay(); } - } - if ( + } else if ( replaySourceRef.current && !isRecordedReplayLockedAt(currentBeatRef.current) ) { diff --git a/lib/storyShare.ts b/lib/storyShare.ts index a78c4a5..3d346e8 100644 --- a/lib/storyShare.ts +++ b/lib/storyShare.ts @@ -155,7 +155,11 @@ export function parseStoryShareDoc(value: unknown): StoryShareDoc { if (typeof value.exportedAt !== "number" || !Number.isFinite(value.exportedAt)) { throw new Error("剧情分享文件缺少导出时间"); } - if (!isRecord(value.current) || typeof value.current.sceneIndex !== "number") { + if ( + !isRecord(value.current) || + !Number.isInteger(value.current.sceneIndex) || + (value.current.sceneIndex as number) < 0 + ) { throw new Error("剧情分享文件缺少当前位置"); } if ( From 7c676fc43b468dd516930f12209f39dc44c9e58d Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 8 Jun 2026 15:45:36 +0800 Subject: [PATCH 7/7] fix(play): guard handleExportStory against duplicate clicks Adds a ref-based mutex so concurrent /api/story-pack requests and duplicate file downloads cannot be triggered by rapid clicking. Co-Authored-By: Claude Opus 4.6 --- app/play/page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/play/page.tsx b/app/play/page.tsx index 1ac9708..c88cfb5 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -630,6 +630,7 @@ function PlayInner() { 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. @@ -1052,7 +1053,8 @@ function PlayInner() { const handleExportStory = useCallback(() => { const s = sessionRef.current; - if (!s || s.history.length === 0) return; + 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, @@ -1082,6 +1084,8 @@ function PlayInner() { setTimeout(() => URL.revokeObjectURL(url), 2000); } catch { window.alert("剧情分享打包失败"); + } finally { + exportingStoryRef.current = false; } })(); }, []);