画布图片下载自动兼容 Photoshop #74
@ -4476,7 +4476,7 @@ export function CanvasChatPanel({
|
||||
signal: controller.signal,
|
||||
onProgress: setDownloadProgress,
|
||||
});
|
||||
toast.success("原图已开始下载");
|
||||
toast.success("已开始下载");
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
toast.message("已取消下载");
|
||||
|
||||
@ -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({
|
||||
<DropdownMenuSeparator />
|
||||
) : null}
|
||||
{imageDownloadEnabled ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={isDownloadingOriginalImage}
|
||||
onClick={() => {
|
||||
@ -9660,11 +9656,6 @@ export function ImageNode({
|
||||
)}
|
||||
{imageDownloadItem.label}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={isDownloadingOriginalImage} onClick={() => void handleDownload("png")}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载为 PNG(兼容 Photoshop)
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
{imageDeleteEnabled ? (
|
||||
<DropdownMenuItem
|
||||
|
||||
@ -221,7 +221,9 @@ export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer>
|
||||
|| (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: {
|
||||
|
||||
@ -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<CanvasImageDownloadResult> {
|
||||
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<CanvasImageDownloadResult> {
|
||||
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 };
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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("无法确认原图");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user