126 lines
6.5 KiB
TypeScript
126 lines
6.5 KiB
TypeScript
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", () => {
|
|
expect(
|
|
resolveAlibabaExtendApiKey(
|
|
[
|
|
{ apiKey: "older-key", updatedAt: "2026-09-01T00:00:00.000Z" },
|
|
{ apiKey: "current-key", updatedAt: "2026-09-10T00:00:00.000Z" },
|
|
],
|
|
"stale-extend-setting-key",
|
|
),
|
|
).toBe("current-key");
|
|
});
|
|
|
|
test("only falls back to a legacy key when no provider key exists", () => {
|
|
expect(
|
|
resolveAlibabaExtendApiKey(
|
|
[{ apiKey: "", updatedAt: "2026-09-10T00:00:00.000Z" }],
|
|
"legacy-key",
|
|
),
|
|
).toBe("legacy-key");
|
|
expect(resolveAlibabaExtendApiKey([], "")).toBeNull();
|
|
});
|
|
|
|
test("uses an official fal provider key, not a key configured for a relay", () => {
|
|
expect(resolveFalExtendApiKey([
|
|
{ baseUrl: "https://relay.example.test", apiKey: "relay-key" },
|
|
{ baseUrl: "https://queue.fal.run", apiKey: "Key official-key" },
|
|
])).toBe("official-key");
|
|
});
|
|
});
|
|
|
|
test("outpainting restores every original pixel in place and keeps the generated border", async () => {
|
|
const geometry = getOutpaintGeometry(13, 9, 1.5, 1.5);
|
|
const { paddedWidth, paddedHeight, left, top } = geometry;
|
|
const pixel = Buffer.from([42, 93, 190, 255, 30, 80, 20, 75]);
|
|
const raw = Buffer.concat(Array.from({ length: 59 }, () => pixel)).subarray(0, 13 * 9 * 4);
|
|
const source = await sharp(raw, { raw: { width: 13, height: 9, channels: 4 } }).png().toBuffer();
|
|
const expanded = await sharp({ create: { width: paddedWidth, height: paddedHeight, channels: 3, background: "#d8bc9e" } }).png().toBuffer();
|
|
const composed = await composeOutpaint(source, expanded, geometry);
|
|
const area = await sharp(composed).extract({ left, top, width: 13, height: 9 }).raw().toBuffer();
|
|
expect(area.equals(raw)).toBe(true);
|
|
expect([...await sharp(composed).extract({ left: 0, top: 0, width: 1, height: 1 }).raw().toBuffer()]).toEqual([216, 188, 158, 255]);
|
|
await expect(composeOutpaint(source, source, geometry)).rejects.toThrow("尺寸不正确");
|
|
});
|
|
|
|
test("a 4K source keeps its target output size while the model works within its limit", () => {
|
|
const g = getOutpaintGeometry(4096, 4096, 1.5, 1.5);
|
|
expect([g.targetWidth, g.targetHeight, g.left, g.top]).toEqual([6144, 6144, 1024, 1024]);
|
|
expect([g.paddedWidth, g.paddedHeight]).toEqual([2048, 2048]);
|
|
expect(g.expandLeft + g.sourceWidth + g.expandRight).toBe(g.paddedWidth);
|
|
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;
|
|
}
|
|
});
|