加快画布原图下载和 Agent 图片显示
This commit is contained in:
parent
8cf113b554
commit
5a95e7da49
@ -133,15 +133,13 @@ function buildImageResponse(
|
|||||||
etag: string;
|
etag: string;
|
||||||
vary?: string;
|
vary?: string;
|
||||||
cacheStatus?: "HIT" | "MISS";
|
cacheStatus?: "HIT" | "MISS";
|
||||||
cacheControl?: string;
|
|
||||||
attachment?: boolean;
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": params.contentType,
|
"Content-Type": params.contentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(body.length),
|
||||||
ETag: params.etag,
|
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) {
|
if (params.vary) {
|
||||||
headers.Vary = params.vary;
|
headers.Vary = params.vary;
|
||||||
@ -149,11 +147,6 @@ function buildImageResponse(
|
|||||||
if (params.cacheStatus) {
|
if (params.cacheStatus) {
|
||||||
headers["X-Image-Proxy-Cache"] = 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, {
|
return new NextResponse(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers,
|
headers,
|
||||||
@ -460,20 +453,29 @@ export async function GET(request: NextRequest) {
|
|||||||
if (original) return downloadFailure("Source is not an image");
|
if (original) return downloadFailure("Source is not an image");
|
||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
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 {
|
try {
|
||||||
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return upstreamFailure(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 isSvg = contentType.toLowerCase().includes("image/svg");
|
||||||
const isGif = contentType.toLowerCase().includes("image/gif");
|
const isGif = contentType.toLowerCase().includes("image/gif");
|
||||||
|
|
||||||
|
|||||||
@ -78,7 +78,7 @@ import {
|
|||||||
type UploadProgress,
|
type UploadProgress,
|
||||||
} from "@/lib/client/upload-with-retry";
|
} from "@/lib/client/upload-with-retry";
|
||||||
import {
|
import {
|
||||||
downloadCanvasImageAsPng,
|
downloadOriginalImage,
|
||||||
getCanvasImageDownloadErrorMessage,
|
getCanvasImageDownloadErrorMessage,
|
||||||
type CanvasImageDownloadProgress,
|
type CanvasImageDownloadProgress,
|
||||||
} from "@/lib/client/canvas-image-download";
|
} 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;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
originalWidth: number;
|
originalWidth: number;
|
||||||
@ -1470,6 +1470,7 @@ function getImageDimensions(url: string): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
const objectUrl = typeof source === "string" ? null : URL.createObjectURL(source);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const resolveOnce = (dimensions: {
|
const resolveOnce = (dimensions: {
|
||||||
width: number;
|
width: number;
|
||||||
@ -1482,9 +1483,11 @@ function getImageDimensions(url: string): Promise<{
|
|||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
img.onload = null;
|
img.onload = null;
|
||||||
img.onerror = null;
|
img.onerror = null;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
resolve(dimensions);
|
resolve(dimensions);
|
||||||
};
|
};
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
|
img.removeAttribute("src");
|
||||||
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
|
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
|
||||||
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
|
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
|
||||||
|
|
||||||
@ -1517,7 +1520,7 @@ function getImageDimensions(url: string): Promise<{
|
|||||||
// 加载失败时使用默认尺寸
|
// 加载失败时使用默认尺寸
|
||||||
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
|
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = objectUrl ?? String(source);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4468,9 +4471,8 @@ export function CanvasChatPanel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadCanvasImageAsPng({
|
await downloadOriginalImage({
|
||||||
url: originalUrl,
|
url: originalUrl,
|
||||||
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onProgress: setDownloadProgress,
|
onProgress: setDownloadProgress,
|
||||||
});
|
});
|
||||||
@ -7806,7 +7808,6 @@ export function CanvasChatPanel({
|
|||||||
existingMessageImageKeys.add(messageImageKey);
|
existingMessageImageKeys.add(messageImageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(url);
|
|
||||||
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
|
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
|
||||||
const compareSourceUrlRaw =
|
const compareSourceUrlRaw =
|
||||||
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
|
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
|
||||||
@ -7839,7 +7840,7 @@ export function CanvasChatPanel({
|
|||||||
const placeholderNode = reservedPlaceholderNode
|
const placeholderNode = reservedPlaceholderNode
|
||||||
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
|
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
|
||||||
: null;
|
: null;
|
||||||
const size = { width: dimensions.width, height: dimensions.height };
|
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
|
||||||
const position = placeholderNode
|
const position = placeholderNode
|
||||||
? getCenteredReplacementPosition(placeholderNode, size)
|
? getCenteredReplacementPosition(placeholderNode, size)
|
||||||
: getAnchoredGenerationPosition({
|
: getAnchoredGenerationPosition({
|
||||||
@ -7857,8 +7858,6 @@ export function CanvasChatPanel({
|
|||||||
prompt: generationPrompt,
|
prompt: generationPrompt,
|
||||||
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
|
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
originalWidth: dimensions.originalWidth,
|
|
||||||
originalHeight: dimensions.originalHeight,
|
|
||||||
generationPrompt,
|
generationPrompt,
|
||||||
sourceMessageId: messageId || undefined,
|
sourceMessageId: messageId || undefined,
|
||||||
sourceImageKey,
|
sourceImageKey,
|
||||||
@ -9583,7 +9582,6 @@ export function CanvasChatPanel({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(url);
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const compareSourceUrlRaw =
|
const compareSourceUrlRaw =
|
||||||
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
|
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
|
||||||
@ -9616,20 +9614,7 @@ export function CanvasChatPanel({
|
|||||||
const placeholderNode = reservedPlaceholderNode
|
const placeholderNode = reservedPlaceholderNode
|
||||||
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
|
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
|
||||||
: null;
|
: null;
|
||||||
const size = placeholderNode
|
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
|
||||||
? { 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 position = placeholderNode
|
const position = placeholderNode
|
||||||
? getCenteredReplacementPosition(placeholderNode, size)
|
? getCenteredReplacementPosition(placeholderNode, size)
|
||||||
: getAnchoredGenerationPosition({
|
: getAnchoredGenerationPosition({
|
||||||
@ -9648,8 +9633,6 @@ export function CanvasChatPanel({
|
|||||||
prompt: generationPrompt || "对话生成",
|
prompt: generationPrompt || "对话生成",
|
||||||
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
|
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
originalWidth: dimensions.originalWidth,
|
|
||||||
originalHeight: dimensions.originalHeight,
|
|
||||||
generationPrompt: generationPrompt || undefined,
|
generationPrompt: generationPrompt || undefined,
|
||||||
sourceMessageId: message.id,
|
sourceMessageId: message.id,
|
||||||
sourceImageKey: imageEntry.sourceImageKey,
|
sourceImageKey: imageEntry.sourceImageKey,
|
||||||
@ -10376,7 +10359,7 @@ export function CanvasChatPanel({
|
|||||||
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
||||||
);
|
);
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(imageUrl);
|
const dimensions = await getImageDimensions(file);
|
||||||
const nodeIndex = nodes.length + slotIndex;
|
const nodeIndex = nodes.length + slotIndex;
|
||||||
const spacing = 20;
|
const spacing = 20;
|
||||||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||||||
@ -10454,7 +10437,7 @@ export function CanvasChatPanel({
|
|||||||
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
||||||
);
|
);
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(imageUrl);
|
const dimensions = await getImageDimensions(file);
|
||||||
const nodeIndex = nodes.length + slotIndex;
|
const nodeIndex = nodes.length + slotIndex;
|
||||||
const spacing = 20;
|
const spacing = 20;
|
||||||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||||||
@ -13740,7 +13723,7 @@ export function CanvasChatPanel({
|
|||||||
};
|
};
|
||||||
onUploaded?.(uploadedBase);
|
onUploaded?.(uploadedBase);
|
||||||
clearComposerUploadProgress(250);
|
clearComposerUploadProgress(250);
|
||||||
const dimensions = await getImageDimensions(imageUrl);
|
const dimensions = await getImageDimensions(file);
|
||||||
const spacing = 20;
|
const spacing = 20;
|
||||||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||||||
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
||||||
@ -14929,7 +14912,7 @@ export function CanvasChatPanel({
|
|||||||
firstImageUrl = imageUrl;
|
firstImageUrl = imageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(imageUrl);
|
const dimensions = await getImageDimensions(file);
|
||||||
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
|
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
|
||||||
|
|
||||||
// 获取图片原始尺寸后添加到画布
|
// 获取图片原始尺寸后添加到画布
|
||||||
|
|||||||
@ -179,6 +179,7 @@ import {
|
|||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
import {
|
import {
|
||||||
downloadCanvasImageAsPng,
|
downloadCanvasImageAsPng,
|
||||||
|
downloadOriginalImage,
|
||||||
getCanvasImageDownloadErrorMessage,
|
getCanvasImageDownloadErrorMessage,
|
||||||
type CanvasImageDownloadProgress,
|
type CanvasImageDownloadProgress,
|
||||||
} from "@/lib/client/canvas-image-download";
|
} from "@/lib/client/canvas-image-download";
|
||||||
@ -2519,15 +2520,6 @@ export function ImageNode({
|
|||||||
}),
|
}),
|
||||||
[data.src, displayImageQuality, displayImageWidth]
|
[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(
|
const displayCompareSrc = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildImageProxyUrl(compareSource, {
|
buildImageProxyUrl(compareSource, {
|
||||||
@ -8232,9 +8224,8 @@ export function ImageNode({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
|
|
||||||
const isDownloadingOriginalImage = Boolean(downloadProgress);
|
const isDownloadingOriginalImage = Boolean(downloadProgress);
|
||||||
const handleDownload = async () => {
|
const handleDownload = async (format: "original" | "png" = "original") => {
|
||||||
if (!data.src) return;
|
if (!data.src) return;
|
||||||
if (isDownloadingOriginalImage) {
|
if (isDownloadingOriginalImage) {
|
||||||
toast.info("原图正在下载中");
|
toast.info("原图正在下载中");
|
||||||
@ -8250,20 +8241,15 @@ export function ImageNode({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadCanvasImageAsPng({
|
const request = {
|
||||||
url: data.src,
|
url: resolveOriginalImageUrl(data.src) || data.src,
|
||||||
name: displayTitle,
|
name: displayTitle,
|
||||||
candidates: [
|
|
||||||
displayImageSrc,
|
|
||||||
fullscreenPreviewSrc,
|
|
||||||
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
|
|
||||||
delivery: "proxy",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onProgress: setDownloadProgress,
|
onProgress: setDownloadProgress,
|
||||||
});
|
};
|
||||||
toast.success("原图已开始下载");
|
if (format === "png") await downloadCanvasImageAsPng({ ...request, url: data.src });
|
||||||
|
else await downloadOriginalImage(request);
|
||||||
|
toast.success(format === "png" ? "PNG 已开始下载" : "原图已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
toast.message("已取消下载");
|
toast.message("已取消下载");
|
||||||
@ -9656,6 +9642,7 @@ export function ImageNode({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
) : null}
|
) : null}
|
||||||
{imageDownloadEnabled ? (
|
{imageDownloadEnabled ? (
|
||||||
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -9673,6 +9660,11 @@ export function ImageNode({
|
|||||||
)}
|
)}
|
||||||
{imageDownloadItem.label}
|
{imageDownloadItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={isDownloadingOriginalImage} onClick={() => void handleDownload("png")}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
下载为 PNG(兼容 Photoshop)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{imageDeleteEnabled ? (
|
{imageDeleteEnabled ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@ -66,6 +66,30 @@ test("original download metadata reports missing sources with cause, advice and
|
|||||||
expect(data.error).toContain(data.requestId);
|
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() {
|
function browserMocks() {
|
||||||
const frames: any[] = []; const links: any[] = [];
|
const frames: any[] = []; const links: any[] = [];
|
||||||
return { frames, links,
|
return { frames, links,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user