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;
+ }
+});
--
2.47.2