Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 137 additions & 51 deletions apps/sim/blocks/blocks/video_generator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { VideoIcon } from '@/components/icons'
import { falVideoModels, getFalVideoModel } from '@/lib/media/fal-video-catalog'
import { AuthMode, type BlockConfig, IntegrationType, type SubBlockConfig } from '@/blocks/types'
import { normalizeFileInput, parseOptionalBooleanInput } from '@/blocks/utils'

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

const FALAI_LATEST_MODEL_OPTIONS = [
{ label: 'Google Veo 3.1', id: 'veo-3.1' },
{ label: 'Google Veo 3.1 Fast', id: 'veo-3.1-fast' },
{ label: 'OpenAI Sora 2', id: 'sora-2' },
{ label: 'OpenAI Sora 2 Pro', id: 'sora-2-pro' },
{ label: 'ByteDance Seedance 2.0', id: 'seedance-2.0' },
{ label: 'ByteDance Seedance 2.0 Fast', id: 'seedance-2.0-fast' },
{ label: 'Kling 3.0 Pro', id: 'kling-v3-pro' },
{ label: 'Kling 3.0 4K', id: 'kling-v3-4k' },
{ label: 'Kling O3 Pro', id: 'kling-o3-pro' },
{ label: 'Kling O3 4K', id: 'kling-o3-4k' },
{ label: 'MiniMax Hailuo 2.3 Pro', id: 'minimax-hailuo-2.3-pro' },
{ label: 'MiniMax Hailuo 2.3 Standard', id: 'minimax-hailuo-2.3-standard' },
{ label: 'WAN 2.2 A14B Turbo', id: 'wan-2.2-a14b-turbo' },
{ label: 'LTX 2.3', id: 'ltx-2.3' },
{ label: 'LTX 2.3 Fast', id: 'ltx-2.3-fast' },
]

const FALAI_VEO_MODELS = ['veo-3.1', 'veo-3.1-fast']
const FALAI_SORA_MODELS = ['sora-2', 'sora-2-pro']
const FALAI_SEEDANCE_STANDARD_MODELS = ['seedance-2.0']
Expand All @@ -51,23 +34,98 @@ const FALAI_AUDIO_DEFAULT_ON_MODELS = [
]
const FALAI_AUDIO_DEFAULT_OFF_MODELS = ['kling-o3-pro', 'kling-o3-4k']

