11import { execFile , execSync } from 'node:child_process'
2+ import { existsSync } from 'node:fs'
23import fs from 'node:fs/promises'
34import os from 'node:os'
45import path from 'node:path'
@@ -31,13 +32,23 @@ function ensureFfmpeg(): void {
3132 }
3233}
3334
34- /** ffprobe ships alongside ffmpeg; fall back to PATH resolution. */
35+ /**
36+ * Mirrors fluent-ffmpeg's resolution order (FFPROBE_PATH, then PATH, then
37+ * ffmpeg's own directory) so replacing its ffprobe call does not narrow where
38+ * the binary may live for self-hosters.
39+ */
3540function resolveFfprobePath ( ) : string {
3641 ensureFfmpeg ( )
37- if ( ! ffmpegPath ) return 'ffprobe'
38- const dir = path . dirname ( ffmpegPath )
3942 const binary = process . platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'
40- return path . join ( dir , binary )
43+
44+ const configured = process . env . FFPROBE_PATH ?. trim ( )
45+ if ( configured && existsSync ( configured ) ) return configured
46+
47+ if ( ffmpegPath ) {
48+ const sibling = path . join ( path . dirname ( ffmpegPath ) , binary )
49+ if ( existsSync ( sibling ) ) return sibling
50+ }
51+ return binary
4152}
4253
4354/**
@@ -53,6 +64,10 @@ export const DEFAULT_FFMPEG_TIMEOUT_MS = 5 * 60 * 1000
5364const PROBE_TIMEOUT_MS = 15 * 1000
5465const PROBE_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
5566
67+ /** Names the actionable cause: the budget covers all clips, so fewer/shorter inputs is the fix. */
68+ const TIME_BUDGET_EXCEEDED =
69+ 'FFmpeg operation exceeded its time budget — try fewer, shorter, or lower-resolution inputs'
70+
5671export type FfmpegOperation =
5772 | 'overlay_audio'
5873 | 'mux'
@@ -110,7 +125,10 @@ export interface FfmpegResult {
110125export interface FfmpegRunOptions {
111126 /** Aborts and SIGKILLs every process spawned for the operation. */
112127 signal ?: AbortSignal
113- /** Wall-clock budget for the whole operation. Defaults to DEFAULT_FFMPEG_TIMEOUT_MS. */
128+ /**
129+ * Wall-clock budget for the whole operation. Defaults to, and is capped at,
130+ * DEFAULT_FFMPEG_TIMEOUT_MS — a caller may shorten the ceiling, never raise it.
131+ */
114132 timeoutMs ?: number
115133}
116134
@@ -155,12 +173,17 @@ const EXT_TO_MIME: Record<string, string> = {
155173 flac : 'audio/flac' ,
156174 aac : 'audio/aac' ,
157175 opus : 'audio/opus' ,
176+ weba : 'audio/webm' ,
158177 png : 'image/png' ,
159178 jpg : 'image/jpeg' ,
160179 jpeg : 'image/jpeg' ,
161180 gif : 'image/gif' ,
181+ webp : 'image/webp' ,
162182}
163183
184+ /** extract_audio can only name an audio container; the rest would silently produce nothing useful. */
185+ const AUDIO_EXTS = new Set ( [ 'mp3' , 'm4a' , 'wav' , 'ogg' , 'flac' , 'aac' , 'opus' , 'weba' ] )
186+
164187/**
165188 * Temp-file names are built as `${prefix}.${ext}` and joined against the temp
166189 * dir, so an extension carrying `/` or `..` escapes that dir once `path.join`
@@ -186,11 +209,12 @@ function mimeFromExt(ext: string): string {
186209}
187210
188211/** Only formats with a known muxer and a safe file name may name an output. */
189- function resolveOutputExt ( format : string ) : string {
190- const ext = format . trim ( ) . toLowerCase ( )
191- if ( ! isSafeExt ( ext ) || ! EXT_TO_MIME [ ext ] ) {
212+ function resolveOutputExt ( format : string , allowed ?: Set < string > ) : string {
213+ const ext = String ( format ) . trim ( ) . toLowerCase ( )
214+ const supported = allowed ?? new Set ( Object . keys ( EXT_TO_MIME ) )
215+ if ( ! isSafeExt ( ext ) || ! EXT_TO_MIME [ ext ] || ! supported . has ( ext ) ) {
192216 throw new Error (
193- `Unsupported output format "${ format } ". Supported: ${ Object . keys ( EXT_TO_MIME ) . join ( ', ' ) } `
217+ `Unsupported output format "${ format } ". Supported: ${ [ ... supported ] . join ( ', ' ) } `
194218 )
195219 }
196220 return ext
@@ -207,8 +231,8 @@ function resolveScaleDimension(value: number, label: 'width' | 'height'): number
207231}
208232
209233function clampProbedDimension ( value : number | undefined , fallback : number ) : number {
210- if ( ! Number . isInteger ( value ) ) return fallback
211- return Math . min ( Math . max ( value as number , MIN_SCALE_DIMENSION ) , MAX_SCALE_DIMENSION )
234+ if ( ! Number . isInteger ( value ) || ( value as number ) < MIN_SCALE_DIMENSION ) return fallback
235+ return Math . min ( value as number , MAX_SCALE_DIMENSION )
212236}
213237
214238function resolveNonNegativeSeconds ( value : number , label : string ) : number {
@@ -229,21 +253,34 @@ function resolveVolume(value: number, label: string): number {
229253 * Every caller-supplied bound is checked before a temp dir is created or a
230254 * binary is resolved, so a rejected request costs nothing and the error is the
231255 * validation failure rather than a missing-FFmpeg message.
256+ *
257+ * Only the options an operation actually consumes are validated. An LLM caller
258+ * routinely emits surplus parameters, and failing the whole call over a value
259+ * the operation ignores would be a regression, not a safeguard.
232260 */
233261function assertOptionsWithinBounds ( operation : FfmpegOperation , options : FfmpegOptions ) : void {
234- if ( options . start !== undefined ) resolveNonNegativeSeconds ( options . start , 'start' )
235- if ( options . end !== undefined ) resolveNonNegativeSeconds ( options . end , 'end' )
236- if ( options . volume !== undefined ) resolveVolume ( options . volume , 'volume' )
237- if ( options . musicVolume !== undefined ) resolveVolume ( options . musicVolume , 'musicVolume' )
238-
262+ if ( operation === 'trim' || operation === 'thumbnail' ) {
263+ if ( options . start !== undefined ) resolveNonNegativeSeconds ( options . start , 'start' )
264+ }
265+ if ( operation === 'trim' && options . end !== undefined ) {
266+ const end = resolveNonNegativeSeconds ( options . end , 'end' )
267+ const start = resolveNonNegativeSeconds ( options . start ?? 0 , 'start' )
268+ if ( end < start ) {
269+ throw new Error ( `end (${ end } s) must be greater than or equal to start (${ start } s)` )
270+ }
271+ }
272+ if ( operation === 'mix_audio' ) {
273+ if ( options . volume !== undefined ) resolveVolume ( options . volume , 'volume' )
274+ if ( options . musicVolume !== undefined ) resolveVolume ( options . musicVolume , 'musicVolume' )
275+ }
239276 if ( operation === 'convert' ) {
240277 if ( ! options . format ) throw new Error ( 'convert requires a target format' )
241278 resolveOutputExt ( options . format )
242279 }
243280 if ( operation === 'extract_audio' ) {
244- resolveOutputExt ( options . format || 'mp3' )
281+ resolveOutputExt ( options . format || 'mp3' , AUDIO_EXTS )
245282 }
246- if ( operation === 'scale_pad' && options . width && options . height ) {
283+ if ( operation === 'scale_pad' && options . width !== undefined && options . height !== undefined ) {
247284 resolveScaleDimension ( options . width , 'width' )
248285 resolveScaleDimension ( options . height , 'height' )
249286 }
@@ -308,7 +345,7 @@ class OperationBudget {
308345 throw new Error ( 'FFmpeg operation aborted' )
309346 }
310347 if ( this . remainingMs ( ) <= 0 ) {
311- throw new Error ( 'FFmpeg operation exceeded its time budget' )
348+ throw new Error ( TIME_BUDGET_EXCEEDED )
312349 }
313350 }
314351}
@@ -372,17 +409,31 @@ function runCommand(
372409 }
373410 settle ( new Error ( reason ) )
374411 }
412+ /**
413+ * fluent-ffmpeg's kill() is a silent no-op until the child exists, and
414+ * `.save()` spawns asynchronously (it may shell out for capability checks
415+ * first). A kill landing in that window would otherwise reject the promise
416+ * while the encode goes on to spawn orphaned and unkillable — so re-issue
417+ * it once the process is up. 'start' fires immediately after the spawn.
418+ */
419+ function onStart ( ) : void {
420+ if ( settled ) {
421+ try {
422+ command . kill ( 'SIGKILL' )
423+ } catch {
424+ // Nothing to signal; the promise has already settled.
425+ }
426+ }
427+ }
375428 function onAbort ( ) : void {
376429 kill ( 'FFmpeg operation aborted' )
377430 }
378431
379- const timer = setTimeout (
380- ( ) => kill ( 'FFmpeg operation exceeded its time budget' ) ,
381- budget . remainingMs ( )
382- )
432+ const timer = setTimeout ( ( ) => kill ( TIME_BUDGET_EXCEEDED ) , budget . remainingMs ( ) )
383433 budget . signal ?. addEventListener ( 'abort' , onAbort , { once : true } )
384434
385435 command
436+ . on ( 'start' , onStart )
386437 . on ( 'end' , ( ) => settle ( ) )
387438 . on ( 'error' , ( err ) => settle ( new Error ( `FFmpeg error: ${ err . message } ` ) ) )
388439 . save ( outputPath )
@@ -410,6 +461,25 @@ interface FfprobeOutput {
410461 } >
411462}
412463
464+ /**
465+ * Distinguishes the three ways a probe fails. Node's `execFile` error message
466+ * is `Command failed: <full argv>` plus stderr, which both leaks the server's
467+ * binary and temp paths to the caller and — once stderr is quiet — renders a
468+ * timeout, a corrupt file, and a missing file byte-identical.
469+ */
470+ function describeProbeFailure ( err : Error & { killed ?: boolean ; code ?: unknown } , stderr : string ) {
471+ if ( err . code === 'ABORT_ERR' ) return 'aborted'
472+ if ( err . killed ) return 'timed out'
473+ const detail = stderr . trim ( ) . split ( '\n' ) . pop ( )
474+ if ( ! detail ) return 'unreadable media'
475+ // ffprobe prefixes its diagnostic with the input path; keep the diagnostic,
476+ // drop the server's directory layout.
477+ return detail . replace (
478+ / ( ^ | \s ) ( \/ \S + ) / g,
479+ ( _match , lead : string , abs : string ) => `${ lead } ${ path . basename ( abs ) } `
480+ )
481+ }
482+
413483/**
414484 * Spawned directly rather than through `fluent-ffmpeg.ffprobe`, which gives no
415485 * handle on the child and so cannot be killed: a crafted input that wedges
@@ -421,16 +491,16 @@ function probeFile(filePath: string, budget: OperationBudget): Promise<MediaProb
421491 return new Promise ( ( resolve , reject ) => {
422492 execFile (
423493 resolveFfprobePath ( ) ,
424- [ '-v' , 'quiet ' , '-print_format' , 'json' , '-show_format' , '-show_streams' , '-i' , filePath ] ,
494+ [ '-v' , 'error ' , '-print_format' , 'json' , '-show_format' , '-show_streams' , '-i' , filePath ] ,
425495 {
426496 timeout,
427497 killSignal : 'SIGKILL' ,
428498 maxBuffer : PROBE_MAX_OUTPUT_BYTES ,
429499 signal : budget . signal ,
430500 } ,
431- ( err , stdout ) => {
501+ ( err , stdout , stderr ) => {
432502 if ( err ) {
433- reject ( new Error ( `FFprobe error: ${ err . message } ` ) )
503+ reject ( new Error ( `FFprobe error: ${ describeProbeFailure ( err , stderr ) } ` ) )
434504 return
435505 }
436506 let metadata : FfprobeOutput
@@ -483,7 +553,11 @@ export async function runFfmpegOperation(
483553 budget . assertLive ( )
484554
485555 if ( operation === 'probe' ) {
486- return { probe : await probeMedia ( inputs [ 0 ] , runOptions ) }
556+ return {
557+ probe : await withTempDir ( budget , async ( { dir } ) =>
558+ probeFile ( await writeInput ( dir , inputs [ 0 ] , 0 ) , budget )
559+ ) ,
560+ }
487561 }
488562
489563 return withTempDir ( budget , async ( ctx ) => {
0 commit comments