fix(ai): add VIP98K expansion fallback
This commit is contained in:
parent
86c196ce51
commit
2489ce54f2
@ -16,6 +16,9 @@ type ExtendConfig = {
|
||||
yScale?: number;
|
||||
};
|
||||
|
||||
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
|
||||
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface ExecuteExtendJobParams {
|
||||
userId: string;
|
||||
imageUrl?: string | null;
|
||||
@ -123,6 +126,97 @@ async function uploadImageToS3ForAPI(imageBase64: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@ -130,18 +224,27 @@ async function extendImage(
|
||||
yScale: number,
|
||||
): Promise<string> {
|
||||
if (config.provider === "vip98k") {
|
||||
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);
|
||||
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) {
|
||||
|
||||
@ -145,8 +145,11 @@ async function fetchVip98kCatalog(params: { baseURL: string; apiKey: string }) {
|
||||
).flatMap((section: any) => (Array.isArray(section?.models) ? section.models : []));
|
||||
if (authorized.length === 0) throw new Error("VIP98K authorized model catalog is empty");
|
||||
if (publicModels.length === 0) throw new Error("VIP98K public invocation catalog is empty");
|
||||
const publicById = new Map(
|
||||
publicModels.map((model: Vip98kCatalogModel) => [getModelId(model).toLowerCase(), model]),
|
||||
const publicById = new Map<string, Vip98kCatalogModel>(
|
||||
publicModels.map((model: Vip98kCatalogModel) => [
|
||||
getModelId(model).toLowerCase(),
|
||||
model,
|
||||
]),
|
||||
);
|
||||
return authorized.map((model: Vip98kCatalogModel) => ({
|
||||
...(publicById.get(getModelId(model).toLowerCase()) || {}),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user