fix(ai): add VIP98K expansion fallback
This commit is contained in:
parent
4f43c7a8c7
commit
4c6ce023eb
@ -16,6 +16,9 @@ type ExtendConfig = {
|
|||||||
yScale?: number;
|
yScale?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
|
||||||
|
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
export interface ExecuteExtendJobParams {
|
export interface ExecuteExtendJobParams {
|
||||||
userId: string;
|
userId: string;
|
||||||
imageUrl?: string | null;
|
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(
|
async function extendImage(
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
config: ExtendConfig,
|
config: ExtendConfig,
|
||||||
@ -130,18 +224,27 @@ async function extendImage(
|
|||||||
yScale: number,
|
yScale: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (config.provider === "vip98k") {
|
if (config.provider === "vip98k") {
|
||||||
const result = await executeConfiguredVip98kTask({
|
try {
|
||||||
modelId: "image-expansion",
|
const result = await executeConfiguredVip98kTask({
|
||||||
kind: "image-tool",
|
modelId: "image-expansion",
|
||||||
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
|
kind: "image-tool",
|
||||||
imageUrls: [imageUrl],
|
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
|
||||||
imageParams: { xScale, yScale },
|
imageUrls: [imageUrl],
|
||||||
});
|
imageParams: { xScale, yScale },
|
||||||
const outputUrl = result.outputUrls[0];
|
});
|
||||||
if (!outputUrl) {
|
const outputUrl = result.outputUrls[0];
|
||||||
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
|
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) {
|
if (!config.apiKey) {
|
||||||
|
|||||||
@ -145,8 +145,11 @@ async function fetchVip98kCatalog(params: { baseURL: string; apiKey: string }) {
|
|||||||
).flatMap((section: any) => (Array.isArray(section?.models) ? section.models : []));
|
).flatMap((section: any) => (Array.isArray(section?.models) ? section.models : []));
|
||||||
if (authorized.length === 0) throw new Error("VIP98K authorized model catalog is empty");
|
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");
|
if (publicModels.length === 0) throw new Error("VIP98K public invocation catalog is empty");
|
||||||
const publicById = new Map(
|
const publicById = new Map<string, Vip98kCatalogModel>(
|
||||||
publicModels.map((model: Vip98kCatalogModel) => [getModelId(model).toLowerCase(), model]),
|
publicModels.map((model: Vip98kCatalogModel) => [
|
||||||
|
getModelId(model).toLowerCase(),
|
||||||
|
model,
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
return authorized.map((model: Vip98kCatalogModel) => ({
|
return authorized.map((model: Vip98kCatalogModel) => ({
|
||||||
...(publicById.get(getModelId(model).toLowerCase()) || {}),
|
...(publicById.get(getModelId(model).toLowerCase()) || {}),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user