加快画布原图下载和 Agent 图片显示 #72

Merged
WuYongJie merged 1 commits from codex/original-delivery-speed-20261111 into main 2026-11-11 13:35:48 +08:00
4 changed files with 69 additions and 68 deletions
Showing only changes of commit 5a95e7da49 - Show all commits

View File

@ -133,15 +133,13 @@ function buildImageResponse(
etag: string;
vary?: string;
cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
}
) {
const headers: Record<string, string> = {
"Content-Type": params.contentType,
"Content-Length": String(body.length),
ETag: params.etag,
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
};
if (params.vary) {
headers.Vary = params.vary;
@ -149,11 +147,6 @@ function buildImageResponse(
if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
}
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, {
status: 200,
headers,
@ -460,20 +453,29 @@ export async function GET(request: NextRequest) {
if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
if (original) {
if (upstreamResponse.status !== 200 || !upstreamResponse.body) {
await upstreamResponse.body?.cancel();
return downloadFailure("源图片传输中断,请重试");
}
const headers = new Headers({
"Content-Type": contentType,
"Content-Disposition": "attachment",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
});
const length = upstreamResponse.headers.get("content-length");
const encoding = upstreamResponse.headers.get("content-encoding");
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
return new NextResponse(upstreamResponse.body, { headers });
}
try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
}
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -78,7 +78,7 @@ import {
type UploadProgress,
} from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -1462,7 +1462,7 @@ function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
}
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(url: string): Promise<{
function getImageDimensions(source: string | File): Promise<{
width: number;
height: number;
originalWidth: number;
@ -1470,6 +1470,7 @@ function getImageDimensions(url: string): Promise<{
}> {
return new Promise((resolve) => {
const img = new Image();
const objectUrl = typeof source === "string" ? null : URL.createObjectURL(source);
let settled = false;
const resolveOnce = (dimensions: {
width: number;
@ -1482,9 +1483,11 @@ function getImageDimensions(url: string): Promise<{
window.clearTimeout(timeout);
img.onload = null;
img.onerror = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(dimensions);
};
const timeout = window.setTimeout(() => {
img.removeAttribute("src");
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
@ -1517,7 +1520,7 @@ function getImageDimensions(url: string): Promise<{
// 加载失败时使用默认尺寸
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
};
img.src = url;
img.src = objectUrl ?? String(source);
});
}
@ -4468,9 +4471,8 @@ export function CanvasChatPanel({
});
try {
await downloadCanvasImageAsPng({
await downloadOriginalImage({
url: originalUrl,
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
signal: controller.signal,
onProgress: setDownloadProgress,
});
@ -7806,7 +7808,6 @@ export function CanvasChatPanel({
existingMessageImageKeys.add(messageImageKey);
}
const dimensions = await getImageDimensions(url);
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
@ -7839,7 +7840,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = { width: dimensions.width, height: dimensions.height };
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -7857,8 +7858,6 @@ export function CanvasChatPanel({
prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt,
sourceMessageId: messageId || undefined,
sourceImageKey,
@ -9583,7 +9582,6 @@ export function CanvasChatPanel({
continue;
}
const dimensions = await getImageDimensions(url);
if (cancelled) return;
const compareSourceUrlRaw =
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
@ -9616,20 +9614,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = placeholderNode
? { width: dimensions.width, height: dimensions.height }
: anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -9648,8 +9633,6 @@ export function CanvasChatPanel({
prompt: generationPrompt || "对话生成",
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey,
@ -10376,7 +10359,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -10454,7 +10437,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -13740,7 +13723,7 @@ export function CanvasChatPanel({
};
onUploaded?.(uploadedBase);
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
@ -14929,7 +14912,7 @@ export function CanvasChatPanel({
firstImageUrl = imageUrl;
}
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布

View File

@ -179,6 +179,7 @@ import {
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -2519,15 +2520,6 @@ export function ImageNode({
}),
[data.src, displayImageQuality, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
@ -8232,9 +8224,8 @@ export function ImageNode({
}
};
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => {
const handleDownload = async (format: "original" | "png" = "original") => {
if (!data.src) return;
if (isDownloadingOriginalImage) {
toast.info("原图正在下载中");
@ -8250,20 +8241,15 @@ export function ImageNode({
});
try {
await downloadCanvasImageAsPng({
url: data.src,
const request = {
url: resolveOriginalImageUrl(data.src) || data.src,
name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
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("已取消下载");
@ -9656,6 +9642,7 @@ export function ImageNode({
<DropdownMenuSeparator />
) : null}
{imageDownloadEnabled ? (
<>
<DropdownMenuItem
disabled={isDownloadingOriginalImage}
onClick={() => {
@ -9673,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

@ -66,6 +66,30 @@ test("original download metadata reports missing sources with cause, advice and
expect(data.error).toContain(data.requestId);
});
test("the original proxy returns the first bytes while the upstream is still sending the file", async () => {
const head = Uint8Array.from([255, 216, 255, 224]);
const tail = Uint8Array.from([4, 8, 255, 217]);
let upstream!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(controller) { upstream = controller; controller.enqueue(head); },
});
const route = serverLoader({
"next/server": { NextResponse: Response },
fetch: async () => new Response(body, { headers: { "content-type": "image/jpeg", "content-length": "8" } }),
})("app/api/image-proxy/route.ts");
const response = await route.GET({
nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fimages.example%2Foriginal.jpg&original=1"),
signal: new AbortController().signal, headers: new Headers(),
});
expect(response.headers.get("content-length")).toBe("8");
const reader = response.body.getReader();
expect((await reader.read()).value).toEqual(head);
upstream.enqueue(tail);
upstream.close();
expect((await reader.read()).value).toEqual(tail);
expect((await reader.read()).done).toBe(true);
});
function browserMocks() {
const frames: any[] = []; const links: any[] = [];
return { frames, links,