86 lines
4.6 KiB
TypeScript
86 lines
4.6 KiB
TypeScript
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();
|
||
}
|