显示下载中的图片格式转换进度
This commit is contained in:
parent
ed3b1b5e8a
commit
bbecafc826
@ -4467,7 +4467,6 @@ export function CanvasChatPanel({
|
|||||||
setDownloadProgress({
|
setDownloadProgress({
|
||||||
phase: "preparing",
|
phase: "preparing",
|
||||||
message: "正在准备原图...",
|
message: "正在准备原图...",
|
||||||
percent: 5,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export function CanvasImageDownloadProgressDialog({
|
|||||||
: null;
|
: null;
|
||||||
const loadedLabel = formatBytes(progress.loadedBytes);
|
const loadedLabel = formatBytes(progress.loadedBytes);
|
||||||
const totalLabel = formatBytes(progress.totalBytes);
|
const totalLabel = formatBytes(progress.totalBytes);
|
||||||
|
const converting = progress.phase === "converting";
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@ -41,12 +42,12 @@ export function CanvasImageDownloadProgressDialog({
|
|||||||
{progress.phase === "saving" ? (
|
{progress.phase === "saving" ? (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 motion-safe:animate-spin" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] font-semibold">下载原图</div>
|
<div className="text-[14px] font-semibold">下载原图</div>
|
||||||
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
|
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{onCancel ? (
|
{onCancel ? (
|
||||||
@ -63,14 +64,19 @@ export function CanvasImageDownloadProgressDialog({
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
||||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
|
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span>
|
||||||
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
<div
|
||||||
|
className="relative h-2 overflow-hidden rounded-full bg-white/10"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={converting ? "图片格式转换" : "图片下载"}
|
||||||
|
aria-valuenow={percent ?? undefined}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
|
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
|
||||||
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
|
percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
|
||||||
)}
|
)}
|
||||||
style={percent !== null ? { width: `${percent}%` } : undefined}
|
style={percent !== null ? { width: `${percent}%` } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -8236,7 +8236,6 @@ export function ImageNode({
|
|||||||
setDownloadProgress({
|
setDownloadProgress({
|
||||||
phase: "preparing",
|
phase: "preparing",
|
||||||
message: "正在准备原图...",
|
message: "正在准备原图...",
|
||||||
percent: 5,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-deliv
|
|||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
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 = {
|
export type CanvasImageDownloadProgress = {
|
||||||
phase: CanvasImageDownloadPhase;
|
phase: CanvasImageDownloadPhase;
|
||||||
@ -224,7 +224,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams):
|
|||||||
// The resolver names the attachment using the stored MIME type.
|
// The resolver names the attachment using the stored MIME type.
|
||||||
const extension = delivery.filename.split(".").pop();
|
const extension = delivery.filename.split(".").pop();
|
||||||
if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
|
if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
|
||||||
return downloadCanvasImageAsPng(params);
|
return downloadCanvasImageAsPng(params, "converting");
|
||||||
}
|
}
|
||||||
const result = triggerCdnDownload(delivery, signal);
|
const result = triggerCdnDownload(delivery, signal);
|
||||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
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.
|
// A local blob can still be downloaded while its upload is in progress.
|
||||||
if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
|
if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
|
||||||
void response.body?.cancel().catch(() => {});
|
void response.body?.cancel().catch(() => {});
|
||||||
return downloadCanvasImageAsPng(params);
|
return downloadCanvasImageAsPng(params, "converting");
|
||||||
}
|
}
|
||||||
const blob = await readResponseBlobWithProgress(response, onProgress);
|
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
@ -267,14 +267,15 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
|
|||||||
|
|
||||||
async function readResponseBlobWithProgress(
|
async function readResponseBlobWithProgress(
|
||||||
response: Response,
|
response: Response,
|
||||||
onProgress?: (progress: CanvasImageDownloadProgress) => void
|
onProgress?: (progress: CanvasImageDownloadProgress) => void,
|
||||||
|
message = "正在下载原图..."
|
||||||
) {
|
) {
|
||||||
const totalBytes = Number(response.headers.get("content-length") || 0);
|
const totalBytes = Number(response.headers.get("content-length") || 0);
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
phase: "downloading",
|
phase: "downloading",
|
||||||
message: "正在下载原图...",
|
message,
|
||||||
loadedBytes: blob.size,
|
loadedBytes: blob.size,
|
||||||
totalBytes: totalBytes || blob.size,
|
totalBytes: totalBytes || blob.size,
|
||||||
percent: 100,
|
percent: 100,
|
||||||
@ -294,7 +295,7 @@ async function readResponseBlobWithProgress(
|
|||||||
loadedBytes += value.byteLength;
|
loadedBytes += value.byteLength;
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
phase: "downloading",
|
phase: "downloading",
|
||||||
message: "正在下载原图...",
|
message,
|
||||||
loadedBytes,
|
loadedBytes,
|
||||||
totalBytes: totalBytes || undefined,
|
totalBytes: totalBytes || undefined,
|
||||||
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
|
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
|
||||||
@ -312,7 +313,7 @@ export async function downloadCanvasImageAsPng({
|
|||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
url,
|
url,
|
||||||
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting" = "preparing"): Promise<CanvasImageDownloadResult> {
|
||||||
const raw = String(url || "").trim();
|
const raw = String(url || "").trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
throw new Error("图片地址无效");
|
throw new Error("图片地址无效");
|
||||||
@ -325,11 +326,9 @@ export async function downloadCanvasImageAsPng({
|
|||||||
"canvas-image"
|
"canvas-image"
|
||||||
);
|
);
|
||||||
|
|
||||||
onProgress?.({
|
const isConverting = preparationPhase === "converting";
|
||||||
phase: "preparing",
|
const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
|
||||||
message: "正在准备原图...",
|
onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
|
||||||
percent: 8,
|
|
||||||
});
|
|
||||||
|
|
||||||
let response: Response | null = null;
|
let response: Response | null = null;
|
||||||
let errorPayload: DownloadErrorPayload | null = null;
|
let errorPayload: DownloadErrorPayload | null = null;
|
||||||
@ -368,7 +367,10 @@ export async function downloadCanvasImageAsPng({
|
|||||||
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
||||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
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));
|
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
||||||
await waitForRetry(delaySeconds * 1000, signal);
|
await waitForRetry(delaySeconds * 1000, signal);
|
||||||
continue;
|
continue;
|
||||||
@ -399,7 +401,6 @@ export async function downloadCanvasImageAsPng({
|
|||||||
retryAfterSeconds > 0
|
retryAfterSeconds > 0
|
||||||
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
|
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
|
||||||
: "请求较多,正在自动重试...",
|
: "请求较多,正在自动重试...",
|
||||||
percent: 8,
|
|
||||||
});
|
});
|
||||||
await waitForRetry(retryAfterSeconds * 1000, signal);
|
await waitForRetry(retryAfterSeconds * 1000, signal);
|
||||||
}
|
}
|
||||||
@ -428,7 +429,9 @@ export async function downloadCanvasImageAsPng({
|
|||||||
const filename =
|
const filename =
|
||||||
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
||||||
`${filenameBase}.png`;
|
`${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?.({
|
onProgress?.({
|
||||||
phase: "saving",
|
phase: "saving",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
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" };
|
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 () => {
|
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
||||||
const dom = browserMocks(); const calls: string[] = [];
|
const dom = browserMocks(); const calls: string[] = [];
|
||||||
|
const progress: CanvasImageDownloadProgress[] = [];
|
||||||
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
||||||
calls.push(url);
|
calls.push(url);
|
||||||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
|
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");
|
} })("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(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
|
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.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
|
||||||
expect(dom.links).toHaveLength(0);
|
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 () => {
|
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 () => {
|
test("one click prepares a compatible PNG when the CDN original is WebP", async () => {
|
||||||
const dom = browserMocks();
|
const dom = browserMocks();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const progress: CanvasImageDownloadProgress[] = [];
|
||||||
const url = "https://cdn.example/image.png";
|
const url = "https://cdn.example/image.png";
|
||||||
const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => {
|
const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => {
|
||||||
calls.push(path);
|
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(init?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" });
|
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 });
|
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "商品.png", bytes: 900 });
|
||||||
} })("lib/client/canvas-image-download.ts");
|
} })("lib/client/canvas-image-download.ts");
|
||||||
expect(await client.downloadOriginalImage({ url, name: "商品" })).toEqual({ filename: "商品.png", bytes: 900 });
|
expect(await client.downloadOriginalImage({ url, name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) }))
|
||||||
expect(calls[1]).toBe("/api/canvas/download-image");
|
.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.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]);
|
||||||
expect(dom.links).toHaveLength(0);
|
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 () => {
|
test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => {
|
||||||
const dom = browserMocks();
|
const dom = browserMocks();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const progress: CanvasImageDownloadProgress[] = [];
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
|
const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
|
||||||
const client = serverLoader({ ...dom, fetch: async (path: string) => {
|
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; } }),
|
return new Response(new ReadableStream({ cancel() { cancelled = true; } }),
|
||||||
{ headers: { "content-type": "image/avif" } });
|
{ 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");
|
} })("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(cancelled).toBe(true);
|
||||||
expect(calls[2]).toBe("/api/canvas/download-image");
|
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);
|
expect(dom.links).toHaveLength(1);
|
||||||
URL.revokeObjectURL(dom.links[0].href);
|
URL.revokeObjectURL(dom.links[0].href);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user