支持98K扩图线路替换 fal 上游 #77
@ -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" ? (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 Bria 生成新增区域并保留原图主体和文字。密钥从已启用的 fal 官方服务商读取。
|
||||
使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。
|
||||
</div>
|
||||
) : extendConfig.provider === "vip98k" ? (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
|
||||
使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的
|
||||
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -275,6 +275,8 @@ export type EnhanceConfig = {
|
||||
|
||||
export type ExtendConfig = {
|
||||
provider: "vip98k" | "dashscope" | "fal";
|
||||
modelId: string;
|
||||
routeOptionId: string;
|
||||
xScale: string;
|
||||
yScale: string;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
85
lib/ai/extend/vip98k-outpaint.ts
Normal file
85
lib/ai/extend/vip98k-outpaint.ts
Normal file
@ -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();
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user