const withFalAIModelOptions = (
subBlocks: SubBlockConfig[],
options: SubBlockConfig['options']
): SubBlockConfig[] =>
subBlocks.map((subBlock) => {
const FAL_MODEL_DEFAULT = { id: 'default', label: 'Model default' } as const
const endpointModels = falVideoModels.filter((model) => !model.legacyId)

function tuningField(
id: string,
title: string,
key: 'durations' | 'aspectRatios' | 'resolutions' | 'audioField' | 'promptOptimizer'
): SubBlockConfig {
return {
id,
title,
type: 'dropdown',
value: () => FAL_MODEL_DEFAULT.id,
condition: {
field: 'provider',
value: 'falai',
and: {
field: 'model',
value: endpointModels.filter((model) => model[key]).map((model) => model.modelId),
},
},
dependsOn: ['model'],
options: (params) => {
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
const model = getFalVideoModel(params?.values.model)
if (!model?.[key]) return []
if (key === 'audioField' || key === 'promptOptimizer') {
return [FAL_MODEL_DEFAULT, { id: 'true', label: 'On' }, { id: 'false', label: 'Off' }]
}
const duration = Number(params?.values.falDuration)
const resolution = params?.values.falResolution
const limit =
typeof resolution === 'string' ? model.maxDurationByResolution?.[resolution] : undefined
const choices = (model[key] ?? []).flatMap((value) => {
const seconds = Number.parseFloat(String(value))
if (key === 'durations' && limit !== undefined && seconds > limit) return []
if (
key === 'resolutions' &&
duration > (model.maxDurationByResolution?.[value] ?? Number.POSITIVE_INFINITY)
)
return []
const option = key === 'durations' ? String(seconds) : String(value)
return [{ id: option, label: option }]
})
return [FAL_MODEL_DEFAULT, ...choices]
},
}
}

/** Separate IDs keep saved provider settings from seeding a newly selected endpoint. */
const falVideoFields = [
tuningField('falDuration', 'Duration (seconds)', 'durations'),
tuningField('falAspectRatio', 'Aspect Ratio', 'aspectRatios'),
tuningField('falResolution', 'Resolution', 'resolutions'),
tuningField('falGenerateAudio', 'Generate Audio', 'audioField'),
tuningField('falPromptOptimizer', 'Prompt Optimizer', 'promptOptimizer'),
]

function withFalVideoCatalog(subBlocks: SubBlockConfig[]): SubBlockConfig[] {
return subBlocks.flatMap((subBlock) => {
const condition = subBlock.condition
if (
subBlock.id === 'model' &&
typeof condition === 'object' &&
condition?.field === 'provider' &&
condition.value === 'falai'
typeof condition !== 'object' ||
condition?.field !== 'provider' ||
condition.value !== 'falai'
) {
return { ...subBlock, options }
return [subBlock]
}

return subBlock
if (subBlock.id === 'model') {
return [
{
...subBlock,
searchable: true,
options: [
...falVideoModels
.map((model) => ({
id: model.modelId,
label: model.label,
description: model.endpoint,
}))
.sort((left, right) =>
left.label.localeCompare(right.label, 'en', { numeric: true })
),
{ id: 'sora-2', label: 'OpenAI Sora 2 (retired)', hidden: true },
{ id: 'sora-2-pro', label: 'OpenAI Sora 2 Pro (retired)', hidden: true },
],
},
]
}
if (subBlock.id === 'apiKey' && !condition.not) return [...falVideoFields, subBlock]
return [subBlock]
})
}

export const VideoGeneratorBlock: BlockConfig = {
type: 'video_generator',
Expand Down Expand Up @@ -1590,26 +1648,37 @@ export const VideoGeneratorV2Block: BlockConfig = {
return 'video_runway'
}
},
params: (params) => ({
provider: params.provider,
apiKey: params.apiKey,
model: params.model,
endpoint: params.endpoint,
prompt: params.prompt,
duration: params.duration ? Number(params.duration) : undefined,
aspectRatio: params.aspectRatio,
resolution: params.resolution,
visualReference: normalizeFileInput(params.visualReference, { single: true }),
consistencyMode: params.consistencyMode,
stylePreset: params.stylePreset,
promptOptimizer: parseOptionalBooleanInput(params.promptOptimizer),
generateAudio: parseOptionalBooleanInput(params.generateAudio),
cameraControl: params.cameraControl
? typeof params.cameraControl === 'string'
? JSON.parse(params.cameraControl)
: params.cameraControl
: undefined,
}),
params: (params) => {
const model = params.provider === 'falai' ? getFalVideoModel(params.model) : undefined
const useFalFields = model && !model.legacyId
const setting = (legacy: string, catalog: string) => {
const value = params[useFalFields ? catalog : legacy]
return useFalFields && value === FAL_MODEL_DEFAULT.id ? undefined : value
}
const duration = setting('duration', 'falDuration')
return {
provider: params.provider,
apiKey: params.apiKey,
model: params.model,
endpoint: params.endpoint,
prompt: params.prompt,
duration: duration ? Number(duration) : undefined,
aspectRatio: setting('aspectRatio', 'falAspectRatio'),
resolution: setting('resolution', 'falResolution'),
visualReference: normalizeFileInput(params.visualReference, { single: true }),
consistencyMode: params.consistencyMode,
stylePreset: params.stylePreset,
promptOptimizer: parseOptionalBooleanInput(
setting('promptOptimizer', 'falPromptOptimizer')
),
generateAudio: parseOptionalBooleanInput(setting('generateAudio', 'falGenerateAudio')),
cameraControl: params.cameraControl
? typeof params.cameraControl === 'string'
? JSON.parse(params.cameraControl)
: params.cameraControl
: undefined,
}
},
},
},
inputs: {
Expand Down Expand Up @@ -1671,5 +1740,22 @@ export const VideoGeneratorV3Block: BlockConfig = {
bgColor: '#181C1E',
icon: VideoIcon,
hideFromToolbar: false,
subBlocks: withFalAIModelOptions(VideoGeneratorV2Block.subBlocks, FALAI_LATEST_MODEL_OPTIONS),
subBlocks: withFalVideoCatalog(VideoGeneratorV2Block.subBlocks),
inputs: {
...VideoGeneratorV2Block.inputs,
falDuration: {
type: 'number',
description: 'Duration in seconds for the selected fal endpoint',
},
falAspectRatio: { type: 'string', description: 'Aspect ratio for the selected fal endpoint' },
falResolution: { type: 'string', description: 'Resolution for the selected fal endpoint' },
falGenerateAudio: {
type: 'boolean',
description: 'Generate audio with the selected fal endpoint',
},
falPromptOptimizer: {
type: 'boolean',
description: 'Enable prompt optimization on the selected fal endpoint',
},
},
}
105 changes: 103 additions & 2 deletions apps/sim/lib/internal/video/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/execution-limits', () => executionLimitsMock)

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

executionLimitsMockFns.mockGetMaxExecutionTimeout.mockReturnValue(5000)

Expand Down Expand Up @@ -65,9 +67,8 @@ describe('Video provider client', () => {
}),
jsonResponse({ status: 'COMPLETED' }),
jsonResponse({
video: { url: 'https://cdn.example/fal.mp4', width: 1920, height: 1080, duration: 8 },
video: { url: 'data:video/mp4;base64,dmlkZW8=', width: 1920, height: 1080, duration: 8 },
}),
new Response(Buffer.from('video')),
],
submitUrl: 'https://queue.fal.run/fal-ai/veo3.1',
},
Expand Down Expand Up @@ -97,6 +98,106 @@ describe('Video provider client', () => {
).toHaveLength(1)
})

