Compare commits

..

No commits in common. "main" and "codex/qiniu-cdn-preview-20261111" have entirely different histories.

22 changed files with 121 additions and 494 deletions

View File

@ -2378,9 +2378,9 @@ export function LocalAiImagePipelineSections({
{ {
label: label:
extendConfig.provider === "fal" extendConfig.provider === "fal"
? "fal · FLUX 2 Pro 扩图" ? "Bria 扩图服务"
: extendConfig.provider === "vip98k" : extendConfig.provider === "vip98k"
? `98K · ${extendConfig.modelId || "image-expansion"}` ? "VIP98K 服务商"
: "阿里云服务商", : "阿里云服务商",
tone: "enabled", tone: "enabled",
}, },
@ -2395,11 +2395,11 @@ export function LocalAiImagePipelineSections({
> >
{extendConfig.provider === "fal" ? ( {extendConfig.provider === "fal" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm"> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。 使用 Bria 生成新增区域并保留原图主体和文字。密钥从已启用的 fal 官方服务商读取。
</div> </div>
) : extendConfig.provider === "vip98k" ? ( ) : extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm"> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的 使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。 VIP98K 服务商读取,仅在这里维护扩展比例。
</div> </div>
) : ( ) : (

View File

@ -275,8 +275,6 @@ export type EnhanceConfig = {
export type ExtendConfig = { export type ExtendConfig = {
provider: "vip98k" | "dashscope" | "fal"; provider: "vip98k" | "dashscope" | "fal";
modelId: string;
routeOptionId: string;
xScale: string; xScale: string;
yScale: string; yScale: string;
}; };

View File

@ -2577,8 +2577,6 @@ export function AdminSettingsClient() {
// 图像拓展API配置状态 // 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({ const [extendConfig, setExtendConfig] = useState({
provider: "dashscope" as "vip98k" | "dashscope" | "fal", provider: "dashscope" as "vip98k" | "dashscope" | "fal",
modelId: "",
routeOptionId: "",
xScale: "1.5", // X轴放大倍数 xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数 yScale: "1.5", // Y轴放大倍数
}); });
@ -2894,15 +2892,11 @@ export function AdminSettingsClient() {
if (extendConfigData?.setting?.value) { if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as { const config = extendConfigData.setting.value as {
provider?: "vip98k" | "dashscope" | "fal"; provider?: "vip98k" | "dashscope" | "fal";
modelId?: string;
routeOptionId?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
}; };
setExtendConfig({ setExtendConfig({
provider: config.provider === "vip98k" || config.provider === "fal" ? config.provider : "dashscope", provider: config.provider === "vip98k" || config.provider === "fal" ? config.provider : "dashscope",
modelId: config.modelId || "",
routeOptionId: config.routeOptionId || "",
xScale: config.xScale ? String(config.xScale) : "1.5", xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5", yScale: config.yScale ? String(config.yScale) : "1.5",
}); });
@ -5511,8 +5505,6 @@ export function AdminSettingsClient() {
key: "extend_api_config", key: "extend_api_config",
value: { value: {
provider: extendConfig.provider, provider: extendConfig.provider,
modelId: extendConfig.modelId,
routeOptionId: extendConfig.routeOptionId,
xScale, xScale,
yScale, yScale,
}, },

View File

@ -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");

View File

@ -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);
} }

View File

@ -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>

View File

@ -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);
// 获取图片原始尺寸后添加到画布 // 获取图片原始尺寸后添加到画布

View File

@ -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}
/> />

View File

@ -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("已取消下载");

View File

@ -7,8 +7,7 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config"; import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint"; import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { executeConfiguredVip98kTask, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime"; import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
@ -18,8 +17,6 @@ type ExtendConfig = {
apiKey?: string; apiKey?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
modelId?: string;
routeOptionId?: string;
}; };
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit"; const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
@ -541,14 +538,6 @@ export async function executeExtendJob(
if (config.provider === "fal") { if (config.provider === "fal") {
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale }); buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
modelId = FAL_OUTPAINT_MODEL; modelId = FAL_OUTPAINT_MODEL;
} else if (config.provider === "vip98k" && config.modelId === VIP98K_OUTPAINT_MODEL) {
if (!config.routeOptionId) throw new ExtendServiceError("98K 扩图线路未配置", 503);
const provider = await getVip98kRuntimeProvider();
buffer = await expandWithVip98kOutpaint({
imageUrl: inputImageUrl, baseUrl: provider.baseUrl, apiKey: provider.apiKey,
routeOptionId: config.routeOptionId, xScale, yScale,
});
modelId = VIP98K_OUTPAINT_MODEL;
} else { } else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl); const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId); const url = await extendImage(prepared, config, xScale, yScale, requestId);
@ -560,7 +549,7 @@ export async function executeExtendJob(
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0"; modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
} }
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined; const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" || modelId === VIP98K_OUTPAINT_MODEL ? "png" : "jpg"); const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" ? "png" : "jpg");
console.log("[Extend Service] Result uploaded:", { console.log("[Extend Service] Result uploaded:", {
requestId, requestId,
includeBase64, includeBase64,

View File

@ -1,85 +0,0 @@
import sharp from "sharp";
export const VIP98K_OUTPAINT_MODEL = "gpt-image-2.5-flare";
const RATIOS = [[1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16]];
export function getVip98kOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
if (targetWidth * targetHeight > 64_000_000) throw new Error("扩图后的尺寸过大,请降低扩展倍数");
const ratio = targetWidth / targetHeight;
const [a, b] = RATIOS.reduce((best, next) =>
Math.abs(Math.log(ratio / (next[0] / next[1]))) < Math.abs(Math.log(ratio / (best[0] / best[1]))) ? next : best);
const paddedWidth = Math.max(targetWidth, Math.ceil(targetHeight * a / b));
const paddedHeight = Math.max(targetHeight, Math.ceil(targetWidth * b / a));
const factor = Math.min(1, 2048 / Math.max(paddedWidth, paddedHeight));
const modelWidth = Math.max(1, Math.round(paddedWidth * factor));
const modelHeight = Math.max(1, Math.round(paddedHeight * factor));
const sourceWidth = Math.max(1, Math.round(width * factor));
const sourceHeight = Math.max(1, Math.round(height * factor));
return {
targetWidth, targetHeight, modelWidth, modelHeight, sourceWidth, sourceHeight,
left: Math.floor((modelWidth - sourceWidth) / 2),
top: Math.floor((modelHeight - sourceHeight) / 2),
aspectRatio: `${a}:${b}`,
resolution: Math.max(targetWidth, targetHeight) > 1536 ? "2K" : "1K",
};
}
export async function expandWithVip98kOutpaint(params: {
imageUrl: string;
baseUrl: string;
apiKey: string;
routeOptionId: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(params.imageUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const g = getVip98kOutpaintGeometry(info.width, info.height, params.xScale, params.yScale);
const input = await sharp(source).resize(g.sourceWidth, g.sourceHeight).ensureAlpha().extend({
left: g.left, right: g.modelWidth - g.sourceWidth - g.left,
top: g.top, bottom: g.modelHeight - g.sourceHeight - g.top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
}).png().toBuffer();
const body = new FormData();
const prompt = [
"Extend this product image outward on all four sides, continuing its background naturally.",
`The reference canvas is ${g.modelWidth}x${g.modelHeight}; the original image is centered at (${g.left},${g.top}) with size ${g.sourceWidth}x${g.sourceHeight}.`,
"Fill only the surrounding empty area as a continuation of the same scene.",
"Preserve the existing product identity, structure, colors, and all Chinese text and dimension numbers.",
"Keep approximately the same composition and the product centered.",
"Avoid adding new products or additional text. Return a complete seamless image.",
].join(" ");
for (const [key, value] of Object.entries({
model: VIP98K_OUTPAINT_MODEL, prompt, n: "1", size: g.aspectRatio,
resolution: g.resolution, quality: "high", response_format: "url",
})) body.append(key, value);
body.append("image[]", new Blob([new Uint8Array(input)], { type: "image/png" }), "outpaint.png");
const base = params.baseUrl.replace(/\/$/, "").replace(/\/v1$/, "");
const generated = await fetch(`${base}/v1/images/edits`, {
method: "POST", body,
headers: { Authorization: `Bearer ${params.apiKey}`, "X-Relay-Route-Option": params.routeOptionId },
signal: AbortSignal.timeout(180_000),
});
const result = await generated.json();
if (!generated.ok) throw new Error(`98K 扩图失败:${result?.error?.message || `HTTP ${generated.status}`}`);
const image = result?.data?.[0];
let expanded: Buffer;
if (typeof image?.b64_json === "string") {
expanded = Buffer.from(image.b64_json, "base64");
} else if (typeof image?.url === "string") {
const outputUrl = new URL(image.url);
const headers = new Headers();
if (outputUrl.origin === new URL(base).origin) headers.set("Authorization", `Bearer ${params.apiKey}`);
const output = await fetch(outputUrl, { headers, signal: AbortSignal.timeout(60_000) });
if (!output.ok) throw new Error(`无法读取扩图结果(HTTP ${output.status})`);
expanded = Buffer.from(await output.arrayBuffer());
} else {
throw new Error("98K 扩图未返回图片");
}
return sharp(expanded).resize(g.targetWidth, g.targetHeight, { fit: "cover", position: "centre" }).png().toBuffer();
}

View File

@ -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: {

View File

@ -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",

View File

@ -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();
} }

View File

@ -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;

View File

@ -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)));
} }

