From b8799f4804a5b5c5993813b17ac25ab0db831089 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 12 Nov 2026 11:25:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=8798K?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E7=94=BB=E5=B8=83=E6=89=A9=E5=9B=BE=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../image-pipeline-sections.tsx | 8 +- app/(admin)/settings/_lib/types.ts | 2 + .../settings/admin-settings-client.tsx | 8 ++ lib/ai/extend/service.ts | 15 +++- lib/ai/extend/vip98k-outpaint.ts | 85 +++++++++++++++++++ tests/routes/extend-provider-config.test.ts | 67 +++++++++++++++ 6 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 lib/ai/extend/vip98k-outpaint.ts diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index 226fd6d9a..0bd5ef24d 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -2378,9 +2378,9 @@ export function LocalAiImagePipelineSections({ { label: extendConfig.provider === "fal" - ? "Bria 扩图服务" + ? "fal · FLUX 2 Pro 扩图" : extendConfig.provider === "vip98k" - ? "VIP98K 服务商" + ? `98K · ${extendConfig.modelId || "image-expansion"}` : "阿里云服务商", tone: "enabled", }, @@ -2395,11 +2395,11 @@ export function LocalAiImagePipelineSections({ > {extendConfig.provider === "fal" ? (
- 使用 Bria 生成新增区域并保留原图主体和文字。密钥从已启用的 fal 官方服务商读取。 + 使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。
) : extendConfig.provider === "vip98k" ? (
- 使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的 + 使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的 VIP98K 服务商读取,仅在这里维护扩展比例。
) : ( diff --git a/app/(admin)/settings/_lib/types.ts b/app/(admin)/settings/_lib/types.ts index 93dfff7b5..2b9baec95 100644 --- a/app/(admin)/settings/_lib/types.ts +++ b/app/(admin)/settings/_lib/types.ts @@ -275,6 +275,8 @@ export type EnhanceConfig = { export type ExtendConfig = { provider: "vip98k" | "dashscope" | "fal"; + modelId: string; + routeOptionId: string; xScale: string; yScale: string; }; diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 9b27f130f..4f8c167c3 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -2577,6 +2577,8 @@ export function AdminSettingsClient() { // 图像拓展API配置状态 const [extendConfig, setExtendConfig] = useState({ provider: "dashscope" as "vip98k" | "dashscope" | "fal", + modelId: "", + routeOptionId: "", xScale: "1.5", // X轴放大倍数 yScale: "1.5", // Y轴放大倍数 }); @@ -2892,11 +2894,15 @@ export function AdminSettingsClient() { if (extendConfigData?.setting?.value) { const config = extendConfigData.setting.value as { provider?: "vip98k" | "dashscope" | "fal"; + modelId?: string; + routeOptionId?: string; xScale?: number; yScale?: number; }; setExtendConfig({ provider: config.provider === "vip98k" || config.provider === "fal" ? config.provider : "dashscope", + modelId: config.modelId || "", + routeOptionId: config.routeOptionId || "", xScale: config.xScale ? String(config.xScale) : "1.5", yScale: config.yScale ? String(config.yScale) : "1.5", }); @@ -5505,6 +5511,8 @@ export function AdminSettingsClient() { key: "extend_api_config", value: { provider: extendConfig.provider, + modelId: extendConfig.modelId, + routeOptionId: extendConfig.routeOptionId, xScale, yScale, }, diff --git a/lib/ai/extend/service.ts b/lib/ai/extend/service.ts index d98c18930..7adb6d77c 100644 --- a/lib/ai/extend/service.ts +++ b/lib/ai/extend/service.ts @@ -7,7 +7,8 @@ import { } from "@/lib/db/queries"; import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config"; import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint"; -import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime"; +import { executeConfiguredVip98kTask, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime"; +import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint"; import { db } from "@/lib/db"; import { generatedImage } from "@/lib/db/schema"; import { uploadFile } from "@/lib/storage/service"; @@ -17,6 +18,8 @@ type ExtendConfig = { apiKey?: string; xScale?: number; yScale?: number; + modelId?: string; + routeOptionId?: string; }; const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit"; @@ -538,6 +541,14 @@ export async function executeExtendJob( if (config.provider === "fal") { buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale }); modelId = FAL_OUTPAINT_MODEL; + } else if (config.provider === "vip98k" && config.modelId === VIP98K_OUTPAINT_MODEL) { + if (!config.routeOptionId) throw new ExtendServiceError("98K 扩图线路未配置", 503); + const provider = await getVip98kRuntimeProvider(); + buffer = await expandWithVip98kOutpaint({ + imageUrl: inputImageUrl, baseUrl: provider.baseUrl, apiKey: provider.apiKey, + routeOptionId: config.routeOptionId, xScale, yScale, + }); + modelId = VIP98K_OUTPAINT_MODEL; } else { const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl); const url = await extendImage(prepared, config, xScale, yScale, requestId); @@ -549,7 +560,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" ? "png" : "jpg"); + const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" || modelId === VIP98K_OUTPAINT_MODEL ? "png" : "jpg"); console.log("[Extend Service] Result uploaded:", { requestId, includeBase64, diff --git a/lib/ai/extend/vip98k-outpaint.ts b/lib/ai/extend/vip98k-outpaint.ts new file mode 100644 index 000000000..977f92bfa --- /dev/null +++ b/lib/ai/extend/vip98k-outpaint.ts @@ -0,0 +1,85 @@ +import sharp from "sharp"; + +export const VIP98K_OUTPAINT_MODEL = "gpt-image-2.5-flare"; + +const RATIOS = [[1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16]]; + +export function getVip98kOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) { + const targetWidth = Math.round(width * xScale); + const targetHeight = Math.round(height * yScale); + if (targetWidth * targetHeight > 64_000_000) throw new Error("扩图后的尺寸过大,请降低扩展倍数"); + const ratio = targetWidth / targetHeight; + const [a, b] = RATIOS.reduce((best, next) => + Math.abs(Math.log(ratio / (next[0] / next[1]))) < Math.abs(Math.log(ratio / (best[0] / best[1]))) ? next : best); + const paddedWidth = Math.max(targetWidth, Math.ceil(targetHeight * a / b)); + const paddedHeight = Math.max(targetHeight, Math.ceil(targetWidth * b / a)); + const factor = Math.min(1, 2048 / Math.max(paddedWidth, paddedHeight)); + const modelWidth = Math.max(1, Math.round(paddedWidth * factor)); + const modelHeight = Math.max(1, Math.round(paddedHeight * factor)); + const sourceWidth = Math.max(1, Math.round(width * factor)); + const sourceHeight = Math.max(1, Math.round(height * factor)); + return { + targetWidth, targetHeight, modelWidth, modelHeight, sourceWidth, sourceHeight, + left: Math.floor((modelWidth - sourceWidth) / 2), + top: Math.floor((modelHeight - sourceHeight) / 2), + aspectRatio: `${a}:${b}`, + resolution: Math.max(targetWidth, targetHeight) > 1536 ? "2K" : "1K", + }; +} + +export async function expandWithVip98kOutpaint(params: { + imageUrl: string; + baseUrl: string; + apiKey: string; + routeOptionId: string; + xScale: number; + yScale: number; +}) { + const response = await fetch(params.imageUrl, { signal: AbortSignal.timeout(120_000) }); + if (!response.ok) throw new Error("无法读取待扩展的原图"); + const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer())) + .rotate().png().toBuffer({ resolveWithObject: true }); + const g = getVip98kOutpaintGeometry(info.width, info.height, params.xScale, params.yScale); + const input = await sharp(source).resize(g.sourceWidth, g.sourceHeight).ensureAlpha().extend({ + left: g.left, right: g.modelWidth - g.sourceWidth - g.left, + top: g.top, bottom: g.modelHeight - g.sourceHeight - g.top, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }).png().toBuffer(); + const body = new FormData(); + const prompt = [ + "Extend this product image outward on all four sides, continuing its background naturally.", + `The reference canvas is ${g.modelWidth}x${g.modelHeight}; the original image is centered at (${g.left},${g.top}) with size ${g.sourceWidth}x${g.sourceHeight}.`, + "Fill only the surrounding empty area as a continuation of the same scene.", + "Preserve the existing product identity, structure, colors, and all Chinese text and dimension numbers.", + "Keep approximately the same composition and the product centered.", + "Avoid adding new products or additional text. Return a complete seamless image.", + ].join(" "); + for (const [key, value] of Object.entries({ + model: VIP98K_OUTPAINT_MODEL, prompt, n: "1", size: g.aspectRatio, + resolution: g.resolution, quality: "high", response_format: "url", + })) body.append(key, value); + body.append("image[]", new Blob([new Uint8Array(input)], { type: "image/png" }), "outpaint.png"); + const base = params.baseUrl.replace(/\/$/, "").replace(/\/v1$/, ""); + const generated = await fetch(`${base}/v1/images/edits`, { + method: "POST", body, + headers: { Authorization: `Bearer ${params.apiKey}`, "X-Relay-Route-Option": params.routeOptionId }, + signal: AbortSignal.timeout(180_000), + }); + const result = await generated.json(); + if (!generated.ok) throw new Error(`98K 扩图失败:${result?.error?.message || `HTTP ${generated.status}`}`); + const image = result?.data?.[0]; + let expanded: Buffer; + if (typeof image?.b64_json === "string") { + expanded = Buffer.from(image.b64_json, "base64"); + } else if (typeof image?.url === "string") { + const outputUrl = new URL(image.url); + const headers = new Headers(); + if (outputUrl.origin === new URL(base).origin) headers.set("Authorization", `Bearer ${params.apiKey}`); + const output = await fetch(outputUrl, { headers, signal: AbortSignal.timeout(60_000) }); + if (!output.ok) throw new Error(`无法读取扩图结果(HTTP ${output.status})`); + expanded = Buffer.from(await output.arrayBuffer()); + } else { + throw new Error("98K 扩图未返回图片"); + } + return sharp(expanded).resize(g.targetWidth, g.targetHeight, { fit: "cover", position: "centre" }).png().toBuffer(); +} diff --git a/tests/routes/extend-provider-config.test.ts b/tests/routes/extend-provider-config.test.ts index a665ef3c3..11824e35f 100644 --- a/tests/routes/extend-provider-config.test.ts +++ b/tests/routes/extend-provider-config.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; 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"; test.describe("image extension provider configuration", () => { test("uses the most recently updated Alibaba image provider key", () => { @@ -56,3 +57,69 @@ test("a 4K source keeps its target output size while the model works within its expect(g.expandTop + g.sourceHeight + g.expandBottom).toBe(g.paddedHeight); expect(Math.abs(g.expandLeft - g.expandRight)).toBeLessThanOrEqual(1); }); + +test("relay outpainting pads to a supported ratio without cutting the source", () => { + const g = getVip98kOutpaintGeometry(621, 515, 1.5, 1.5); + expect([g.targetWidth, g.targetHeight]).toEqual([932, 773]); + expect(g.aspectRatio).toBe("4:3"); + expect(g.modelWidth).toBeGreaterThanOrEqual(g.targetWidth); + expect(g.modelHeight).toBeGreaterThanOrEqual(g.targetHeight); + expect(g.left + g.sourceWidth).toBeLessThanOrEqual(g.modelWidth); + const large = getVip98kOutpaintGeometry(4096, 4096, 1.5, 1.5); + expect([large.targetWidth, large.targetHeight]).toEqual([6144, 6144]); + expect(Math.max(large.modelWidth, large.modelHeight)).toBe(2048); +}); + +test("relay outpainting uses one image request and keeps the complete generated result", async () => { + const originalFetch = globalThis.fetch; + const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer(); + const generated = await sharp({ create: { width: 80, height: 60, channels: 3, background: "blue" } }).png().toBuffer(); + try { + for (const outputUrl of ["https://relay.test/api/external/assets/result", "https://cdn.test/result.png"]) { + let submitted = 0; + globalThis.fetch = async (input, init) => { + const url = String(input); + if (url === "https://source.test/input.png") return new Response(new Uint8Array(source)); + if (url === "https://relay.test/v1/images/edits") { + submitted++; + const body = init?.body as FormData; + expect(body.get("model")).toBe("gpt-image-2.5-flare"); + expect(body.get("n")).toBe("1"); + expect(body.get("size")).toBe("4:3"); + expect(body.get("response_format")).toBe("url"); + expect(body.get("image[]")).toBeInstanceOf(Blob); + expect(new Headers(init?.headers).get("X-Relay-Route-Option")).toBe("selected-route"); + return Response.json({ data: [{ url: outputUrl }] }); + } + expect(url).toBe(outputUrl); + expect(new Headers(init?.headers).get("Authorization")).toBe(outputUrl.includes("relay.test") ? "Bearer test-key" : null); + return new Response(new Uint8Array(generated)); + }; + const result = await expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test/v1", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 }); + const metadata = await sharp(result).metadata(); + expect([metadata.width, metadata.height]).toEqual([60, 45]); + const pixel = await sharp(result).extract({ left: 30, top: 22, width: 1, height: 1 }).removeAlpha().raw().toBuffer(); + expect([...pixel]).toEqual([0, 0, 255]); + expect(submitted).toBe(1); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("relay outpainting does not resubmit a failed generation", async () => { + const originalFetch = globalThis.fetch; + const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer(); + let submitted = 0; + try { + globalThis.fetch = async (input) => { + if (String(input) === "https://source.test/input.png") return new Response(new Uint8Array(source)); + submitted++; + return Response.json({ error: { message: "service unavailable" } }, { status: 503 }); + }; + await expect(expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 })).rejects.toThrow("service unavailable"); + expect(submitted).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } +});