import * as plugins from './plugins.js'; import type * as interfaces from './interfaces.js'; import { Readable, Writable } from 'stream'; /** * Input source for FfmpegCommand * Supports file paths, memory buffers, and Web Streams */ export type TFfmpegInput = | string // File path | Buffer // Memory buffer | Uint8Array // Web-compatible binary | ReadableStream; // Web ReadableStream /** * Output destination for FfmpegCommand */ export type TFfmpegOutput = | string // File path | 'buffer' // Return as Buffer | 'stream' // Return as ReadableStream | WritableStream; // Pipe to Web WritableStream /** * Input options when using Buffer or Stream */ export interface IInputOptions { /** Input format (required for buffer/stream input) */ format?: interfaces.TOutputFormat; /** Input duration hint for progress calculation */ duration?: number; } /** * Result of running an FfmpegCommand */ export interface IFfmpegResult { /** Output buffer (when outputting to buffer) */ buffer?: Buffer; /** Output stream (when outputting to stream) */ stream?: ReadableStream; /** Path to output file (when outputting to file) */ outputPath?: string; /** Duration of processing in ms */ processingTime: number; } /** * Progress callback type for FfmpegCommand */ export type TFfmpegProgressCallback = (progress: interfaces.IProgressInfo) => void; /** * Modern fluent builder API for ffmpeg commands * * @example * ```typescript * // File to file * await ffmpeg.create() * .input('/path/to/input.mp4') * .videoCodec('libx264') * .audioBitrate('128k') * .output('/path/to/output.mp4') * .run(); * * // Buffer to buffer * const outputBuffer = await ffmpeg.create() * .input(inputBuffer, { format: 'mp4' }) * .videoCodec('libx264') * .toBuffer('webm'); * * // With progress * await ffmpeg.create() * .input('/path/to/input.mp4') * .videoCodec('libx264') * .onProgress(p => console.log(`${p.percent}%`)) * .output('/path/to/output.mp4') * .run(); * ``` */ export class FfmpegCommand { private ffmpegPath: string; private ffprobePath: string; // Input configuration private inputSource: TFfmpegInput | null = null; private inputOpts: IInputOptions = {}; private customInputArgs: string[] = []; // Output configuration private outputDest: TFfmpegOutput | null = null; private outputFormat: interfaces.TOutputFormat | null = null; private customOutputArgs: string[] = []; // Video settings private _videoCodec: interfaces.TVideoCodec | null = null; private _videoBitrate: string | null = null; private _width: number | null = null; private _height: number | null = null; private _fps: number | null = null; private _crf: number | null = null; private _preset: interfaces.TPreset | null = null; private _noVideo = false; // Audio settings private _audioCodec: interfaces.TAudioCodec | null = null; private _audioBitrate: string | null = null; private _sampleRate: number | null = null; private _audioChannels: number | null = null; private _noAudio = false; // Timing private _startTime: number | string | null = null; private _duration: number | string | null = null; // Video filters private videoFilters: string[] = []; // Audio filters private audioFilters: string[] = []; // Complex filter private _complexFilter: string | null = null; // Extra arguments private extraInputArgs: string[] = []; private extraOutputArgs: string[] = []; // Callbacks private progressCallback: TFfmpegProgressCallback | null = null; // Options private _overwrite = true; constructor(ffmpegPath: string, ffprobePath: string) { this.ffmpegPath = ffmpegPath; this.ffprobePath = ffprobePath; } // ==================== INPUT METHODS ==================== /** * Set the input source * @param source - File path, Buffer, or Readable stream * @param options - Input options (format required for buffer/stream) */ input(source: TFfmpegInput, options: IInputOptions = {}): this { this.inputSource = source; this.inputOpts = options; return this; } /** * Seek to position before input (fast seek) * @param time - Time in seconds or timecode string */ seekInput(time: number | string): this { this._startTime = time; return this; } /** * Add custom input arguments */ inputArgs(...args: string[]): this { this.extraInputArgs.push(...args); return this; } // ==================== VIDEO METHODS ==================== /** * Set video codec */ videoCodec(codec: interfaces.TVideoCodec): this { this._videoCodec = codec; return this; } /** * Set video bitrate * @param bitrate - e.g., '1M', '2000k' */ videoBitrate(bitrate: string): this { this._videoBitrate = bitrate; return this; } /** * Set output dimensions * @param width - Width in pixels (-1 for auto) * @param height - Height in pixels (-1 for auto) */ size(width: number, height?: number): this { this._width = width; this._height = height ?? null; return this; } /** * Set frame rate */ fps(rate: number): this { this._fps = rate; return this; } /** * Set Constant Rate Factor (quality) * @param value - 0-51, lower is better quality */ crf(value: number): this { this._crf = value; return this; } /** * Set encoding preset */ preset(value: interfaces.TPreset): this { this._preset = value; return this; } /** * Remove video stream */ noVideo(): this { this._noVideo = true; return this; } /** * Add a video filter * @param filter - Filter string (e.g., 'scale=1920:1080', 'hflip') */ videoFilter(filter: string): this { this.videoFilters.push(filter); return this; } /** * Scale video * @param width - Width (-1 or -2 for auto) * @param height - Height (-1 or -2 for auto) */ scale(width: number | string, height: number | string): this { // Use -2 to ensure even dimensions const w = width === -1 ? '-2' : String(width); const h = height === -1 ? '-2' : String(height); this.videoFilters.push(`scale=${w}:${h}`); return this; } /** * Crop video */ crop(width: number, height: number, x = 0, y = 0): this { this.videoFilters.push(`crop=${width}:${height}:${x}:${y}`); return this; } /** * Rotate video * @param angle - Rotation in radians or 'PI/2', 'PI', etc. */ rotate(angle: number | string): this { this.videoFilters.push(`rotate=${angle}`); return this; } /** * Flip video horizontally */ flipHorizontal(): this { this.videoFilters.push('hflip'); return this; } /** * Flip video vertically */ flipVertical(): this { this.videoFilters.push('vflip'); return this; } /** * Add padding to video */ pad(width: number, height: number, x = 0, y = 0, color = 'black'): this { this.videoFilters.push(`pad=${width}:${height}:${x}:${y}:${color}`); return this; } // ==================== AUDIO METHODS ==================== /** * Set audio codec */ audioCodec(codec: interfaces.TAudioCodec): this { this._audioCodec = codec; return this; } /** * Set audio bitrate * @param bitrate - e.g., '128k', '320k' */ audioBitrate(bitrate: string): this { this._audioBitrate = bitrate; return this; } /** * Set audio sample rate * @param rate - Sample rate in Hz (e.g., 44100, 48000) */ sampleRate(rate: number): this { this._sampleRate = rate; return this; } /** * Set number of audio channels * @param channels - 1 for mono, 2 for stereo */ audioChannels(channels: number): this { this._audioChannels = channels; return this; } /** * Remove audio stream */ noAudio(): this { this._noAudio = true; return this; } /** * Add an audio filter * @param filter - Filter string (e.g., 'volume=2', 'aecho=0.8:0.88:60:0.4') */ audioFilter(filter: string): this { this.audioFilters.push(filter); return this; } /** * Set audio volume * @param level - Volume multiplier (e.g., 2 for double, 0.5 for half) */ volume(level: number): this { this.audioFilters.push(`volume=${level}`); return this; } /** * Normalize audio */ normalize(): this { this.audioFilters.push('loudnorm'); return this; } // ==================== TIMING METHODS ==================== /** * Set start time (seek) * @param time - Time in seconds or timecode string */ seek(time: number | string): this { this._startTime = time; return this; } /** * Set output duration * @param time - Duration in seconds or timecode string */ duration(time: number | string): this { this._duration = time; return this; } /** * Set both start and end time */ trim(start: number | string, end: number | string): this { this._startTime = start; // Calculate duration from end - start if (typeof start === 'number' && typeof end === 'number') { this._duration = end - start; } else { // For string timestamps, set end time as duration (approximate) this._duration = end; } return this; } // ==================== FILTER METHODS ==================== /** * Set a complex filter graph * @param filterGraph - Complex filter string */ complexFilter(filterGraph: string): this { this._complexFilter = filterGraph; return this; } // ==================== OUTPUT METHODS ==================== /** * Set output format */ format(fmt: interfaces.TOutputFormat): this { this.outputFormat = fmt; return this; } /** * Set output destination (file path) */ output(dest: string): this { this.outputDest = dest; return this; } /** * Add custom output arguments */ outputArgs(...args: string[]): this { this.extraOutputArgs.push(...args); return this; } /** * Set overwrite flag */ overwrite(value = true): this { this._overwrite = value; return this; } // ==================== CALLBACK METHODS ==================== /** * Set progress callback */ onProgress(callback: TFfmpegProgressCallback): this { this.progressCallback = callback; return this; } // ==================== EXECUTION METHODS ==================== /** * Run the command and output to file */ async run(): Promise { if (!this.outputDest || typeof this.outputDest !== 'string') { throw new Error('Output path must be set. Use .output() or .toBuffer()/.toStream()'); } const startTime = Date.now(); const args = this.buildArgs(this.outputDest); await this.execute(args); return { outputPath: this.outputDest, processingTime: Date.now() - startTime, }; } /** * Run the command and return output as Buffer * @param format - Output format */ async toBuffer(format?: interfaces.TOutputFormat): Promise { if (format) { this.outputFormat = format; } if (!this.outputFormat) { throw new Error('Output format must be specified when outputting to buffer. Use .format() or pass format to .toBuffer()'); } const args = this.buildArgs('pipe:1'); return this.executeToBuffer(args); } /** * Run the command and return output as Web ReadableStream * @param format - Output format */ toStream(format?: interfaces.TOutputFormat): ReadableStream { if (format) { this.outputFormat = format; } if (!this.outputFormat) { throw new Error('Output format must be specified when outputting to stream. Use .format() or pass format to .toStream()'); } const args = this.buildArgs('pipe:1'); const nodeStream = this.executeToNodeStream(args); // Convert Node.js Readable to Web ReadableStream return Readable.toWeb(nodeStream) as ReadableStream; } /** * Pipe output to a Web WritableStream * @param writable - Web WritableStream * @param format - Output format */ async pipe(writable: WritableStream, format?: interfaces.TOutputFormat): Promise { if (format) { this.outputFormat = format; } if (!this.outputFormat) { throw new Error('Output format must be specified when piping. Use .format() or pass format to .pipe()'); } const args = this.buildArgs('pipe:1'); const nodeReadable = this.executeToNodeStream(args); // Convert Node.js Readable to Web ReadableStream and pipe to WritableStream const webReadable = Readable.toWeb(nodeReadable) as ReadableStream; await webReadable.pipeTo(writable); } /** * Get the ffmpeg arguments that would be used (for debugging) */ getArgs(outputPath = 'output'): string[] { return this.buildArgs(outputPath); } // ==================== PRIVATE METHODS ==================== private buildArgs(outputPath: string): string[] { const args: string[] = []; // Overwrite flag if (this._overwrite) { args.push('-y'); } // Seek before input (fast seek) if (this._startTime !== null) { args.push('-ss', this.formatTime(this._startTime)); } // Extra input args args.push(...this.extraInputArgs); // Input format (for buffer/stream input) if (this.inputOpts.format && this.isMemoryInput()) { args.push('-f', this.inputOpts.format); } // Input source if (typeof this.inputSource === 'string') { args.push('-i', this.inputSource); } else { // Buffer or stream - read from stdin args.push('-i', 'pipe:0'); } // Duration (after input) if (this._duration !== null) { args.push('-t', this.formatTime(this._duration)); } // Video options if (this._noVideo) { args.push('-vn'); } else { if (this._videoCodec) { args.push('-c:v', this._videoCodec); } if (this._videoBitrate) { args.push('-b:v', this._videoBitrate); } if (this._crf !== null) { args.push('-crf', String(this._crf)); } if (this._preset) { args.push('-preset', this._preset); } if (this._fps) { args.push('-r', String(this._fps)); } // Size (via scale filter or direct) if (this._width !== null || this._height !== null) { const w = this._width !== null ? String(this._width) : '-2'; const h = this._height !== null ? String(this._height) : '-2'; this.videoFilters.unshift(`scale=${w}:${h}`); } } // Audio options if (this._noAudio) { args.push('-an'); } else { if (this._audioCodec) { args.push('-c:a', this._audioCodec); } if (this._audioBitrate) { args.push('-b:a', this._audioBitrate); } if (this._sampleRate) { args.push('-ar', String(this._sampleRate)); } if (this._audioChannels) { args.push('-ac', String(this._audioChannels)); } } // Complex filter if (this._complexFilter) { args.push('-filter_complex', this._complexFilter); } else { // Video filters if (this.videoFilters.length > 0) { args.push('-vf', this.videoFilters.join(',')); } // Audio filters if (this.audioFilters.length > 0) { args.push('-af', this.audioFilters.join(',')); } } // Output format if (this.outputFormat) { args.push('-f', this.outputFormat); } // Extra output args args.push(...this.extraOutputArgs); // Output args.push(outputPath); return args; } private async execute(args: string[]): Promise { const inputDuration = await this.getInputDuration(); // Add progress output if callback is set if (this.progressCallback) { args.unshift('-progress', 'pipe:2'); } return new Promise((resolve, reject) => { const proc = plugins.child_process.spawn(this.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], }); // Handle input this.pipeInputToProcess(proc); // Handle progress if (this.progressCallback && proc.stderr) { this.parseProgress(proc.stderr, inputDuration); } let stderr = ''; proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); proc.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`FFmpeg exited with code ${code}: ${stderr}`)); } }); proc.on('error', reject); }); } private executeToBuffer(args: string[]): Promise { const inputDuration = this.inputOpts.duration; return new Promise((resolve, reject) => { const proc = plugins.child_process.spawn(this.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], }); // Handle input this.pipeInputToProcess(proc); const chunks: Buffer[] = []; proc.stdout?.on('data', (chunk: Buffer) => { chunks.push(chunk); }); // Handle progress on stderr if (this.progressCallback && proc.stderr) { this.parseProgress(proc.stderr, inputDuration); } let stderr = ''; proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); proc.on('close', (code) => { if (code === 0) { resolve(Buffer.concat(chunks)); } else { reject(new Error(`FFmpeg exited with code ${code}: ${stderr}`)); } }); proc.on('error', reject); }); } private executeToNodeStream(args: string[]): Readable { const proc = plugins.child_process.spawn(this.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], }); // Handle input this.pipeInputToProcess(proc); // Handle progress on stderr if (this.progressCallback && proc.stderr) { this.parseProgress(proc.stderr, this.inputOpts.duration); } proc.on('error', (err) => { proc.stdout?.destroy(err); }); return proc.stdout!; } private parseProgress(stderr: Readable, duration?: number): void { let progressData: Partial = {}; stderr.on('data', (data: Buffer) => { const lines = data.toString().split('\n'); for (const line of lines) { const [key, value] = line.split('='); if (!key || !value) continue; switch (key.trim()) { case 'frame': progressData.frame = parseInt(value, 10); break; case 'fps': progressData.fps = parseFloat(value); break; case 'total_size': progressData.size = parseInt(value, 10); break; case 'out_time_ms': progressData.time = parseInt(value, 10) / 1000000; if (duration && progressData.time) { progressData.percent = Math.min(100, (progressData.time / duration) * 100); } break; case 'bitrate': progressData.bitrate = value.trim(); break; case 'speed': progressData.speed = value.trim(); break; case 'progress': if ((value.trim() === 'continue' || value.trim() === 'end') && this.progressCallback && progressData.frame !== undefined) { this.progressCallback(progressData as interfaces.IProgressInfo); } break; } } }); } private async getInputDuration(): Promise { if (this.inputOpts.duration) { return this.inputOpts.duration; } if (typeof this.inputSource !== 'string') { return undefined; } try { const result = await this.runProbe([ '-v', 'quiet', '-print_format', 'json', '-show_format', this.inputSource, ]); const data = JSON.parse(result); return parseFloat(data.format?.duration) || undefined; } catch { return undefined; } } private runProbe(args: string[]): Promise { return new Promise((resolve, reject) => { const proc = plugins.child_process.spawn(this.ffprobePath, args); let stdout = ''; let stderr = ''; proc.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); }); proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); proc.on('close', (code) => { if (code === 0) { resolve(stdout); } else { reject(new Error(`FFprobe exited with code ${code}: ${stderr}`)); } }); proc.on('error', reject); }); } private formatTime(time: number | string): string { if (typeof time === 'string') { return time; } const hours = Math.floor(time / 3600); const minutes = Math.floor((time % 3600) / 60); const seconds = time % 60; return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toFixed(3).padStart(6, '0')}`; } private isNodeStream(value: unknown): value is Readable { return value !== null && typeof value === 'object' && typeof (value as any).pipe === 'function' && !(value instanceof ReadableStream); } private isWebReadableStream(value: unknown): value is ReadableStream { return value instanceof ReadableStream; } private isMemoryInput(): boolean { return Buffer.isBuffer(this.inputSource) || this.inputSource instanceof Uint8Array || this.isNodeStream(this.inputSource) || this.isWebReadableStream(this.inputSource); } private pipeInputToProcess(proc: ReturnType): void { if (Buffer.isBuffer(this.inputSource)) { proc.stdin?.write(this.inputSource); proc.stdin?.end(); } else if (this.inputSource instanceof Uint8Array) { // Convert Uint8Array to Buffer proc.stdin?.write(Buffer.from(this.inputSource)); proc.stdin?.end(); } else if (this.isWebReadableStream(this.inputSource)) { // Convert Web ReadableStream to Node.js Readable and pipe // Cast to any to handle type mismatch between global ReadableStream and node:stream/web types const nodeReadable = Readable.fromWeb(this.inputSource as any); nodeReadable.pipe(proc.stdin!); } else if (this.isNodeStream(this.inputSource)) { (this.inputSource as Readable).pipe(proc.stdin!); } else if (typeof this.inputSource === 'string') { proc.stdin?.end(); } } }