diff --git a/extensions/fal/image-generation-provider.test.ts b/extensions/fal/image-generation-provider.test.ts index 72299fb2a98f..9746377f92d4 100644 --- a/extensions/fal/image-generation-provider.test.ts +++ b/extensions/fal/image-generation-provider.test.ts @@ -1,5 +1,6 @@ // Fal tests cover image generation provider plugin behavior. import type { ImageGenerationRequest } from "openclaw/plugin-sdk/image-generation"; +import { generateImage } from "openclaw/plugin-sdk/image-generation-runtime"; import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -114,6 +115,121 @@ describe("fal image-generation provider", () => { ); }); + it.each([ + { + model: "krea/v2/medium/text-to-image", + supported: "2.35:1", + unsupported: "21:9", + }, + { + model: "krea/v2/large/text-to-image", + supported: "2.35:1", + unsupported: "21:9", + }, + { + model: "fal-ai/nano-banana-2", + supported: "21:9", + unsupported: "2.35:1", + }, + { + model: "fal-ai/nano-banana-2/edit", + supported: "21:9", + unsupported: "2.35:1", + }, + ])( + "publishes the native aspect-ratio contract for $model", + ({ model, supported, unsupported }) => { + const aspectRatios = provider.capabilities.geometry?.aspectRatiosByModel?.[model]; + + expect(aspectRatios).toContain(supported); + expect(aspectRatios).not.toContain(unsupported); + }, + ); + + it.each([ + { + model: "krea/v2/medium/text-to-image", + requested: "21:9", + applied: "2.35:1", + mode: "generate", + }, + { + model: "krea/v2/large/text-to-image", + requested: "21:9", + applied: "2.35:1", + mode: "style", + }, + { + model: "fal-ai/nano-banana-2", + requested: "2.35:1", + applied: "21:9", + mode: "generate", + }, + { + model: "fal-ai/nano-banana-2/edit", + requested: "2.35:1", + applied: "21:9", + mode: "edit", + }, + ])("normalizes unsupported $model geometry before provider submission", async (testCase) => { + const image = sourceImage("reference"); + const inputImages = testCase.mode === "generate" ? undefined : [image]; + try { + fetchWithSsrFGuardMock + .mockResolvedValueOnce(releasedJson({ images: [{ url: "https://v3.fal.media/out.png" }] })) + .mockResolvedValueOnce(releasedImage(Buffer.from("png-data"))); + + const result = await generateImage( + { + cfg: { + agents: { + defaults: { + mediaModels: { image: { primary: `fal/${testCase.model}` } }, + }, + }, + }, + prompt: "preserve the closest native image shape", + aspectRatio: testCase.requested, + ...(inputImages ? { inputImages } : {}), + }, + { + getProvider: () => provider, + listProviders: () => [provider], + }, + ); + + expect(result.normalization?.aspectRatio).toEqual({ + requested: testCase.requested, + applied: testCase.applied, + }); + expectFalJsonPost({ + url: `https://fal.run/${testCase.model}`, + body: { + prompt: "preserve the closest native image shape", + aspect_ratio: testCase.applied, + ...(testCase.mode === "style" + ? { + creativity: "medium", + image_style_references: [ + { image_url: `data:image/png;base64,${image.buffer.toString("base64")}` }, + ], + } + : testCase.mode === "edit" + ? { + num_images: 1, + output_format: "png", + image_urls: [`data:image/png;base64,${image.buffer.toString("base64")}`], + } + : testCase.model.startsWith("krea/") + ? { creativity: "medium" } + : { num_images: 1, output_format: "png" }), + }, + }); + } finally { + fetchWithSsrFGuardMock.mockReset(); + } + }); + it("generates image buffers from the fal sync API", async () => { const releaseRequest = vi.fn(async () => {}); const releaseDownload = vi.fn(async () => {}); diff --git a/extensions/fal/image-generation-provider.ts b/extensions/fal/image-generation-provider.ts index 693797c23c96..5144101c0c20 100644 --- a/extensions/fal/image-generation-provider.ts +++ b/extensions/fal/image-generation-provider.ts @@ -682,16 +682,25 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider { [FAL_KREA_2_LARGE_MODEL]: [], }, aspectRatios: [...FAL_SUPPORTED_ASPECT_RATIOS], - aspectRatiosByModel: { - [FAL_NANO_BANANA_MODEL]: [...NANO_BANANA_LEGACY_SUPPORTED_ASPECT_RATIOS], - [`${FAL_NANO_BANANA_MODEL}/edit`]: [...NANO_BANANA_LEGACY_SUPPORTED_ASPECT_RATIOS], - [FAL_NANO_BANANA_2_LITE_MODEL]: [...NANO_BANANA_SUPPORTED_ASPECT_RATIOS], - [`${FAL_NANO_BANANA_2_LITE_MODEL}/edit`]: [...NANO_BANANA_SUPPORTED_ASPECT_RATIOS], - [FAL_GROK_IMAGINE_MODEL]: [...GROK_IMAGINE_SUPPORTED_ASPECT_RATIOS], - [`${FAL_GROK_IMAGINE_MODEL}/edit`]: [...GROK_IMAGINE_SUPPORTED_ASPECT_RATIOS], - [`${FAL_GROK_IMAGINE_MODEL}/quality`]: [...GROK_IMAGINE_SUPPORTED_ASPECT_RATIOS], - [`${FAL_GROK_IMAGINE_MODEL}/quality/edit`]: [...GROK_IMAGINE_SUPPORTED_ASPECT_RATIOS], - }, + aspectRatiosByModel: Object.fromEntries( + [ + FAL_NANO_BANANA_MODEL, + `${FAL_NANO_BANANA_MODEL}/edit`, + FAL_NANO_BANANA_2_LITE_MODEL, + `${FAL_NANO_BANANA_2_LITE_MODEL}/edit`, + FAL_GROK_IMAGINE_MODEL, + `${FAL_GROK_IMAGINE_MODEL}/edit`, + `${FAL_GROK_IMAGINE_MODEL}/quality`, + `${FAL_GROK_IMAGINE_MODEL}/quality/edit`, + FAL_KREA_2_MEDIUM_MODEL, + FAL_KREA_2_LARGE_MODEL, + `${FAL_NANO_BANANA_MODEL}-2`, + `${FAL_NANO_BANANA_MODEL}-2/edit`, + ].flatMap((model) => { + const aspectRatios = resolveFalImageModelSchema(model).aspectRatios; + return aspectRatios ? [[model, [...aspectRatios]] as const] : []; + }), + ), resolutions: ["1K", "2K", "4K"], resolutionsByModel: { [FAL_KREA_2_MEDIUM_MODEL]: [],