From 8f56b5a567c641717229345cf4e4bea4b193986c Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 11 Nov 2026 14:29:42 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=9B=BE=E7=89=87=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E8=87=AA=E5=8A=A8=E5=85=BC=E5=AE=B9=20Photoshop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/canvas/CanvasChatPanel.tsx | 2 +- components/canvas/nodes/ImageNode.tsx | 17 ++------- lib/canvas/photoshop-safe-download.ts | 4 +- lib/client/canvas-image-download.ts | 23 +++++++++-- tests/routes/canvas-download-speed.test.ts | 4 ++ tests/routes/original-image-download.test.ts | 40 ++++++++++++++++++++ 6 files changed, 71 insertions(+), 19 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index b76c10cfd..5331ddf28 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -4476,7 +4476,7 @@ export function CanvasChatPanel({ signal: controller.signal, onProgress: setDownloadProgress, }); - toast.success("原图已开始下载"); + toast.success("已开始下载"); } catch (error) { if (controller.signal.aborted) { toast.message("已取消下载"); diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index fc64b3b82..cbd4e6253 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -178,7 +178,6 @@ import { } from "@/components/ui/tooltip"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { - downloadCanvasImageAsPng, downloadOriginalImage, getCanvasImageDownloadErrorMessage, type CanvasImageDownloadProgress, @@ -8225,7 +8224,7 @@ export function ImageNode({ }; const isDownloadingOriginalImage = Boolean(downloadProgress); - const handleDownload = async (format: "original" | "png" = "original") => { + const handleDownload = async () => { if (!data.src) return; if (isDownloadingOriginalImage) { toast.info("原图正在下载中"); @@ -8241,15 +8240,13 @@ export function ImageNode({ }); try { - const request = { + await downloadOriginalImage({ url: resolveOriginalImageUrl(data.src) || data.src, name: displayTitle, signal: controller.signal, onProgress: setDownloadProgress, - }; - if (format === "png") await downloadCanvasImageAsPng({ ...request, url: data.src }); - else await downloadOriginalImage(request); - toast.success(format === "png" ? "PNG 已开始下载" : "原图已开始下载"); + }); + toast.success("已开始下载"); } catch (error) { if (controller.signal.aborted) { toast.message("已取消下载"); @@ -9642,7 +9639,6 @@ export function ImageNode({ ) : null} {imageDownloadEnabled ? ( - <> { @@ -9660,11 +9656,6 @@ export function ImageNode({ )} {imageDownloadItem.label} - void handleDownload("png")}> - - 下载为 PNG(兼容 Photoshop) - - ) : null} {imageDeleteEnabled ? ( || (metadata.space === "rgb16" && metadata.depth === "ushort"); if (metadata.format === "png" && rgb && !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes; - return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer(); + const output = image.rotate().toColorspace("srgb"); + if (metadata.icc) output.withIccProfile("srgb"); + return output.png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer(); } function buildCanvasAssetFilename(params: { diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts index 2252bef8d..33f9251a5 100644 --- a/lib/client/canvas-image-download.ts +++ b/lib/client/canvas-image-download.ts @@ -189,8 +189,11 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt 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 { +const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]); + +/** Preserve compatible originals and prepare a PNG for other stored images. */ +export async function downloadOriginalImage(params: DownloadCanvasImageParams): Promise { + const { url, name, signal, onProgress } = params; const source = String(url || "").trim(); if (!source) throw new Error("图片地址无效"); const proxy = buildImageProxyUrl(source, { delivery: "original" }); @@ -218,6 +221,11 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D const metadata = await checkedFetch(target.pathname + target.search); const delivery = await metadata.json(); if (delivery.delivery === "cdn") { + // The resolver names the attachment using the stored MIME type. + const extension = delivery.filename.split(".").pop(); + if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) { + return downloadCanvasImageAsPng(params); + } const result = triggerCdnDownload(delivery, signal); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); return result; @@ -225,9 +233,16 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D if (delivery.delivery !== "proxy") throw new Error("下载链接无效"); } const response = await checkedFetch(proxy); + const contentType = response.headers.get("content-type") || ""; + if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image"); + const extension = getImageDownloadExtension(contentType); + // A local blob can still be downloaded while its upload is in progress. + if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) { + void response.body?.cancel().catch(() => {}); + return downloadCanvasImageAsPng(params); + } 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); + signal?.throwIfAborted(); const filename = extension ? `${filenameBase}.${extension}` : filenameBase; triggerBlobDownload(blob, filename); return { filename, bytes: blob.size }; diff --git a/tests/routes/canvas-download-speed.test.ts b/tests/routes/canvas-download-speed.test.ts index 9aa01762a..51b4bca07 100644 --- a/tests/routes/canvas-download-speed.test.ts +++ b/tests/routes/canvas-download-speed.test.ts @@ -112,6 +112,10 @@ test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizi const converted = await module.convertImageToPhotoshopPng(jpeg); const metadata = await sharp(converted).metadata(); expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" }); + const webp = await sharp(bytes).withIccProfile("p3").webp().toBuffer(); + const webpPng = await module.convertImageToPhotoshopPng(webp); + const webpPngMetadata = await sharp(webpPng).metadata(); + expect(webpPngMetadata).toMatchObject({ format: "png", width: 32, height: 48, hasAlpha: true, hasProfile: true }); }); test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => { let url = "https://cdn.example/uploads/test.png"; diff --git a/tests/routes/original-image-download.test.ts b/tests/routes/original-image-download.test.ts index 3188af83b..cb4e41c84 100644 --- a/tests/routes/original-image-download.test.ts +++ b/tests/routes/original-image-download.test.ts @@ -146,6 +146,46 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy URL.revokeObjectURL(dom.links[0].href); }); +test("one click prepares a compatible PNG when the CDN original is WebP", async () => { + const dom = browserMocks(); + const calls: string[] = []; + const url = "https://cdn.example/image.png"; + const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => { + calls.push(path); + if (calls.length === 1) { + return Response.json({ delivery: "cdn", downloadUrl: url, filename: "商品.webp", bytes: 500 }); + } + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" }); + return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "商品.png", bytes: 900 }); + } })("lib/client/canvas-image-download.ts"); + expect(await client.downloadOriginalImage({ url, name: "商品" })).toEqual({ filename: "商品.png", bytes: 900 }); + expect(calls[1]).toBe("/api/canvas/download-image"); + expect(dom.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]); + expect(dom.links).toHaveLength(0); +}); + +test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => { + const dom = browserMocks(); + const calls: string[] = []; + let cancelled = false; + const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10); + const client = serverLoader({ ...dom, fetch: async (path: string) => { + calls.push(path); + if (calls.length === 1) return Response.json({ delivery: "proxy" }); + if (calls.length === 2) { + return new Response(new ReadableStream({ cancel() { cancelled = true; } }), + { headers: { "content-type": "image/avif" } }); + } + return new Response(bytes, { headers: { "content-type": "image/png" } }); + } })("lib/client/canvas-image-download.ts"); + expect(await client.downloadOriginalImage({ url: "https://external.example/image", name: "商品" })).toEqual({ filename: "商品.png", bytes: bytes.length }); + expect(cancelled).toBe(true); + expect(calls[2]).toBe("/api/canvas/download-image"); + expect(dom.links).toHaveLength(1); + URL.revokeObjectURL(dom.links[0].href); +}); + test("original downloads reject processed references before network access", async () => { const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts"); await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");