使用98K原生智能扩图并保留完整输出
This commit is contained in:
parent
8fca3b25c5
commit
78b111130b
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user