From 658c3345f3f4fd1bef510e39bb1ad7721eabec2c Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 8 Aug 2026 17:55:59 +0800 Subject: [PATCH] fix(ai): add VIP98K expansion fallback --- lib/ai/extend/service.ts | 125 +++++++++++++++++++++++++++--- scripts/test-vip98k-capability.ts | 7 +- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/lib/ai/extend/service.ts b/lib/ai/extend/service.ts index b7025e252..76c2d238c 100644 --- a/lib/ai/extend/service.ts +++ b/lib/ai/extend/service.ts @@ -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 { } } +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 { 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) { diff --git a/scripts/test-vip98k-capability.ts b/scripts/test-vip98k-capability.ts index 91eb30871..fea520c39 100644 --- a/scripts/test-vip98k-capability.ts +++ b/scripts/test-vip98k-capability.ts @@ -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( + publicModels.map((model: Vip98kCatalogModel) => [ + getModelId(model).toLowerCase(), + model, + ]), ); return authorized.map((model: Vip98kCatalogModel) => ({ ...(publicById.get(getModelId(model).toLowerCase()) || {}),