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("无法确认原图");