it('retrieves a nested endpoint result when fal omits the queue URLs', async () => {
const app = 'fal-ai/kandinsky5'
const model = `${app}/text-to-video`
const request = `/${app}/requests/fal-1`
vi.stubGlobal('fetch', async (url: string | URL) => {
switch (new URL(url).pathname) {
case `/${model}`:
return jsonResponse({ request_id: 'fal-1' })
case `${request}/status`:
return jsonResponse({ status: 'COMPLETED' })
case request:
return jsonResponse({ video: { url: 'data:video/mp4;base64,dmlkZW8=' } })
default:
return new Response(null, { status: 405 })
}
})
const result = generateVideo(
{ provider: 'falai', apiKey: 'key', model, prompt: 'A cinematic sunrise' },
{ requestId: 'request-1' }
)
const completion = expect(result).resolves.toMatchObject({ buffer: Buffer.from('video') })
await vi.advanceTimersByTimeAsync(5000)
await completion
})

it('keeps the fal API key off a foreign polling origin', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({ request_id: 'fal-1', status_url: 'https://untrusted.example/job' })
)
.mockResolvedValue(jsonResponse({ status: 'FAILED' }))
vi.stubGlobal('fetch', mockFetch)

const result = generateVideo(
{ provider: 'falai', apiKey: 'private-key', model: 'veo-3.1', prompt: 'A cinematic sunrise' },
{ requestId: 'request-1' }
)
const rejection = expect(result).rejects.toThrow()
await vi.advanceTimersByTimeAsync(5000)
await rejection
expect(mockFetch.mock.calls.map(([url]) => new URL(url).origin)).not.toContain(
'https://untrusted.example'
)
})

it('preserves the completed hosted receipt for reconciliation when Stop interrupts delivery', async () => {
const controller = new AbortController()
const requestId = 'completed-video'
const endpoint = 'fal-ai/veo3.1'
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
init?.signal?.throwIfAborted()
const path = new URL(url).pathname
if (path === `/${endpoint}`) return jsonResponse({ request_id: requestId })
if (path.endsWith('/status')) {
controller.abort(new Error('Stopped'))
return jsonResponse({ status: 'COMPLETED' })
}
if (path.endsWith('/billing-events')) {
return jsonResponse({
billing_events: [
{ request_id: requestId, endpoint_id: endpoint, cost_estimate_nano_usd: 400000000 },
],
})
}
throw new Error('Unexpected provider request')
})
const pending: string[] = []
const result = observeServiceCosts(
async () => {
throw new Error('Unexpected successful charge')
},
() =>
executeVideoOperation(
{
provider: 'falai',
model: 'veo-3.1',
prompt: 'A sunrise',
apiKey: 'key',
useHostedCostTracking: true,
},
{
requestId: 'trace-id',
userId: 'user',
headers: new Headers(),
signal: controller.signal,
}
),
async (receipt) => {
pending.push(receipt)
}
)
const rejection = expect(result).rejects.toThrow('Stopped')
await vi.advanceTimersByTimeAsync(5000)
await rejection
expect(pending).toHaveLength(1)
expect(pending[0]).toContain(requestId)
expect(pending[0]).toContain('0.4')
})

it('cancels during the provider wait without polling or resubmitting', async () => {
const controller = new AbortController()
const mockFetch = vi.fn().mockResolvedValueOnce(jsonResponse({ id: 'task-1' }))
Expand Down
Loading
Loading