Skip to content

Commit 52323f6

Browse files
committed
fix(ffmpeg): guard concat output size, validate scale/volume, clarify ffprobe errors
- Route concat output through finalize() so it enforces the empty-file and 200 MB output-size checks like every other operation - Validate scale (width:height) and volume (multiplier/dB) against strict patterns before interpolating into the filter graph (prevents filter injection) - Default compress audio to -c:a copy so audio isn't silently re-encoded - Surface a clear 'ffprobe not found' message and document that Change Speed also requires ffprobe
1 parent 7ac0c43 commit 52323f6

2 files changed

Lines changed: 43 additions & 20 deletions

File tree

apps/docs/content/docs/en/tools/ffmpeg.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ Pick an **Operation**, supply the input file, and fill in the fields shown for t
3434
**Notes & limits.**
3535
- **Concatenate** uses FFmpeg's concat demuxer with stream copy, so all inputs must share the same codec, resolution, and format (typical for clips exported from one source). Mixed-codec inputs will fail.
3636
- **Change Speed** automatically retimes whichever streams exist — video via `setpts`, audio via `atempo` (chained for speeds beyond 0.5×–2×).
37-
- Input and output files are capped at 200 MB each.
38-
- Requires the FFmpeg binary on the server (bundled via `ffmpeg-static`; `ffprobe` from the system PATH is needed for **Get Media Info**).
37+
- Input and output files are capped at 200 MB each (this includes the joined output of **Concatenate**).
38+
- **Scale** and **Volume** accept only `width:height` dimensions and numeric/decibel values respectively — other characters are rejected.
39+
- Requires the FFmpeg binary on the server (bundled via `ffmpeg-static`). `ffprobe` from the system PATH is additionally needed for **Get Media Info** and **Change Speed** (which inspects streams before retiming).
3940
{/* MANUAL-CONTENT-END */}
4041

4142

apps/sim/app/api/tools/ffmpeg/process/route.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ function probeMedia(inputPath: string): Promise<ProbeResult> {
192192
return new Promise<ProbeResult>((resolve, reject) => {
193193
ffmpeg.ffprobe(inputPath, (err, metadata) => {
194194
if (err) {
195-
reject(new Error(`FFprobe error: ${err.message}`))
195+
const message = /cannot find ffprobe|ENOENT|not found/i.test(err.message)
196+
? 'ffprobe binary not found. Install it on the server (it ships with a full ffmpeg install: apk add ffmpeg / apt-get install ffmpeg / brew install ffmpeg).'
197+
: `FFprobe error: ${err.message}`
198+
reject(new Error(message))
196199
return
197200
}
198201
const videoStream = metadata.streams.find((s) => s.codec_type === 'video')
@@ -246,6 +249,18 @@ function normalizeScale(scale: string): string {
246249
return scale.trim().replace(/x/gi, ':')
247250
}
248251

252+
/**
253+
* Strict `width:height` form (each a positive integer or a negative auto value
254+
* like -1/-2). Rejects anything that could append extra filter stages.
255+
*/
256+
const SCALE_FILTER_PATTERN = /^-?\d{1,5}:-?\d{1,5}$/
257+
258+
/**
259+
* A linear multiplier (`1.5`, `0.5`) or a decibel value (`10dB`, `-6dB`).
260+
* Rejects commas, brackets, and any other filter-graph metacharacters.
261+
*/
262+
const VOLUME_FILTER_PATTERN = /^-?\d+(\.\d+)?(dB)?$/i
263+
249264
async function storeOutputFile(
250265
buffer: Buffer,
251266
fileName: string,
@@ -367,21 +382,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
367382
.output(outputPath)
368383
)
369384

370-
const outputBuffer = await fs.readFile(outputPath)
371385
const mimeType = getMimeForFormat(outExt)
372-
const fileName = `ffmpeg-concat-${Date.now()}.${outExt}`
373-
const file = await storeOutputFile(
374-
outputBuffer,
375-
fileName,
376-
mimeType,
377-
executionContext,
378-
authResult.userId
379-
)
380-
381-
return NextResponse.json({
382-
success: true,
383-
output: { file, fileName, format: outExt, size: outputBuffer.length },
384-
})
386+
return await finalize(outputPath, outExt, mimeType, executionContext, authResult.userId)
385387
}
386388

387389
// Single-input operations
@@ -451,12 +453,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
451453
}
452454

453455
if (operation === 'compress') {
456+
let scaleFilter: string | undefined
457+
if (body.scale) {
458+
scaleFilter = normalizeScale(body.scale)
459+
if (!SCALE_FILTER_PATTERN.test(scaleFilter)) {
460+
return NextResponse.json(
461+
{ error: 'Invalid scale. Use width:height with integers, e.g. 1280:720 or 1280:-2' },
462+
{ status: 400 }
463+
)
464+
}
465+
}
454466
const outputPath = path.join(tempDir, `output.${outExt}`)
455467
await runFfmpeg((cmd) => {
456-
let c = cmd.input(inputPath).videoCodec(body.videoCodec || 'libx264')
468+
let c = cmd
469+
.input(inputPath)
470+
.videoCodec(body.videoCodec || 'libx264')
471+
.audioCodec(body.audioCodec || 'copy')
457472
if (body.crf !== undefined) c = c.outputOptions(['-crf', String(body.crf)])
458473
if (body.videoBitrate) c = c.videoBitrate(body.videoBitrate)
459-
if (body.scale) c = c.videoFilters(`scale=${normalizeScale(body.scale)}`)
474+
if (scaleFilter) c = c.videoFilters(`scale=${scaleFilter}`)
460475
return c.toFormat(outExt).output(outputPath)
461476
})
462477
return await finalize(outputPath, outExt, mimeType, executionContext, authResult.userId)
@@ -480,10 +495,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
480495
{ status: 400 }
481496
)
482497
}
498+
const volume = body.volume.trim()
499+
if (!VOLUME_FILTER_PATTERN.test(volume)) {
500+
return NextResponse.json(
501+
{ error: 'Invalid volume. Use a multiplier (e.g. 1.5) or decibels (e.g. 10dB, -6dB)' },
502+
{ status: 400 }
503+
)
504+
}
483505
const outputPath = path.join(tempDir, `output.${outExt}`)
484506
const isVideo = isVideoExtension(inputExt)
485507
await runFfmpeg((cmd) => {
486-
let c = cmd.input(inputPath).audioFilters(`volume=${body.volume}`)
508+
let c = cmd.input(inputPath).audioFilters(`volume=${volume}`)
487509
if (isVideo) c = c.outputOptions(['-c:v', 'copy'])
488510
return c.toFormat(outExt).output(outputPath)
489511
})

0 commit comments

Comments
 (0)