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

428 lines
14 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, 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<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 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<CanvasImageDownloadResult> {
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<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;
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 };
}