fix: export canvas images as ps-safe png
This commit is contained in:
parent
61bb42a266
commit
ea1bc4c78b
76
app/api/canvas/download-image/route.ts
Normal file
76
app/api/canvas/download-image/route.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
buildAttachmentContentDisposition,
|
||||
fetchCanvasDownloadAsset,
|
||||
sanitizeCanvasDownloadFileName,
|
||||
} from "@/lib/canvas/photoshop-safe-download";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
function getRequestOrigin(request: NextRequest): string {
|
||||
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
|
||||
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
|
||||
if (forwardedHost && forwardedProto) {
|
||||
return `${forwardedProto}://${forwardedHost}`;
|
||||
}
|
||||
return new URL(request.url).origin;
|
||||
}
|
||||
|
||||
function resolveDownloadUrl(rawUrl: unknown, origin: string): string {
|
||||
const value = String(rawUrl || "").trim();
|
||||
if (!value) return "";
|
||||
try {
|
||||
const parsed = new URL(value, origin);
|
||||
if (!/^https?:$/i.test(parsed.protocol)) return "";
|
||||
return parsed.toString();
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const url = resolveDownloadUrl(body.url, requestOrigin);
|
||||
if (!url) {
|
||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||
const result = await fetchCanvasDownloadAsset({
|
||||
file: { url, name },
|
||||
index: 0,
|
||||
requestOrigin,
|
||||
cookie: request.headers.get("cookie") || "",
|
||||
includeSequencePrefix: false,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error || "图片下载失败" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(result.bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": result.contentType,
|
||||
"Content-Length": String(result.bytes.length),
|
||||
"Content-Disposition": buildAttachmentContentDisposition(result.filename),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadImage] Failed:", error);
|
||||
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import JSZip from "jszip";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
@ -10,40 +11,8 @@ type DownloadFileItem = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30000;
|
||||
const FETCH_RETRY_COUNT = 3;
|
||||
const MAX_CONCURRENCY = 8;
|
||||
const MAX_DOWNLOAD_FILES = 200;
|
||||
const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
function sanitizeFileName(value: string, fallback: string): string {
|
||||
const cleaned = String(value || "")
|
||||
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function inferExt(url: string, contentType: string): string {
|
||||
const type = String(contentType || "").toLowerCase();
|
||||
if (type.includes("jpeg")) return "jpg";
|
||||
if (type.includes("png")) return "png";
|
||||
if (type.includes("webp")) return "webp";
|
||||
if (type.includes("gif")) return "gif";
|
||||
if (type.includes("bmp")) return "bmp";
|
||||
if (type.includes("tiff")) return "tiff";
|
||||
if (type.includes("mp4")) return "mp4";
|
||||
if (type.includes("webm")) return "webm";
|
||||
if (type.includes("quicktime")) return "mov";
|
||||
|
||||
const cleanUrl = String(url || "").split("?")[0] || "";
|
||||
const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/);
|
||||
if (matched?.[1]) return matched[1].toLowerCase();
|
||||
return "png";
|
||||
}
|
||||
|
||||
function sanitizeFolderName(value: string): string {
|
||||
const cleaned = String(value || "")
|
||||
@ -65,23 +34,6 @@ function buildContentDisposition(folderName: string): string {
|
||||
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestOrigin(request: NextRequest): string {
|
||||
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
|
||||
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
|
||||
@ -116,94 +68,6 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
|
||||
.slice(0, MAX_DOWNLOAD_FILES);
|
||||
}
|
||||
|
||||
function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; BuyiCanvasDownloader/1.0; +https://buyi.ai)",
|
||||
Referer: requestOrigin,
|
||||
};
|
||||
|
||||
try {
|
||||
const targetOrigin = new URL(fileUrl).origin;
|
||||
if (targetOrigin === requestOrigin && cookie) {
|
||||
headers.cookie = cookie;
|
||||
}
|
||||
} catch (_error) {
|
||||
// URL has already been normalized before this point; keep this defensive.
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function normalizeDownloadError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || "download failed";
|
||||
if (typeof error === "string") return error;
|
||||
return "download failed";
|
||||
}
|
||||
|
||||
async function fetchDownloadFile(params: {
|
||||
file: DownloadFileItem;
|
||||
index: number;
|
||||
requestOrigin: string;
|
||||
cookie: string;
|
||||
}): Promise<
|
||||
| { ok: true; filename: string; bytes: Buffer }
|
||||
| { ok: false; url: string; name?: string; error: string }
|
||||
> {
|
||||
let lastError = "";
|
||||
|
||||
for (let attempt = 1; attempt <= FETCH_RETRY_COUNT; attempt += 1) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
params.file.url,
|
||||
{
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
headers: buildRemoteFetchHeaders(params.file.url, params.requestOrigin, params.cookie),
|
||||
},
|
||||
FETCH_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = `HTTP ${response.status}`;
|
||||
if (attempt < FETCH_RETRY_COUNT && RETRYABLE_STATUS_CODES.has(response.status)) {
|
||||
await sleep(350 * attempt);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length === 0) {
|
||||
throw new Error("empty response");
|
||||
}
|
||||
|
||||
const ext = inferExt(params.file.url, response.headers.get("content-type") || "");
|
||||
const baseName = sanitizeFileName(
|
||||
params.file.name || `canvas-image-${params.index + 1}`,
|
||||
`canvas-image-${params.index + 1}`
|
||||
);
|
||||
const sequencePrefix = String(params.index + 1).padStart(3, "0");
|
||||
const filename = `${sequencePrefix}-${baseName}.${ext}`;
|
||||
return { ok: true, filename, bytes };
|
||||
} catch (error) {
|
||||
lastError = normalizeDownloadError(error);
|
||||
if (attempt < FETCH_RETRY_COUNT) {
|
||||
await sleep(350 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
url: params.file.url,
|
||||
name: params.file.name,
|
||||
error: lastError || "图片下载失败",
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -238,7 +102,7 @@ export async function POST(request: NextRequest) {
|
||||
const folder = zip.folder(folderName) || zip;
|
||||
const cookie = request.headers.get("cookie") || "";
|
||||
const results: Array<
|
||||
| { ok: true; filename: string; bytes: Buffer }
|
||||
| { ok: true; filename: string; bytes: Buffer; contentType: string }
|
||||
| { ok: false; url: string; name?: string; error: string }
|
||||
| null
|
||||
> = new Array(files.length).fill(null);
|
||||
@ -252,7 +116,7 @@ export async function POST(request: NextRequest) {
|
||||
cursor += 1;
|
||||
if (index >= files.length) break;
|
||||
const file = files[index]!;
|
||||
results[index] = await fetchDownloadFile({
|
||||
results[index] = await fetchCanvasDownloadAsset({
|
||||
file,
|
||||
index,
|
||||
requestOrigin,
|
||||
|
||||
@ -278,6 +278,21 @@ const ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER = 0.72;
|
||||
const ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER = 0.46;
|
||||
const ANGLE_PANEL_SOURCE_WIDTH = 520;
|
||||
const ANGLE_PANEL_SOURCE_HEIGHT = 700;
|
||||
const parseAttachmentFilename = (contentDisposition: string | null) => {
|
||||
if (!contentDisposition) return "";
|
||||
const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1]);
|
||||
} catch (_error) {
|
||||
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() || "";
|
||||
};
|
||||
const ANGLE_PANEL_PREVIEW_SIZE = 392;
|
||||
const ANGLE_PANEL_CAMERA_SCENE_SIZE = 384;
|
||||
const ANGLE_PANEL_WIREFRAME_RADIUS = 132;
|
||||
@ -6784,7 +6799,7 @@ export function ImageNode({
|
||||
}
|
||||
};
|
||||
|
||||
// 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转)
|
||||
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
|
||||
const handleDownload = async () => {
|
||||
if (!data.src) return;
|
||||
const originalUrl = String(resolveOriginalImageUrl(data.src) || data.src || "").trim();
|
||||
@ -6793,20 +6808,32 @@ export function ImageNode({
|
||||
return;
|
||||
}
|
||||
|
||||
const urlParts = originalUrl.split(".");
|
||||
const ext = urlParts[urlParts.length - 1]?.split("?")[0] || "png";
|
||||
const filename = `${sanitizeFileName(displayTitle)}.${ext}`;
|
||||
|
||||
try {
|
||||
toast.info("正在准备下载...");
|
||||
toast.info("正在生成 PS 兼容 PNG...");
|
||||
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
||||
);
|
||||
const response = await fetch("/api/canvas/download-image", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: originalUrl,
|
||||
name: displayTitle,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败(${response.status})`);
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(payload?.error || `下载失败(${response.status})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob.type.toLowerCase().includes("image/png")) {
|
||||
throw new Error("下载结果不是标准 PNG");
|
||||
}
|
||||
const filename =
|
||||
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
||||
`${sanitizeFileName(displayTitle)}.png`;
|
||||
|
||||
// 创建 Blob URL
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
@ -6827,17 +6854,13 @@ export function ImageNode({
|
||||
toast.success("下载成功");
|
||||
} catch (error) {
|
||||
console.error("下载失败:", error);
|
||||
try {
|
||||
triggerBrowserDownload(originalUrl, filename);
|
||||
toast.message("代理下载失败,已切换为原图直链下载");
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
console.error("原图直链下载失败:", fallbackError);
|
||||
}
|
||||
|
||||
try {
|
||||
triggerBrowserDownload(originalUrl, undefined, { openInNewTab: true });
|
||||
toast.message("已打开原图,可直接另存为");
|
||||
toast.message(
|
||||
error instanceof Error
|
||||
? `${error.message},已打开原图,可稍后重试标准 PNG 下载`
|
||||
: "标准 PNG 下载失败,已打开原图,可稍后重试"
|
||||
);
|
||||
} catch (openError) {
|
||||
console.error("打开原图失败:", openError);
|
||||
toast.error("下载失败,请重试");
|
||||
|
||||
305
lib/canvas/photoshop-safe-download.ts
Normal file
305
lib/canvas/photoshop-safe-download.ts
Normal file
@ -0,0 +1,305 @@
|
||||
type DownloadFileItem = {
|
||||
url: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type CanvasDownloadAssetResult =
|
||||
| {
|
||||
ok: true;
|
||||
filename: string;
|
||||
bytes: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
url: string;
|
||||
name?: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_FETCH_RETRY_COUNT = 3;
|
||||
const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDownloadError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || "download failed";
|
||||
if (typeof error === "string") return error;
|
||||
return "download failed";
|
||||
}
|
||||
|
||||
export function sanitizeCanvasDownloadFileName(value: string, fallback: string): 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;
|
||||
}
|
||||
|
||||
export function buildAttachmentContentDisposition(filename: string): string {
|
||||
const safeAsciiName =
|
||||
filename
|
||||
.replace(/[^\x20-\x7E]+/g, "")
|
||||
.replace(/"/g, "")
|
||||
.trim() || `canvas-image-${Date.now()}.png`;
|
||||
return `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,video/*,*/*;q=0.8",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; BuyiCanvasDownloader/1.0; +https://buyi.ai)",
|
||||
Referer: requestOrigin,
|
||||
};
|
||||
|
||||
try {
|
||||
const targetOrigin = new URL(fileUrl).origin;
|
||||
if (targetOrigin === requestOrigin && cookie) {
|
||||
headers.cookie = cookie;
|
||||
}
|
||||
} catch (_error) {
|
||||
// URL has already been normalized before this point; keep this defensive.
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getContentTypeMime(contentType: string): string {
|
||||
return String(contentType || "")
|
||||
.split(";")[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function hasAsciiAt(buffer: Buffer, offset: number, value: string): boolean {
|
||||
return buffer.subarray(offset, offset + value.length).toString("latin1") === value;
|
||||
}
|
||||
|
||||
function looksLikeSvg(buffer: Buffer, contentType: string): boolean {
|
||||
if (getContentTypeMime(contentType) === "image/svg+xml") return true;
|
||||
const text = buffer.subarray(0, Math.min(buffer.length, 1024)).toString("utf8").trimStart();
|
||||
return /^(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(text);
|
||||
}
|
||||
|
||||
function looksLikeErrorDocument(buffer: Buffer, contentType: string): boolean {
|
||||
const mime = getContentTypeMime(contentType);
|
||||
if (
|
||||
mime.startsWith("text/") ||
|
||||
mime === "application/json" ||
|
||||
mime === "application/xml" ||
|
||||
mime === "application/xhtml+xml"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const text = buffer.subarray(0, Math.min(buffer.length, 512)).toString("utf8").trimStart();
|
||||
return /^(?:<!doctype\s+html|<html[\s>]|<\?xml|<error[\s>]|[{[])/i.test(text);
|
||||
}
|
||||
|
||||
function detectAsset(bytes: Buffer, contentType: string): { kind: "image" | "video"; mimeType: string; ext: string } | null {
|
||||
if (bytes.length < 4) return null;
|
||||
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
return { kind: "image", mimeType: "image/png", ext: "png" };
|
||||
}
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return { kind: "image", mimeType: "image/jpeg", ext: "jpg" };
|
||||
}
|
||||
if (hasAsciiAt(bytes, 0, "RIFF") && hasAsciiAt(bytes, 8, "WEBP")) {
|
||||
return { kind: "image", mimeType: "image/webp", ext: "webp" };
|
||||
}
|
||||
if (hasAsciiAt(bytes, 0, "GIF87a") || hasAsciiAt(bytes, 0, "GIF89a")) {
|
||||
return { kind: "image", mimeType: "image/gif", ext: "gif" };
|
||||
}
|
||||
if (hasAsciiAt(bytes, 0, "BM")) {
|
||||
return { kind: "image", mimeType: "image/bmp", ext: "bmp" };
|
||||
}
|
||||
if (
|
||||
bytes.subarray(0, 4).equals(Buffer.from([0x49, 0x49, 0x2a, 0x00])) ||
|
||||
bytes.subarray(0, 4).equals(Buffer.from([0x4d, 0x4d, 0x00, 0x2a]))
|
||||
) {
|
||||
return { kind: "image", mimeType: "image/tiff", ext: "tiff" };
|
||||
}
|
||||
if (looksLikeSvg(bytes, contentType)) {
|
||||
return { kind: "image", mimeType: "image/svg+xml", ext: "svg" };
|
||||
}
|
||||
if (hasAsciiAt(bytes, 4, "ftyp")) {
|
||||
const brand = bytes.subarray(8, Math.min(bytes.length, 32)).toString("latin1").toLowerCase();
|
||||
if (brand.includes("avif")) return { kind: "image", mimeType: "image/avif", ext: "avif" };
|
||||
if (brand.includes("heic") || brand.includes("heif") || brand.includes("mif1")) {
|
||||
return { kind: "image", mimeType: "image/heic", ext: "heic" };
|
||||
}
|
||||
return { kind: "video", mimeType: "video/mp4", ext: "mp4" };
|
||||
}
|
||||
if (bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) {
|
||||
return { kind: "video", mimeType: "video/webm", ext: "webm" };
|
||||
}
|
||||
|
||||
const mime = getContentTypeMime(contentType);
|
||||
if (mime.startsWith("image/")) {
|
||||
const ext = mime.includes("jpeg") ? "jpg" : mime.split("/")[1] || "png";
|
||||
return { kind: "image", mimeType: mime, ext };
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
const ext = mime.includes("quicktime") ? "mov" : mime.split("/")[1] || "mp4";
|
||||
return { kind: "video", mimeType: mime, ext };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer> {
|
||||
const sharp = await import("sharp");
|
||||
const image = sharp.default(bytes, {
|
||||
animated: false,
|
||||
failOn: "none",
|
||||
limitInputPixels: false,
|
||||
});
|
||||
const metadata = await image.metadata();
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error("无法解析图片尺寸");
|
||||
}
|
||||
return image.rotate().toColorspace("srgb").png({ compressionLevel: 6, adaptiveFiltering: true }).toBuffer();
|
||||
}
|
||||
|
||||
function buildCanvasAssetFilename(params: {
|
||||
item: DownloadFileItem;
|
||||
index: number;
|
||||
ext: string;
|
||||
includeSequencePrefix: boolean;
|
||||
}) {
|
||||
const baseName = sanitizeCanvasDownloadFileName(
|
||||
params.item.name || `canvas-image-${params.index + 1}`,
|
||||
`canvas-image-${params.index + 1}`
|
||||
);
|
||||
if (!params.includeSequencePrefix) {
|
||||
return `${baseName}.${params.ext}`;
|
||||
}
|
||||
const sequencePrefix = String(params.index + 1).padStart(3, "0");
|
||||
return `${sequencePrefix}-${baseName}.${params.ext}`;
|
||||
}
|
||||
|
||||
async function normalizeCanvasDownloadAsset(params: {
|
||||
item: DownloadFileItem;
|
||||
index: number;
|
||||
bytes: Buffer;
|
||||
contentType: string;
|
||||
includeSequencePrefix: boolean;
|
||||
}): Promise<CanvasDownloadAssetResult> {
|
||||
if (params.bytes.length === 0) {
|
||||
throw new Error("empty response");
|
||||
}
|
||||
if (looksLikeErrorDocument(params.bytes, params.contentType) && !looksLikeSvg(params.bytes, params.contentType)) {
|
||||
throw new Error("源地址返回的不是图片文件");
|
||||
}
|
||||
|
||||
const detected = detectAsset(params.bytes, params.contentType);
|
||||
if (!detected) {
|
||||
throw new Error("无法识别图片格式");
|
||||
}
|
||||
|
||||
if (detected.kind === "video") {
|
||||
return {
|
||||
ok: true,
|
||||
filename: buildCanvasAssetFilename({
|
||||
item: params.item,
|
||||
index: params.index,
|
||||
ext: detected.ext,
|
||||
includeSequencePrefix: params.includeSequencePrefix,
|
||||
}),
|
||||
bytes: params.bytes,
|
||||
contentType: detected.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
const pngBytes = await convertImageToPhotoshopPng(params.bytes);
|
||||
return {
|
||||
ok: true,
|
||||
filename: buildCanvasAssetFilename({
|
||||
item: params.item,
|
||||
index: params.index,
|
||||
ext: "png",
|
||||
includeSequencePrefix: params.includeSequencePrefix,
|
||||
}),
|
||||
bytes: pngBytes,
|
||||
contentType: "image/png",
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCanvasDownloadAsset(params: {
|
||||
file: DownloadFileItem;
|
||||
index: number;
|
||||
requestOrigin: string;
|
||||
cookie: string;
|
||||
includeSequencePrefix?: boolean;
|
||||
timeoutMs?: number;
|
||||
retryCount?: number;
|
||||
}): Promise<CanvasDownloadAssetResult> {
|
||||
let lastError = "";
|
||||
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
||||
|
||||
for (let attempt = 1; attempt <= retryCount; attempt += 1) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
params.file.url,
|
||||
{
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
headers: buildRemoteFetchHeaders(params.file.url, params.requestOrigin, params.cookie),
|
||||
},
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = `HTTP ${response.status}`;
|
||||
if (attempt < retryCount && RETRYABLE_STATUS_CODES.has(response.status)) {
|
||||
await sleep(350 * attempt);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return await normalizeCanvasDownloadAsset({
|
||||
item: params.file,
|
||||
index: params.index,
|
||||
bytes,
|
||||
contentType: response.headers.get("content-type") || "",
|
||||
includeSequencePrefix: params.includeSequencePrefix !== false,
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = normalizeDownloadError(error);
|
||||
if (attempt < retryCount) {
|
||||
await sleep(350 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
url: params.file.url,
|
||||
name: params.file.name,
|
||||
error: lastError || "图片下载失败",
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user