ai-chatbot/lib/client/canvas-image-download.ts

333 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>((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<CanvasImageDownloadResult> {
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,
preferDirect: true,
}),
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,
}
);
}
if (response.headers.get("content-type")?.includes("application/json")) {
const result = await response.json();
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string"
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效");
if (signal?.aborted) throw signal.reason || new DOMException("已取消下载", "AbortError");
const frame = document.createElement("iframe");
frame.hidden = true;
frame.src = result.downloadUrl;
document.body.appendChild(frame);
window.setTimeout(() => frame.remove(), 10 * 60_000);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return { filename: result.filename, bytes: result.bytes || 0 };
}
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 };
}