From b64a512dfbb0a526aa7b0fa06bfad2407071c842 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 8 May 2026 11:17:05 +0800 Subject: [PATCH] fix: adapt fal image sizing parameters --- lib/ai/providers/fal-image.ts | 172 ++++++++++++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 6 deletions(-) diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index 07f3eb42a..2bf11349b 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -59,6 +59,34 @@ const GPT_IMAGE_2_EDIT_IMAGE_SIZE_PRESETS = new Set([ "landscape_16_9", ]); const GPT_IMAGE_2_EDIT_QUALITIES = new Set(["low", "medium", "high"]); +const FAL_ASPECT_RATIO_VALUES = new Set([ + "21:9", + "16:9", + "3:2", + "4:3", + "5:4", + "1:1", + "4:5", + "3:4", + "2:3", + "9:16", + "9:21", +]); +const FAL_IMAGE_SIZE_PRESETS = new Set([ + "square_hd", + "square", + "portrait_4_3", + "portrait_16_9", + "landscape_4_3", + "landscape_16_9", +]); +const FAL_IMAGE_SIZE_BY_ASPECT_RATIO: Record = { + "1:1": "square_hd", + "4:3": "landscape_4_3", + "3:4": "portrait_4_3", + "16:9": "landscape_16_9", + "9:16": "portrait_16_9", +}; type FalImageSize = string | { width: number; height: number }; @@ -172,6 +200,7 @@ function normalizeAspectRatio(aspectRatio?: string) { "3:4", "2:3", "9:16", + "9:21", "4:1", "1:4", "8:1", @@ -191,6 +220,82 @@ function normalizeResolution(imageSize?: string) { return DEFAULT_RESOLUTION; } +function normalizeExplicitAspectRatio(aspectRatio?: string) { + const normalized = normalizeAspectRatio(aspectRatio); + if (normalized === "auto") return undefined; + return FAL_ASPECT_RATIO_VALUES.has(normalized) ? normalized : undefined; +} + +function normalizeAnyExplicitAspectRatio(aspectRatio?: string) { + const normalized = normalizeAspectRatio(aspectRatio); + return normalized === "auto" ? undefined : normalized; +} + +function parseAspectRatioValue(aspectRatio?: string) { + const normalized = normalizeAnyExplicitAspectRatio(aspectRatio); + if (!normalized) return undefined; + const [width, height] = normalized.split(":").map((part) => Number(part)); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return undefined; + } + return width / height; +} + +function roundToMultiple(value: number, multiple: number) { + return Math.max(multiple, Math.round(value / multiple) * multiple); +} + +function resolveFalCustomImageSize(aspectRatio?: string, resolution?: string): FalImageSize { + const ratio = parseAspectRatioValue(aspectRatio) || 1; + const bucket = normalizeResolution(resolution); + const areaByBucket: Record = { + "0.5K": 768 * 768, + "1K": 1024 * 1024, + "2K": 1536 * 1536, + "4K": 2048 * 2048, + }; + const targetArea = areaByBucket[bucket] || areaByBucket["1K"]; + const width = roundToMultiple(Math.sqrt(targetArea * ratio), 16); + const height = roundToMultiple(width / ratio, 16); + return { width, height }; +} + +function normalizeFalImageSize(imageSize?: string, aspectRatio?: string): FalImageSize { + const rawSize = String(imageSize || "").trim(); + const normalizedSize = rawSize.toLowerCase().replace(/\s+/g, ""); + if (FAL_IMAGE_SIZE_PRESETS.has(normalizedSize)) { + return normalizedSize; + } + + const customSizeMatch = normalizedSize.match(/^(\d{2,5})[x×](\d{2,5})$/i); + if (customSizeMatch) { + const width = Number(customSizeMatch[1]); + const height = Number(customSizeMatch[2]); + if ( + Number.isFinite(width) && + Number.isFinite(height) && + width >= 256 && + height >= 256 && + width <= 4096 && + height <= 4096 + ) { + return { + width: roundToMultiple(width, 16), + height: roundToMultiple(height, 16), + }; + } + } + + const normalizedAspectRatio = normalizeAnyExplicitAspectRatio(aspectRatio); + if (normalizedAspectRatio && FAL_IMAGE_SIZE_BY_ASPECT_RATIO[normalizedAspectRatio]) { + return FAL_IMAGE_SIZE_BY_ASPECT_RATIO[normalizedAspectRatio]; + } + + return normalizedAspectRatio + ? resolveFalCustomImageSize(normalizedAspectRatio, rawSize) + : "square_hd"; +} + function normalizeOutputFormat(outputFormat?: string) { if (!outputFormat) return DEFAULT_OUTPUT_FORMAT; const normalized = outputFormat.trim().toLowerCase(); @@ -228,6 +333,31 @@ function normalizeFalEndpointId(modelId: string, hasInputImages: boolean) { return endpoint; } +function isFalNanoBananaModelId(modelId: string) { + const normalized = modelId.trim().toLowerCase().replace(/\/edit$/, ""); + return FAL_NANO_BANANA_MODEL_IDS.has(normalized); +} + +function isFalFluxKontextDevModelId(modelId: string) { + return modelId.trim().toLowerCase().replace(/\/$/, "") === "fal-ai/flux-kontext/dev"; +} + +function isFalKontextModelId(modelId: string) { + return modelId.trim().toLowerCase().includes("kontext"); +} + +function shouldUseFalImageSize(modelId: string, hasInputImages: boolean) { + if (hasInputImages) return false; + const normalized = modelId.trim().toLowerCase(); + if (isFalNanoBananaModelId(normalized) || isFalKontextModelId(normalized)) return false; + return ( + normalized.startsWith("fal-ai/flux/") || + normalized.startsWith("fal-ai/flux-lora") || + normalized.includes("/flux/dev") || + normalized.includes("/flux/schnell") + ); +} + function normalizeGptImage2EditQuality(value?: string) { if (!value) return "high"; const normalized = value.trim().toLowerCase(); @@ -266,7 +396,12 @@ function normalizeGptImage2EditImageSize( const sizeBucket = rawSize.toUpperCase(); const ratio = normalizeAspectRatio(aspectRatio).toLowerCase(); - return GPT_IMAGE_2_EDIT_SIZE_BY_BUCKET[sizeBucket]?.[ratio] || "auto"; + return ( + GPT_IMAGE_2_EDIT_SIZE_BY_BUCKET[sizeBucket]?.[ratio] || + (normalizeAnyExplicitAspectRatio(aspectRatio) + ? resolveFalCustomImageSize(aspectRatio, sizeBucket) + : "auto") + ); } function getFalImageSizeLabel(size: FalImageSize) { @@ -519,24 +654,47 @@ function buildFalRequestPayload(params: { input.openai_api_key = openaiApiKey; } } else { + const explicitAspectRatio = normalizeExplicitAspectRatio(aspectRatio); input = { prompt, num_images: imageCount, - aspect_ratio: aspectRatio, - resolution, output_format: outputFormat, safety_tolerance: safetyTolerance, - limit_generations: true, }; - if (imageUrls.length > 0) { - input.image_urls = imageUrls; + if (isFalNanoBananaModelId(falEndpointId)) { + input.aspect_ratio = aspectRatio; + input.resolution = resolution; + input.limit_generations = true; + } else if (isFalFluxKontextDevModelId(falEndpointId) && imageUrls.length > 0) { + input.image_url = imageUrls[0]; + input.enable_safety_checker = true; + input.resolution_mode = explicitAspectRatio || "match_input"; + } else if (isFalKontextModelId(falEndpointId)) { + if (explicitAspectRatio) { + input.aspect_ratio = explicitAspectRatio; + } + if (imageUrls.length > 0) { + input.image_url = imageUrls[0]; + if (imageUrls.length > 1 || falEndpointId.toLowerCase().includes("/multi")) { + input.image_urls = imageUrls; + } + } + } else if (shouldUseFalImageSize(falEndpointId, imageUrls.length > 0)) { + input.image_size = normalizeFalImageSize(params.imageParams?.imageSize, aspectRatio); + input.enable_safety_checker = true; + } else if (explicitAspectRatio) { + input.aspect_ratio = explicitAspectRatio; } if (seed !== undefined) { input.seed = seed; } + if (imageUrls.length > 0 && !input.image_url && !input.image_urls) { + input.image_urls = imageUrls; + } + if (typeof params.imageParams?.enableWebSearch === "boolean") { input.enable_web_search = params.imageParams.enableWebSearch; } @@ -606,6 +764,7 @@ export async function submitFalImageTask(params: { imageCount: request.imageCount, hasInputImages: request.imageUrls.length > 0, resolution: request.outputSizeLabel, + inputKeys: Object.keys(request.input), }); const enqueueResult = await fal.queue.submit(request.falEndpointId, { @@ -680,6 +839,7 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { aspectRatio: request.aspectRatio, resolution: request.outputSizeLabel, hasInputImages: request.imageUrls.length > 0, + inputKeys: Object.keys(request.input), }); const fal = await createFalClientFromApiKey(apiKey);