fix: export canvas images as ps-safe png

This commit is contained in:
Your Name 2026-06-02 10:53:26 +08:00
parent 61bb42a266
commit ea1bc4c78b
4 changed files with 426 additions and 158 deletions

View 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 });
}
}

View File

@ -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,

View File

@ -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("下载失败,请重试");

View 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 || "图片下载失败",
};
}