Skip to content

Commit 627509f

Browse files
committed
fix(media): fit concat inside the shared area budget and stop input prep on cancel
Two review findings, both real. concat clamped each probed axis independently, so a 4096x4096 source produced a normalization target of exactly that — nearly double the area budget scale_pad enforces through the same scale=/pad= graph. The pair is now scaled down together, so aspect ratio survives and one ceiling governs both entry points. Dimensions come back even too, which yuv420p requires and per-axis rounding did not guarantee. Preparing the inputs is itself the expensive half of a many-file call — up to the whole byte budget in storage reads — and the abort signal was only consulted after the loop, so an explicit stop was observed only once every download had already finished. The loop now checks it per input. Also corrects the rationale comments on the mirrored limits. maxItems/minimum/ maximum do not reach the model: copilot's NormalizeToolParameters allowlists type/properties/items/description/enum/required, so the bounds travel only the contract path, where Sim's router validates against them. That is still worth having — it turns an out-of-range argument into a structured rejection before any storage read or child process — but the model learns the limits from the parameter descriptions, and the comments now say so instead of claiming the schema teaches it.
1 parent f1726e1 commit 627509f

5 files changed

Lines changed: 126 additions & 7 deletions

File tree

apps/sim/lib/copilot/tools/server/media/ffmpeg.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,46 @@ describe('ffmpeg server tool input admission', () => {
381381
expect(fetchWorkspaceFileBufferMock).not.toHaveBeenCalled()
382382
})
383383

