import { aiGenerationClient } from "../../api/aiGenerationClient"; import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection"; function sanitizeDownloadFilename(value: string, fallback: string) { const normalized = value .trim() .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, " ") .slice(0, 80) .trim(); return normalized || fallback; } function getResultDownloadExtension(url: string, contentType: string | null, isVideo: boolean) { const mime = (contentType || "").split(";")[0]?.trim().toLowerCase(); if (mime === "image/jpeg") return "jpg"; if (mime === "image/png") return "png"; if (mime === "image/webp") return "webp"; if (mime === "image/gif") return "gif"; if (mime === "video/webm") return "webm"; if (mime === "video/quicktime") return "mov"; if (mime === "video/mp4") return "mp4"; try { const pathname = new URL(url, window.location.href).pathname; const matched = pathname.match(/\.([a-z0-9]{2,5})$/i); if (matched?.[1]) return matched[1].toLowerCase(); } catch { // Keep the fallback below for malformed but still browser-loadable URLs. } return isVideo ? "mp4" : "png"; } type LocalFilePickerWritable = { write: (data: Blob) => Promise; close: () => Promise; }; type LocalFilePickerHandle = { createWritable: () => Promise; }; type LocalFilePickerWindow = Window & { showSaveFilePicker?: (options?: { suggestedName?: string; types?: Array<{ description: string; accept: Record; }>; }) => Promise; }; async function saveBlobToLocal(blob: Blob, filename: string, isVideo: boolean): Promise<"saved" | "started"> { const startBrowserDownload = () => { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = filename; anchor.rel = "noopener"; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); return "started" as const; }; const savePicker = (window as LocalFilePickerWindow).showSaveFilePicker; if (savePicker) { try { const pickerTypes = [ isVideo ? { description: "Video", accept: { [blob.type || "video/mp4"]: [`.${filename.split(".").pop() || "mp4"}`] } } : { description: "Image", accept: { [blob.type || "image/png"]: [`.${filename.split(".").pop() || "png"}`] } }, ]; const handle = await savePicker({ suggestedName: filename, types: pickerTypes }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); return "saved"; } catch (error) { if (error instanceof DOMException && error.name === "AbortError") throw error; return startBrowserDownload(); } } return startBrowserDownload(); } async function assertDownloadBlobIsUsable(blob: Blob, isVideo: boolean) { const mime = String(blob.type || "").toLowerCase(); if (/^(?:application|text)\/(?:json|xml|html|plain)|\+xml/.test(mime)) { throw new Error("下载失败:结果链接已过期或返回了错误内容,请重新生成后再下载。"); } const header = new Uint8Array(await blob.slice(0, 16).arrayBuffer()); const asText = new TextDecoder("utf-8", { fatal: false }).decode(header); const isPng = header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47; const isJpeg = header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff; const isGif = asText.startsWith("GIF8"); const isWebp = asText.startsWith("RIFF") && asText.slice(8, 12) === "WEBP"; const isMp4 = asText.slice(4, 8) === "ftyp"; const looksLikeErrorDocument = asText.startsWith(" { let blob: Blob | null = null; let contentType: string | null = null; if (taskId) { try { const result = await aiGenerationClient.downloadTaskResult(taskId); blob = result.blob; contentType = result.contentType || result.blob.type || null; } catch { blob = null; } } if (!blob) { const needsProxy = /aliyuncs\.com/i.test(url); const fetchUrl = needsProxy ? buildApiUrl(`ai/proxy-download?url=${encodeURIComponent(url)}`) : url; const fetchOpts: RequestInit = needsProxy ? { headers: buildAuthHeaders() } : { credentials: "omit" }; const response = await fetch(fetchUrl, fetchOpts); if (!response.ok) { throw new Error(`下载失败:资源请求返回 ${response.status}`); } blob = await response.blob(); contentType = blob.type || response.headers.get("content-type"); } if (!blob.size) { throw new Error("下载失败:资源内容为空"); } await assertDownloadBlobIsUsable(blob, isVideo); const extension = getResultDownloadExtension(url, contentType, isVideo); const filename = `${sanitizeDownloadFilename(filenameBase, isVideo ? "generated-video" : "generated-image")}.${extension}`; return saveBlobToLocal(blob, filename, isVideo); }