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/lib/imageZipDownload.ts b/lib/imageZipDownload.ts new file mode 100644 index 0000000..ba5a00a --- /dev/null +++ b/lib/imageZipDownload.ts @@ -0,0 +1,189 @@ +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 { + const dataMatch = /^data:image\/([^;,]+)/i.exec(url); + if (dataMatch?.[1]) { + const sub = dataMatch[1].toLowerCase(); + if (sub === "svg+xml") return "svg"; + return sub === "jpeg" ? "jpg" : sub; + } + + try { + const base = + typeof window !== "undefined" ? window.location.href : "http://localhost"; + const ext = new URL(url, base).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(); + const delayMs = blob.size > 5_000_000 ? 60_000 : 1_500; + setTimeout(() => URL.revokeObjectURL(blobUrl), delayMs); +} + +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) : ""; + for (let n = 2; n < 10_000; n++) { + const candidate = `${base}-${n}${ext}`; + if (!usedPaths.has(candidate)) { + usedPaths.add(candidate); + return candidate; + } + } + const fallback = `${base}-${Date.now()}${ext}`; + usedPaths.add(fallback); + return fallback; +} + +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