Compare commits

..

No commits in common. "main" and "codex/original-download-metadata-cache-20261111" have entirely different histories.

7 changed files with 40 additions and 118 deletions

View File

@ -4467,6 +4467,7 @@ export function CanvasChatPanel({
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
@ -4475,7 +4476,7 @@ export function CanvasChatPanel({
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("已开始下载");
toast.success("原图已开始下载");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");

View File

@ -28,7 +28,6 @@ export function CanvasImageDownloadProgressDialog({
: null;
const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes);
const converting = progress.phase === "converting";
return createPortal(
<div
@ -42,12 +41,12 @@ export function CanvasImageDownloadProgressDialog({
{progress.phase === "saving" ? (
<Download className="h-4 w-4" />
) : (
<Loader2 className="h-4 w-4 motion-safe:animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
)}
</span>
<div>
<div className="text-[14px] font-semibold">下载原图</div>
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div>
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
</div>
</div>
{onCancel ? (
@ -64,19 +63,14 @@ export function CanvasImageDownloadProgressDialog({
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span>
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
<span>{percent !== null ? `${percent}%` : "..."}</span>
</div>
<div
className="relative h-2 overflow-hidden rounded-full bg-white/10"
role="progressbar"
aria-label={converting ? "图片格式转换" : "图片下载"}
aria-valuenow={percent ?? undefined}
>
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
<div
className={cn(
"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 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>

View File

@ -178,6 +178,7 @@ import {
} from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
@ -8224,7 +8225,7 @@ export function ImageNode({
};
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => {
const handleDownload = async (format: "original" | "png" = "original") => {
if (!data.src) return;
if (isDownloadingOriginalImage) {
toast.info("原图正在下载中");
@ -8236,16 +8237,19 @@ export function ImageNode({
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
await downloadOriginalImage({
const request = {
url: resolveOriginalImageUrl(data.src) || data.src,
name: displayTitle,
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("已开始下载");
};
if (format === "png") await downloadCanvasImageAsPng({ ...request, url: data.src });
else await downloadOriginalImage(request);
toast.success(format === "png" ? "PNG 已开始下载" : "原图已开始下载");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
@ -9638,6 +9642,7 @@ export function ImageNode({
<DropdownMenuSeparator />
) : null}
{imageDownloadEnabled ? (
<>
<DropdownMenuItem
disabled={isDownloadingOriginalImage}
onClick={() => {
@ -9655,6 +9660,11 @@ 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

View File

@ -221,9 +221,7 @@ 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;
const output = image.rotate().toColorspace("srgb");
if (metadata.icc) output.withIccProfile("srgb");
return output.png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
}
function buildCanvasAssetFilename(params: {

View File

@ -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" | "converting" | "downloading" | "saving";
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
export type CanvasImageDownloadProgress = {
phase: CanvasImageDownloadPhase;
@ -189,11 +189,8 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt
return { filename: result.filename, bytes: result.bytes || 0 };
}
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;
/** Keep the source format and bytes for chat, gallery and reference downloads. */
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效");
const proxy = buildImageProxyUrl(source, { delivery: "original" });
@ -221,11 +218,6 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams):
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, "converting");
}
const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result;
@ -233,16 +225,9 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams):
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, "converting");
}
const blob = await readResponseBlobWithProgress(response, onProgress);
signal?.throwIfAborted();
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size };
@ -267,15 +252,14 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
async function readResponseBlobWithProgress(
response: Response,
onProgress?: (progress: CanvasImageDownloadProgress) => void,
message = "正在下载原图..."
onProgress?: (progress: CanvasImageDownloadProgress) => void
) {
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,
@ -295,7 +279,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,
@ -313,7 +297,7 @@ export async function downloadCanvasImageAsPng({
onProgress,
signal,
url,
}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting" = "preparing"): Promise<CanvasImageDownloadResult> {
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const raw = String(url || "").trim();
if (!raw) {
throw new Error("图片地址无效");
@ -326,9 +310,11 @@ export async function downloadCanvasImageAsPng({
"canvas-image"
);
const isConverting = preparationPhase === "converting";
const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
onProgress?.({
phase: "preparing",
message: "正在准备原图...",
percent: 8,
});
let response: Response | null = null;
let errorPayload: DownloadErrorPayload | null = null;
@ -367,10 +353,7 @@ export async function downloadCanvasImageAsPng({
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
}
onProgress?.({
phase: preparationPhase,
message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
});
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
await waitForRetry(delaySeconds * 1000, signal);
continue;
@ -401,6 +384,7 @@ export async function downloadCanvasImageAsPng({
retryAfterSeconds > 0
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
: "请求较多,正在自动重试...",
percent: 8,
});
await waitForRetry(retryAfterSeconds * 1000, signal);
}
@ -429,9 +413,7 @@ export async function downloadCanvasImageAsPng({
const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${filenameBase}.png`;
const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图...";
onProgress?.({ phase: "downloading", message: downloadMessage });
const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage);
const blob = await readResponseBlobWithProgress(response, onProgress);
onProgress?.({
phase: "saving",

View File

@ -112,10 +112,6 @@ 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";

View File

@ -1,6 +1,5 @@
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" };
@ -125,18 +124,16 @@ 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", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) });
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
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,62 +146,6 @@ 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 progress: CanvasImageDownloadProgress[] = [];
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" });
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: "商品", 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);
});
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) => {
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", "content-length": String(bytes.length) } });
} })("lib/client/canvas-image-download.ts");
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);
});
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("无法确认原图");