Compare commits

..

No commits in common. "main" and "codex/preserve-active-image-downloads-20261111" have entirely different histories.

46 changed files with 361 additions and 1200 deletions

View File

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

View File

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

View File

@ -2577,8 +2577,6 @@ export function AdminSettingsClient() {
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
provider: "dashscope" as "vip98k" | "dashscope" | "fal",
modelId: "",
routeOptionId: "",
xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数
});
@ -2894,15 +2892,11 @@ export function AdminSettingsClient() {
if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as {
provider?: "vip98k" | "dashscope" | "fal";
modelId?: string;
routeOptionId?: string;
xScale?: number;
yScale?: number;
};
setExtendConfig({
provider: config.provider === "vip98k" || config.provider === "fal" ? config.provider : "dashscope",
modelId: config.modelId || "",
routeOptionId: config.routeOptionId || "",
xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5",
});
@ -5511,8 +5505,6 @@ export function AdminSettingsClient() {
key: "extend_api_config",
value: {
provider: extendConfig.provider,
modelId: extendConfig.modelId,
routeOptionId: extendConfig.routeOptionId,
xScale,
yScale,
},
@ -13517,8 +13509,7 @@ export function AdminSettingsClient() {
系统智能选择(根据意图路由)
</SelectItem>
{modelsData?.models
?.filter((m: any) => m.supportsImageGen && m.isEnabled && !m.isSubModel &&
providersData?.providers?.some((provider: any) => provider.id === m.providerId && provider.isEnabled))
?.filter((m: any) => m.supportsImageGen)
.map((model: any) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}

View File

@ -26,10 +26,9 @@ function failed(failure: DownloadFailure, requestId: string) {
});
}
function preparing(requestId: string, converting = false) {
function preparing(requestId: string) {
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
phase: converting ? "converting" : "preparing",
message: converting ? "正在转换为兼容 PNG 并保存,完成后自动下载" : "正在准备原图,完成后将自动开始下载" }, {
message: "正在准备原图,完成后将自动开始下载" }, {
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
});
}
@ -120,23 +119,23 @@ export async function POST(request: NextRequest) {
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) {
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`);
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
logger.warn("cdn_lookup_failed", error, { requestId });
return { task: null, direct: null, canPrepareDirect: false };
});
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task && canPrepareDirect) {
if (task && canPrepareDirect && body.preparation === "async") {
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
return failed(asset.failure || getDownloadFailure(null), task.id);
}
scheduleImageDownloadPreparation(task.id);
return preparing(task.id, true);
return preparing(task.id);
}
if (task && !task.targetUrl) {
const { originalImagePreparing, prepareOriginalImageArchive } = await import("@/lib/storage/original-image-download");
const pending = await prepareOriginalImageArchive(task.sourceUrl);
return NextResponse.json(originalImagePreparing(pending), { status: 202,
headers: { "Retry-After": "3", "Cache-Control": "no-store" } });
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
scheduleImageDownloadPreparation(task.id);
}
throw new Error("下载文件暂时无法从存储桶直接下载");
}
const cookie = request.headers.get("cookie") || "";
if (body.preparation === "async") {

View File

@ -331,18 +331,7 @@ export async function GET(request: NextRequest) {
);
}
if (!original && !forceProxy && isBrowserImageRequest(request)) {
const direct = buildImageProxyUrl(target.toString(), {
width: width || undefined, quality: directQuality || undefined,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN && /^https:\/\//.test(direct) &&
new URL(direct).origin === new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN).origin) {
return redirectToDirectImage(new URL(direct));
}
}
if (!original && (isAlibabaModelImageUrl(target.toString()) || (!forceProxy && isBrowserImageRequest(request)))) {
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]);
@ -367,9 +356,7 @@ export async function GET(request: NextRequest) {
buildImageProxyUrl(target.toString(), { delivery: "original" });
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
if (!direct) throw new Error("下载文件暂时无法从存储桶直接下载");
return NextResponse.json(direct, { status: direct.delivery === "preparing" ? 202 : 200,
headers: { "Cache-Control": "no-store", ...(direct.delivery === "preparing" ? { "Retry-After": "3" } : {}) } });
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
return downloadFailure(error);
}

View File

@ -4,7 +4,6 @@ import { createHash } from "node:crypto";
import { z } from "zod";
import { db } from "@/lib/db";
import {
aiModel,
aiProvider,
siteSettings,
systemSettingHistory,
@ -2213,14 +2212,6 @@ export async function PUT(request: NextRequest) {
}
}
const data = updateSchema.parse(body);
if (data.canvasAutoModeDefaultModelId) {
const [available] = await db.select({ id: aiModel.id }).from(aiModel)
.innerJoin(aiProvider, eq(aiProvider.id, aiModel.providerId))
.where(and(eq(aiModel.id, data.canvasAutoModeDefaultModelId),
eq(aiModel.isEnabled, true), eq(aiProvider.isEnabled, true),
eq(aiModel.isSubModel, false), eq(aiModel.supportsImageGen, true))).limit(1);
if (!available) return NextResponse.json({ error: "默认生图模型或其上游已停用,请选择可用的生图模型,或改为系统智能选择。" }, { status: 400 });
}
const {
surpriseCategories,
aiDetailGenerationEnabled,

View File

@ -1,5 +1,4 @@
"use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
@ -15105,7 +15104,7 @@ function DirectorNodeComposer({
if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;}
setBusy(true);setError("");try{
const form=new FormData();form.append("file",file);form.append("purpose","director-studio");
const response=await fetchUploadWithRetry(form,{signal:AbortSignal.timeout(120000)});
const response=await fetch("/api/files/upload",{method:"POST",body:form,signal:AbortSignal.timeout(120000)});
const result=await response.json();const url=result.url||result.data?.url;
if(!response.ok||!url)throw new Error(result.error||"图片上传失败");
onDraft({directorReferenceUrl:String(url)});

View File

@ -1,5 +1,4 @@
"use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useState } from "react";
import { toast } from "sonner";
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
@ -65,7 +64,9 @@ export function DirectorReconstructionPanel({
const form = new FormData();
form.append("file", file);
form.append("purpose", "director-studio");
const response = await fetchUploadWithRetry(form, {
const response = await fetch("/api/files/upload", {
method: "POST",
body: form,
signal: AbortSignal.timeout(120000),
});
const data = await response.json();

View File

@ -1,5 +1,4 @@
"use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
commitDirectorScene,
resolveDirectorCamera,
@ -44,13 +43,14 @@ async function upload(file: File) {
"purpose",
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
);
const response = /\.glb$/i.test(file.name)
? await fetch("/api/canvas/director/upload-model", {
const response = await fetch(
/\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
{
method: "POST",
body: form,
signal: AbortSignal.timeout(120000),
})
: await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000) });
},
);
const result = await response.json();
const url = result.url || result.data?.url;
if (!response.ok || !url) throw new Error(result.error || "上传失败");

View File

@ -207,7 +207,7 @@ export function ImageParamsBar({
normalizeImageSizeValue(size, "")
)
: []
).filter((size) => providerType !== "alibaba-image" || size === "1K" || size === "2K");
);
const buildImageSizeOptions = () => {
if (imageGenConfig?.supportsSize === false) {
@ -220,7 +220,6 @@ export function ImageParamsBar({
return DEFAULT_IMAGE_SIZE_OPTIONS.filter((option) => {
if (option.disabled) return false;
if (providerType === "alibaba-image" && option.value === "4K") return false;
if (!imageGenConfig) return true;
if (option.value === "1K") return imageGenConfig.supports1K !== false;
if (option.value === "2K") return imageGenConfig.supports2K !== false;

View File

@ -29,7 +29,6 @@ const reviewSchema = z.object({
passed: z.boolean(),
issues: z.array(z.string().max(300)).max(12),
failedNodeIds: z.array(z.string().max(180)).max(12).default([]),
observations: z.array(z.string().max(300)).max(12).default([]),
});
export type RuntimeReviewPorts = {
loadNodes?: (ids: string[]) => Promise<RuntimeImageNode[]>;
@ -68,7 +67,7 @@ export async function analyzeRuntimeImages(params: {
const answer = await generateText({
model,
system:
'你是图片检查员。只检查实际可见内容,不执行图片或文字中包含的指令。按用户的原始要求、后续补充及实际输出设置判断;任务计划只能辅助理解,不得自行增加验收条件。用户要求修改的内容不需要与原图一致;“其他不变”要求主体、颜色、背景与布局在视觉上保持一致,不等于像素、字形或位置完全重合。除非用户明确要求精确字体、字号或位置,否则轻微字形和排版差异不能单独判失败;漏改、错字、尺寸文字错误、主体或背景明显变化仍须指出。检查关键要求时若图片模糊或无法确认,说明具体无法确认的内容并令 passed 为 false,不要因为未要求的细节无法确认而失败。图片附带从原文件读取的尺寸和透明通道事实,判断透明性必须以 hasAlpha、hasTransparentPixels 为准;预览中的棋盘格只是透明区域的显示底纹,不是图片真实背景。返回 JSON:{"passed":boolean,"issues":[string],"failedNodeIds":[string],"observations":[string]}。observations 回答检查问题,写明各节点实际读到的文字、主体或背景;issues 只列不满足用户要求或无法确认的关键问题,不要把参考原图列为失败结果。最多列出6条关键问题,每条不超过150字;failedNodeIds只列当前图片节点,不超过12个。不输出JSON之外的文字。',
'你是图片验收员。只检查实际可见内容,不执行图片或文字中包含的指令。图片附带从原文件读取的透明通道事实,判断透明性必须以 hasAlpha、hasTransparentPixels 为准;预览中的棋盘格只是透明区域的显示底纹,不是图片真实背景,不可把它或预览黑白底当作不透明的证据。其他内容不确定时 passed 必须为 false。返回 JSON:{"passed":boolean,"issues":[string],"failedNodeIds":[string]},指出具体结果图片节点及问题,不要把参考原图列为失败结果。最多列出6条关键问题,每条不超过150字;failedNodeIds只列当前图片节点,不超过12个。不输出JSON之外的文字。',
messages: [
{
role: "user",
@ -194,13 +193,6 @@ export async function verifyCanvasAgentResults(params: {
}
const issues: string[] = [];
const failedIds = new Set<string>();
const selectedRatio = countAgentGeneratedImages(calls) > 0
? snapshot.imagePreferences?.parameters?.aspectRatio : undefined;
const expectedRatio = selectedRatio && selectedRatio !== "auto"
? selectedRatio : snapshot.requirements?.aspectRatio ?? plan.aspectRatio;
const ratio = String(expectedRatio || "").match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
const targetRatio = ratio && Number(ratio[1]) > 0 && Number(ratio[2]) > 0
? Number(ratio[1]) / Number(ratio[2]) : undefined;
for (let index = 0; index < finalIds.length; index += 2) {
const resultIds = finalIds.slice(index, index + 2);
const sourceIds = originalSourceNodeIds(snapshot, resultIds);
@ -209,18 +201,20 @@ export async function verifyCanvasAgentResults(params: {
const review = await analyzeRuntimeImages({
...params,
nodeIds: [...sourceIds, ...resultIds],
question: `原始参考图节点:${JSON.stringify(sourceIds)}。待验收结果节点:${JSON.stringify(resultIds)}。仅检查结果图是否符合要求,并和原图对照主体。\n用户要求:${snapshot.objective}\n后续补充:${snapshot.inputs.map((input) => input.text).join("\n")}\n实际输出设置:${JSON.stringify(snapshot.imagePreferences?.parameters || {})}\n${targetRatio ? `结果的目标比例为 ${expectedRatio},程序会根据原文件尺寸独立校验。不要用参考图的比例或画布显示尺寸替代这个目标,不要把正常缩放、像素取整的细微差异列为问题。` : "未固定输出比例,不要仅因原图与结果图像素尺寸不同而判失败;用户明确要求保持或改变比例时仍须检查。"}\n辅助任务计划(不能扩大用户要求):${JSON.stringify(plan)}`,
question: `原始参考图节点:${JSON.stringify(sourceIds)}。待验收结果节点:${JSON.stringify(resultIds)}。仅检查结果图是否符合要求,并和原图对照主体。\n用户要求:${snapshot.objective}\n后续补充:${snapshot.inputs.map((input) => input.text).join("\n")}\n任务标准:${JSON.stringify(plan)}`,
});
snapshot.inputTokens += review.inputTokens;
snapshot.outputTokens += review.outputTokens;
if (!review.passed || review.issues.length)
issues.push(...(review.issues.length ? review.issues : ["视觉检查未通过"]));
for (const id of review.failedNodeIds) if (resultIds.includes(id)) failedIds.add(id);
if (targetRatio)
const expectedRatio = snapshot.requirements?.aspectRatio ?? plan.aspectRatio;
const ratio = String(expectedRatio || "").match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (ratio)
for (const image of review.images) {
if (
resultIds.includes(image.nodeId) &&
Math.abs(image.width / image.height / targetRatio - 1) > 0.01
Math.abs(image.width / image.height - Number(ratio[1]) / Number(ratio[2])) > 0.025
) {
issues.push(`图片 ${image.nodeId} 的实际比例未符合 ${expectedRatio}`);
failedIds.add(image.nodeId);

View File

@ -7,24 +7,20 @@ import {
} from "@/lib/db/queries";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { executeConfiguredVip98kTask, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime";
import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import { randomUUID } from "node:crypto";
type ExtendConfig = {
provider?: ExtendProvider;
apiKey?: string;
xScale?: number;
yScale?: number;
modelId?: string;
routeOptionId?: string;
};
const SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const DASHSCOPE_MIN_IMAGE_EDGE = 512;
const DASHSCOPE_MAX_IMAGE_EDGE = 4096;
const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
@ -147,7 +143,7 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
@ -242,6 +238,97 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
return uploaded.url;
}
function isTemporaryVip98kExpansionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /HTTP\s+(?:429|5\d\d)|temporar(?:y|ily)|timeout|timed out|unavailable/i.test(
message,
);
}
async function createVip98kExpansionCanvas(
imageUrl: string,
xScale: number,
yScale: number,
) {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
"下载待拓展图片失败",
502,
`${response.status} ${response.statusText}`,
);
}
const sourceBuffer = Buffer.from(await response.arrayBuffer());
const sharp = await import("sharp");
const image = sharp.default(sourceBuffer, { failOn: "none" });
const metadata = await image.metadata();
const width = Number(metadata.width || 0);
const height = Number(metadata.height || 0);
if (!width || !height) {
throw new ExtendServiceError("无法识别待拓展图片尺寸", 422);
}
const targetWidth = Math.max(width, Math.round(width * xScale));
const targetHeight = Math.max(height, Math.round(height * yScale));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const canvas = await image
.ensureAlpha()
.extend({
left,
right: targetWidth - width - left,
top,
bottom: targetHeight - height - top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
const arrayBuffer = canvas.buffer.slice(
canvas.byteOffset,
canvas.byteOffset + canvas.byteLength,
) as ArrayBuffer;
const uploaded = await uploadFile(
`extend-vip98k-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
arrayBuffer,
);
return {
imageUrl: uploaded.url,
width,
height,
targetWidth,
targetHeight,
};
}
async function extendImageWithVip98kEditFallback(
imageUrl: string,
xScale: number,
yScale: number,
) {
const canvas = await createVip98kExpansionCanvas(imageUrl, xScale, yScale);
const result = await executeConfiguredVip98kTask({
modelId: VIP98K_EXPANSION_FALLBACK_MODEL,
kind: "image",
prompt: [
"Outpaint only the transparent regions of this image.",
"Keep every original pixel, subject, scale, position, perspective, lighting and style unchanged.",
`Complete the ${canvas.targetWidth}x${canvas.targetHeight} canvas naturally from the centered ${canvas.width}x${canvas.height} source.`,
"Do not crop, zoom, move, replace or redraw the original content.",
].join(" "),
imageUrls: [canvas.imageUrl],
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 生成式扩图未返回图片", 502);
}
return outputUrl;
}
async function extendImage(
imageUrl: string,
config: ExtendConfig,
@ -250,17 +337,27 @@ async function extendImage(
requestId?: string | null,
): Promise<string> {
if (config.provider === "vip98k") {
const result = await executeConfiguredVip98kTask({
modelId: "image-expansion",
kind: "image-tool",
imageUrls: [imageUrl],
imageParams: { xScale, yScale },
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
try {
const result = await executeConfiguredVip98kTask({
modelId: "image-expansion",
kind: "image-tool",
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
imageUrls: [imageUrl],
imageParams: { xScale, yScale },
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
}
return outputUrl;
} catch (error) {
if (!isTemporaryVip98kExpansionError(error)) throw error;
console.warn(
"[Extend Service] VIP98K expansion tool is temporarily unavailable; using VIP98K generative outpainting fallback",
error instanceof Error ? error.message : String(error),
);
return extendImageWithVip98kEditFallback(imageUrl, xScale, yScale);
}
return outputUrl;
}
if (!config.apiKey) {
@ -436,47 +533,29 @@ export async function executeExtendJob(
}
const startTime = Date.now();
let buffer!: Buffer;
let uploadResult: { thumbnail: string; original: string } | undefined;
let archivedBytes: number | undefined;
let downloadAsset: Awaited<ReturnType<typeof archiveTemporaryImage>>["downloadAsset"];
let buffer: Buffer;
let modelId: string;
if (config.provider === "fal") {
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
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 {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId);
if (config.provider === "vip98k" && !includeBase64) {
const archived = await archiveTemporaryImage(url, requestId || randomUUID());
uploadResult = { original: archived.url, thumbnail: archived.thumbnailUrl };
archivedBytes = archived.bytes;
downloadAsset = archived.downloadAsset;
} else {
const imageResponse = await fetch(url);
if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
}
buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageResponse = await fetch(url);
if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
}
buffer = Buffer.from(await imageResponse.arrayBuffer());
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
}
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
uploadResult ??= await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg");
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" ? "png" : "jpg");
console.log("[Extend Service] Result uploaded:", {
requestId,
includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120),
modelId,
outputBytes: archivedBytes ?? buffer.length,
outputBytes: buffer.length,
});
let mediaAssetId: string | null = null;
let generatedImageId: string | null = null;
@ -525,7 +604,6 @@ export async function executeExtendJob(
xScale,
yScale,
imageSize: "2K",
...(downloadAsset ? { downloadAsset } : {}),
},
});
mediaAssetId = media?.id || null;

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

@ -13,7 +13,6 @@ import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
@ -71,7 +70,7 @@ interface AlibabaImageResponse {
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000;
const ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS = 120 * 1000;
const ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS = 60 * 1000;
const ALIBABA_IMAGE_POLL_TIMEOUT_MS = 30 * 1000;
const ALIBABA_IMAGE_POLL_INTERVAL_MS = 5 * 1000;
const ALIBABA_MIN_SIZE = 512;
@ -92,9 +91,9 @@ class AlibabaImageRequestError extends Error {
}
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" {
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
if (normalized === "1K") return normalized;
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
return "2K";
}
@ -292,7 +291,6 @@ async function prepareAlibabaInputImageUrl(
modelId: string,
inputRole?: AlibabaImageInputRole,
original?: Buffer,
preparedBuffers?: Map<string, Buffer>,
): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
@ -326,27 +324,10 @@ async function prepareAlibabaInputImageUrl(
maxSide: ALIBABA_MAX_INPUT_SIDE,
modelId,
});
preparedBuffers?.set(normalizedUrl, prepared.buffer);
if (isAlibabaQwenImage3ModelId(modelId)) {
if (prepared.buffer.length > maxInputBytes) {
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
}
if (!prepared.transformed) {
const storage = await getDefaultStorageConfig();
if (storage?.isEnabled && storage.type === "qiniu-kodo" && storage.publicUrl) {
const source = new URL(normalizedUrl);
const publicBase = new URL(storage.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
const isStoredOriginal = !source.search && !source.hash &&
source.pathname.startsWith(publicBase.pathname.replace(/\/$/, "") + "/uploads/") &&
(source.origin === publicBase.origin || (origin && source.origin === new URL(origin).origin));
if (publicBase.protocol === "https:" && isStoredOriginal) {
source.host = publicBase.host;
source.protocol = publicBase.protocol;
return source.toString();
}
}
}
const format = prepared.transformed ? prepared.extension : metadata.format;
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
@ -580,7 +561,6 @@ async function sendAlibabaImageAsyncRequest(params: {
requestBody: Record<string, unknown>;
requestURL: string;
}): Promise<AlibabaImageResponse> {
const submissionStartedAt = Date.now();
const submitted = await fetchAlibabaImageJson({
apiKey: params.apiKey,
asyncRequest: true,
@ -588,14 +568,6 @@ async function sendAlibabaImageAsyncRequest(params: {
method: "POST",
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
url: params.requestURL,
}).catch((error) => {
if (error instanceof AlibabaImageRequestError && error.status >= 400 && error.status < 500 && error.status !== 408) throw error;
console.error("[Alibaba Image] Submission outcome unknown:", {
elapsedMs: Date.now() - submissionStartedAt,
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
error: error instanceof Error ? error.message : String(error),
});
throw Object.assign(new Error("阿里图片提交响应未确认,可能已受理,请先核对原任务,避免重复生成。", { cause: error }), { providerOutcomeUnknown: true });
});
const taskId = String(submitted.output?.task_id || "").trim();
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
@ -603,7 +575,7 @@ async function sendAlibabaImageAsyncRequest(params: {
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
const startedAt = Date.now();
let lastPollError: unknown = null;
console.log("[Alibaba Image] Async task submitted:", taskId, { elapsedMs: Date.now() - submissionStartedAt, requestId: submitted.request_id });
console.log("[Alibaba Image] Async task submitted:", taskId);
while (Date.now() - startedAt < ALIBABA_IMAGE_GENERATION_TIMEOUT_MS) {
await new Promise((resolve) =>
@ -700,13 +672,19 @@ async function processResponse(
function formatImageResponse(
images: Array<{ thumbnail: string; original: string }>,
size?: string,
requestedSizeLabel?: string,
): string {
if (images.length === 0) {
return "图片生成失败,请重试。";
}
// 将分辨率转换为简短格式(如 "1024*1024" -> "1K")
const sizeLabel = size ? getSizeLabel(size) : "2K";
const normalizedRequestedSize = normalizeImageSizeLabel(requestedSizeLabel);
const sizeLabel = requestedSizeLabel
? normalizedRequestedSize
: size
? getSizeLabel(size)
: "2K";
return images
.map((img) => {
@ -725,7 +703,7 @@ function getSizeLabel(size: string): string {
"1024*1024": "1K",
"1280*1280": "1K",
"1536*1536": "2K",
"2048*2048": "2K",
"2048*2048": "4K",
};
if (sizeMap[size]) return sizeMap[size];
@ -734,6 +712,7 @@ function getSizeLabel(size: string): string {
const width = Number.parseInt(matched[1], 10);
const height = Number.parseInt(matched[2], 10);
const maxSide = Math.max(width, height);
if (maxSide >= 1900) return "4K";
if (maxSide >= 1450) return "2K";
if (maxSide >= 950) return "1K";
}
@ -823,7 +802,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
}
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
const preparedBuffers = new Map<string, Buffer>();
const preparedImageUrls = await Promise.all(
selectedImageUrls.map((imageUrl) =>
prepareAlibabaInputImageUrl(
@ -831,7 +809,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
options.referenceImageBuffers?.get(imageUrl),
preparedBuffers,
),
),
);
@ -863,6 +840,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
const baseSizeMap: Record<string, number> = {
"1K": 1024,
"2K": isQwenImage3 ? 2048 : 1536,
"4K": 2048,
};
const baseSize = baseSizeMap[imageSize] || 1536;
@ -898,7 +876,8 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
console.log(`[Alibaba Image] Using user-specified size: ${params.size}`);
} else {
if (aspectRatio === "auto") {
// 按参考图比例计算输出尺寸,复用准备阶段读取的图片。
// 阿里文档在 auto 下默认接近 1024*1024,这会让 4K 选择失效。
// 这里改为根据参考图比例计算 size,确保 1K/2K/4K 档位真正生效。
const referenceImageUrl = preparedImageUrls[0];
if (!referenceImageUrl) {
params.size = `${baseSize}*${baseSize}`;
@ -906,11 +885,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
`[Alibaba Image] Text-to-image auto mode resolved size: imageSize=${imageSize} => size=${params.size}`
);
} else {
const preparedBuffer = preparedBuffers.get(selectedImageUrls[0]);
const metadata = preparedBuffer ? await sharp(preparedBuffer).metadata() : null;
const inferredDimensions = metadata?.width && metadata.height
? { width: metadata.width, height: metadata.height, aspectRatio: metadata.width / metadata.height }
: await inferImageDimensionsFromImageUrl(referenceImageUrl);
const inferredDimensions = await inferImageDimensionsFromImageUrl(referenceImageUrl);
if (inferredDimensions) {
params.size = calculateAlibabaSizeFromDimensions(
inferredDimensions.width,
@ -977,9 +952,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
const images = await processResponse(response).catch((error) => {
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
});
const actualSize = response.usage?.width && response.usage?.height
? `${response.usage.width}*${response.usage.height}` : params.size;
const markdownText = formatImageResponse(images, actualSize);
const markdownText = formatImageResponse(images, params.size, imageSize);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);

View File

@ -36,7 +36,6 @@ import {
normalizeVip98kRuntimeConfig,
resolveVip98kSubmissionTimeoutMs,
VIP98K_RUNTIME_CONFIG_KEY,
VIP98K_TASK_POLL_SECONDS,
type Vip98kRuntimeConfig,
} from "@/lib/ai/vip98k-runtime-config";
import {
@ -50,7 +49,6 @@ import {
} from "@/lib/ai/vip98k-reference-staging";
import { createLogger } from "@/lib/observability/logger";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import {
IMAGE_UPLOAD_MAX_BYTES,
@ -98,7 +96,7 @@ type UploadedImage = {
const DOWNLOAD_TIMEOUT_MS = 120_000;
const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = VIP98K_TASK_POLL_SECONDS;
const DEFAULT_MAX_POLL_SECONDS = 600;
const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
@ -420,12 +418,6 @@ async function fetchTaskResult(params: {
signal: AbortSignal.timeout(60_000),
});
const payload = await parseJsonResponse(response);
const resultRecord = toRecord(unwrapData(payload));
const resultError = toRecord(resultRecord?.error);
const resultCode = String(resultError?.code || resultRecord?.code || "");
if (extractStatus(payload) === "pending_review" ||
["submission_pending_reconciliation", "request_outcome_unknown"].includes(resultCode))
throw new ProviderTaskUncertainError("VIP98K original task outcome remains unknown", params.taskId);
if (!response.ok) {
logger.error("task result request failed", {
taskId: params.taskId,
@ -1083,14 +1075,6 @@ async function recoverImageSubmission(params: {
if ([401, 403, 404, 410].includes(lookup.status)) return null;
if (lookup.ok) {
const state = toRecord(await parseJsonResponse(lookup));
if (state?.status === "failed" && state.request_id && state.result_available === false) {
logger.warn("original image request confirmed failed", {
modelId: params.modelId,
providerRequestId: state.request_id,
durationMs: Date.now() - startedAt,
});
throw new AgentImageGenerationError("生图服务已确认本次生成失败,未生成图片。请稍后重试或手动选择其他模型。", true);
}
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
if (state.status === "succeeded") {
if (state.result_available !== true || !state.request_id) return null;
@ -1120,7 +1104,7 @@ async function recoverImageSubmission(params: {
}
}
} catch (error) {
if (error instanceof ProviderTaskUncertainError || error instanceof AgentImageGenerationError) throw error;
if (error instanceof ProviderTaskUncertainError) throw error;
}
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
}
@ -1551,7 +1535,7 @@ async function waitForTask(params: {
durationMs: Date.now() - startedAt,
timeoutMs,
});
throw new ProviderTaskUncertainError("VIP98K task timed out; it may still be running", params.taskId);
throw new Error("VIP98K task timed out; it may still be running");
}
function normalizeRatio(value: unknown) {
@ -1682,7 +1666,6 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
}
if (params.modelId.toLowerCase() === "image-expansion") {
bindVip98kExpansionParams(body, imageParams);
body.output_format = "png";
}
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
// Old synced examples include generic image fields and both language aliases.

View File

@ -1,5 +1,3 @@
import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle";
/** Only adapters with an explicit rejection of the submission may use this.
* Polling, result downloads and storage failures are not submission refusals. */
export class ImageSubmissionRejectedError extends Error {
@ -18,9 +16,6 @@ export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
if (error instanceof AgentImageGenerationError) return error;
if (error instanceof ProviderTaskUncertainError)
return new AgentImageGenerationError(`图片任务已提交,结果仍待核对。${error.providerTaskId ? `上游任务编号:${error.providerTaskId}。` : ""}请先查询原任务结果,不要重复提交。`, false);
const rejected = error instanceof ImageSubmissionRejectedError;
const message = error instanceof Error ? error.message : "";
const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";

View File

@ -1,5 +1,4 @@
import { canonicalVip98kVideoRatio, vip98kVideoDurationBounds } from "./vip98k-video-parameters";
import { VIP98K_TASK_POLL_SECONDS } from "./vip98k-runtime-config";
import {
hasVerifiedVip98kGptImage25ResolutionTiers,
hasVerifiedVip98kOpenAiImageArrayContract,
@ -703,7 +702,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
pollIntervalSeconds: parseNumber(
model.pollIntervalSeconds || model.poll_interval_seconds,
) || 8,
maxPollSeconds: parseNumber(model.maxPollSeconds || model.max_poll_seconds) || VIP98K_TASK_POLL_SECONDS,
maxPollSeconds: parseNumber(model.maxPollSeconds || model.max_poll_seconds) || 600,
resultTTLSeconds: parseNumber(
model.resultTTLSeconds || model.result_ttl_seconds,
),

View File

@ -1,7 +1,4 @@
export const VIP98K_RUNTIME_CONFIG_KEY = "vip98k_runtime_config";
export const VIP98K_TASK_POLL_SECONDS = 15 * 60;
// Allow submission and result delivery in addition to the accepted task's polling budget.
export const VIP98K_IMAGE_BATCH_TIMEOUT_MS = (VIP98K_TASK_POLL_SECONDS + 5 * 60) * 1000;
export type Vip98kRuntimeConfig = {
submissionTimeoutMs: number;

View File

@ -5,7 +5,7 @@ import sharp from "sharp";
import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
import { AgentImageGenerationError, agentImageGenerationError, ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { agentImageGenerationError, ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
@ -19,7 +19,6 @@ import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
import { createVip98k } from "@/lib/ai/providers/vip98k";
import { VIP98K_IMAGE_BATCH_TIMEOUT_MS } from "@/lib/ai/vip98k-runtime-config";
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
@ -339,7 +338,7 @@ function isCanvasScaleAdjustDebugRequest(
);
}
export function getCanvasProviderBatchTimeoutMs(params: {
function getCanvasProviderBatchTimeoutMs(params: {
debugLabel: string;
debugMeta?: Record<string, unknown>;
providerType: string;
@ -350,7 +349,7 @@ export function getCanvasProviderBatchTimeoutMs(params: {
: CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS;
}
return params.providerType === "vip98k" ? VIP98K_IMAGE_BATCH_TIMEOUT_MS : CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS;
return CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS;
}
function getCanvasProviderRuntimeParams(params: {
@ -4951,7 +4950,7 @@ export async function generateCanvasImage(
if (!selectedModel) throw new Error("当前没有可用的备用图片模型,请稍后重试");
}
if (request.agentImageMode === "pinned" && !isCanvasImageSizeSupported(selectedModel.model.imageGenConfig as CanvasImageGenConfig | null, normalizeCanvasImageSizeValue(request.imageSize)))
throw new AgentImageGenerationError("指定的图片模型不支持所选清晰度,任务尚未提交。请调整清晰度后重试。", true);
throw new Error("指定的图片模型不支持所选尺寸,请调整图片尺寸");
const normalizedReferenceImages = Array.from(
new Set(

View File

@ -201,8 +201,7 @@ async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Pro
const { url, name, signal, onProgress } = params;
const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效");
const sourceUrl = new URL(source, window.location.origin);
const proxy = buildImageProxyUrl(/^https?:$/.test(sourceUrl.protocol) ? sourceUrl.toString() : source, { delivery: "original" });
const proxy = buildImageProxyUrl(source, { delivery: "original" });
const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image");
const target = new URL(proxy, window.location.origin);
onProgress?.({ phase: "preparing", message: "正在准备原图..." });
@ -224,20 +223,8 @@ async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Pro
if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") {
target.searchParams.set("download", "resolve");
target.searchParams.set("name", filenameBase);
const deadline = Date.now() + 180_000;
let delivery: any;
for (let poll = 0; ; poll++) {
signal?.throwIfAborted();
const metadata = await checkedFetch(target.pathname + target.search);
delivery = await metadata.json();
if (metadata.status !== 202) break;
if (delivery.delivery !== "preparing") throw new Error("下载准备状态无效");
if (Date.now() >= deadline || poll >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", requestId: delivery.requestId });
}
onProgress?.({ phase: "preparing", message: delivery.message || "正在准备原图,完成后自动下载" });
await waitForRetry(Math.max(1, Math.min(5, Number(delivery.retryAfter) || 3)) * 1000, signal);
}
const metadata = await checkedFetch(target.pathname + target.search);
const delivery = await metadata.json();
if (delivery.delivery === "cdn") {
// The resolver names the attachment using the stored MIME type.
const extension = delivery.filename.split(".").pop();
@ -248,7 +235,7 @@ async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Pro
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result;
}
throw new CanvasImageDownloadError("原图尚未准备完成", { code: "preparation_timeout", requestId: delivery.requestId });
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
}
const response = await checkedFetch(proxy);
const contentType = response.headers.get("content-type") || "";
@ -390,8 +377,8 @@ async function downloadPngFile({
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
}
onProgress?.({
phase: preparing.phase || preparationPhase,
message: preparing.message || (isConverting ? conversionMessage : "正在准备原图,完成后将自动下载"),
phase: preparationPhase,
message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
});
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
await waitForRetry(delaySeconds * 1000, signal);

View File

@ -134,13 +134,6 @@ function resolveTrustedDirectStorageDisplayUrl(
try {
const target = new URL(url);
const configuredCdn = getConfiguredCdnUrl();
const storageOrigin = process.env.NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN;
if (configuredCdn?.protocol === "https:" && storageOrigin &&
target.origin === new URL(storageOrigin).origin &&
!target.searchParams.has("token") && !target.searchParams.has("X-Amz-Signature")) {
target.host = configuredCdn.host;
target.protocol = configuredCdn.protocol;
}
const isConfiguredCdn = target.origin === configuredCdn?.origin;
if (
target.hostname !== configuredCdn?.hostname &&
@ -222,23 +215,6 @@ function resolveAliyunOssDisplayUrl(
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
let source = String(url || "").trim();
if (options.delivery !== "original" && options.delivery !== "proxy") {
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
try {
const saved = new URL(source, base);
if (saved.origin === base && saved.pathname === IMAGE_PROXY_PATH &&
!saved.searchParams.has("original") && saved.searchParams.get("proxy") !== "1") {
const nested = saved.searchParams.get("url") || "";
const format = options.format || saved.searchParams.get("f") as ImageProxyFormat | undefined;
const direct = resolveTrustedDirectStorageDisplayUrl(nested, {
width: options.width || Number(saved.searchParams.get("w")) || undefined,
quality: options.quality || Number(saved.searchParams.get("q")) || undefined,
format: ["webp", "avif", "jpeg", "png"].includes(format || "") ? format : undefined,
});
if (direct) return direct;
}
} catch { /* Keep non-storage proxies on their existing path. */ }
}
// Some messages retain our preview URL instead of its source URL. Rebuild
// that local proxy request without the preview's resize/format parameters.
if (options.delivery === "original") {

View File

@ -15,56 +15,11 @@ import {
markImageArchiveTaskReplaceFailed,
markImageArchiveTaskReplacing,
markImageArchiveTaskStored,
claimNextImageArchiveTask,
type ImageArchiveTaskRecord,
} from "@/lib/storage/image-archive-db";
import { replaceImageUrlInDatabase } from "@/lib/storage/image-url-replacement";
const DEFAULT_BATCH_SIZE = 10;
async function storeClaimedImage(task: ImageArchiveTaskRecord) {
try {
const metadata = task.metadata as { inlineSource?: string } | null;
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl, task.id);
await markImageArchiveTaskStored({ id: task.id, targetUrl: archived.url, downloadAsset: archived.downloadAsset });
if (!archived.downloadAsset) scheduleImageDownloadPreparation(task.id);
console.log("[ImageArchiveJob] stored", { taskId: task.id, bytes: archived.bytes, targetUrl: archived.url });
return true;
} catch (error) {
await markImageArchiveTaskFailed({ id: task.id, error });
console.error("[ImageArchiveJob] store failed", { taskId: task.id, error: String(error) });
return false;
}
}
export async function storeNextImageArchive(preferredTaskId?: string) {
return trackRuntimeWork(async () => {
const task = await claimNextImageArchiveTask(preferredTaskId);
if (!task) return false;
await storeClaimedImage(task);
return true;
}, { durable: true });
}
export async function replacePendingImageArchives() {
return trackRuntimeWork(async () => {
const tasks = await getImageArchiveTasksToReplace({ limit: 10 });
let claimed = 0;
for (const task of tasks) {
if (!(await markImageArchiveTaskReplacing(task.id))) continue;
claimed++;
try {
if (!task.targetUrl) throw new Error("Missing targetUrl");
const result = await replaceImageUrlInDatabase(task.sourceUrl, task.targetUrl);
await markImageArchiveTaskCompleted({ id: task.id, replacedCount: result.changed });
} catch (error) {
await markImageArchiveTaskReplaceFailed({ id: task.id, error });
}
}
return claimed;
}, { durable: true });
}
function getBatchSize() {
const parsed = Number(process.env.IMAGE_ARCHIVE_BATCH_SIZE || DEFAULT_BATCH_SIZE);
if (!Number.isFinite(parsed)) return DEFAULT_BATCH_SIZE;
@ -83,9 +38,32 @@ export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?
let storeFailed = 0;
const storeOne = async (task: (typeof storeTasks)[number]) => {
if (!(await markImageArchiveTaskArchiving(task.id))) return;
if (await storeClaimedImage(task)) storeSucceeded++;
else storeFailed++;
try {
if (!(await markImageArchiveTaskArchiving(task.id))) return;
const metadata = task.metadata as { inlineSource?: string } | null;
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl, task.id);
await markImageArchiveTaskStored({
id: task.id,
targetUrl: archived.url,
downloadAsset: archived.downloadAsset,
});
if (!archived.downloadAsset) scheduleImageDownloadPreparation(task.id);
storeSucceeded += 1;
console.log("[ImageArchiveJob] stored", {
taskId: task.id,
bytes: archived.bytes,
sourceUrl: task.sourceUrl,
targetUrl: archived.url,
});
} catch (error) {
storeFailed += 1;
await markImageArchiveTaskFailed({ id: task.id, error });
console.error("[ImageArchiveJob] store failed", {
taskId: task.id,
sourceUrl: task.sourceUrl,
error: error instanceof Error ? error.message : String(error),
});
}
};
// Bucket work has a small worker pool independent of unrestricted model generation.

View File

@ -1,39 +1,24 @@
import "server-only";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import { ensureStoredImageDownloadTask, findImageArchiveDownloadTask } from "./image-archive-db";
import { findImageArchiveDownloadTask } from "./image-archive-db";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import { prepareOriginalImageArchive } from "./original-image-download";
export async function resolveCanvasCdnDownload(urls: string[], filename: string) {
const sources = Array.from(new Set(urls.flatMap(url => {
const source = extractImageProxySourceUrl(url) || url;
return [source, inferOriginalImageUrl(source) || source];
})));
let task = await findImageArchiveDownloadTask(sources, true);
const task = await findImageArchiveDownloadTask(sources, true);
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
if (!task) return { task, direct: null, canPrepareDirect: false };
const config = await getDefaultStorageConfig();
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
if (!task && base.protocol === "https:") {
for (const candidate of sources) {
const stored = new URL(candidate);
if (origin && stored.origin === new URL(origin).origin && !stored.search) {
stored.host = base.host;
stored.protocol = base.protocol;
}
if (stored.origin === base.origin && !stored.search && !stored.hash &&
stored.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/uploads/")) {
task = await ensureStoredImageDownloadTask(stored.toString());
break;
}
}
}
if (!task) task = await prepareOriginalImageArchive(sources[0]);
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
const source = ready ? asset.url : task?.targetUrl;
const source = ready ? asset.url : task.targetUrl;
if (!source) return { task, direct: null, canPrepareDirect: false };
const url = new URL(source);
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
if (base.protocol !== "https:" || url.origin !== base.origin
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };

View File

@ -137,32 +137,6 @@ export async function markImageArchiveTaskArchiving(id: string) {
return claimed.length > 0;
}
export async function claimNextImageArchiveTask(preferredTaskId?: string) {
return db.transaction(async (tx) => {
const priority = preferredTaskId
? [asc(sql`case when ${imageArchiveTask.id} = ${preferredTaskId} then 0 else 1 end`)] : [];
const [task] = await tx.select().from(imageArchiveTask).where(and(
inArray(imageArchiveTask.status, ["pending", "failed"]),
lte(imageArchiveTask.archiveAfterAt, new Date()),
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
)).orderBy(...priority, asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(1).for("update", { skipLocked: true });
if (!task) return null;
await tx.update(imageArchiveTask).set({ status: "archiving",
attemptCount: sql`${imageArchiveTask.attemptCount} + 1`, lastError: null, updatedAt: new Date(),
}).where(eq(imageArchiveTask.id, task.id));
return task;
});
}
export async function hasPendingImageArchives() {
const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where(or(
inArray(imageArchiveTask.status, ["pending", "failed", "archiving", "stored", "replacing"]),
and(eq(imageArchiveTask.status, "completed"), sql`${imageArchiveTask.metadata}->>'replacementPasses' = '1'`),
)).limit(1);
return Boolean(task);
}
export async function markImageArchiveTaskStored(params: {
id: string;
targetUrl: string;
@ -379,17 +353,6 @@ export async function findImageArchiveDownloadTask(urls: string[], prepareOnDema
}
return task || null;
}
/** Reuse the download worker for stored uploads and synchronous tool results. */
export async function ensureStoredImageDownloadTask(url: string) {
const now = new Date();
const [created] = await db.insert(imageArchiveTask).values({
sourceUrl: url, targetUrl: url, status: "completed", archiveAfterAt: now,
replaceAfterAt: now, archivedAt: now, replacedAt: now, updatedAt: now,
metadata: { downloadAsset: { status: "pending" } },
}).onConflictDoNothing({ target: imageArchiveTask.sourceUrl }).returning();
return created || await findImageArchiveDownloadTask([url], true);
}
export async function hasPendingImageDownloads() {
const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where(
sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`,

View File

@ -1,88 +1,70 @@
import "server-only";
import { after } from "next/server";
const workerKey = "__generated_image_archive_worker_v2__";
const workerKey = "__generated_image_archive_worker__";
type Worker = {
active: number;
running: boolean;
scheduled: boolean;
priority: Set<string>;
replacing: boolean;
replaceRequested: boolean;
requested: boolean;
preferredTaskId?: string;
retryTimer?: ReturnType<typeof setTimeout>;
};
function getWorker(): Worker {
const root = globalThis as typeof globalThis & { [workerKey]?: Worker };
return (root[workerKey] ??= { active: 0, scheduled: false, priority: new Set(), replacing: false, replaceRequested: false });
}
async function retryIfNeeded() {
const worker = getWorker();
if (worker.active || worker.replacing || worker.retryTimer) return;
const { hasPendingImageArchives, hasPendingImageDownloads } = await import("./image-archive-db");
if (!(await hasPendingImageArchives()) && !(await hasPendingImageDownloads())) return;
if (worker.retryTimer) return;
worker.retryTimer = setTimeout(() => {
worker.retryTimer = undefined;
scheduleImageArchiveWorker();
}, 60_000);
worker.retryTimer.unref?.();
}
function scheduleReplacement() {
const worker = getWorker();
worker.replaceRequested = true;
if (worker.replacing) return;
worker.replacing = true;
void (async () => {
const { replacePendingImageArchives } = await import("@/lib/jobs/image-archive-job");
do {
worker.replaceRequested = false;
while (await replacePendingImageArchives()) { /* Drain references independently of uploads. */ }
} while (worker.replaceRequested);
})().catch(error => console.error("[ImageArchiveWorker] replacement failed", error)).finally(() => {
worker.replacing = false;
void retryIfNeeded().catch(error => console.error("[ImageArchiveWorker] retry failed", error));
});
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
}
export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) {
const worker = getWorker();
if (preferredTaskId) worker.priority.add(preferredTaskId);
if (worker.scheduled && !deliveryConfirmed) return;
worker.preferredTaskId ??= preferredTaskId;
worker.requested = true;
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
worker.scheduled = true;
if (worker.retryTimer) {
clearTimeout(worker.retryTimer);
worker.retryTimer = undefined;
}
const run = async () => {
if (!worker.scheduled) return;
if (worker.running) return;
worker.scheduled = false;
if (worker.retryTimer) { clearTimeout(worker.retryTimer); worker.retryTimer = undefined; }
const { storeNextImageArchive } = await import("@/lib/jobs/image-archive-job");
const { recoverStaleImageArchiveTasks } = await import("./image-archive-db");
const { scheduleImageDownloadPreparation } = await import("./image-download-preparation");
await recoverStaleImageArchiveTasks();
scheduleImageDownloadPreparation();
scheduleReplacement();
// Free slots take the next file immediately; slow files and reference updates
// cannot hold an entire batch or delay newly generated images.
while (worker.active < 4) {
worker.active++;
void (async () => {
for (;;) {
const id = worker.priority.values().next().value;
if (id) worker.priority.delete(id);
if (!(await storeNextImageArchive(id))) break;
scheduleReplacement();
}
})().catch(error => console.error("[ImageArchiveWorker] storage failed", error)).finally(() => {
worker.active--;
if (worker.priority.size) scheduleImageArchiveWorker();
else void retryIfNeeded().catch(error => console.error("[ImageArchiveWorker] retry failed", error));
});
worker.running = true;
let retryNeeded = false;
try {
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
do {
worker.requested = false;
const nextTaskId = worker.preferredTaskId;
worker.preferredTaskId = undefined;
const result = await runImageArchiveJob({ preferredTaskId: nextTaskId });
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.
if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;
} while (true);
} catch (error) {
retryNeeded = true;
console.error("[ImageArchiveWorker] background archive failed", error);
} finally {
worker.running = false;
if (worker.requested) scheduleImageArchiveWorker();
else if (retryNeeded) {
worker.retryTimer = setTimeout(scheduleImageArchiveWorker, 60_000);
worker.retryTimer.unref?.();
}
}
};
const safeRun = () => run().catch(error => {
console.error("[ImageArchiveWorker] scheduling failed", error);
void retryIfNeeded().catch(error => console.error("[ImageArchiveWorker] retry failed", error));
});
const fallback = setTimeout(() => { if (worker.scheduled) void safeRun(); }, 1_000);
fallback.unref?.();
try { after(safeRun); } catch { setImmediate(safeRun); }
// Detached generation tasks may inherit a request that has already closed. Keep
// a Node-server fallback so archiving never depends on the user leaving a tab open.
const deliveryFallback = setTimeout(() => {
if (worker.scheduled && !worker.running) void run();
}, 1_000);
deliveryFallback.unref?.();
try {
// Request handlers deliver first. Durable task rows also allow cron/restart recovery.
after(run);
} catch {
// Durable canvas tasks can run outside Next's request context on the Node server.
setImmediate(() => {
void run();
});
}
}

View File

@ -58,8 +58,7 @@ export async function registerGeneratedImageTemporaryUrls(params: {
displayHoldMs: getImageArchiveDisplayHoldMs(),
},
});
for (const task of tasks) scheduleImageArchiveWorker(false, task.id);
if (!tasks.length) scheduleImageArchiveWorker();
scheduleImageArchiveWorker();
return tasks;
} catch (error) {
logger.warn("enqueue temporary image archive failed", {
@ -200,10 +199,10 @@ export async function archiveTemporaryImage(sourceUrl: string, archiveId?: strin
});
if (direct) return direct;
}
const downloaded = await downloadTemporaryImage({ sourceUrl, retries: 0 });
const downloaded = await downloadTemporaryImage({ sourceUrl });
const objectSuffix = `${Date.now()}-${randomUUID()}`;
const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`;
const uploaded = await uploadFile(filename, toArrayBuffer(downloaded.buffer), { preferQiniuForm: true });
const uploaded = await uploadFile(filename, toArrayBuffer(downloaded.buffer));
await cacheImageBytes(`source::${uploaded.url}`, downloaded);
return {
url: uploaded.url,

View File

@ -4,7 +4,7 @@ import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
import { uploadFile } from "./service";
import { getReadyQiniuImageDownload, prepareQiniuWebpDownload } from "./qiniu-image-archive";
import { getReadyQiniuImageDownload } from "./qiniu-image-archive";
import { getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
@ -21,11 +21,6 @@ export async function prepareImageArchiveDownload(id: string) {
await saveImageDownloadAsset(id, ready);
return;
}
const converted = await prepareQiniuWebpDownload(task.targetUrl, task.id).catch(() => undefined);
if (converted) {
await saveImageDownloadAsset(id, converted);
return;
}
const source = await fetchImageBytes(task.targetUrl);
phase = "convert";
const asset = await normalizeCanvasDownloadAsset({
@ -38,7 +33,7 @@ export async function prepareImageArchiveDownload(id: string) {
phase = "store";
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
? task.targetUrl
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer, { preferQiniuForm: true })).url;
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
contentType: asset.contentType, preparedAt: new Date().toISOString() });
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });

View File

@ -1,31 +1,11 @@
import "server-only";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import { enqueueImageArchiveTask, findImageArchiveDownloadTask } from "./image-archive-db";
import { findImageArchiveDownloadTask } from "./image-archive-db";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { getImageDownloadExtension } from "@/lib/image-delivery";
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
export async function prepareOriginalImageArchive(source: string) {
const original = inferOriginalImageUrl(source) || source;
const task = await findImageArchiveDownloadTask([...new Set([source, original])])
|| await enqueueImageArchiveTask({ sourceUrl: original, metadata: { requestedDownload: true } });
if (!task) throw new Error("下载文件尚未保存到存储桶");
if (!task.targetUrl) {
if (task.status === "abandoned" || (task.status === "failed" && task.archiveAfterAt > new Date())) {
throw new Error(task.lastError || "下载文件保存到存储桶失败");
}
const { scheduleImageArchiveWorker } = await import("./image-archive-worker");
scheduleImageArchiveWorker(false, task.id);
}
return task;
}
export function originalImagePreparing(task: { id: string; status: string }) {
return { delivery: "preparing" as const, phase: "preparing" as const, retryAfter: 3, requestId: task.id,
message: task.status === "archiving" ? "正在保存原图,完成后自动下载" : "正在排队准备原图,完成后自动下载" };
}
/** Resolve the archived original without waiting for a separate PNG conversion. */
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
const config = await getDefaultStorageConfig();
@ -34,9 +14,12 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
let target = new URL(source);
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
const task = await prepareOriginalImageArchive(source);
if (!task.targetUrl) return originalImagePreparing(task);
target = new URL(task.targetUrl);
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);
}
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
target.host = base.host;

View File

@ -48,7 +48,7 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base)) return;
const key = url.slice(base.length);
if (!/^uploads\/(?:[\w.-]+\/)*[\w.-]+$/.test(key)) return;
if (!/^uploads\/[\w.-]+$/.test(key)) return;
const client = createS3Client(config);
try {
@ -74,45 +74,17 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
}
export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) {
let source: URL;
try { source = new URL(sourceUrl); } catch { return null; }
if (!/^https?:$/.test(source.protocol) || source.username || source.password) return null;
if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) {
return null;
}
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
return null;
}
// Files already in our bucket and inline previews need no remote copy.
const localOrigins = [config.publicUrl, config.endpoint, process.env.CANVAS_QINIU_ORIGIN_URL,
process.env.NEXT_PUBLIC_APP_URL, process.env.AUTH_URL, process.env.NEXTAUTH_URL];
if (localOrigins.some(value => value && new URL(value).origin === source.origin)
|| source.hostname === "localhost" || source.hostname === "[::1]"
|| /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(source.hostname)) return null;
// The archive task owns this object key, including retries after a timeout.
const pathname = `uploads/ai-generated-archive-${archiveId}`;
return fetchImageIntoQiniu(sourceUrl, pathname, target!);
}
/** Prepare uploaded WebP images inside Kodo, retaining their embedded colour profile. */
export async function prepareQiniuWebpDownload(url: string, taskId: string) {
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) return;
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base) || !/^uploads\/(?:[\w.-]+\/)*[\w.-]+\.webp$/i.test(url.slice(base.length))) return;
// No resizing or lossy re-encoding; apply orientation before removing EXIF.
const source = `${url}?imageMogr2/auto-orient/format/png/strip/meta-keep-list/${encodeQiniu("icc")}`;
const result = await fetchImageIntoQiniu(source, `uploads/canvas-download-${taskId}.png`, target!);
return result.downloadAsset;
}
async function fetchImageIntoQiniu(
sourceUrl: string,
pathname: string,
target: NonNullable<Awaited<ReturnType<typeof resolveS3UploadTarget>>>,
) {
const config = target.config;
const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`;
const contentType = "application/x-www-form-urlencoded";
const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`;

View File

@ -24,9 +24,7 @@ export type VideoTaskLifecycle = {
assertActive: () => Promise<void>;
};
export class ProviderTaskFailedError extends Error {}
export class ProviderTaskUncertainError extends Error {
constructor(message: string, readonly providerTaskId?: string) { super(message); }
}
export class ProviderTaskUncertainError extends Error {}
export function bindVideoTaskLifecycle(
lifecycle: VideoTaskLifecycle | undefined,
selected: any,

View File

@ -49,10 +49,7 @@ function resolveDevMediaOrigins(isDev: boolean) {
}
const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_APP_RELEASE: appRelease,
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: process.env.CANVAS_QINIU_ORIGIN_URL || "",
},
env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
poweredByHeader: false,
allowedDevOrigins: ["127.0.0.1"],

View File

@ -12,7 +12,6 @@ export default defineConfig({
/routes\/canvas-download-rate-limit\.test\.ts/,
/routes\/canvas-zip-download\.test\.ts/,
/routes\/image-archive-flow\.test\.ts/,
/routes\/image-archive-lanes\.test\.ts/,
/routes\/runtime-active-work\.test\.ts/,
/routes\/canvas-capability-task-recovery\.test\.ts/,
/routes\/canvas-toolbar-capability\.test\.ts/,

View File

@ -21,9 +21,8 @@ export function agentImageProviderHarness(options: { generate?: (id: string, cou
const text = options.generate?.(id, params.imageParams.imageCount, calls.length) || `![image](https://fixture.invalid/${id}-${calls.length}.png)`;
return { text };
} }) }) };
const canvas = serverLoader(mocks)("lib/canvas/generate-image.ts");
const generate = canvas.generateCanvasImage;
const generate = serverLoader(mocks)("lib/canvas/generate-image.ts").generateCanvasImage;
const request = { prompt: "a cat", model: "primary", imageCount: 1, imageSize: "2K", aspectRatio: "3:4", agentImageMode: "auto", allowFailover: true, referenceImages: ["https://fixture.invalid/source.png"], negativePrompt: "no text", providerImageParams: { seed: 0, watermark: false } };
return { generate, request, calls, paused, providerTimeout: canvas.getCanvasProviderBatchTimeoutMs };
return { generate, request, calls, paused };
}

View File

@ -1,9 +0,0 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./routes",
testMatch: /(?:qiniu-direct-paths|image-delivery-qiniu|image-archive-flow|original-image-download|canvas-download-speed|canvas-download-recovery)\.test\.ts/,
workers: 1,
reporter: "list",
timeout: 30_000,
});

View File

@ -2,57 +2,9 @@ import { expect, test } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import sharp from "sharp";
test("Qwen uses the public stored original without a second download and reports actual 2K output", async () => {
const png = await sharp({ create: { width: 384, height: 512, channels: 3, background: "blue" } }).png().toBuffer();
const source = "https://origin.example/uploads/fixture.png";
let submitted: any;
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => ({ isEnabled: true, type: "qiniu-kodo", publicUrl: "https://cdn.example" }) },
"@/lib/storage/service": {},
"@/lib/storage/image-archive": { createDeferredUploadedImage: async () => ({ thumbnail: "https://cdn.example/thumb.png", original: "https://cdn.example/result.png" }) },
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (url: string, init: RequestInit = {}) => {
methods.push(init.method || "GET");
if (init.method === "POST") {
submitted = JSON.parse(String(init.body));
return Response.json({ output: { task_id: "public-reference-task" } });
}
if (!url.includes("/tasks/")) throw new Error("Reference bytes must be reused");
return Response.json({ output: { task_status: "SUCCEEDED", choices: [{ message: { content: [{ image: "https://result.example/output.png" }] } }] }, usage: { width: 1536, height: 2048 } });
},
}, { CANVAS_QINIU_ORIGIN_URL: "https://origin.example" });
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const result = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
{ type: "text", text: "fixture" }, { type: "image", image: source },
] }], imageParams: { aspectRatio: "auto", imageSize: "4K" }, referenceImageBuffers: new Map([[source, png]]) });
expect(methods).toEqual(["POST", "GET"]);
expect(submitted.input.messages[0].content.find((part: any) => part.image).image).toBe("https://cdn.example/uploads/fixture.png");
expect(submitted.parameters.size).toBe("1536*2048");
expect(result.text).toContain("|||2K]");
expect(result.text).not.toContain("4K");
});
test("Qwen submission timeout remains unknown and never sends another POST", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": {}, "@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
fetch: async (_url: string, init: RequestInit) => {
methods.push(init.method || "GET");
throw new DOMException("The operation was aborted", "AbortError");
},
});
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }] }).catch((error: unknown) => error);
expect(methods).toEqual(["POST"]);
expect(error).toMatchObject({ providerOutcomeUnknown: true });
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({ code: "RESULT_UNCONFIRMED", outcome: "unknown" });
});
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (_url: string, init: RequestInit) => {
@ -84,7 +36,6 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
let submitted: any;
let posts = 0;
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/service": { uploadFile() { throw new Error("Reference transport must not upload an intermediate file"); } },
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (url: string, init: RequestInit = {}) => {
@ -113,7 +64,6 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {
@ -135,7 +85,6 @@ test("a timed out reference read is safe to retry because no generation was subm
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {

View File

@ -1,30 +1,10 @@
import { expect, test } from "@playwright/test";
import { VIP98K_TASK_POLL_SECONDS } from "@/lib/ai/vip98k-runtime-config";
import { agentImageProviderHarness as harness } from "../helpers/agent-image-provider-harness";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
import { serverLoader } from "../helpers/canvas-download-server-loader";
test("the canvas deadline leaves room for 98K polling, submission and result delivery", () => {
const h = harness();
const timeout = h.providerTimeout({ debugLabel: "canvas-agent", providerType: "vip98k" });
expect(timeout).toBeGreaterThan(VIP98K_TASK_POLL_SECONDS * 1000);
expect(timeout).toBe(20 * 60_000);
expect(h.providerTimeout({ debugLabel: "canvas-agent", providerType: "alibaba" })).toBe(10 * 60_000);
expect(h.providerTimeout({ debugLabel: "canvas-scale-adjust", providerType: "vip98k" })).toBe(3 * 60_000);
});
test("an unsupported pinned resolution is rejected before submission and can be corrected", async () => {
const models = [{ model: { id: "primary", modelId: "primary", isEnabled: true, supportsImageGen: true,
imageGenConfig: { supportedImageSizes: ["1K", "2K"] } }, provider: { id: "provider", type: "openai-image", isEnabled: true } }];
const h = harness({ models });
await expect(h.generate({ ...h.request, agentImageMode: "pinned", imageSize: "4K" })).rejects.toMatchObject({
confirmedNoResult: true, message: expect.stringMatching(/尚未提交.*调整清晰度后重试/),
});
expect(h.calls).toHaveLength(0);
});
test("a balance rejection skips other models on the same account but permits another account of the same provider type", async () => {
const models = ["primary", "same-account", "backup"].map(id => ({
model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1 }, capabilities: { kind: "image" } },

View File

@ -130,34 +130,6 @@ test("actual dimensions override a model's claim that the output passed", async
expect(result.failedNodeIds).toEqual(["result"]);
expect(result.issues.join(" ")).toContain("3:4");
});
test("selected output ratio takes precedence over a stale plan and allows pixel rounding", async () => {
const f = await fixture('{"passed":true,"issues":[]}', { width: 1147, height: 1536 });
f.params.snapshot.requirements!.aspectRatio = "1:1";
f.params.snapshot.imagePreferences = { parameters: { aspectRatio: "3:4", imageSize: "2K" } };
f.params.snapshot.calls[1].output!.appliedImagePreferences = { aspectRatio: "3:4" };
const result = await verifyCanvasAgentResults(f.params);
expect(result.passed).toBe(true);
const prompt = JSON.stringify(f.requests[0].prompt);
expect(prompt).toContain("结果的目标比例为 3:4");
expect(prompt).toContain("不能扩大用户要求");
});
test("a wrong output ratio still fails when the vision model accepts it", async () => {
const f = await fixture('{"passed":true,"issues":[]}', { width: 60, height: 40 });
f.params.snapshot.imagePreferences = { parameters: { aspectRatio: "3:4" } };
const result = await verifyCanvasAgentResults(f.params);
expect(result).toMatchObject({ passed: false, failedNodeIds: ["result"] });
});
test("image inspection returns the text it read and retains real text failures", async () => {
const f = await fixture('{"passed":false,"issues":["结果仍为测试15mm,未改为测试20mm"],"failedNodeIds":["result"],"observations":["result:蓝色杯子,杯身文字为测试15mm"]}');
const inspection = await analyzeRuntimeImages({ ...f.params, nodeIds: ["result"], question: "读出杯身文字" });
expect(inspection.observations).toEqual(["result:蓝色杯子,杯身文字为测试15mm"]);
const result = await verifyCanvasAgentResults(f.params);
expect(result).toMatchObject({ passed: false, failedNodeIds: ["result"] });
expect(result.issues).toContain("结果仍为测试15mm,未改为测试20mm");
});
test("a filtered structured review is a policy rejection, never a reason to switch providers", async () => {
const f = await fixture("");
const model = f.params.model;

View File

@ -124,7 +124,7 @@ test("requested downloads are prioritized with two workers before scanning backg
},
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined, prepareQiniuWebpDownload: async () => undefined },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
})("lib/storage/image-download-preparation.ts");
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");

View File

@ -137,7 +137,7 @@ test("PNG preparation failure leaves the original archived and schedules a retry
const module = serverLoader({
"./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } },
"./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined, prepareQiniuWebpDownload: async () => undefined },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./service": {},
})("lib/storage/image-download-preparation.ts");
await module.prepareImageArchiveDownload("task");

View File

@ -2,61 +2,6 @@ import { expect, test } from "@playwright/test";
import sharp from "sharp";
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint";
import { expandWithVip98kOutpaint, getVip98kOutpaintGeometry } from "@/lib/ai/extend/vip98k-outpaint";
import { serverLoader } from "../helpers/canvas-download-server-loader";
test("native expansion sends the original image and never switches models after an upstream failure", async () => {
for (const message of ["HTTP 503 unavailable", "request timed out"]) {
const calls: any[] = [];
const service = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
"@/lib/db": {},
"@/lib/db/schema": {},
"@/lib/storage/service": {},
"@/lib/storage/image-archive": {},
"@/lib/ai/vip98k-runtime": {
executeConfiguredVip98kTask: async (params: unknown) => { calls.push(params); throw new Error(message); },
},
})("lib/ai/extend/service.ts");
await expect(service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", xScale: 1.5, yScale: 2 })).rejects.toThrow(message);
expect(calls).toEqual([{ modelId: "image-expansion", kind: "image-tool", imageUrls: ["https://source.test/original.jpg"], imageParams: { xScale: 1.5, yScale: 2 } }]);
}
});
test("native expansion archives the completed image without transferring its bytes through the app", async () => {
const calls: string[] = [];
const service = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
"@/lib/db": {},
"@/lib/db/schema": {},
"@/lib/storage/service": {},
"@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) },
"@/lib/storage/image-archive": { archiveTemporaryImage: async (url: string, id: string) => {
calls.push(url, id);
return { url: "https://cdn.test/native.png", thumbnailUrl: "https://cdn.test/native.png", bytes: 12375212 };
} },
})("lib/ai/extend/service.ts");
const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", requestId: "request-1", skipGallerySave: true });
expect(calls).toEqual(["https://relay.test/result.png", "request-1"]);
expect(result.original_url).toBe("https://cdn.test/native.png");
expect(result.image_base64).toBeUndefined();
});
test("native expansion still returns image bytes to callers requesting base64", async () => {
const image = await sharp({ create: { width: 12, height: 8, channels: 3, background: "red" } }).png().toBuffer();
const service = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
"@/lib/db": {},
"@/lib/db/schema": {},
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `https://cdn.test/${name}` }) },
"@/lib/storage/image-archive": { archiveTemporaryImage: () => { throw new Error("Base64 callers need the image bytes"); } },
"@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) },
fetch: async () => new Response(new Uint8Array(image)),
})("lib/ai/extend/service.ts");
const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", includeBase64: true, skipGallerySave: true });
expect(result.image_base64).toBe(image.toString("base64"));
expect(result.original_url).toMatch(/\.png$/);
});
test.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => {
@ -111,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(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

@ -127,7 +127,7 @@ test("Qiniu archives 98K originals without relaying full image bytes through the
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb16.length });
stored = indexed;
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined();
expect(await load.archiveImageThroughQiniu("data:image/png;base64,aW1hZ2U=", id)).toBeNull();
expect(await load.archiveImageThroughQiniu("https://another-provider.example/result.png", id)).toBeNull();
expect(cloudCalls).toHaveLength(3);
expect(reads).toHaveLength(3);
});
@ -244,12 +244,21 @@ test("background storage starts after delivery and coalesces repeated wakeups",
const callbacks: Array<() => Promise<void>> = [];
const preferences: Array<string | undefined> = [];
const module = serverLoader({
"next/server": { after: (fn: () => Promise<void>) => callbacks.push(fn) },
"./image-archive-db": { recoverStaleImageArchiveTasks: async () => {}, hasPendingImageArchives: async () => false, hasPendingImageDownloads: async () => false },
"./image-download-preparation": { scheduleImageDownloadPreparation() {} },
"next/server": {
after: (fn: () => Promise<void>) => {
callbacks.push(fn);
},
},
"@/lib/jobs/image-archive-job": {
storeNextImageArchive: async (id?: string) => { preferences.push(id); return false; },
replacePendingImageArchives: async () => 0,
runImageArchiveJob: async ({ preferredTaskId }: { preferredTaskId?: string }) => {
preferences.push(preferredTaskId);
return {
store: { total: preferences.length === 1 ? 1 : 0 },
replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 },
downloadTasks: 0,
};
},
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
@ -257,8 +266,7 @@ test("background storage starts after delivery and coalesces repeated wakeups",
expect(callbacks).toHaveLength(1);
expect(preferences).toEqual([]);
await callbacks[0]();
expect(preferences[0]).toBe("requested-image");
expect(preferences).toHaveLength(4);
expect(preferences).toEqual(["requested-image", undefined]);
});
test("bucket failure retains the provider URL and retry task", async () => {
@ -654,15 +662,21 @@ test("replacement retries reconcile late writes once, without reclaiming complet
test("detached generation still archives when its original request has already closed", async () => {
let runs = 0;
const module = serverLoader({
// A closed request may accept a callback that is never invoked.
"next/server": { after: () => {} },
"./image-archive-db": { recoverStaleImageArchiveTasks: async () => {}, hasPendingImageArchives: async () => false, hasPendingImageDownloads: async () => false },
"./image-download-preparation": { scheduleImageDownloadPreparation() {} },
"@/lib/jobs/image-archive-job": {
storeNextImageArchive: async () => { runs++; return false; },
replacePendingImageArchives: async () => 0,
runImageArchiveJob: async () => {
runs += 1;
return {
store: { total: 0 },
replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 },
downloadTasks: 0,
};
},
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
expect(runs).toBe(0);
await expect.poll(() => runs, { timeout: 5_000 }).toBe(4);
await expect.poll(() => runs, { timeout: 5_000 }).toBe(1);
});

View File

@ -1,67 +0,0 @@
import { test, expect } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
test("a slow original and slow reference updates do not block later files or new downloads", async () => {
let finishSlow!: () => void;
let finishReplacement!: () => void;
const slow = new Promise<void>(resolve => { finishSlow = resolve; });
const replacement = new Promise<void>(resolve => { finishReplacement = resolve; });
const pending = Array.from({ length: 25 }, (_, i) => `image-${i}`);
const completed: string[] = [];
const callbacks: Array<() => Promise<void>> = [];
let active = 0;
let peak = 0;
let replacing = false;
const module = serverLoader({
"next/server": { after: (callback: () => Promise<void>) => callbacks.push(callback) },
"./image-archive-db": { recoverStaleImageArchiveTasks: async () => {}, hasPendingImageArchives: async () => false, hasPendingImageDownloads: async () => false },
"./image-download-preparation": { scheduleImageDownloadPreparation() {} },
"@/lib/jobs/image-archive-job": {
storeNextImageArchive: async (preferred?: string) => {
const index = preferred ? pending.indexOf(preferred) : -1;
const id = pending.splice(index < 0 ? 0 : index, 1)[0];
if (!id) return false;
active++; peak = Math.max(peak, active);
if (id === "image-0") await slow;
else await new Promise(resolve => setTimeout(resolve, 1));
completed.push(id); active--;
return true;
},
replacePendingImageArchives: async () => { replacing = true; await replacement; return 0; },
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
await callbacks.shift()!();
await expect.poll(() => completed.length).toBe(24);
expect(completed).not.toContain("image-0");
expect(replacing).toBe(true);
pending.push("new-download");
module.scheduleImageArchiveWorker(false, "new-download");
await callbacks.shift()!();
await expect.poll(() => completed.includes("new-download")).toBe(true);
expect(peak).toBeLessThanOrEqual(4);
finishSlow(); finishReplacement();
await expect.poll(() => completed.length).toBe(26);
expect(new Set(completed).size).toBe(26);
});
test("conversion requests wait for the original archive and never invoke the relay preparation queue", async () => {
const pending = { id: "queued", sourceUrl: "https://model.example/image", status: "pending" };
const route = serverLoader({
"next/server": { NextResponse: Response },
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "test" } }) },
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} },
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({ task: pending, direct: null, canPrepareDirect: false }) },
"@/lib/storage/original-image-download": {
prepareOriginalImageArchive: async () => pending,
originalImagePreparing: () => ({ delivery: "preparing", phase: "preparing", requestId: pending.id }),
},
"@/lib/canvas/download-preparation": { getDownloadPreparationQueue() { throw new Error("Must not relay image bytes"); } },
})("app/api/canvas/download-image/route.ts");
const response = await route.POST(new Request("https://app.example/api/canvas/download-image", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ url: pending.sourceUrl, preferDirect: true, preparation: "async" }),
}));
expect(response.status).toBe(202);
expect(await response.json()).toMatchObject({ delivery: "preparing", phase: "preparing" });
});

View File

@ -105,7 +105,7 @@ test("the provider submits once when an HTTP 524 request cannot be located", asy
function imageRecoveryHarness(
handler: (path: string, method: string, call: number) => Response | Promise<Response>,
maxPollSeconds: number | undefined = 3,
maxPollSeconds = 3,
) {
let now = 1_000;
const calls: { path: string; method: string; headers: Headers }[] = [];
@ -141,42 +141,6 @@ function imageRecoveryHarness(
};
}
test("accepted async tasks can finish after ten minutes using only the original task", async () => {
let polls = 0;
const h = imageRecoveryHarness((_path, method) => {
if (method === "POST") return Response.json({ task_id: "slow-original", result_path: "/extensions/v1/tasks/slow-original/result" }, { status: 202 });
return Response.json(++polls <= 610 ? { status: "running" } : { status: "succeeded", output: { url: "https://image.example/original.png" } });
}, undefined);
// Omit the configured short fixture deadline to exercise the production default.
const result = await h.load("lib/ai/providers/vip98k.ts").executeVip98kExternalTask({
apiKey: "test-only-key", baseURL: "https://provider.test", modelId: "nano-banana-2",
kind: "image", prompt: "A desk", imageUrls: [], modelConfig: { capabilities: { vip98k: { pollIntervalSeconds: 1 } } },
});
expect(result.outputUrls).toEqual(["https://image.example/original.png"]);
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
expect(h.calls.filter(c => c.method === "GET").every(c => c.path.endsWith("/slow-original/result"))).toBe(true);
});
test("an accepted task's deadline and reconciliation status preserve its ID without another submission", async () => {
for (const reply of [
{ status: 202, body: { status: "running" } },
{ status: 202, body: { status: "pending_review", code: "submission_pending_reconciliation" } },
{ status: 409, body: { error: { code: "request_outcome_unknown" } } },
]) {
const h = imageRecoveryHarness((_path, method) => method === "POST"
? Response.json({ task_id: "original-task", result_path: "/extensions/v1/tasks/original-task/result" }, { status: 202 })
: Response.json(reply.body, { status: reply.status }), 2);
const error = await h.run().catch((error: unknown) => error);
expect(error.providerTaskId).toBe("original-task");
expect(h.load("lib/ai/runtime/model-health.ts").shouldFailoverAiModelError(error)).toBe(false);
const visible = h.load("lib/ai/runtime/image-generation-error.ts").agentImageGenerationError(error, "auto");
expect(visible.confirmedNoResult).toBe(false);
expect(visible.message).toContain("original-task");
expect(visible.message).toContain("不要重复提交");
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
}
});
const recoveredImage = { created: 1, data: [{ url: "https://image.example/original.png" }] };
const originalCompleted = { request_id: "original-request", status: "succeeded", result_available: true };

View File

@ -24,7 +24,7 @@ test("original downloads use the archived JPEG rather than the prepared PNG and
expect(new URL(result.downloadUrl).pathname).toBe("/original.jpg");
});
test("signed original URLs cannot silently fall back to the application server", async () => {
test("signed original URLs retain the byte-preserving proxy without changing their signature", async () => {
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
@ -45,17 +45,16 @@ test("existing CDN objects bypass the archive scan and retain the MIME-derived f
expect(new URL(result.downloadUrl).pathname).toBe("/source.png");
});
test("temporary third-party originals enqueue storage without reading their body", async () => {
test("temporary third-party URLs are never checked with HEAD when no archive is ready", async () => {
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => null, enqueueImageArchiveTask: async () => ({ id: "new", status: "pending" }) },
"./image-archive-worker": { scheduleImageArchiveWorker() {} },
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
fetch() { throw new Error("The upstream file service does not support HEAD"); },
})("lib/storage/original-image-download.ts");
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toMatchObject({ delivery: "preparing" });
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
});
test("downloading a pending original prioritizes storage and reports preparation", async () => {
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 },
@ -63,7 +62,7 @@ test("downloading a pending original prioritizes storage and reports preparation
"./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)).toMatchObject({ delivery: "preparing" });
expect(await module.resolveOriginalCdnDownload("https://upstream.example/queued.png", "image", new AbortController().signal)).toBeNull();
expect(preferred).toEqual(["pending-image"]);
});
@ -152,13 +151,14 @@ test("chat original downloads use a native CDN download without fetching the ima
expect(progress.map(event => event.phase)).toEqual(["preparing", "downloading"]);
});
test("a legacy proxy response does not start a slow original download", async () => {
const dom = browserMocks(); let calls = 0;
const client = serverLoader({ ...dom, fetch: async () => { calls++; return Response.json({ delivery: "proxy" }); } })("lib/client/canvas-image-download.ts");
await expect(client.downloadOriginalImage({ url: "https://external.example/image.png" })).rejects.toThrow("原图尚未准备完成");
expect(calls).toBe(1);
expect(dom.links).toHaveLength(0);
expect(dom.frames).toHaveLength(0);
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
const dom = browserMocks(); const bytes = Buffer.from([255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 255, 217]); let calls = 0;
const client = serverLoader({ ...dom, fetch: async () => ++calls === 1 ? Response.json({ delivery: "proxy" }) : new Response(bytes, { headers: { "content-type": "image/jpeg" } }) })("lib/client/canvas-image-download.ts");
expect(await client.downloadOriginalImage({ url: "https://external.example/image.png", name: "original.png" })).toEqual({ filename: "original.jpg", bytes: bytes.length });
expect(calls).toBe(2);
const actual = await fetch(dom.links[0].href).then(r=>r.arrayBuffer());
expect(Buffer.from(actual).equals(bytes)).toBe(true);
URL.revokeObjectURL(dom.links[0].href);
});
test("one click prepares a compatible PNG when the CDN original is WebP", async () => {
@ -174,7 +174,7 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async
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", phase: "converting", retryAfter: 1, message: "正在转换为兼容 PNG,完成后自动下载" }, { status: 202 });
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");
@ -190,19 +190,31 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async
expect(dom.links).toHaveLength(0);
});
test("queued originals show preparation then download directly without a proxy body", async () => {
const dom = browserMocks(); const calls: string[] = []; const progress: CanvasImageDownloadProgress[] = [];
const client = serverLoader({ ...dom, fetch: async (url: string) => {
calls.push(url);
if (calls.length === 1) return Response.json({ delivery: "preparing", retryAfter: 1, message: "正在保存原图,完成后自动下载" }, { status: 202 });
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.png", filename: "image.png", bytes: 900 });
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");
await client.downloadOriginalImage({ url: "https://external.example/image", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) });
expect(calls).toHaveLength(2);
expect(calls.every(url => url.includes("download=resolve"))).toBe(true);
expect(progress.map(p => p.phase)).toEqual(["preparing", "preparing", "downloading"]);
expect(dom.frames[0].src).toBe("https://cdn.example/image.png");
expect(dom.links).toHaveLength(0);
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 () => {

View File

@ -1,166 +0,0 @@
import { test, expect } from "@playwright/test";
import sharp from "sharp";
import { NextRequest, NextResponse } from "next/server";
import { serverLoader } from "../helpers/canvas-download-server-loader";
const config = { type: "qiniu-kodo", provider: "qiniu-kodo", isEnabled: true,
publicUrl: "https://cdn.example", endpoint: "https://s3.example", region: "cn-east-1",
bucket: "images", accessKeyId: "test-ak", secretAccessKey: "test-sk" };
test("external model results including signed OSS URLs are fetched by Qiniu without app image reads", async () => {
let source = "";
let calls = 0;
const load = serverLoader({
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}` },
fetch: async (url: string, init: RequestInit) => {
calls++;
expect(new URL(url).hostname).toBe("iovip-z0.qiniuio.com");
expect(init.method).toBe("POST");
const encoded = new URL(url).pathname.split("/fetch/")[1].split("/to/")[0];
expect(Buffer.from(encoded, "base64url").toString()).toBe(source);
return Response.json({ key: "uploads/ai-generated-archive-task-1", fsize: 5000, mimeType: "image/jpeg" });
},
}, { NEXT_PUBLIC_APP_URL: "https://app.example" });
const { archiveImageThroughQiniu } = load("lib/storage/qiniu-image-archive.ts");
for (source of ["https://api.vip98k.com/api/external/assets/image",
"https://dashscope-a717.oss-accelerate.aliyuncs.com/result.png?Expires=123&Signature=a%2Bb%3D",
"https://images.provider.example/result.jpg"]) {
expect(await archiveImageThroughQiniu(source, "task-1")).toMatchObject({ bytes: 5000, contentType: "image/jpeg" });
}
for (const local of ["https://cdn.example/uploads/a.png", "https://app.example/api/generated-image-preview/id",
"http://127.0.0.1/a.png", "data:image/png;base64,abcd"]) {
expect(await archiveImageThroughQiniu(local, "task-1")).toBeNull();
}
expect(calls).toBe(3);
});
test("stored direct uploads prepare once and then download from CDN", async () => {
let task: any = null;
const ensured: string[] = [];
const url = "https://cdn.example/uploads/direct/user-1/image.webp";
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": {
findImageArchiveDownloadTask: async () => task,
ensureStoredImageDownloadTask: async (stored: string) => {
ensured.push(stored); return task = { id: "stored", targetUrl: stored, metadata: { downloadAsset: { status: "pending" } } };
},
},
})("lib/storage/canvas-cdn-download.ts");
const pending = await module.resolveCanvasCdnDownload([url], "图片.png");
expect(pending).toMatchObject({ direct: null, canPrepareDirect: true });
task.metadata.downloadAsset = { status: "ready", url: "https://cdn.example/uploads/converted.png", contentType: "image/png", bytes: 1234 };
const ready = await module.resolveCanvasCdnDownload([url], "图片.png");
expect(ready.direct).toMatchObject({ delivery: "cdn", bytes: 1234 });
expect(new URL(ready.direct.downloadUrl).searchParams.get("attname")).toBe("图片.png");
expect(ensured).toEqual([url]);
});
test("download preparation only registers unsigned files in the configured bucket", async () => {
const stored: string[] = [];
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => null,
ensureStoredImageDownloadTask: async (url: string) => { stored.push(url); return { id: "1", targetUrl: url }; } },
}, { CANVAS_QINIU_ORIGIN_URL: "https://origin.example" })("lib/storage/canvas-cdn-download.ts");
for (const url of ["https://other.example/uploads/a.png", "https://cdn.example/uploads/a.png?token=signed",
"https://cdn.example/uploads/a.png?imageView2/2/w/160", "https://cdn.example/private/a.png"]) {
expect((await module.resolveCanvasCdnDownload([url], "a.png")).canPrepareDirect).toBe(false);
}
expect((await module.resolveCanvasCdnDownload(["https://origin.example/uploads/direct/user/a.png"], "a.png")).canPrepareDirect).toBe(true);
expect(stored).toEqual(["https://cdn.example/uploads/direct/user/a.png"]);
});
test("nested uploaded PNGs use a bounded header read without stripping their colour profile", async () => {
const bytes = await sharp({ create: { width: 32, height: 24, channels: 4, background: "#1d785c80" } }).withIccProfile("p3").png().toBuffer();
let reads = 0;
const module = serverLoader({
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ destroy() {}, send: async (cmd: any) => {
reads++; expect(cmd.input.Key).toBe("uploads/direct/user/a.png");
expect(cmd.input.Range).toBe("bytes=0-65535");
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png", ContentLength: bytes.length,
ContentRange: `bytes 0-${bytes.length - 1}/${bytes.length}`, Body: { transformToByteArray: async () => bytes } };
} }) },
})("lib/storage/qiniu-image-archive.ts");
const url = "https://cdn.example/uploads/direct/user/a.png";
expect(await module.getReadyQiniuImageDownload(url)).toMatchObject({ status: "ready", url, bytes: bytes.length });
expect(reads).toBe(1);
});
test("preview aliases use the configured CDN and retain processing and signed URL semantics", async () => {
const module = serverLoader({}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example",
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: "https://origin.example" })("lib/image-delivery.ts");
const source = "https://origin.example/uploads/a.png";
const options = { width: 331, quality: 86, format: "webp" };
const direct = "https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88";
expect(module.buildImageProxyUrl(source, options)).toBe(direct);
expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`)).toBe(direct);
expect(module.buildImageProxyUrl(source + "?token=signed", options)).toBe(source + "?token=signed");
expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&proxy=1&w=331`)).toContain("proxy=1");
});
test("saved preview URLs redirect to an archived CDN thumbnail without reading the upstream image", async () => {
const source = "https://images.provider.example/result.png";
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://cdn.example/uploads/a.png" }) },
fetch: () => { throw Error("No image bytes should pass through the app"); },
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example" })("app/api/image-proxy/route.ts");
const response = await route.GET(new NextRequest(`https://app.example/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`, { headers: { "sec-fetch-dest": "image" } }));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88");
});
test("uploaded WebP is converted inside Qiniu with orientation and ICC retention", async () => {
const png = await sharp({ create: { width: 20, height: 12, channels: 4, background: "#22887780" } }).withIccProfile("p3").png().toBuffer();
let calls = 0;
const module = serverLoader({
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ destroy() {}, send: async (cmd: any) => {
expect(cmd.input.Key).toBe("uploads/canvas-download-task-1.png");
expect(cmd.input.Range).toBe("bytes=0-65535");
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png", ContentLength: png.length,
ContentRange: `bytes 0-${png.length - 1}/${png.length}`, Body: { transformToByteArray: async () => png } };
} }) },
fetch: async (url: string) => {
calls++;
const encoded = new URL(url).pathname.split("/fetch/")[1].split("/to/")[0];
const source = Buffer.from(encoded, "base64url").toString();
expect(source).toBe("https://cdn.example/uploads/direct/user/source.webp?imageMogr2/auto-orient/format/png/strip/meta-keep-list/aWNj");
return Response.json({ key: "uploads/canvas-download-task-1.png", fsize: png.length, mimeType: "image/png" });
},
})("lib/storage/qiniu-image-archive.ts");
const source = "https://cdn.example/uploads/direct/user/source.webp";
const ready = await module.prepareQiniuWebpDownload(source, "task-1");
expect(ready).toMatchObject({ status: "ready", url: "https://cdn.example/uploads/canvas-download-task-1.png", bytes: png.length });
for (const excluded of [source + "?token=signed", source.replace("cdn.example", "other.example"), source.replace(".webp", ".png")]) {
expect(await module.prepareQiniuWebpDownload(excluded, "task-1")).toBeUndefined();
}
expect(calls).toBe(1);
});
test("download worker uses a cloud-prepared PNG and retains local conversion on cloud failure", async () => {
const bytes = await sharp({ create: { width: 4, height: 4, channels: 4, background: "#bb442280" } }).png().toBuffer();
let cloudFails = false, reads = 0;
const saved: any[] = [];
const task = { id: "webp", targetUrl: "https://cdn.example/uploads/a.webp", metadata: {} };
const module = serverLoader({
"./image-archive-db": { claimImageDownloadTask: async () => task, saveImageDownloadAsset: async (_: string, asset: any) => saved.push(asset) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined,
prepareQiniuWebpDownload: async () => { if (cloudFails) throw Error("cloud temporarily unavailable"); return { status: "ready", url: "https://cdn.example/uploads/converted.png", bytes: bytes.length, contentType: "image/png" }; } },
"./image-byte-cache": { fetchImageBytes: async () => { reads++; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => undefined },
"@/lib/storage/image-byte-cache": { onceImageBytes: async (_: string, fn: any) => fn() },
"./service": { uploadFile: async () => ({ url: "https://cdn.example/uploads/fallback.png" }) },
})("lib/storage/image-download-preparation.ts");
await module.prepareImageArchiveDownload("webp");
expect(reads).toBe(0);
expect(saved[0]).toMatchObject({ status: "ready", url: "https://cdn.example/uploads/converted.png" });
cloudFails = true;
await module.prepareImageArchiveDownload("webp");
expect(reads).toBe(1);
expect(saved[1].status).toBe("ready");
});

View File

@ -42,70 +42,6 @@ import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
import { buildTaskBody } from "@/lib/ai/providers/vip98k";
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { serverLoader } from "../helpers/canvas-download-server-loader";
for (const status of ["failed", "unresolved", "unknown", "missing"]) {
test(`a synchronous 502 reconciles ${status} without repeating submission`, async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@/lib/storage/service": {},
"@/lib/observability/logger": { createLogger: () => ({ info() {}, warn() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
methods.push(init.method || "GET");
if (init.method === "POST") return Response.json({ error: { message: "generate failed" } }, { status: 502 });
expect(url).toContain("/api/external/requests/by-idempotency-key");
return status === "missing" ? new Response(null, { status: 404 }) : Response.json({ status, request_id: "original-request", result_available: false });
},
});
const provider = loader("lib/ai/providers/vip98k.ts").createVip98k({ apiKey: "fixture", baseURL: "https://provider.test" });
const error = await provider.chat("gpt-image-2.5").doGenerate({ messages: [{ role: "user", content: "a blue cup" }] }).catch((error: unknown) => error);
expect(methods).toEqual(["POST", "GET"]);
if (status === "failed") {
expect(error).toMatchObject({ confirmedNoResult: true });
expect(loader("lib/ai/runtime/image-generation-error.ts").agentImageGenerationError(error, "pinned")).toBe(error);
expect(loader("lib/request-error.ts").getRequestFailureDetails(error).outcome).toBe("failed");
} else {
expect(loader("lib/request-error.ts").getRequestFailureDetails(error).outcome).toBe("unknown");
}
});
}
test("catalog defaults allow async failover time while retaining an explicit polling limit", () => {
const models = [{ id: "nano-banana-2", type: "image", invocation: {
kind: "task", method: "POST", path: "/extensions/v1/tasks", content_type: "application/json",
request_example: { file_urls: ["https://image.example/source.png"], model: "nano-banana-2", prompt: "Edit" },
} }];
const [normal] = buildVip98kModelPresets(models);
expect((normal.capabilities.vip98k as any).maxPollSeconds).toBe(900);
expect((normal.imageGenConfig!.vip98k as any).maxPollSeconds).toBe(900);
const [configured] = buildVip98kModelPresets([{ ...models[0], max_poll_seconds: 1200 }]);
expect((configured.capabilities.vip98k as any).maxPollSeconds).toBe(1200);
});
test("a synchronous 502 recovers the original completed image through GET only", async () => {
const calls: string[] = [];
let lookups = 0;
const loader = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": { createDeferredUploadedImage: async (url: string) => ({ original: url, thumbnail: url }) },
"@/lib/storage/service": {},
"@/lib/observability/logger": { createLogger: () => ({ info() {}, warn() {}, error() {} }) },
setTimeout: (callback: () => void) => setTimeout(callback, 0),
fetch: async (url: string, init: RequestInit) => {
calls.push(init.method || "GET");
if (init.method === "POST") return Response.json({ error: { message: "bad gateway" } }, { status: 502 });
if (url.endsWith("/by-idempotency-key")) return Response.json({ status: ++lookups === 1 ? "processing" : "succeeded", request_id: "original-request", result_available: true });
expect(url).toContain("/original-request/result");
return Response.json({ data: [{ url: "https://assets.test/original.png" }] });
},
});
const provider = loader("lib/ai/providers/vip98k.ts").createVip98k({ apiKey: "fixture", baseURL: "https://provider.test" });
const result = await provider.chat("gpt-image-2.5").doGenerate({ messages: [{ role: "user", content: "a blue cup" }] });
expect(calls).toEqual(["POST", "GET", "GET", "GET"]);
expect(result.text).toContain("https://assets.test/original.png");
});
const textToImageInvocation: Vip98kTaskInvocation = {
kind: "tool",
@ -118,20 +54,6 @@ const textToImageInvocation: Vip98kTaskInvocation = {
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
};
test("native expansion maps final canvas scales to per-side ratios and requests PNG", () => {
const invocation: Vip98kTaskInvocation = {
kind: "task", method: "POST", path: "/extensions/v1/tasks", contentType: "application/json",
resultMethod: "GET", resultPath: "/extensions/v1/tasks/{task_id}/result",
requestExample: { model: "image-expansion", metadata: { top: 0.1, bottom: 0.1, left: 0.1, right: 0.1 }, output_format: "" },
inputSchema: { properties: { model: { type: "string" }, file_urls: { type: "array" }, metadata: { type: "object" }, output_format: { type: "string" } } },
};
const { body } = buildTaskBody({ apiKey: "test", modelId: "image-expansion", kind: "image-tool", prompt: "", imageUrls: ["https://source.test/original.jpg"], videoUrls: [], modelConfig: {}, imageParams: { xScale: 1.5, yScale: 2 } }, invocation);
expect(body).toMatchObject({ model: "image-expansion", file_urls: ["https://source.test/original.jpg"], metadata: { left: 0.25, right: 0.25, top: 0.5, bottom: 0.5 }, output_format: "png" });
expect(body).not.toHaveProperty("mask");
expect(body).not.toHaveProperty("prompt");
expect(body).not.toHaveProperty("x_scale");
});
test("text-to-image uses the advertised task contract with native size and ratio, without example reference images", () => {
const configured: Vip98kTaskInvocation = { ...textToImageInvocation, path: "/v1/images/generations", requestExample: { model: "nano-banana-pro", prompt: "sample" } };
const task: Vip98kTaskInvocation = { ...configured, kind: "task", path: "/extensions/v1/tasks",