Skip to content

Commit ade1c78

Browse files
authored
feat(video): expand the fal text-to-video model catalog (#9640)
* feat(video): expand the fal text-to-video model catalog * fix(video): retain hosted fal receipts before delivery * fix(video): poll nested fal models through the app queue
1 parent ecd9263 commit ade1c78

11 files changed

Lines changed: 1480 additions & 431 deletions

File tree

‎apps/sim/blocks/blocks/video_generator.ts‎

Lines changed: 137 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { VideoIcon } from '@/components/icons'
2+
import { falVideoModels, getFalVideoModel } from '@/lib/media/fal-video-catalog'
23
import { AuthMode, type BlockConfig, IntegrationType, type SubBlockConfig } from '@/blocks/types'
34
import { normalizeFileInput, parseOptionalBooleanInput } from '@/blocks/utils'
45

@@ -13,24 +14,6 @@ const FALAI_PREVIOUS_MODEL_OPTIONS = [
1314
{ label: 'LTXV 0.9.8', id: 'ltxv-0.9.8' },
1415
]
1516

16-
const FALAI_LATEST_MODEL_OPTIONS = [
17-
{ label: 'Google Veo 3.1', id: 'veo-3.1' },
18-
{ label: 'Google Veo 3.1 Fast', id: 'veo-3.1-fast' },
19-
{ label: 'OpenAI Sora 2', id: 'sora-2' },
20-
{ label: 'OpenAI Sora 2 Pro', id: 'sora-2-pro' },
21-
{ label: 'ByteDance Seedance 2.0', id: 'seedance-2.0' },
22-
{ label: 'ByteDance Seedance 2.0 Fast', id: 'seedance-2.0-fast' },
23-
{ label: 'Kling 3.0 Pro', id: 'kling-v3-pro' },
24-
{ label: 'Kling 3.0 4K', id: 'kling-v3-4k' },
25-
{ label: 'Kling O3 Pro', id: 'kling-o3-pro' },
26-
{ label: 'Kling O3 4K', id: 'kling-o3-4k' },
27-
{ label: 'MiniMax Hailuo 2.3 Pro', id: 'minimax-hailuo-2.3-pro' },
28-
{ label: 'MiniMax Hailuo 2.3 Standard', id: 'minimax-hailuo-2.3-standard' },
29-
{ label: 'WAN 2.2 A14B Turbo', id: 'wan-2.2-a14b-turbo' },
30-
{ label: 'LTX 2.3', id: 'ltx-2.3' },
31-
{ label: 'LTX 2.3 Fast', id: 'ltx-2.3-fast' },
32-
]
33-
3417
const FALAI_VEO_MODELS = ['veo-3.1', 'veo-3.1-fast']
3518
const FALAI_SORA_MODELS = ['sora-2', 'sora-2-pro']
3619
const FALAI_SEEDANCE_STANDARD_MODELS = ['seedance-2.0']
@@ -51,23 +34,98 @@ const FALAI_AUDIO_DEFAULT_ON_MODELS = [
5134
]
5235
const FALAI_AUDIO_DEFAULT_OFF_MODELS = ['kling-o3-pro', 'kling-o3-4k']
5336

54-
const withFalAIModelOptions = (
55-
subBlocks: SubBlockConfig[],
56-
options: SubBlockConfig['options']
57-
): SubBlockConfig[] =>
58-
subBlocks.map((subBlock) => {
37+
const FAL_MODEL_DEFAULT = { id: 'default', label: 'Model default' } as const
38+
const endpointModels = falVideoModels.filter((model) => !model.legacyId)
39+
40+
function tuningField(
41+
id: string,
42+
title: string,
43+
key: 'durations' | 'aspectRatios' | 'resolutions' | 'audioField' | 'promptOptimizer'
44+
): SubBlockConfig {
45+
return {
46+
id,
47+
title,
48+
type: 'dropdown',
49+
value: () => FAL_MODEL_DEFAULT.id,
50+
condition: {
51+
field: 'provider',
52+
value: 'falai',
53+
and: {
54+
field: 'model',
55+
value: endpointModels.filter((model) => model[key]).map((model) => model.modelId),
56+
},
57+
},
58+
dependsOn: ['model'],
59+
options: (params) => {
60+
const model = getFalVideoModel(params?.values.model)
61+
if (!model?.[key]) return []
62+
if (key === 'audioField' || key === 'promptOptimizer') {
63+
return [FAL_MODEL_DEFAULT, { id: 'true', label: 'On' }, { id: 'false', label: 'Off' }]
64+
}
65+
const duration = Number(params?.values.falDuration)
66+
const resolution = params?.values.falResolution
67+
const limit =
68+
typeof resolution === 'string' ? model.maxDurationByResolution?.[resolution] : undefined
69+
const choices = (model[key] ?? []).flatMap((value) => {
70+
const seconds = Number.parseFloat(String(value))
71+
if (key === 'durations' && limit !== undefined && seconds > limit) return []
72+
if (
73+
key === 'resolutions' &&
74+
duration > (model.maxDurationByResolution?.[value] ?? Number.POSITIVE_INFINITY)
75+
)
76+
return []
77+
const option = key === 'durations' ? String(seconds) : String(value)
78+
return [{ id: option, label: option }]
79+
})
80+
return [FAL_MODEL_DEFAULT, ...choices]
81+
},
82+
}
83+
}
84+
85+
/** Separate IDs keep saved provider settings from seeding a newly selected endpoint. */
86+
const falVideoFields = [
87+
tuningField('falDuration', 'Duration (seconds)', 'durations'),
88+
tuningField('falAspectRatio', 'Aspect Ratio', 'aspectRatios'),
89+
tuningField('falResolution', 'Resolution', 'resolutions'),
90+
tuningField('falGenerateAudio', 'Generate Audio', 'audioField'),
91+
tuningField('falPromptOptimizer', 'Prompt Optimizer', 'promptOptimizer'),
92+
]
93+
94+
function withFalVideoCatalog(subBlocks: SubBlockConfig[]): SubBlockConfig[] {
95+
return subBlocks.flatMap((subBlock) => {
5996
const condition = subBlock.condition
6097
if (
61-
subBlock.id === 'model' &&
62-
typeof condition === 'object' &&
63-
condition?.field === 'provider' &&
64-
condition.value === 'falai'
98+
typeof condition !== 'object' ||
99+
condition?.field !== 'provider' ||
100+
condition.value !== 'falai'
65101
) {
66-
return { ...subBlock, options }
102+
return [subBlock]
67103
}
68-
69-
return subBlock
104+
if (subBlock.id === 'model') {
105+
return [
106+
{
107+
...subBlock,
108+
searchable: true,
109+
options: [
110+
...falVideoModels
111+
.map((model) => ({
112+
id: model.modelId,
113+
label: model.label,
114+
description: model.endpoint,
115+
}))
116+
.sort((left, right) =>
117+
left.label.localeCompare(right.label, 'en', { numeric: true })
118+
),
119+
{ id: 'sora-2', label: 'OpenAI Sora 2 (retired)', hidden: true },
120+
{ id: 'sora-2-pro', label: 'OpenAI Sora 2 Pro (retired)', hidden: true },
121+
],
122+
},
123+
]
124+
}
125+
if (subBlock.id === 'apiKey' && !condition.not) return [...falVideoFields, subBlock]
126+
return [subBlock]
70127
})
128+
}
71129

72130
export const VideoGeneratorBlock: BlockConfig = {
73131
type: 'video_generator',
@@ -1590,26 +1648,37 @@ export const VideoGeneratorV2Block: BlockConfig = {
15901648
return 'video_runway'
15911649
}
15921650
},
1593-
params: (params) => ({
1594-
provider: params.provider,
1595-
apiKey: params.apiKey,
1596-
model: params.model,
1597-
endpoint: params.endpoint,
1598-
prompt: params.prompt,
1599-
duration: params.duration ? Number(params.duration) : undefined,
1600-
aspectRatio: params.aspectRatio,
1601-
resolution: params.resolution,
1602-
visualReference: normalizeFileInput(params.visualReference, { single: true }),
1603-
consistencyMode: params.consistencyMode,
1604-
stylePreset: params.stylePreset,
1605-
promptOptimizer: parseOptionalBooleanInput(params.promptOptimizer),
1606-
generateAudio: parseOptionalBooleanInput(params.generateAudio),
1607-
cameraControl: params.cameraControl
1608-
? typeof params.cameraControl === 'string'
1609-
? JSON.parse(params.cameraControl)
1610-
: params.cameraControl
1611-
: undefined,
1612-
}),
1651+
params: (params) => {
1652+
const model = params.provider === 'falai' ? getFalVideoModel(params.model) : undefined
1653+
const useFalFields = model && !model.legacyId
1654+
const setting = (legacy: string, catalog: string) => {
1655+
const value = params[useFalFields ? catalog : legacy]
1656+
return useFalFields && value === FAL_MODEL_DEFAULT.id ? undefined : value
1657+
}
1658+
const duration = setting('duration', 'falDuration')
1659+
return {
1660+
provider: params.provider,
1661+
apiKey: params.apiKey,
1662+
model: params.model,
1663+
endpoint: params.endpoint,
1664+
prompt: params.prompt,
1665+
duration: duration ? Number(duration) : undefined,
1666+
aspectRatio: setting('aspectRatio', 'falAspectRatio'),
1667+
resolution: setting('resolution', 'falResolution'),
1668+
visualReference: normalizeFileInput(params.visualReference, { single: true }),
1669+
consistencyMode: params.consistencyMode,
1670+
stylePreset: params.stylePreset,
1671+
promptOptimizer: parseOptionalBooleanInput(
1672+
setting('promptOptimizer', 'falPromptOptimizer')
1673+
),
1674+
generateAudio: parseOptionalBooleanInput(setting('generateAudio', 'falGenerateAudio')),
1675+
cameraControl: params.cameraControl
1676+
? typeof params.cameraControl === 'string'
1677+
? JSON.parse(params.cameraControl)
1678+
: params.cameraControl
1679+
: undefined,
1680+
}
1681+
},
16131682
},
16141683
},
16151684
inputs: {
@@ -1671,5 +1740,22 @@ export const VideoGeneratorV3Block: BlockConfig = {
16711740
bgColor: '#181C1E',
16721741
icon: VideoIcon,
16731742
hideFromToolbar: false,
1674-
subBlocks: withFalAIModelOptions(VideoGeneratorV2Block.subBlocks, FALAI_LATEST_MODEL_OPTIONS),
1743+
subBlocks: withFalVideoCatalog(VideoGeneratorV2Block.subBlocks),
1744+
inputs: {
1745+
...VideoGeneratorV2Block.inputs,
1746+
falDuration: {
1747+
type: 'number',
1748+
description: 'Duration in seconds for the selected fal endpoint',
1749+
},
1750+
falAspectRatio: { type: 'string', description: 'Aspect ratio for the selected fal endpoint' },
1751+
falResolution: { type: 'string', description: 'Resolution for the selected fal endpoint' },
1752+
falGenerateAudio: {
1753+
type: 'boolean',
1754+
description: 'Generate audio with the selected fal endpoint',
1755+
},
1756+
falPromptOptimizer: {
1757+
type: 'boolean',
1758+
description: 'Enable prompt optimization on the selected fal endpoint',
1759+
},
1760+
},
16751761
}

‎apps/sim/lib/internal/video/client.test.ts‎

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88
vi.mock('@/lib/core/execution-limits', () => executionLimitsMock)
99

1010
import { generateVideo } from '@/lib/internal/video/client'
11+
import { executeVideoOperation } from '@/lib/internal/video/operations'
12+
import { observeServiceCosts } from '@/lib/mothership/billing/service-observer'
1113

1214
executionLimitsMockFns.mockGetMaxExecutionTimeout.mockReturnValue(5000)
1315

@@ -65,9 +67,8 @@ describe('Video provider client', () => {
6567
}),
6668
jsonResponse({ status: 'COMPLETED' }),
6769
jsonResponse({
68-
video: { url: 'https://cdn.example/fal.mp4', width: 1920, height: 1080, duration: 8 },
70+
video: { url: 'data:video/mp4;base64,dmlkZW8=', width: 1920, height: 1080, duration: 8 },
6971
}),
70-
new Response(Buffer.from('video')),
7172
],
7273
submitUrl: 'https://queue.fal.run/fal-ai/veo3.1',
7374
},
@@ -97,6 +98,106 @@ describe('Video provider client', () => {
9798
).toHaveLength(1)
9899
})
99100

