diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index 656a29344..40a14685f 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -133,15 +133,13 @@ function buildImageResponse( etag: string; vary?: string; cacheStatus?: "HIT" | "MISS"; - cacheControl?: string; - attachment?: boolean; } ) { const headers: Record = { "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"); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 6db5bf306..b76c10cfd 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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); // 获取图片原始尺寸后添加到画布 diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index fcaa93a67..fc64b3b82 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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({ ) : null} {imageDownloadEnabled ? ( + <> { @@ -9673,6 +9660,11 @@ export function ImageNode({ )} {imageDownloadItem.label} + void handleDownload("png")}> + + 下载为 PNG(兼容 Photoshop) + + ) : null} {imageDeleteEnabled ? ( { + const head = Uint8Array.from([255, 216, 255, 224]); + const tail = Uint8Array.from([4, 8, 255, 217]); + let upstream!: ReadableStreamDefaultController; + const body = new ReadableStream({ + 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,