加快画布原图下载和 Agent 图片显示
This commit is contained in:
parent
8cf113b554
commit
5a95e7da49
@ -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");
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
// 获取图片原始尺寸后添加到画布
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user