使用98K原生智能扩图,失败时保留明确错误 #78

Merged
WuYongJie merged 1 commits from codex/native-image-expansion-20261112 into main 2026-11-12 14:23:29 +08:00
4 changed files with 46 additions and 115 deletions

View File

@ -22,8 +22,7 @@ type ExtendConfig = {
routeOptionId?: string;
};
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const 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;
@ -146,7 +145,7 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
@ -241,97 +240,6 @@ 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,
@ -340,27 +248,17 @@ async function extendImage(
requestId?: string | null,
): Promise<string> {
if (config.provider === "vip98k") {
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);
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);
}
return outputUrl;
}
if (!config.apiKey) {
@ -560,7 +458,7 @@ export async function executeExtendJob(
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
}
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" || modelId === VIP98K_OUTPAINT_MODEL ? "png" : "jpg");
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg");
console.log("[Extend Service] Result uploaded:", {
requestId,
includeBase64,

View File

@ -1666,6 +1666,7 @@ 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

@ -3,6 +3,24 @@ 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/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.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => {

View File

@ -54,6 +54,20 @@ 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",