101+
it('retrieves a nested endpoint result when fal omits the queue URLs', async () => {
102+
const app = 'fal-ai/kandinsky5'
103+
const model = `${app}/text-to-video`
104+
const request = `/${app}/requests/fal-1`
105+
vi.stubGlobal('fetch', async (url: string | URL) => {
106+
switch (new URL(url).pathname) {
107+
case `/${model}`:
108+
return jsonResponse({ request_id: 'fal-1' })
109+
case `${request}/status`:
110+
return jsonResponse({ status: 'COMPLETED' })
111+
case request:
112+
return jsonResponse({ video: { url: 'data:video/mp4;base64,dmlkZW8=' } })
113+
default:
114+
return new Response(null, { status: 405 })
115+
}
116+
})
117+
const result = generateVideo(
118+
{ provider: 'falai', apiKey: 'key', model, prompt: 'A cinematic sunrise' },
119+
{ requestId: 'request-1' }
120+
)
121+
const completion = expect(result).resolves.toMatchObject({ buffer: Buffer.from('video') })
122+
await vi.advanceTimersByTimeAsync(5000)
123+
await completion
124+
})
125+
126+
it('keeps the fal API key off a foreign polling origin', async () => {
127+
const mockFetch = vi
128+
.fn()
129+
.mockResolvedValueOnce(
130+
jsonResponse({ request_id: 'fal-1', status_url: 'https://untrusted.example/job' })
131+
)
132+
.mockResolvedValue(jsonResponse({ status: 'FAILED' }))
133+
vi.stubGlobal('fetch', mockFetch)
134+
135+
const result = generateVideo(
136+
{ provider: 'falai', apiKey: 'private-key', model: 'veo-3.1', prompt: 'A cinematic sunrise' },
137+
{ requestId: 'request-1' }
138+
)
139+
const rejection = expect(result).rejects.toThrow()
140+
await vi.advanceTimersByTimeAsync(5000)
141+
await rejection
142+
expect(mockFetch.mock.calls.map(([url]) => new URL(url).origin)).not.toContain(
143+
'https://untrusted.example'
144+
)
145+
})
146+
147+
it('preserves the completed hosted receipt for reconciliation when Stop interrupts delivery', async () => {
148+
const controller = new AbortController()
149+
const requestId = 'completed-video'
150+
const endpoint = 'fal-ai/veo3.1'
151+
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
152+
init?.signal?.throwIfAborted()
153+
const path = new URL(url).pathname
154+
if (path === `/${endpoint}`) return jsonResponse({ request_id: requestId })
155+
if (path.endsWith('/status')) {
156+
controller.abort(new Error('Stopped'))
157+
return jsonResponse({ status: 'COMPLETED' })
158+
}
159+
if (path.endsWith('/billing-events')) {
160+
return jsonResponse({
161+
billing_events: [
162+
{ request_id: requestId, endpoint_id: endpoint, cost_estimate_nano_usd: 400000000 },
163+
],
164+
})
165+
}
166+
throw new Error('Unexpected provider request')
167+
})
168+
const pending: string[] = []
169+
const result = observeServiceCosts(
170+
async () => {
171+
throw new Error('Unexpected successful charge')
172+
},
173+
() =>
174+
executeVideoOperation(
175+
{
176+
provider: 'falai',
177+
model: 'veo-3.1',
178+
prompt: 'A sunrise',
179+
apiKey: 'key',
180+
useHostedCostTracking: true,
181+
},
182+
{
183+
requestId: 'trace-id',
184+
userId: 'user',
185+
headers: new Headers(),
186+
signal: controller.signal,
187+
}
188+
),
189+
async (receipt) => {
190+
pending.push(receipt)
191+
}
192+
)
193+
const rejection = expect(result).rejects.toThrow('Stopped')
194+
await vi.advanceTimersByTimeAsync(5000)
195+
await rejection
196+
expect(pending).toHaveLength(1)
197+
expect(pending[0]).toContain(requestId)
198+
expect(pending[0]).toContain('0.4')
199+
})
200+
100201
it('cancels during the provider wait without polling or resubmitting', async () => {
101202
const controller = new AbortController()
102203
const mockFetch = vi.fn().mockResolvedValueOnce(jsonResponse({ id: 'task-1' }))

0 commit comments

Comments
 (0)