import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { inferOriginalImageUrl } from "@/lib/image-url"; import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error"; export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving"; export type CanvasImageDownloadProgress = { phase: CanvasImageDownloadPhase; message: string; loadedBytes?: number; totalBytes?: number; percent?: number; }; export type CanvasImageDownloadResult = { filename: string; bytes: number; }; type CanvasImageDownloadErrorCode = | "rate_limited" | "source_unavailable" | "preparation_timeout" | "request_failed"; export class CanvasImageDownloadError extends Error { code: CanvasImageDownloadErrorCode; retryAfterSeconds?: number; status?: number; failure?: DownloadFailure; requestId?: string; constructor( message: string, options: { code: CanvasImageDownloadErrorCode; retryAfterSeconds?: number; status?: number; failure?: DownloadFailure; requestId?: string; } ) { super(message); this.name = "CanvasImageDownloadError"; this.code = options.code; this.retryAfterSeconds = options.retryAfterSeconds; this.status = options.status; this.failure = options.failure; this.requestId = options.requestId; } } export function getCanvasImageDownloadErrorMessage(error: unknown) { if (error instanceof CanvasImageDownloadError) { if (error.failure) return formatDownloadFailure(error.failure, error.requestId); if (error.code === "rate_limited") { return error.retryAfterSeconds ? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试` : "图片下载请求过于频繁,请稍后重试"; } if (error.code === "source_unavailable") { return "原图暂时无法读取,图片仍保留在画布中,请稍后重试"; } if (error.code === "preparation_timeout") { return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`; } } return formatDownloadFailure(getDownloadFailure(error)); } type DownloadCanvasImageParams = { url: string; name?: string; candidates?: string[]; signal?: AbortSignal; onProgress?: (progress: CanvasImageDownloadProgress) => void; }; type DownloadErrorPayload = { error?: string; retryAfter?: number; failure?: DownloadFailure; requestId?: string; }; const MAX_RATE_LIMIT_RETRIES = 1; function parseRetryAfterSeconds( response: Response, payload: DownloadErrorPayload | null ) { const headerValue = response.headers.get("Retry-After")?.trim() || ""; const numericHeader = Number(headerValue); if (Number.isFinite(numericHeader) && numericHeader >= 0) { return Math.min(60, Math.ceil(numericHeader)); } if (headerValue) { const retryAt = Date.parse(headerValue); if (Number.isFinite(retryAt)) { return Math.min(60, Math.max(0, Math.ceil((retryAt - Date.now()) / 1000))); } } const payloadValue = Number(payload?.retryAfter); return Number.isFinite(payloadValue) && payloadValue >= 0 ? Math.min(60, Math.ceil(payloadValue)) : 1; } function waitForRetry(delayMs: number, signal?: AbortSignal) { if (delayMs <= 0) return Promise.resolve(); return new Promise((resolve, reject) => { const handleAbort = () => { clearTimeout(timer); reject(signal?.reason || new DOMException("已取消下载", "AbortError")); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", handleAbort); resolve(); }, delayMs); if (signal?.aborted) { handleAbort(); return; } signal?.addEventListener("abort", handleAbort, { once: true }); }); } function parseAttachmentFilename(contentDisposition: string | null) { if (!contentDisposition) return ""; const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); if (encodedMatch?.[1]) { try { return decodeURIComponent(encodedMatch[1]); } catch { return encodedMatch[1]; } } const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i); if (quotedMatch?.[1]) return quotedMatch[1]; const plainMatch = contentDisposition.match(/filename=([^;]+)/i); return plainMatch?.[1]?.trim() || ""; } function sanitizeDownloadName(value: string, fallback: string) { const cleaned = String(value || "") .replace(/\.[a-zA-Z0-9]{2,6}$/g, "") .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .trim() .slice(0, 120); return cleaned || fallback; } function inferDownloadNameFromUrl(url: string) { try { const pathname = new URL(url).pathname || ""; return pathname.split("/").pop() || ""; } catch { return String(url || "").split("/").pop()?.split("?")[0] || ""; } } function triggerBlobDownload(blob: Blob, filename: string) { const blobUrl = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = blobUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } function triggerCdnDownload(result: { downloadUrl: string; filename: string; bytes?: number }, signal?: AbortSignal) { if (typeof result.downloadUrl !== "string" || new URL(result.downloadUrl).protocol !== "https:") { throw new Error("下载链接无效"); } signal?.throwIfAborted(); const frame = document.createElement("iframe"); frame.hidden = true; frame.src = result.downloadUrl; document.body.appendChild(frame); window.setTimeout(() => frame.remove(), 10 * 60_000); return { filename: result.filename, bytes: result.bytes || 0 }; } /** Keep the source format and bytes for chat, gallery and reference downloads. */ export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise { const source = String(url || "").trim(); if (!source) throw new Error("图片地址无效"); const proxy = buildImageProxyUrl(source, { delivery: "original" }); const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image"); const target = new URL(proxy, window.location.origin); onProgress?.({ phase: "preparing", message: "正在准备原图..." }); const checkedFetch = async (input: string) => { let response: Response; try { response = await fetch(input, { signal }); } catch (error) { if (signal?.aborted) throw error; const failure = getDownloadFailure(error); throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure }); } if (!response.ok) { const payload = await response.json().catch(() => null) as DownloadErrorPayload | null; throw new CanvasImageDownloadError(payload?.error || "原图下载失败", { code: "request_failed", status: response.status, failure: payload?.failure || getDownloadFailure(`HTTP ${response.status}`), requestId: payload?.requestId }); } return response; }; if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") { target.searchParams.set("download", "resolve"); target.searchParams.set("name", filenameBase); const metadata = await checkedFetch(target.pathname + target.search); const delivery = await metadata.json(); if (delivery.delivery === "cdn") { const result = triggerCdnDownload(delivery, signal); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); return result; } if (delivery.delivery !== "proxy") throw new Error("下载链接无效"); } const response = await checkedFetch(proxy); const blob = await readResponseBlobWithProgress(response, onProgress); if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image"); const extension = getImageDownloadExtension(blob.type); const filename = extension ? `${filenameBase}.${extension}` : filenameBase; triggerBlobDownload(blob, filename); return { filename, bytes: blob.size }; } function toDownloadCandidates(url: string, candidates: string[] = []) { const raw = String(url || "").trim(); const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim(); return Array.from( new Set( [ originalUrl, raw, ...candidates, originalUrl ? buildImageProxyUrl(originalUrl, { delivery: "proxy" }) : "", ] .map((value) => String(value || "").trim()) .filter(Boolean) ) ); } async function readResponseBlobWithProgress( response: Response, onProgress?: (progress: CanvasImageDownloadProgress) => void ) { const totalBytes = Number(response.headers.get("content-length") || 0); if (!response.body) { const blob = await response.blob(); onProgress?.({ phase: "downloading", message: "正在下载原图...", loadedBytes: blob.size, totalBytes: totalBytes || blob.size, percent: 100, }); return blob; } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let loadedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; chunks.push(value); loadedBytes += value.byteLength; onProgress?.({ phase: "downloading", message: "正在下载原图...", loadedBytes, totalBytes: totalBytes || undefined, percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined, }); } return new Blob(chunks, { type: response.headers.get("content-type") || "image/png", }); } export async function downloadCanvasImageAsPng({ candidates, name, onProgress, signal, url, }: DownloadCanvasImageParams): Promise { const raw = String(url || "").trim(); if (!raw) { throw new Error("图片地址无效"); } const urls = toDownloadCandidates(raw, candidates); const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim(); const filenameBase = sanitizeDownloadName( name || inferDownloadNameFromUrl(originalUrl || raw), "canvas-image" ); onProgress?.({ phase: "preparing", message: "正在准备原图...", percent: 8, }); let response: Response | null = null; let errorPayload: DownloadErrorPayload | null = null; const preparationDeadline = Date.now() + 180_000; let rateLimitRetries = 0; let preparationPolls = 0; for (;;) { signal?.throwIfAborted(); try { response = await fetch("/api/canvas/download-image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: urls[0], urls, name: filenameBase, preferDirect: true, preparation: "async", }), signal, }); } catch (error) { if (signal?.aborted) throw error; const failure = getDownloadFailure(error); throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure }); } if (response.status === 202) { rateLimitRetries = 0; preparationPolls++; const preparing = await response.json().catch(() => null); if (preparing?.delivery !== "preparing") { throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 }); } if (Date.now() >= preparationDeadline || preparationPolls >= 60) { throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId }); } onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" }); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); await waitForRetry(delaySeconds * 1000, signal); continue; } if (response.ok) break; errorPayload = (await response.json().catch(() => null)) as | DownloadErrorPayload | null; if (response.status !== 429) break; const retryAfterSeconds = parseRetryAfterSeconds(response, errorPayload); if (rateLimitRetries++ >= MAX_RATE_LIMIT_RETRIES) { throw new CanvasImageDownloadError( errorPayload?.error || "图片下载请求过于频繁", { code: "rate_limited", retryAfterSeconds, status: response.status, failure: errorPayload?.failure, requestId: errorPayload?.requestId, } ); } onProgress?.({ phase: "preparing", message: retryAfterSeconds > 0 ? `请求较多,${retryAfterSeconds} 秒后自动重试...` : "请求较多,正在自动重试...", percent: 8, }); await waitForRetry(retryAfterSeconds * 1000, signal); } if (!response?.ok) { const status = response?.status; throw new CanvasImageDownloadError( errorPayload?.error || `下载失败(${status || "网络异常"})`, { code: status === 502 ? "source_unavailable" : "request_failed", status, failure: errorPayload?.failure, requestId: errorPayload?.requestId, } ); } if (response.headers.get("content-type")?.includes("application/json")) { const result = await response.json(); if (result.delivery !== "cdn") throw new Error("下载链接无效"); const downloaded = triggerCdnDownload(result, signal); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); return downloaded; } const filename = parseAttachmentFilename(response.headers.get("content-disposition")) || `${filenameBase}.png`; const blob = await readResponseBlobWithProgress(response, onProgress); onProgress?.({ phase: "saving", message: "正在保存文件...", loadedBytes: blob.size, totalBytes: blob.size, percent: 100, }); triggerBlobDownload(blob, filename); return { filename, bytes: blob.size }; }