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"; import { serverLoader } from "../helpers/canvas-download-server-loader"; test("native expansion sends the original image and never switches models after an upstream failure", async () => { for (const message of ["HTTP 503 unavailable", "request timed out"]) { const calls: any[] = []; const service = serverLoader({ "@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) }, "@/lib/db": {}, "@/lib/db/schema": {}, "@/lib/storage/service": {}, "@/lib/storage/image-archive": {}, "@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async (params: unknown) => { calls.push(params); throw new Error(message); }, }, })("lib/ai/extend/service.ts"); await expect(service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", xScale: 1.5, yScale: 2 })).rejects.toThrow(message); expect(calls).toEqual([{ modelId: "image-expansion", kind: "image-tool", imageUrls: ["https://source.test/original.jpg"], imageParams: { xScale: 1.5, yScale: 2 } }]); } }); test("native expansion archives the completed image without transferring its bytes through the app", async () => { const calls: string[] = []; const service = serverLoader({ "@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) }, "@/lib/db": {}, "@/lib/db/schema": {}, "@/lib/storage/service": {}, "@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) }, "@/lib/storage/image-archive": { archiveTemporaryImage: async (url: string, id: string) => { calls.push(url, id); return { url: "https://cdn.test/native.png", thumbnailUrl: "https://cdn.test/native.png", bytes: 12375212 }; } }, })("lib/ai/extend/service.ts"); const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", requestId: "request-1", skipGallerySave: true }); expect(calls).toEqual(["https://relay.test/result.png", "request-1"]); expect(result.original_url).toBe("https://cdn.test/native.png"); expect(result.image_base64).toBeUndefined(); }); test("native expansion still returns image bytes to callers requesting base64", async () => { const image = await sharp({ create: { width: 12, height: 8, channels: 3, background: "red" } }).png().toBuffer(); const service = serverLoader({ "@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) }, "@/lib/db": {}, "@/lib/db/schema": {}, "@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `https://cdn.test/${name}` }) }, "@/lib/storage/image-archive": { archiveTemporaryImage: () => { throw new Error("Base64 callers need the image bytes"); } }, "@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) }, fetch: async () => new Response(new Uint8Array(image)), })("lib/ai/extend/service.ts"); const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", includeBase64: true, skipGallerySave: true }); expect(result.image_base64).toBe(image.toString("base64")); expect(result.original_url).toMatch(/\.png$/); }); 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; } });