close
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
269 changes: 35 additions & 234 deletions apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Large diffs are not rendered by default.

287 changes: 29 additions & 258 deletions apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Large diffs are not rendered by default.

128 changes: 128 additions & 0 deletions apps/sim/lib/copilot/tools/server/media/ffmpeg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,131 @@ describe('ffmpeg server tool secret provenance', () => {
})
})
})

describe('ffmpeg server tool input admission', () => {
const context = {
userId: 'user-1',
workspaceId: 'workspace-1',
toolCallId: 'tool-1',
copilotToolExecution: true as const,
resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([], {
userId: 'user-1',
workspaceId: 'workspace-1',
}),
}

beforeEach(() => {
vi.clearAllMocks()
resolveWorkspaceFileReferenceMock.mockResolvedValue(file)
fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('media'))
getBoundWorkspaceFileSecretProvenanceMock.mockResolvedValue(EXACT_EMPTY)
mergeWorkspaceFileSecretProvenanceMock.mockImplementation(mergeProvenance)
runFfmpegOperationMock.mockResolvedValue({
buffer: Buffer.from('output'),
ext: 'mp4',
contentType: 'video/mp4',
})
writeWorkspaceFileByPathMock.mockResolvedValue({
id: 'output-1',
name: 'converted.mp4',
vfsPath: 'files/converted.mp4',
downloadUrl: '/api/files/serve/converted.mp4',
mode: 'create',
})
})

it('refuses more inputs than one call may transcode, before reading any of them', async () => {
const files = Array.from({ length: 21 }, () => ({ path: 'files/input.mp4' }))

const result = await ffmpegServerTool.execute(
{ operation: 'concat', inputs: { files } },
context
)

expect(result.success).toBe(false)
expect(result.message).toContain('at most 20')
expect(resolveWorkspaceFileReferenceMock).not.toHaveBeenCalled()
expect(runFfmpegOperationMock).not.toHaveBeenCalled()
})

it('admits a batch at the input ceiling', async () => {
const files = Array.from({ length: 20 }, () => ({ path: 'files/input.mp4' }))

const result = await ffmpegServerTool.execute(
{ operation: 'concat', inputs: { files } },
context
)

expect(result.success).toBe(true)
expect(runFfmpegOperationMock).toHaveBeenCalledTimes(1)
})

it('rejects on the recorded size before spending the download', async () => {
resolveWorkspaceFileReferenceMock.mockResolvedValue({ ...file, size: 300 * 1024 * 1024 })

const result = await ffmpegServerTool.execute(
{ operation: 'convert', inputs: { files: [{ path: 'files/huge.mp4' }] } },
context
)

expect(result.success).toBe(false)
expect(result.message).toContain('byte limit')
expect(fetchWorkspaceFileBufferMock).not.toHaveBeenCalled()
})

it('stops preparing inputs the moment the caller cancels', async () => {
const controller = new AbortController()
controller.abort()

const result = await ffmpegServerTool.execute(
{
operation: 'concat',
inputs: { files: [{ path: 'files/a.mp4' }, { path: 'files/b.mp4' }] },
},
{ ...context, abortSignal: controller.signal }
)

expect(result.success).toBe(false)
// Not one storage read, let alone all of them.
expect(resolveWorkspaceFileReferenceMock).not.toHaveBeenCalled()
expect(fetchWorkspaceFileBufferMock).not.toHaveBeenCalled()
expect(runFfmpegOperationMock).not.toHaveBeenCalled()
})

it('stops between inputs when the cancel lands mid-preparation', async () => {
const controller = new AbortController()
// Abort once the first input has been read, so the second is never fetched.
fetchWorkspaceFileBufferMock.mockImplementationOnce(async () => {
controller.abort()
return Buffer.from('media')
})

const result = await ffmpegServerTool.execute(
{
operation: 'concat',
inputs: { files: [{ path: 'files/a.mp4' }, { path: 'files/b.mp4' }] },
},
{ ...context, abortSignal: controller.signal }
)

expect(result.success).toBe(false)
expect(fetchWorkspaceFileBufferMock).toHaveBeenCalledTimes(1)
expect(runFfmpegOperationMock).not.toHaveBeenCalled()
})

it('hands the caller cancellation signal to the transcode', async () => {
const controller = new AbortController()

await ffmpegServerTool.execute(
{ operation: 'convert', inputs: { files: [{ path: 'files/input.mp4' }] } },
{ ...context, abortSignal: controller.signal }
)

expect(runFfmpegOperationMock).toHaveBeenCalledWith(
'convert',
expect.anything(),
expect.anything(),
{ signal: controller.signal }
)
})
})
59 changes: 46 additions & 13 deletions apps/sim/lib/copilot/tools/server/media/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
import { MAX_MEDIA_BYTES } from '@/lib/media/falai'
import { type FfmpegOperation, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg'
import { FFMPEG_LIMITS } from '@/lib/media/ffmpeg-limits'
import {
createWorkspaceFileSecretProvenanceFromRegistry,
getBoundWorkspaceFileSecretProvenance,
Expand All @@ -26,6 +27,13 @@ import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-sec
const logger = createLogger('FfmpegTool')
const MEDIA_OPERATION_FAILED_SAFELY = 'The media operation failed safely'

/**
* Backstops the `maxItems` the generated tool schema declares: the byte budget
* below does not bound a call that lists many small clips, and a caller that
* reaches this handler without passing Ajv still must not get an unbounded run.
*/
const { maxInputFiles: MAX_INPUT_FILES } = FFMPEG_LIMITS

const VALID_OPERATIONS: FfmpegOperation[] = [
'overlay_audio',
'mux',
Expand Down Expand Up @@ -93,13 +101,25 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
if (inputPaths.length === 0) {
return { success: false, message: 'At least one input file is required in inputs.files' }
}
if (inputPaths.length > MAX_INPUT_FILES) {
return {
success: false,
message: `${inputPaths.length} input files were requested; at most ${MAX_INPUT_FILES} are allowed per ffmpeg call. Combine them in batches.`,
}
}

let inputRequiresOpaqueError = false
try {
const mediaFiles: MediaFile[] = []
let totalInputBytes = 0
const inputProvenances: WorkspaceFileSecretProvenance[] = []
for (const filePath of inputPaths) {
// Preparing the inputs is itself the expensive part of a many-file call —
// up to the whole byte budget in storage reads. Without this the run only
// notices an explicit stop once every download has already finished.
if (context.abortSignal?.aborted) {
throw new Error('ffmpeg cancelled while preparing inputs')
}
Comment thread
icecrasher321 marked this conversation as resolved.
const fileRecord = await resolveCopilotWorkspaceFileReference(
context,
fileOperations.readContent,
Expand All @@ -108,6 +128,11 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
reference: filePath,
}
)
// Reject on the recorded size before spending the download. The
// accumulated check below still backstops a stale size row.
if (totalInputBytes + fileRecord.size > MAX_MEDIA_BYTES) {
throw new Error(`Input files exceed the ${MAX_MEDIA_BYTES} byte limit`)
}
const fileProvenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, {
fileId: fileRecord.id,
key: fileRecord.key,
Expand Down Expand Up @@ -141,19 +166,27 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
inputRequiresOpaqueError ||=
inputProvenance.status !== 'exact' || inputProvenance.entries.length > 0
assertServerToolNotAborted(context)
const result = await runFfmpegOperation(params.operation, mediaFiles, {
text: params.text,
position: params.position,
start: params.start,
end: params.end,
width: params.width,
height: params.height,
aspectRatio: params.aspectRatio,
volume: params.volume,
musicVolume: params.musicVolume,
loopToVideo: params.loopToVideo,
format: params.format,
})
const result = await runFfmpegOperation(
params.operation,
mediaFiles,
{
text: params.text,
position: params.position,
start: params.start,
end: params.end,
width: params.width,
height: params.height,
aspectRatio: params.aspectRatio,
volume: params.volume,
musicVolume: params.musicVolume,
loopToVideo: params.loopToVideo,
format: params.format,
},
// Every abort of this signal is an explicit user stop — the copilot
// lifecycle tracks a passive client disconnect separately and does not
// abort on it — so a transcode dies when the user says stop, and only then.
{ signal: context.abortSignal }
Comment thread
icecrasher321 marked this conversation as resolved.
)

// probe reports metadata only — no file written.
if (params.operation === 'probe') {
Expand Down
23 changes: 23 additions & 0 deletions apps/sim/lib/media/ffmpeg-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Execution bounds the ffmpeg tool enforces.
*
* These are mirrored into the Go tool catalog
* (`copilot/internal/tools/catalog/other/ffmpeg.go`), which is what lets the
* router reject an out-of-range argument structurally, before any storage read
* or child process. The model itself learns the limits from the parameter
* descriptions — copilot's `NormalizeToolParameters` drops every JSON Schema
* keyword outside its allowlist on the way to a provider, so the numbers are
* stated in prose there too. `ffmpeg-schema-parity.test.ts` fails when the two
* copies drift.
*
* `maxScalePixels` has no JSON Schema equivalent, so it lives in the parameter
* description on the Go side and is enforced here only.
*/
export const FFMPEG_LIMITS = {
/** Every input costs a full re-encode pass in `concat`, the only multi-input operation. */
maxInputFiles: 20,
minScaleDimension: 16,
maxScaleDimension: 4096,
/** DCI 4K in either orientation — bounds the square frames the per-axis cap alone allows. */
maxScalePixels: 4096 * 2304,
} as const
55 changes: 55 additions & 0 deletions apps/sim/lib/media/ffmpeg-schema-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { TOOL_RUNTIME_SCHEMAS } from '@/lib/copilot/generated/tool-schemas-v1'
import { FFMPEG_LIMITS } from '@/lib/media/ffmpeg-limits'

/**
* The ffmpeg bounds live twice: here, where the executor enforces them, and in
* the Go tool catalog, where they become the JSON Schema the model reads and
* Ajv checks at the router. Drift between the two is silent and user-visible —
* the model is told one ceiling and the transcode refuses at another — so pin
* the generated schema against the executor's own numbers.
*
* When this fails, change `ffmpeg.go` in the copilot repo and regenerate; do
* not edit the generated schema.
*/
interface SchemaNode {
properties?: Record<string, SchemaNode>
items?: SchemaNode
maxItems?: number
minimum?: number
maximum?: number
}

const ffmpegParameters = TOOL_RUNTIME_SCHEMAS.ffmpeg?.parameters as SchemaNode | undefined

describe('ffmpeg tool schema parity', () => {
it('declares the tool in the generated catalog', () => {
expect(ffmpegParameters?.properties).toBeDefined()
})

it('caps inputs.files at the executor limit', () => {
expect(ffmpegParameters?.properties?.inputs?.properties?.files?.maxItems).toBe(
FFMPEG_LIMITS.maxInputFiles
)
})

it('bounds the scale dimensions at the executor limits', () => {
for (const axis of ['width', 'height'] as const) {
expect(ffmpegParameters?.properties?.[axis]?.minimum).toBe(FFMPEG_LIMITS.minScaleDimension)
expect(ffmpegParameters?.properties?.[axis]?.maximum).toBe(FFMPEG_LIMITS.maxScaleDimension)
}
})

it('does not offer sandbox-only fields on a tool that runs in this process', () => {
const inputs = ffmpegParameters?.properties?.inputs?.properties
expect(Object.keys(inputs ?? {})).toEqual(['files'])
expect(inputs?.files?.items?.properties).toBeDefined()
expect(Object.keys(inputs?.files?.items?.properties ?? {})).toEqual(['path'])

const outputItem = ffmpegParameters?.properties?.outputs?.properties?.files?.items?.properties
expect(Object.keys(outputItem ?? {}).sort()).toEqual(['mimeType', 'mode', 'path'])
})
})
Loading
Loading