View File

@ -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;

View File

@ -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") || "";

View File

@ -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";

View File

@ -2,7 +2,6 @@ import { expect, test } from "@playwright/test";
import sharp from "sharp"; import sharp from "sharp";
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config"; import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint"; import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint";
import { expandWithVip98kOutpaint, getVip98kOutpaintGeometry } from "@/lib/ai/extend/vip98k-outpaint";
test.describe("image extension provider configuration", () => { test.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => { test("uses the most recently updated Alibaba image provider key", () => {
@ -57,69 +56,3 @@ test("a 4K source keeps its target output size while the model works within its
expect(g.expandTop + g.sourceHeight + g.expandBottom).toBe(g.paddedHeight); expect(g.expandTop + g.sourceHeight + g.expandBottom).toBe(g.paddedHeight);
expect(Math.abs(g.expandLeft - g.expandRight)).toBeLessThanOrEqual(1); expect(Math.abs(g.expandLeft - g.expandRight)).toBeLessThanOrEqual(1);
}); });
test("relay outpainting pads to a supported ratio without cutting the source", () => {
const g = getVip98kOutpaintGeometry(621, 515, 1.5, 1.5);
expect([g.targetWidth, g.targetHeight]).toEqual([932, 773]);
expect(g.aspectRatio).toBe("4:3");
expect(g.modelWidth).toBeGreaterThanOrEqual(g.targetWidth);
expect(g.modelHeight).toBeGreaterThanOrEqual(g.targetHeight);
expect(g.left + g.sourceWidth).toBeLessThanOrEqual(g.modelWidth);
const large = getVip98kOutpaintGeometry(4096, 4096, 1.5, 1.5);
expect([large.targetWidth, large.targetHeight]).toEqual([6144, 6144]);
expect(Math.max(large.modelWidth, large.modelHeight)).toBe(2048);
});
test("relay outpainting uses one image request and keeps the complete generated result", async () => {
const originalFetch = globalThis.fetch;
const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer();
const generated = await sharp({ create: { width: 80, height: 60, channels: 3, background: "blue" } }).png().toBuffer();
try {
for (const outputUrl of ["https://relay.test/api/external/assets/result", "https://cdn.test/result.png"]) {
let submitted = 0;
globalThis.fetch = async (input, init) => {
const url = String(input);
if (url === "https://source.test/input.png") return new Response(new Uint8Array(source));
if (url === "https://relay.test/v1/images/edits") {
submitted++;
const body = init?.body as FormData;
expect(body.get("model")).toBe("gpt-image-2.5-flare");
expect(body.get("n")).toBe("1");
expect(body.get("size")).toBe("4:3");
expect(body.get("response_format")).toBe("url");
expect(body.get("image[]")).toBeInstanceOf(Blob);
expect(new Headers(init?.headers).get("X-Relay-Route-Option")).toBe("selected-route");
return Response.json({ data: [{ url: outputUrl }] });
}
expect(url).toBe(outputUrl);
expect(new Headers(init?.headers).get("Authorization")).toBe(outputUrl.includes("relay.test") ? "Bearer test-key" : null);
return new Response(new Uint8Array(generated));
};
const result = await expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test/v1", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 });
const metadata = await sharp(result).metadata();
expect([metadata.width, metadata.height]).toEqual([60, 45]);
const pixel = await sharp(result).extract({ left: 30, top: 22, width: 1, height: 1 }).removeAlpha().raw().toBuffer();
expect([...pixel]).toEqual([0, 0, 255]);
expect(submitted).toBe(1);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("relay outpainting does not resubmit a failed generation", async () => {
const originalFetch = globalThis.fetch;
const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer();
let submitted = 0;
try {
globalThis.fetch = async (input) => {
if (String(input) === "https://source.test/input.png") return new Response(new Uint8Array(source));
submitted++;
return Response.json({ error: { message: "service unavailable" } }, { status: 503 });
};
await expect(expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 })).rejects.toThrow("service unavailable");
expect(submitted).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@ -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 () => {

View File

@ -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("无法确认原图");