diff --git a/app/api/canvas/download-image/route.ts b/app/api/canvas/download-image/route.ts new file mode 100644 index 000000000..1ab6fc296 --- /dev/null +++ b/app/api/canvas/download-image/route.ts @@ -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; + 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 }); + } +} diff --git a/app/api/canvas/download-zip/route.ts b/app/api/canvas/download-zip/route.ts index dd30f6633..5a9b15675 100644 --- a/app/api/canvas/download-zip/route.ts +++ b/app/api/canvas/download-zip/route.ts @@ -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 { - 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 = { - 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, diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 930918c03..503d1fa90 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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("下载失败,请重试"); diff --git a/lib/canvas/photoshop-safe-download.ts b/lib/canvas/photoshop-safe-download.ts new file mode 100644 index 000000000..7fad30dcb --- /dev/null +++ b/lib/canvas/photoshop-safe-download.ts @@ -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 { + 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 = { + 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*)?]/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 /^(?:]|<\?xml|]|[{[])/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 { + 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 { + 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 { + 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 || "图片下载失败", + }; +}