Files
smartffmpeg/ts/classes.ffmpegcommand.ts

891 lines
22 KiB
TypeScript
Raw Permalink Normal View History

2025-12-11 23:03:14 +00:00
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<Uint8Array>; // Web ReadableStream
/**
* Output destination for FfmpegCommand
*/
export type TFfmpegOutput =
| string // File path
| 'buffer' // Return as Buffer
| 'stream' // Return as ReadableStream
| WritableStream<Uint8Array>; // 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<Uint8Array>;
/** 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<IFfmpegResult> {
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<Buffer> {
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<Uint8Array> {
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<Uint8Array>;
}
/**
* Pipe output to a Web WritableStream
* @param writable - Web WritableStream
* @param format - Output format
*/
async pipe(writable: WritableStream<Uint8Array>, format?: interfaces.TOutputFormat): Promise<void> {
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<Uint8Array>;
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<void> {
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<Buffer> {
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<interfaces.IProgressInfo> = {};
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<number | undefined> {
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<string> {
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<Uint8Array> {
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<typeof plugins.child_process.spawn>): 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();
}
}
}