384+
it('stops preparing inputs the moment the caller cancels', async () => {
385+
const controller = new AbortController()
386+
controller.abort()
387+
388+
const result = await ffmpegServerTool.execute(
389+
{
390+
operation: 'concat',
391+
inputs: { files: [{ path: 'files/a.mp4' }, { path: 'files/b.mp4' }] },
392+
},
393+
{ ...context, abortSignal: controller.signal }
394+
)
395+
396+
expect(result.success).toBe(false)
397+
// Not one storage read, let alone all of them.
398+
expect(resolveWorkspaceFileReferenceMock).not.toHaveBeenCalled()
399+
expect(fetchWorkspaceFileBufferMock).not.toHaveBeenCalled()
400+
expect(runFfmpegOperationMock).not.toHaveBeenCalled()
401+
})
402+
403+
it('stops between inputs when the cancel lands mid-preparation', async () => {
404+
const controller = new AbortController()
405+
// Abort once the first input has been read, so the second is never fetched.
406+
fetchWorkspaceFileBufferMock.mockImplementationOnce(async () => {
407+
controller.abort()
408+
return Buffer.from('media')
409+
})
410+
411+
const result = await ffmpegServerTool.execute(
412+
{
413+
operation: 'concat',
414+
inputs: { files: [{ path: 'files/a.mp4' }, { path: 'files/b.mp4' }] },
415+
},
416+
{ ...context, abortSignal: controller.signal }
417+
)
418+
419+
expect(result.success).toBe(false)
420+
expect(fetchWorkspaceFileBufferMock).toHaveBeenCalledTimes(1)
421+
expect(runFfmpegOperationMock).not.toHaveBeenCalled()
422+
})
423+
384424
it('hands the caller cancellation signal to the transcode', async () => {
385425
const controller = new AbortController()
386426

apps/sim/lib/copilot/tools/server/media/ffmpeg.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
114114
let totalInputBytes = 0
115115
const inputProvenances: WorkspaceFileSecretProvenance[] = []
116116
for (const filePath of inputPaths) {
117+
// Preparing the inputs is itself the expensive part of a many-file call —
118+
// up to the whole byte budget in storage reads. Without this the run only
119+
// notices an explicit stop once every download has already finished.
120+
if (context.abortSignal?.aborted) {
121+
throw new Error('ffmpeg cancelled while preparing inputs')
122+
}
117123
const fileRecord = await resolveCopilotWorkspaceFileReference(
118124
context,
119125
fileOperations.readContent,

apps/sim/lib/media/ffmpeg-limits.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
* Execution bounds the ffmpeg tool enforces.
33
*
44
* These are mirrored into the Go tool catalog
5-
* (`copilot/internal/tools/catalog/other/ffmpeg.go`) so the model reads the
6-
* limits off its own schema instead of discovering them as a failed tool call.
7-
* `ffmpeg-schema-parity.test.ts` fails when the two copies drift.
5+
* (`copilot/internal/tools/catalog/other/ffmpeg.go`), which is what lets the
6+
* router reject an out-of-range argument structurally, before any storage read
7+
* or child process. The model itself learns the limits from the parameter
8+
* descriptions — copilot's `NormalizeToolParameters` drops every JSON Schema
9+
* keyword outside its allowlist on the way to a provider, so the numbers are
10+
* stated in prose there too. `ffmpeg-schema-parity.test.ts` fails when the two
11+
* copies drift.
812
*
913
* `maxScalePixels` has no JSON Schema equivalent, so it lives in the parameter
1014
* description on the Go side and is enforced here only.

apps/sim/lib/media/ffmpeg.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,52 @@ describe('runFfmpegOperation scale targets', () => {
183183
})
184184
})
185185

186+
describe('runFfmpegOperation concat normalization target', () => {
187+
const twoVideoStreams = JSON.stringify({
188+
streams: [{ codec_type: 'video', codec_name: 'h264', width: 4096, height: 4096 }],
189+
format: { duration: '2', format_name: 'mp4' },
190+
})
191+
192+
it('fits an oversized square source inside the same area budget scale_pad enforces', async () => {
193+
probeReport.json = twoVideoStreams
194+
195+
await runFfmpegOperation('concat', [videoInput, videoInput])
196+
197+
// First filter is the normalization pass for input 0.
198+
const target = capturedVideoFilters[0].match(/scale=(\d+):(\d+):/)
199+
expect(target).not.toBeNull()
200+
const width = Number(target![1])
201+
const height = Number(target![2])
202+
expect(width * height).toBeLessThanOrEqual(4096 * 2304)
203+
// Aspect ratio of the square source survives the fit.
204+
expect(width).toBe(height)
205+
})
206+
207+
it('emits even dimensions, which yuv420p requires', async () => {
208+
probeReport.json = JSON.stringify({
209+
streams: [{ codec_type: 'video', codec_name: 'h264', width: 1919, height: 1081 }],
210+
format: { duration: '2', format_name: 'mp4' },
211+
})
212+
213+
await runFfmpegOperation('concat', [videoInput, videoInput])
214+
215+
const target = capturedVideoFilters[0].match(/scale=(\d+):(\d+):/)
216+
expect(Number(target![1]) % 2).toBe(0)
217+
expect(Number(target![2]) % 2).toBe(0)
218+
})
219+
220+
it('leaves an ordinary source untouched', async () => {
221+
probeReport.json = JSON.stringify({
222+
streams: [{ codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080 }],
223+
format: { duration: '2', format_name: 'mp4' },
224+
})
225+
226+
await runFfmpegOperation('concat', [videoInput, videoInput])
227+
228+
expect(capturedVideoFilters[0]).toContain('scale=1920:1080')
229+
})
230+
})
231+
186232
describe('runFfmpegOperation process bounds', () => {
187233
it('kills a command that outlives the operation budget', async () => {
188234
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })

apps/sim/lib/media/ffmpeg.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,7 @@ async function concat(
535535
// Clamped, not rejected: these describe the caller's own file rather than a
536536
// value they asserted, but a container is free to declare a frame size far
537537
// larger than anything worth normalizing to.
538-
const width = clampProbedDimension(probes[0].width || 1280)
539-
const height = clampProbedDimension(probes[0].height || 720)
538+
const { width, height } = clampProbedFrame(probes[0].width || 1280, probes[0].height || 720)
540539
const fps = 30
541540

542541
// Normalize every clip to identical codec/size/fps/pixfmt, and SYNTHESIZE silent
@@ -623,8 +622,32 @@ async function trim(
623622
return readOut(outputPath, ext)
624623
}
625624

626-
function clampProbedDimension(value: number): number {
627-
return Math.min(Math.max(Math.round(value), MIN_SCALE_DIMENSION), MAX_SCALE_DIMENSION)
625+
/**
626+
* Fit a probed source frame inside the same budget `scale_pad` enforces.
627+
*
628+
* Clamping each axis on its own is not enough: two axes at the per-axis ceiling
629+
* are 4096x4096, nearly double the area limit, and that target is baked into the
630+
* same `scale=`/`pad=` graph. Scale the pair down together instead, so aspect
631+
* ratio survives and one ceiling governs both entry points.
632+
*
633+
* Dimensions come back even because the normalization encodes yuv420p, which
634+
* has no odd-sized frame.
635+
*/
636+
function clampProbedFrame(width: number, height: number): { width: number; height: number } {
637+
let w = clampProbedAxis(width)
638+
let h = clampProbedAxis(height)
639+
const area = w * h
640+
if (area > MAX_SCALE_PIXELS) {
641+
const ratio = Math.sqrt(MAX_SCALE_PIXELS / area)
642+
w = clampProbedAxis(w * ratio)
643+
h = clampProbedAxis(h * ratio)
644+
}
645+
return { width: w, height: h }
646+
}
647+
648+
function clampProbedAxis(value: number): number {
649+
const bounded = Math.min(Math.max(Math.round(value), MIN_SCALE_DIMENSION), MAX_SCALE_DIMENSION)
650+
return bounded - (bounded % 2)
628651
}
629652

630653
function resolveScaleDimension(value: number, label: string): number {

0 commit comments

Comments
 (0)