feat(gallery): download scene gallery as zip

Signed-off-by: baizhi958216 <1475289190@qq.com>
This commit is contained in:
baizhi958216
2026-06-07 15:45:46 +08:00
parent df48e73d62
commit 7925e9c459
4 changed files with 276 additions and 84 deletions
+17 -84
View File
@@ -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 <a download> 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<void> {
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 下载到本机"
>
<i
className={`fa-solid ${downloadingScenes ? "fa-spinner animate-spin" : "fa-download"} text-[11px]`}
/>
{downloadingScenes ? "下载中" : "下载图集"}
{downloadingScenes ? "打包中" : "下载图集"}
</button>
</div>
</div>
{/* Download-in-progress hint — Chrome/Edge/Firefox throw a "允许此网站
下载多个文件" prompt after the first <a download>.click(); without
this banner most users miss it and only the first file lands. */}
{(downloadingScenes || downloadingPortraits) && (
<div
className="absolute inset-x-0 z-30 flex justify-center pointer-events-none px-4"
style={{ top: "calc(max(0.75rem, env(safe-area-inset-top)) + 60px)" }}
>
<span className="flex items-center gap-2 rounded-full bg-black/70 px-4 py-2 text-[11px] text-white/95 backdrop-blur-sm shadow-lg max-w-[92vw]">
<i className="fa-solid fa-circle-exclamation text-[11px] text-amber-300" />
,,
<i
className={`fa-solid ${downloadingScenes ? "fa-file-zipper" : "fa-circle-exclamation"} text-[11px] text-amber-300`}
/>
{downloadingScenes
? "正在抓取图片并打包 zip,完成后会自动开始下载"
: "浏览器顶部如弹出「允许此网站下载多个文件」,请点「允许」,否则只能下到第一张"}
</span>
</div>
)}