feat(gallery): download scene gallery as zip
Signed-off-by: baizhi958216 <1475289190@qq.com>
This commit is contained in:
@@ -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<ImageZipDownloadResult> {
|
||||
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<string>();
|
||||
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<ImageZipDownloadResult> {
|
||||
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<Blob | null>(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<Blob | null> {
|
||||
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>): 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";
|
||||
}
|
||||
Reference in New Issue
Block a user