Skip to content

Commit f760903

Browse files
committed
fix(video-generator): preserve Agent audio and model prompt limits
1 parent eb73cc8 commit f760903

6 files changed

Lines changed: 85 additions & 7 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,9 +1935,9 @@ export const VideoGeneratorV3Block: BlockConfig = {
19351935
? normalizeFileInput(params.visualReference, { single: true })
19361936
: undefined
19371937
const model = getDirectVideoModel(params.provider, params.model, Boolean(visualReference))
1938-
const setting = (key: string, supported: unknown) => {
1938+
const setting = (key: string, supported: unknown, fallbackKey?: string) => {
19391939
if (!supported) return undefined
1940-
const value = params[key]
1940+
const value = params[key] ?? (fallbackKey ? params[fallbackKey] : undefined)
19411941
return value === FAL_MODEL_DEFAULT.id ? undefined : value
19421942
}
19431943
const duration = setting('duration', model?.settings?.durations)
@@ -1951,7 +1951,7 @@ export const VideoGeneratorV3Block: BlockConfig = {
19511951
aspectRatio: setting('aspectRatio', model?.settings?.aspectRatios),
19521952
resolution: setting('resolution', model?.settings?.resolutions),
19531953
generateAudio: parseOptionalBooleanInput(
1954-
setting('directGenerateAudio', model?.settings?.audio)
1954+
setting('directGenerateAudio', model?.settings?.audio, 'generateAudio')
19551955
),
19561956
promptOptimizer: parseOptionalBooleanInput(
19571957
setting('promptOptimizer', model?.settings?.promptOptimizer)

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import {
77
inputValidationMock,
88
inputValidationMockFns,
99
} from '@sim/testing/mocks/input-validation.mock'
10+
import { storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock'
11+
import { uploadsMock } from '@sim/testing/mocks/uploads.mock'
1012
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1113

14+
vi.mock('@/lib/uploads', () => uploadsMock)
1215
vi.mock('@/lib/core/execution-limits', () => executionLimitsMock)
1316
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
1417

@@ -34,6 +37,40 @@ describe('Video provider client', () => {
3437
vi.useRealTimers()
3538
})
3639

40+
it.each([
41+
{ model: 'gen4.5', limit: 1000 },
42+
{ model: 'klingO3_4k', limit: 2500 },
43+
{ model: 'kling_4', limit: 26000 },
44+
])(
45+
'accepts the published prompt limit for $model and rejects overflow before submission',
46+
async ({ model, limit }) => {
47+
const mockFetch = vi
48+
.fn<typeof fetch>()
49+
.mockResolvedValueOnce(jsonResponse({ id: 'job' }))
50+
.mockResolvedValueOnce(
51+
jsonResponse({ status: 'SUCCEEDED', output: ['data:video/mp4;base64,dmlkZW8='] })
52+
)
53+
vi.stubGlobal('fetch', mockFetch)
54+
storageServiceMockFns.mockUploadFile.mockResolvedValue({
55+
path: '/api/files/video.mp4',
56+
size: 5,
57+
})
58+
const input = { provider: 'runway' as const, model, apiKey: 'key', prompt: 'a'.repeat(limit) }
59+
const context = { requestId: 'prompt-boundary', userId: 'user', headers: new Headers() }
60+
const completion = expect(executeVideoOperation(input, context)).resolves.toMatchObject({
61+
model,
62+
})
63+
await Promise.all([completion, vi.advanceTimersByTimeAsync(5000)])
64+
expect(JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body)).promptText).toBe(input.prompt)
65+
66+
mockFetch.mockClear()
67+
await expect(
68+
executeVideoOperation({ ...input, prompt: `${input.prompt}a` }, context)
69+
).rejects.toThrow(`Prompt must be between 3 and ${limit} characters`)
70+
expect(mockFetch).not.toHaveBeenCalled()
71+
}
72+
)
73+
3774
it.each([
3875
{
3976
provider: 'luma' as const,

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,11 @@ async function waitForProvider(context: VideoGenerationContext): Promise<void> {
8383
}
8484

8585
export function getVideoInputValidationError(input: VideoGenerationInput): string | undefined {
86-
if (input.prompt.length < 3 || input.prompt.length > 2000) {
87-
return 'Prompt must be between 3 and 2000 characters'
86+
if (input.provider === 'falai') {
87+
if (input.prompt.length < 3 || input.prompt.length > 2000)
88+
return 'Prompt must be between 3 and 2000 characters'
89+
return getFalAIValidationError(input)
8890
}
89-
if (input.provider === 'falai') return getFalAIValidationError(input)
9091
try {
9192
buildDirectVideoRequest(input)
9293
} catch (error) {

‎apps/sim/lib/internal/video/direct-request.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function buildDirectVideoRequest(input: VideoGenerationInput) {
1515
throw new Error(
1616
`${model.label} ${input.visualReference ? 'does not accept a reference image' : 'requires a reference image'}`
1717
)
18-
const maxLength = Math.min(2000, spec.maxPromptLength ?? 2000)
18+
const maxLength = spec.maxPromptLength ?? 2000
1919
if (input.prompt.length < 3 || input.prompt.length > maxLength)
2020
throw new Error(`Prompt must be between 3 and ${maxLength} characters for ${model.label}`)
2121
let aspectRatio = input.aspectRatio

‎apps/sim/scripts/validate-runway-video-models.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import assert from 'node:assert/strict'
77
import { readFile, writeFile } from 'node:fs/promises'
88
import { createLogger } from '@sim/logger'
99
import { getErrorMessage } from '@sim/utils/errors'
10+
import { isRecordLike } from '@sim/utils/object'
1011
import Ajv from 'ajv/dist/2020'
1112
import { videoToolBodySchema } from '@/lib/api/contracts/tools/media/video'
1213
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
@@ -95,6 +96,7 @@ for (const mode of ['text', 'image'] as const) {
9596
assert(validate(request.body), JSON.stringify(validate.errors))
9697
assert.equal(request.body.model, model.id)
9798
const wireKeys: Record<string, string> = {
99+
prompt: 'promptText',
98100
duration: 'duration',
99101
aspectRatio: 'ratio',
100102
resolution: 'resolution',
@@ -107,6 +109,14 @@ for (const mode of ['text', 'image'] as const) {
107109
requests++
108110
}
109111
check({})
112+
const promptSchema = schema.properties.promptText
113+
assert(isRecordLike(promptSchema) && typeof promptSchema.maxLength === 'number')
114+
const maxPromptLength = promptSchema.maxLength
115+
check({ prompt: 'a'.repeat(maxPromptLength) })
116+
assert.throws(
117+
() => buildDirectVideoRequest({ ...base, prompt: 'a'.repeat(maxPromptLength + 1) }),
118+
/Prompt must be between/
119+
)
110120
for (const field of VideoGeneratorV3Block.subBlocks) {
111121
if (
112122
!['duration', 'aspectRatio', 'resolution', 'directGenerateAudio'].includes(field.id) ||

‎apps/sim/tools/index.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,15 @@ import {
5757
} from '@sim/testing'
5858
import { DrizzleQueryError } from 'drizzle-orm/errors'
5959
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
60+
import { videoToolBodySchema } from '@/lib/api/contracts/tools/media/video'
6061
import { withAssistantActionExecution } from '@/lib/assistant-actions/execution-scope'
6162
import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution'
6263
import type { EnvironmentResolutionSnapshot } from '@/lib/environment/utils'
6364
import { executeBitbucketTool } from '@/lib/internal/bitbucket/execute-tool'
6465
import { createInternalToolFileResult } from '@/lib/internal/tool-operations/file-result'
6566
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
6667
import { executeVantaTool } from '@/lib/internal/vanta/execute-tool'
68+
import { buildDirectVideoRequest } from '@/lib/internal/video/direct-request'
6769
import { projectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
6870
import { VideoGeneratorV3Block } from '@/blocks/blocks/video_generator'
6971
import {
@@ -5115,6 +5117,34 @@ describe('File Parameter Normalization', () => {
51155117
}
51165118
)
51175119

5120+
it.each([
5121+
{ configured: undefined, requested: false, expected: false },
5122+
{ configured: undefined, requested: true, expected: true },
5123+
{ configured: false, requested: true, expected: false },
5124+
])('preserves Agent audio choices and configured overrides ($expected)', async (testCase) => {
5125+
const providerTool = await transformBlockTool(
5126+
{
5127+
type: 'video_generator_v3',
5128+
params: {
5129+
provider: 'runway',
5130+
apiKey: 'test-runway-key',
5131+
directGenerateAudio: testCase.configured,
5132+
},
5133+
},
5134+
{ getAllBlocks: () => [VideoGeneratorV3Block], getTool: () => runwayVideoTool }
5135+
)
5136+
if (!providerTool) throw new Error('Video tool was not exposed to the Agent')
5137+
const prepared = prepareToolExecution(
5138+
providerTool,
5139+
{ model: 'veo3.1', prompt: 'Animate the boat', generateAudio: testCase.requested },
5140+
{},
5141+
'call-video-audio'
5142+
)
5143+
const input = videoToolBodySchema.parse(prepared.toolParams)
5144+
const request = buildDirectVideoRequest({ ...input, provider: 'runway' })
5145+
expect(request.body.audio).toBe(testCase.expected)
5146+
})
5147+
51185148
it('resolves canonical file IDs for single-file params during copilot execution', async () => {
51195149
mockResolveWorkspaceFileReference.mockResolvedValue({
51205150
id: 'wf_123',

0 commit comments

Comments
 (0)