333 lines
9.5 KiB
TypeScript
333 lines
9.5 KiB
TypeScript
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 };
|
||
}
|