diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx
index cbd4e6253..1e77b3938 100644
--- a/components/canvas/nodes/ImageNode.tsx
+++ b/components/canvas/nodes/ImageNode.tsx
@@ -8236,7 +8236,6 @@ export function ImageNode({
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
- percent: 5,
});
try {
diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts
index 33f9251a5..da6aeaafa 100644
--- a/lib/client/canvas-image-download.ts
+++ b/lib/client/canvas-image-download.ts
@@ -2,7 +2,7 @@ import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-deliv
import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
-export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
+export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving";
export type CanvasImageDownloadProgress = {
phase: CanvasImageDownloadPhase;
@@ -224,7 +224,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams):
// 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);
+ return downloadCanvasImageAsPng(params, "converting");
}
const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
@@ -239,7 +239,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams):
// 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);
+ return downloadCanvasImageAsPng(params, "converting");
}
const blob = await readResponseBlobWithProgress(response, onProgress);
signal?.throwIfAborted();
@@ -267,14 +267,15 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
async function readResponseBlobWithProgress(
response: Response,
- onProgress?: (progress: CanvasImageDownloadProgress) => void
+ onProgress?: (progress: CanvasImageDownloadProgress) => void,
+ message = "正在下载原图..."
) {
const totalBytes = Number(response.headers.get("content-length") || 0);
if (!response.body) {
const blob = await response.blob();
onProgress?.({
phase: "downloading",
- message: "正在下载原图...",
+ message,
loadedBytes: blob.size,
totalBytes: totalBytes || blob.size,
percent: 100,
@@ -294,7 +295,7 @@ async function readResponseBlobWithProgress(
loadedBytes += value.byteLength;
onProgress?.({
phase: "downloading",
- message: "正在下载原图...",
+ message,
loadedBytes,
totalBytes: totalBytes || undefined,
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
@@ -312,7 +313,7 @@ export async function downloadCanvasImageAsPng({
onProgress,
signal,
url,
-}: DownloadCanvasImageParams): Promise
{
+}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting" = "preparing"): Promise {
const raw = String(url || "").trim();
if (!raw) {
throw new Error("图片地址无效");
@@ -325,11 +326,9 @@ export async function downloadCanvasImageAsPng({
"canvas-image"
);
- onProgress?.({
- phase: "preparing",
- message: "正在准备原图...",
- percent: 8,
- });
+ const isConverting = preparationPhase === "converting";
+ const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
+ onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
let response: Response | null = null;
let errorPayload: DownloadErrorPayload | null = null;
@@ -368,7 +367,10 @@ export async function downloadCanvasImageAsPng({
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
}
- onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
+ onProgress?.({
+ phase: preparationPhase,
+ message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
+ });
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
await waitForRetry(delaySeconds * 1000, signal);
continue;
@@ -399,7 +401,6 @@ export async function downloadCanvasImageAsPng({
retryAfterSeconds > 0
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
: "请求较多,正在自动重试...",
- percent: 8,
});
await waitForRetry(retryAfterSeconds * 1000, signal);
}
@@ -428,7 +429,9 @@ export async function downloadCanvasImageAsPng({
const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${filenameBase}.png`;
- const blob = await readResponseBlobWithProgress(response, onProgress);
+ const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图...";
+ onProgress?.({ phase: "downloading", message: downloadMessage });
+ const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage);
onProgress?.({
phase: "saving",
diff --git a/tests/routes/original-image-download.test.ts b/tests/routes/original-image-download.test.ts
index cb4e41c84..14be81c52 100644
--- a/tests/routes/original-image-download.test.ts
+++ b/tests/routes/original-image-download.test.ts
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
+import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download";
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
@@ -124,16 +125,18 @@ function browserMocks() {
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
const dom = browserMocks(); const calls: string[] = [];
+ const progress: CanvasImageDownloadProgress[] = [];
const client = serverLoader({ ...dom, fetch: async (url: string) => {
calls.push(url);
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
} })("lib/client/canvas-image-download.ts");
- const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
+ const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) });
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
expect(calls).toHaveLength(1);
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
expect(dom.links).toHaveLength(0);
+ expect(progress.map(event => event.phase)).toEqual(["preparing", "downloading"]);
});
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
@@ -149,6 +152,7 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy
test("one click prepares a compatible PNG when the CDN original is WebP", async () => {
const dom = browserMocks();
const calls: string[] = [];
+ const progress: CanvasImageDownloadProgress[] = [];
const url = "https://cdn.example/image.png";
const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => {
calls.push(path);
@@ -157,10 +161,19 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async
}
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" });
+ if (calls.length === 2) {
+ return Response.json({ delivery: "preparing", retryAfter: 1, message: "正在准备原图" }, { status: 202 });
+ }
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(await client.downloadOriginalImage({ url, name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) }))
+ .toEqual({ filename: "商品.png", bytes: 900 });
+ expect(calls.slice(1)).toEqual(["/api/canvas/download-image", "/api/canvas/download-image"]);
+ expect(progress.map(event => event.phase)).toEqual(["preparing", "converting", "converting", "downloading"]);
+ for (const event of progress.filter(event => event.phase === "converting")) {
+ expect(event.message).toContain("正在转换为兼容 PNG");
+ expect(event.percent).toBeUndefined();
+ }
expect(dom.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]);
expect(dom.links).toHaveLength(0);
});
@@ -168,6 +181,7 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async
test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => {
const dom = browserMocks();
const calls: string[] = [];
+ const progress: CanvasImageDownloadProgress[] = [];
let cancelled = false;
const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
const client = serverLoader({ ...dom, fetch: async (path: string) => {
@@ -177,11 +191,16 @@ test("one click uses the PNG preparation route without buffering an incompatible
return new Response(new ReadableStream({ cancel() { cancelled = true; } }),
{ headers: { "content-type": "image/avif" } });
}
- return new Response(bytes, { headers: { "content-type": "image/png" } });
+ return new Response(bytes, { headers: { "content-type": "image/png", "content-length": String(bytes.length) } });
} })("lib/client/canvas-image-download.ts");
- expect(await client.downloadOriginalImage({ url: "https://external.example/image", name: "商品" })).toEqual({ filename: "商品.png", bytes: bytes.length });
+ expect(await client.downloadOriginalImage({
+ url: "https://external.example/image", name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event),
+ })).toEqual({ filename: "商品.png", bytes: bytes.length });
expect(cancelled).toBe(true);
expect(calls[2]).toBe("/api/canvas/download-image");
+ expect(progress).toContainEqual({
+ phase: "downloading", message: "正在下载 PNG...", loadedBytes: bytes.length, totalBytes: bytes.length, percent: 99,
+ });
expect(dom.links).toHaveLength(1);
URL.revokeObjectURL(dom.links[0].href);
});
--
2.47.2