Compare commits
No commits in common. "main" and "codex/qiniu-cdn-preview-20261111" have entirely different histories.
main
...
codex/qini
@ -133,13 +133,15 @@ 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": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
||||||
};
|
};
|
||||||
if (params.vary) {
|
if (params.vary) {
|
||||||
headers.Vary = params.vary;
|
headers.Vary = params.vary;
|
||||||
@ -147,6 +149,11 @@ 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,
|
||||||
@ -453,29 +460,20 @@ 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");
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { FaviconUpdater } from "@/components/favicon-updater";
|
import { FaviconUpdater } from "@/components/favicon-updater";
|
||||||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||||||
import { AppUpdateNotice } from "@/components/app-update-notice";
|
import { AppUpdateNotice } from "@/components/app-update-notice";
|
||||||
import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
|
|
||||||
import { getSiteSettings } from "@/lib/db/queries";
|
import { getSiteSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@ -143,7 +142,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
|||||||
showManualRecoveryNotice();
|
showManualRecoveryNotice();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return;
|
if (lastReloadAt) return;
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
// If storage is blocked, a reload cannot remember its attempt and must not run.
|
// If storage is blocked, a reload cannot remember its attempt and must not run.
|
||||||
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
|
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
|
||||||
@ -154,10 +153,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
|||||||
showManualRecoveryNotice();
|
showManualRecoveryNotice();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
|
|
||||||
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 800);
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
|
||||||
import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
|
import { preparePageRefresh } from "@/lib/client/page-refresh";
|
||||||
|
|
||||||
export function AppUpdateNotice() {
|
export function AppUpdateNotice() {
|
||||||
const [availableVersion, setAvailableVersion] = useState("");
|
const [availableVersion, setAvailableVersion] = useState("");
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let controller: AbortController | undefined;
|
let controller: AbortController | undefined;
|
||||||
@ -61,7 +60,7 @@ export function AppUpdateNotice() {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return <AlertDialog open={Boolean(availableVersion) && !downloading}>
|
return <AlertDialog open={Boolean(availableVersion)}>
|
||||||
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
|
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>平台已更新</AlertDialogTitle>
|
<AlertDialogTitle>平台已更新</AlertDialogTitle>
|
||||||
|
|||||||
@ -78,7 +78,7 @@ import {
|
|||||||
type UploadProgress,
|
type UploadProgress,
|
||||||
} from "@/lib/client/upload-with-retry";
|
} from "@/lib/client/upload-with-retry";
|
||||||
import {
|
import {
|
||||||
downloadOriginalImage,
|
downloadCanvasImageAsPng,
|
||||||
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(source: string | File): Promise<{
|
function getImageDimensions(url: string): Promise<{
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
originalWidth: number;
|
originalWidth: number;
|
||||||
@ -1470,7 +1470,6 @@ function getImageDimensions(source: string | File): 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;
|
||||||
@ -1483,11 +1482,9 @@ function getImageDimensions(source: string | File): 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);
|
||||||
|
|
||||||
@ -1520,7 +1517,7 @@ function getImageDimensions(source: string | File): Promise<{
|
|||||||
// 加载失败时使用默认尺寸
|
// 加载失败时使用默认尺寸
|
||||||
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
|
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
|
||||||
};
|
};
|
||||||
img.src = objectUrl ?? String(source);
|
img.src = url;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4467,15 +4464,17 @@ export function CanvasChatPanel({
|
|||||||
setDownloadProgress({
|
setDownloadProgress({
|
||||||
phase: "preparing",
|
phase: "preparing",
|
||||||
message: "正在准备原图...",
|
message: "正在准备原图...",
|
||||||
|
percent: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({
|
await downloadCanvasImageAsPng({
|
||||||
url: originalUrl,
|
url: originalUrl,
|
||||||
|
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onProgress: setDownloadProgress,
|
onProgress: setDownloadProgress,
|
||||||
});
|
});
|
||||||
toast.success("已开始下载");
|
toast.success("原图已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
toast.message("已取消下载");
|
toast.message("已取消下载");
|
||||||
@ -7807,6 +7806,7 @@ 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 +7839,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?.size || anchorNode?.size || { width: 300, height: 300 };
|
const size = { width: dimensions.width, height: dimensions.height };
|
||||||
const position = placeholderNode
|
const position = placeholderNode
|
||||||
? getCenteredReplacementPosition(placeholderNode, size)
|
? getCenteredReplacementPosition(placeholderNode, size)
|
||||||
: getAnchoredGenerationPosition({
|
: getAnchoredGenerationPosition({
|
||||||
@ -7857,6 +7857,8 @@ 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,
|
||||||
@ -9581,6 +9583,7 @@ 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))] ||
|
||||||
@ -9613,7 +9616,20 @@ 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?.size || anchorNode?.size || { width: 300, height: 300 };
|
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 position = placeholderNode
|
const position = placeholderNode
|
||||||
? getCenteredReplacementPosition(placeholderNode, size)
|
? getCenteredReplacementPosition(placeholderNode, size)
|
||||||
: getAnchoredGenerationPosition({
|
: getAnchoredGenerationPosition({
|
||||||
@ -9632,6 +9648,8 @@ 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,
|
||||||
@ -10358,7 +10376,7 @@ export function CanvasChatPanel({
|
|||||||
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
||||||
);
|
);
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(file);
|
const dimensions = await getImageDimensions(imageUrl);
|
||||||
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);
|
||||||
@ -10436,7 +10454,7 @@ export function CanvasChatPanel({
|
|||||||
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
|
||||||
);
|
);
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(file);
|
const dimensions = await getImageDimensions(imageUrl);
|
||||||
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);
|
||||||
@ -13722,7 +13740,7 @@ export function CanvasChatPanel({
|
|||||||
};
|
};
|
||||||
onUploaded?.(uploadedBase);
|
onUploaded?.(uploadedBase);
|
||||||
clearComposerUploadProgress(250);
|
clearComposerUploadProgress(250);
|
||||||
const dimensions = await getImageDimensions(file);
|
const dimensions = await getImageDimensions(imageUrl);
|
||||||
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);
|
||||||
@ -14911,7 +14929,7 @@ export function CanvasChatPanel({
|
|||||||
firstImageUrl = imageUrl;
|
firstImageUrl = imageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensions = await getImageDimensions(file);
|
const dimensions = await getImageDimensions(imageUrl);
|
||||||
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
|
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
|
||||||
|
|
||||||
// 获取图片原始尺寸后添加到画布
|
// 获取图片原始尺寸后添加到画布
|
||||||
|
|||||||
@ -28,7 +28,6 @@ export function CanvasImageDownloadProgressDialog({
|
|||||||
: null;
|
: null;
|
||||||
const loadedLabel = formatBytes(progress.loadedBytes);
|
const loadedLabel = formatBytes(progress.loadedBytes);
|
||||||
const totalLabel = formatBytes(progress.totalBytes);
|
const totalLabel = formatBytes(progress.totalBytes);
|
||||||
const converting = progress.phase === "converting";
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@ -42,12 +41,12 @@ export function CanvasImageDownloadProgressDialog({
|
|||||||
{progress.phase === "saving" ? (
|
{progress.phase === "saving" ? (
|
||||||
<Download className="h-4 w-4" />
|
<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>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] font-semibold">下载原图</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>
|
||||||
</div>
|
</div>
|
||||||
{onCancel ? (
|
{onCancel ? (
|
||||||
@ -64,19 +63,14 @@ export function CanvasImageDownloadProgressDialog({
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
<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>
|
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
||||||
className="relative h-2 overflow-hidden rounded-full bg-white/10"
|
|
||||||
role="progressbar"
|
|
||||||
aria-label={converting ? "图片格式转换" : "图片下载"}
|
|
||||||
aria-valuenow={percent ?? undefined}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
|
"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}
|
style={percent !== null ? { width: `${percent}%` } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -178,7 +178,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
import {
|
import {
|
||||||
downloadOriginalImage,
|
downloadCanvasImageAsPng,
|
||||||
getCanvasImageDownloadErrorMessage,
|
getCanvasImageDownloadErrorMessage,
|
||||||
type CanvasImageDownloadProgress,
|
type CanvasImageDownloadProgress,
|
||||||
} from "@/lib/client/canvas-image-download";
|
} from "@/lib/client/canvas-image-download";
|
||||||
@ -2519,6 +2519,15 @@ 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, {
|
||||||
@ -8223,6 +8232,7 @@ export function ImageNode({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
|
||||||
const isDownloadingOriginalImage = Boolean(downloadProgress);
|
const isDownloadingOriginalImage = Boolean(downloadProgress);
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!data.src) return;
|
if (!data.src) return;
|
||||||
@ -8236,16 +8246,24 @@ export function ImageNode({
|
|||||||
setDownloadProgress({
|
setDownloadProgress({
|
||||||
phase: "preparing",
|
phase: "preparing",
|
||||||
message: "正在准备原图...",
|
message: "正在准备原图...",
|
||||||
|
percent: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadOriginalImage({
|
await downloadCanvasImageAsPng({
|
||||||
url: resolveOriginalImageUrl(data.src) || data.src,
|
url: 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("已开始下载");
|
toast.success("原图已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
toast.message("已取消下载");
|
toast.message("已取消下载");
|
||||||
|
|||||||
@ -221,9 +221,7 @@ export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer>
|
|||||||
|| (metadata.space === "rgb16" && metadata.depth === "ushort");
|
|| (metadata.space === "rgb16" && metadata.depth === "ushort");
|
||||||
if (metadata.format === "png" && rgb
|
if (metadata.format === "png" && rgb
|
||||||
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
|
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
|
||||||
const output = image.rotate().toColorspace("srgb");
|
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
|
||||||
if (metadata.icc) output.withIccProfile("srgb");
|
|
||||||
return output.png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCanvasAssetFilename(params: {
|
function buildCanvasAssetFilename(params: {
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||||
import { withActivePageDownload } from "@/lib/client/page-refresh";
|
|
||||||
|
|
||||||
export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving";
|
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||||
|
|
||||||
export type CanvasImageDownloadProgress = {
|
export type CanvasImageDownloadProgress = {
|
||||||
phase: CanvasImageDownloadPhase;
|
phase: CanvasImageDownloadPhase;
|
||||||
@ -190,15 +189,8 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt
|
|||||||
return { filename: result.filename, bytes: result.bytes || 0 };
|
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]);
|
/** Keep the source format and bytes for chat, gallery and reference downloads. */
|
||||||
|
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||||
/** Preserve compatible originals and prepare a PNG for other stored images. */
|
|
||||||
export function downloadOriginalImage(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
|
||||||
return withActivePageDownload(() => downloadOriginalImageFile(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
|
||||||
const { url, name, signal, onProgress } = params;
|
|
||||||
const source = String(url || "").trim();
|
const source = String(url || "").trim();
|
||||||
if (!source) throw new Error("图片地址无效");
|
if (!source) throw new Error("图片地址无效");
|
||||||
const proxy = buildImageProxyUrl(source, { delivery: "original" });
|
const proxy = buildImageProxyUrl(source, { delivery: "original" });
|
||||||
@ -226,11 +218,6 @@ async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Pro
|
|||||||
const metadata = await checkedFetch(target.pathname + target.search);
|
const metadata = await checkedFetch(target.pathname + target.search);
|
||||||
const delivery = await metadata.json();
|
const delivery = await metadata.json();
|
||||||
if (delivery.delivery === "cdn") {
|
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 downloadPngFile(params, "converting");
|
|
||||||
}
|
|
||||||
const result = triggerCdnDownload(delivery, signal);
|
const result = triggerCdnDownload(delivery, signal);
|
||||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||||
return result;
|
return result;
|
||||||
@ -238,16 +225,9 @@ async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Pro
|
|||||||
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
|
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
|
||||||
}
|
}
|
||||||
const response = await checkedFetch(proxy);
|
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 downloadPngFile(params, "converting");
|
|
||||||
}
|
|
||||||
const blob = await readResponseBlobWithProgress(response, onProgress);
|
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;
|
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||||
triggerBlobDownload(blob, filename);
|
triggerBlobDownload(blob, filename);
|
||||||
return { filename, bytes: blob.size };
|
return { filename, bytes: blob.size };
|
||||||
@ -272,15 +252,14 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
|
|||||||
|
|
||||||
async function readResponseBlobWithProgress(
|
async function readResponseBlobWithProgress(
|
||||||
response: Response,
|
response: Response,
|
||||||
onProgress?: (progress: CanvasImageDownloadProgress) => void,
|
onProgress?: (progress: CanvasImageDownloadProgress) => void
|
||||||
message = "正在下载原图..."
|
|
||||||
) {
|
) {
|
||||||
const totalBytes = Number(response.headers.get("content-length") || 0);
|
const totalBytes = Number(response.headers.get("content-length") || 0);
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
phase: "downloading",
|
phase: "downloading",
|
||||||
message,
|
message: "正在下载原图...",
|
||||||
loadedBytes: blob.size,
|
loadedBytes: blob.size,
|
||||||
totalBytes: totalBytes || blob.size,
|
totalBytes: totalBytes || blob.size,
|
||||||
percent: 100,
|
percent: 100,
|
||||||
@ -300,7 +279,7 @@ async function readResponseBlobWithProgress(
|
|||||||
loadedBytes += value.byteLength;
|
loadedBytes += value.byteLength;
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
phase: "downloading",
|
phase: "downloading",
|
||||||
message,
|
message: "正在下载原图...",
|
||||||
loadedBytes,
|
loadedBytes,
|
||||||
totalBytes: totalBytes || undefined,
|
totalBytes: totalBytes || undefined,
|
||||||
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
|
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
|
||||||
@ -312,17 +291,13 @@ async function readResponseBlobWithProgress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadCanvasImageAsPng(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
export async function downloadCanvasImageAsPng({
|
||||||
return withActivePageDownload(() => downloadPngFile(params, "preparing"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadPngFile({
|
|
||||||
candidates,
|
candidates,
|
||||||
name,
|
name,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
url,
|
url,
|
||||||
}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting"): Promise<CanvasImageDownloadResult> {
|
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||||
const raw = String(url || "").trim();
|
const raw = String(url || "").trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
throw new Error("图片地址无效");
|
throw new Error("图片地址无效");
|
||||||
@ -335,9 +310,11 @@ async function downloadPngFile({
|
|||||||
"canvas-image"
|
"canvas-image"
|
||||||
);
|
);
|
||||||
|
|
||||||
const isConverting = preparationPhase === "converting";
|
onProgress?.({
|
||||||
const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
|
phase: "preparing",
|
||||||
onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
|
message: "正在准备原图...",
|
||||||
|
percent: 8,
|
||||||
|
});
|
||||||
|
|
||||||
let response: Response | null = null;
|
let response: Response | null = null;
|
||||||
let errorPayload: DownloadErrorPayload | null = null;
|
let errorPayload: DownloadErrorPayload | null = null;
|
||||||
@ -376,10 +353,7 @@ async function downloadPngFile({
|
|||||||
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
||||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
||||||
}
|
}
|
||||||
onProgress?.({
|
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
||||||
phase: preparationPhase,
|
|
||||||
message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
|
|
||||||
});
|
|
||||||
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
||||||
await waitForRetry(delaySeconds * 1000, signal);
|
await waitForRetry(delaySeconds * 1000, signal);
|
||||||
continue;
|
continue;
|
||||||
@ -410,6 +384,7 @@ async function downloadPngFile({
|
|||||||
retryAfterSeconds > 0
|
retryAfterSeconds > 0
|
||||||
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
|
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
|
||||||
: "请求较多,正在自动重试...",
|
: "请求较多,正在自动重试...",
|
||||||
|
percent: 8,
|
||||||
});
|
});
|
||||||
await waitForRetry(retryAfterSeconds * 1000, signal);
|
await waitForRetry(retryAfterSeconds * 1000, signal);
|
||||||
}
|
}
|
||||||
@ -438,9 +413,7 @@ async function downloadPngFile({
|
|||||||
const filename =
|
const filename =
|
||||||
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
||||||
`${filenameBase}.png`;
|
`${filenameBase}.png`;
|
||||||
const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图...";
|
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||||
onProgress?.({ phase: "downloading", message: downloadMessage });
|
|
||||||
const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage);
|
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
phase: "saving",
|
phase: "saving",
|
||||||
|
|||||||
@ -1,44 +1,10 @@
|
|||||||
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
|
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
|
||||||
export const PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE = "data-page-download-active";
|
|
||||||
export type PageRefreshRequest = {
|
export type PageRefreshRequest = {
|
||||||
save: (action: () => Promise<void>) => void;
|
save: (action: () => Promise<void>) => void;
|
||||||
block: (message: string) => void;
|
block: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadListeners = new Set<() => void>();
|
|
||||||
let activePageDownloads = 0;
|
|
||||||
|
|
||||||
export function isPageDownloadActive() {
|
|
||||||
return activePageDownloads > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeToPageDownloadActivity(listener: () => void) {
|
|
||||||
downloadListeners.add(listener);
|
|
||||||
return () => { downloadListeners.delete(listener); };
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishDownloadActivity() {
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
document.documentElement?.toggleAttribute(PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE, isPageDownloadActive());
|
|
||||||
}
|
|
||||||
for (const listener of downloadListeners) listener();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withActivePageDownload<T>(download: () => Promise<T>): Promise<T> {
|
|
||||||
if (++activePageDownloads === 1) publishDownloadActivity();
|
|
||||||
try {
|
|
||||||
return await download();
|
|
||||||
} finally {
|
|
||||||
if (--activePageDownloads === 0) publishDownloadActivity();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPageDownloadFinished() {
|
|
||||||
if (isPageDownloadActive()) throw new Error("文件正在下载,请等下载完成后再刷新。");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function preparePageRefresh() {
|
export async function preparePageRefresh() {
|
||||||
assertPageDownloadFinished();
|
|
||||||
const actions: Array<() => Promise<void>> = [];
|
const actions: Array<() => Promise<void>> = [];
|
||||||
let blocked = "";
|
let blocked = "";
|
||||||
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
|
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
|
||||||
@ -46,5 +12,4 @@ export async function preparePageRefresh() {
|
|||||||
}));
|
}));
|
||||||
if (blocked) throw new Error(blocked);
|
if (blocked) throw new Error(blocked);
|
||||||
for (const action of actions) await action();
|
for (const action of actions) await action();
|
||||||
assertPageDownloadFinished();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,14 +26,14 @@ function getBatchSize() {
|
|||||||
return Math.max(1, Math.min(50, Math.round(parsed)));
|
return Math.max(1, Math.min(50, Math.round(parsed)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?: string } = {}) {
|
export async function runImageArchiveJob() {
|
||||||
return trackRuntimeWork(async () => {
|
return trackRuntimeWork(async () => {
|
||||||
const batchSize = getBatchSize();
|
const batchSize = getBatchSize();
|
||||||
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
||||||
|
|
||||||
await recoverStaleImageArchiveTasks();
|
await recoverStaleImageArchiveTasks();
|
||||||
scheduleImageDownloadPreparation();
|
scheduleImageDownloadPreparation();
|
||||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize, preferredTaskId });
|
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
||||||
let storeSucceeded = 0;
|
let storeSucceeded = 0;
|
||||||
let storeFailed = 0;
|
let storeFailed = 0;
|
||||||
|
|
||||||
|
|||||||
@ -98,11 +98,8 @@ export async function enqueueImageArchiveTasks(params: {
|
|||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getImageArchiveTasksToStore(params: { limit: number; preferredTaskId?: string }) {
|
export async function getImageArchiveTasksToStore(params: { limit: number }) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const priority = params.preferredTaskId
|
|
||||||
? [asc(sql`case when ${imageArchiveTask.id} = ${params.preferredTaskId} then 0 else 1 end`)]
|
|
||||||
: [];
|
|
||||||
return await db
|
return await db
|
||||||
.select()
|
.select()
|
||||||
.from(imageArchiveTask)
|
.from(imageArchiveTask)
|
||||||
@ -113,7 +110,7 @@ export async function getImageArchiveTasksToStore(params: { limit: number; prefe
|
|||||||
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
|
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(...priority, asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
|
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
|
||||||
.limit(Math.max(1, Math.min(50, params.limit)));
|
.limit(Math.max(1, Math.min(50, params.limit)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ type Worker = {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
scheduled: boolean;
|
scheduled: boolean;
|
||||||
requested: boolean;
|
requested: boolean;
|
||||||
preferredTaskId?: string;
|
|
||||||
retryTimer?: ReturnType<typeof setTimeout>;
|
retryTimer?: ReturnType<typeof setTimeout>;
|
||||||
};
|
};
|
||||||
function getWorker(): Worker {
|
function getWorker(): Worker {
|
||||||
@ -14,9 +13,8 @@ function getWorker(): Worker {
|
|||||||
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
|
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) {
|
export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
|
||||||
const worker = getWorker();
|
const worker = getWorker();
|
||||||
worker.preferredTaskId ??= preferredTaskId;
|
|
||||||
worker.requested = true;
|
worker.requested = true;
|
||||||
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
|
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
|
||||||
worker.scheduled = true;
|
worker.scheduled = true;
|
||||||
@ -33,9 +31,7 @@ export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredT
|
|||||||
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
|
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
|
||||||
do {
|
do {
|
||||||
worker.requested = false;
|
worker.requested = false;
|
||||||
const nextTaskId = worker.preferredTaskId;
|
const result = await runImageArchiveJob();
|
||||||
worker.preferredTaskId = undefined;
|
|
||||||
const result = await runImageArchiveJob({ preferredTaskId: nextTaskId });
|
|
||||||
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true;
|
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true;
|
||||||
// Stop when only backoff tasks remain; do not spin while storage is down.
|
// Stop when only backoff tasks remain; do not spin while storage is down.
|
||||||
if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;
|
if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { findImageArchiveDownloadTask } from "./image-archive-db";
|
|||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { getImageDownloadExtension } from "@/lib/image-delivery";
|
import { getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
|
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
|
||||||
import { HeadObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
/** Resolve the archived original without waiting for a separate PNG conversion. */
|
/** Resolve the archived original without waiting for a separate PNG conversion. */
|
||||||
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
|
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
|
||||||
@ -15,10 +14,6 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
|
|||||||
let target = new URL(source);
|
let target = new URL(source);
|
||||||
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
|
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
|
||||||
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
|
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
|
||||||
if (task?.status === "pending" && !task.targetUrl) {
|
|
||||||
const { scheduleImageArchiveWorker } = await import("./image-archive-worker");
|
|
||||||
scheduleImageArchiveWorker(false, task.id);
|
|
||||||
}
|
|
||||||
target = new URL(task?.targetUrl || source);
|
target = new URL(task?.targetUrl || source);
|
||||||
}
|
}
|
||||||
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
|
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
|
||||||
@ -33,32 +28,8 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
|
|||||||
// Only metadata crosses the application server. The browser receives the file
|
// Only metadata crosses the application server. The browser receives the file
|
||||||
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
|
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
|
||||||
const response = await fetch(target, {
|
const response = await fetch(target, {
|
||||||
method: "HEAD", cache: "default",
|
method: "HEAD", cache: "no-store",
|
||||||
signal: AbortSignal.any([signal, AbortSignal.timeout(3_000)]),
|
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||||
}).catch(async (error) => {
|
|
||||||
signal.throwIfAborted();
|
|
||||||
if (target.search) throw error;
|
|
||||||
const { createS3Client, toDbS3UploadConfig } = await import("./service");
|
|
||||||
const storage = toDbS3UploadConfig(config);
|
|
||||||
if (!storage) throw error;
|
|
||||||
const key = decodeURIComponent(target.pathname.slice(base.pathname.replace(/\/$/, "").length + 1));
|
|
||||||
if (!key) throw error;
|
|
||||||
const client = createS3Client(storage);
|
|
||||||
try {
|
|
||||||
const object = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }), {
|
|
||||||
abortSignal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
|
|
||||||
});
|
|
||||||
return new Response(null, { headers: {
|
|
||||||
"content-type": object.ContentType || "",
|
|
||||||
"content-length": String(object.ContentLength ?? 0),
|
|
||||||
} });
|
|
||||||
} catch (storageError) {
|
|
||||||
const status = (storageError as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
|
|
||||||
if (status === 403 || status === 404) throw new Error(`HTTP ${status}`);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const contentType = response.headers.get("content-type") || "";
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
|||||||
@ -112,10 +112,6 @@ test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizi
|
|||||||
const converted = await module.convertImageToPhotoshopPng(jpeg);
|
const converted = await module.convertImageToPhotoshopPng(jpeg);
|
||||||
const metadata = await sharp(converted).metadata();
|
const metadata = await sharp(converted).metadata();
|
||||||
expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" });
|
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 () => {
|
test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => {
|
||||||
let url = "https://cdn.example/uploads/test.png";
|
let url = "https://cdn.example/uploads/test.png";
|
||||||
|
|||||||
@ -242,7 +242,7 @@ test("inline outputs have short, stable preview identities and durable queue pay
|
|||||||
|
|
||||||
test("background storage starts after delivery and coalesces repeated wakeups", async () => {
|
test("background storage starts after delivery and coalesces repeated wakeups", async () => {
|
||||||
const callbacks: Array<() => Promise<void>> = [];
|
const callbacks: Array<() => Promise<void>> = [];
|
||||||
const preferences: Array<string | undefined> = [];
|
let runs = 0;
|
||||||
const module = serverLoader({
|
const module = serverLoader({
|
||||||
"next/server": {
|
"next/server": {
|
||||||
after: (fn: () => Promise<void>) => {
|
after: (fn: () => Promise<void>) => {
|
||||||
@ -250,10 +250,10 @@ test("background storage starts after delivery and coalesces repeated wakeups",
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"@/lib/jobs/image-archive-job": {
|
"@/lib/jobs/image-archive-job": {
|
||||||
runImageArchiveJob: async ({ preferredTaskId }: { preferredTaskId?: string }) => {
|
runImageArchiveJob: async () => {
|
||||||
preferences.push(preferredTaskId);
|
runs += 1;
|
||||||
return {
|
return {
|
||||||
store: { total: preferences.length === 1 ? 1 : 0 },
|
store: { total: runs === 1 ? 1 : 0 },
|
||||||
replace: { total: 0 },
|
replace: { total: 0 },
|
||||||
stats: { pendingStore: 0, pendingReplace: 0 },
|
stats: { pendingStore: 0, pendingReplace: 0 },
|
||||||
downloadTasks: 0,
|
downloadTasks: 0,
|
||||||
@ -262,11 +262,11 @@ test("background storage starts after delivery and coalesces repeated wakeups",
|
|||||||
},
|
},
|
||||||
})("lib/storage/image-archive-worker.ts");
|
})("lib/storage/image-archive-worker.ts");
|
||||||
module.scheduleImageArchiveWorker();
|
module.scheduleImageArchiveWorker();
|
||||||
module.scheduleImageArchiveWorker(false, "requested-image");
|
module.scheduleImageArchiveWorker();
|
||||||
expect(callbacks).toHaveLength(1);
|
expect(callbacks).toHaveLength(1);
|
||||||
expect(preferences).toEqual([]);
|
expect(runs).toBe(0);
|
||||||
await callbacks[0]();
|
await callbacks[0]();
|
||||||
expect(preferences).toEqual(["requested-image", undefined]);
|
expect(runs).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bucket failure retains the provider URL and retry task", async () => {
|
test("bucket failure retains the provider URL and retry task", async () => {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
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" };
|
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
|
||||||
|
|
||||||
@ -11,8 +10,7 @@ test("original downloads use the archived JPEG rather than the prepared PNG and
|
|||||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
|
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
|
||||||
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
|
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
|
||||||
} }) },
|
} }) },
|
||||||
fetch: async (url: URL, options: { method: string; cache: string }) => {
|
fetch: async (url: URL, options: { method: string }) => {
|
||||||
expect(options.cache).toBe("default");
|
|
||||||
calls.push({ url: url.toString(), method: options.method });
|
calls.push({ url: url.toString(), method: options.method });
|
||||||
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
|
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
|
||||||
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
|
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
|
||||||
@ -54,40 +52,6 @@ test("temporary third-party URLs are never checked with HEAD when no archive is
|
|||||||
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
|
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("downloading a pending original prioritizes its existing archive task and keeps the proxy available", async () => {
|
|
||||||
const preferred: string[] = [];
|
|
||||||
const module = serverLoader({
|
|
||||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
|
||||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ id: "pending-image", status: "pending", targetUrl: null }) },
|
|
||||||
"./image-archive-worker": { scheduleImageArchiveWorker: (_delivery: boolean, id: string) => preferred.push(id) },
|
|
||||||
fetch() { throw new Error("The original must remain available through the proxy while it is queued"); },
|
|
||||||
})("lib/storage/original-image-download.ts");
|
|
||||||
expect(await module.resolveOriginalCdnDownload("https://upstream.example/queued.png", "image", new AbortController().signal)).toBeNull();
|
|
||||||
expect(preferred).toEqual(["pending-image"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => {
|
|
||||||
let closed = false;
|
|
||||||
const module = serverLoader({
|
|
||||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
|
||||||
"./image-archive-db": {},
|
|
||||||
"./service": {
|
|
||||||
toDbS3UploadConfig: () => ({ bucket: "images" }),
|
|
||||||
createS3Client: () => ({
|
|
||||||
send: async (command: { input: { Bucket: string; Key: string } }) => {
|
|
||||||
expect(command.input).toEqual({ Bucket: "images", Key: "uploads/source.png" });
|
|
||||||
return { ContentType: "image/jpeg", ContentLength: 12345 };
|
|
||||||
},
|
|
||||||
destroy: () => { closed = true; },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
fetch: async () => { throw new DOMException("Timed out", "TimeoutError"); },
|
|
||||||
})("lib/storage/original-image-download.ts");
|
|
||||||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/uploads/source.png", "图片.png", new AbortController().signal);
|
|
||||||
expect(result).toMatchObject({ delivery: "cdn", filename: "图片.jpg", bytes: 12345 });
|
|
||||||
expect(closed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
|
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
|
||||||
const route = serverLoader({
|
const route = serverLoader({
|
||||||
"next/server": { NextResponse: Response },
|
"next/server": { NextResponse: Response },
|
||||||
@ -102,30 +66,6 @@ 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,
|
||||||
@ -137,18 +77,16 @@ function browserMocks() {
|
|||||||
|
|
||||||
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
||||||
const dom = browserMocks(); const calls: string[] = [];
|
const dom = browserMocks(); const calls: string[] = [];
|
||||||
const progress: CanvasImageDownloadProgress[] = [];
|
|
||||||
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
||||||
calls.push(url);
|
calls.push(url);
|
||||||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
|
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");
|
} })("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(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
|
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.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
|
||||||
expect(dom.links).toHaveLength(0);
|
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 () => {
|
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
|
||||||
@ -161,62 +99,6 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy
|
|||||||
URL.revokeObjectURL(dom.links[0].href);
|
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 () => {
|
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");
|
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("无法确认原图");
|
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user