import { buildImageProxyUrl } from "@/lib/image-delivery"; import { inferOriginalImageUrl } from "@/lib/image-url"; 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" | "request_failed"; export class CanvasImageDownloadError extends Error { code: CanvasImageDownloadErrorCode; retryAfterSeconds?: number; status?: number; constructor( message: string, options: { code: CanvasImageDownloadErrorCode; retryAfterSeconds?: number; status?: number; } ) { super(message); this.name = "CanvasImageDownloadError"; this.code = options.code; this.retryAfterSeconds = options.retryAfterSeconds; this.status = options.status; } } export function getCanvasImageDownloadErrorMessage(error: unknown) { if (error instanceof CanvasImageDownloadError) { if (error.code === "rate_limited") { return error.retryAfterSeconds ? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试` : "图片下载请求过于频繁,请稍后重试"; } if (error.code === "source_unavailable") { return `${error.message},源图可能已失效,请稍后重试或重新生成图片`; } } return error instanceof Error ? error.message : "图片下载失败,请稍后重试"; } type DownloadCanvasImageParams = { url: string; name?: string; candidates?: string[]; signal?: AbortSignal; onProgress?: (progress: CanvasImageDownloadProgress) => void; }; type DownloadErrorPayload = { error?: string; retryAfter?: number; }; 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 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; for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) { response = await fetch("/api/canvas/download-image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: urls[0], urls, name: filenameBase, }), signal, }); if (response.ok) break; errorPayload = (await response.json().catch(() => null)) as | DownloadErrorPayload | null; if (response.status !== 429) break; const retryAfterSeconds = parseRetryAfterSeconds(response, errorPayload); if (attempt >= MAX_RATE_LIMIT_RETRIES) { throw new CanvasImageDownloadError( errorPayload?.error || "图片下载请求过于频繁", { code: "rate_limited", retryAfterSeconds, status: response.status, } ); } 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, } ); } 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 }; }