diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index 1c2b28bdf..b9c517ccd 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -654,6 +654,64 @@ function createFalClientFromApiKey(apiKey: string) { ); } +async function uploadImageUrlToFalStorage(params: { + fal: Awaited>; + url: string; +}) { + const { fal, url } = params; + if (/fal\.(?:media|run)\//i.test(url) || /fal-ai/i.test(url)) return url; + + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + if (!response.ok) { + throw new Error(`FAL input image download failed before storage upload: ${response.status}`); + } + + const contentType = response.headers.get("content-type") || "image/png"; + const buffer = Buffer.from(await response.arrayBuffer()); + const blob = new Blob([buffer], { type: contentType }); + return fal.storage.upload(blob); +} + +async function prepareFalInputImageUrls(params: { + fal: Awaited>; + input: Record; + sourceImageUrls: string[]; +}) { + const { fal, input, sourceImageUrls } = params; + const cache = new Map>(); + const upload = (url: string) => { + const normalized = String(url || "").trim(); + if (!/^https?:\/\//i.test(normalized)) return Promise.resolve(normalized); + const existing = cache.get(normalized); + if (existing) return existing; + const uploaded = uploadImageUrlToFalStorage({ fal, url: normalized }); + cache.set(normalized, uploaded); + return uploaded; + }; + + const preparedInput = { ...input }; + if (typeof preparedInput.image_url === "string") { + preparedInput.image_url = await upload(preparedInput.image_url); + } + if (Array.isArray(preparedInput.image_urls)) { + preparedInput.image_urls = await Promise.all( + preparedInput.image_urls.map((url) => upload(String(url || ""))) + ); + } + if (typeof preparedInput.mask_url === "string") { + preparedInput.mask_url = await upload(preparedInput.mask_url); + } + + const preparedSourceImageUrls = await Promise.all( + sourceImageUrls.map((url) => upload(url)) + ); + + return { + input: preparedInput, + sourceImageUrls: preparedSourceImageUrls, + }; +} + function buildFalRequestPayload(params: { modelId: string; overrideModelId?: string; @@ -834,18 +892,26 @@ export async function submitFalImageTask(params: { }): Promise { const request = buildFalRequestPayload(params); const fal = await createFalClientFromApiKey(params.apiKey); + const prepared = await prepareFalInputImageUrls({ + fal, + input: request.input, + sourceImageUrls: request.imageUrls, + }); console.log("[Fal Image] Queue submit", { model: request.activeModelId, endpoint: request.falEndpointId, imageCount: request.imageCount, - hasInputImages: request.imageUrls.length > 0, + hasInputImages: prepared.sourceImageUrls.length > 0, resolution: request.outputSizeLabel, - inputKeys: Object.keys(request.input), + inputKeys: Object.keys(prepared.input), + storagePrepared: prepared.sourceImageUrls.some( + (url, index) => url !== request.imageUrls[index] + ), }); const enqueueResult = await fal.queue.submit(request.falEndpointId, { - input: request.input, + input: prepared.input, }); return { @@ -855,7 +921,7 @@ export async function submitFalImageTask(params: { imageCount: request.imageCount, outputSizeLabel: request.outputSizeLabel, prompt: request.prompt, - sourceImageUrls: request.imageUrls, + sourceImageUrls: prepared.sourceImageUrls, }; } @@ -920,12 +986,17 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { }); const fal = await createFalClientFromApiKey(apiKey); + const prepared = await prepareFalInputImageUrls({ + fal, + input: request.input, + sourceImageUrls: request.imageUrls, + }); const timeoutMs = normalizeTimeoutMs(options.imageParams?.timeoutMs); let result: any; try { result = await fal.subscribe(request.falEndpointId, { - input: request.input, + input: prepared.input, logs: true, timeout: timeoutMs, onQueueUpdate: (update: any) => { @@ -945,7 +1016,7 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { const text = await normalizeFalResultToMarkdown({ payload: result, - sourceImageUrls: request.imageUrls, + sourceImageUrls: prepared.sourceImageUrls, imageCount: request.imageCount, outputSizeLabel: request.outputSizeLabel, });