import * as plugins from './plugins.js'; import type * as interfaces from './interfaces.js'; import { FfmpegCommand, type TFfmpegInput, type IInputOptions } from './classes.ffmpegcommand.js'; /** * Event callback types */ export type TProgressCallback = (progress: interfaces.IProgressInfo) => void; export type TErrorCallback = (error: Error) => void; export type TEndCallback = () => void; /** * SmartFfmpeg - A fast Node.js module for media file conversion using ffmpeg * * @example Modern Builder API * ```typescript * const ffmpeg = new SmartFfmpeg(); * * // File to file with fluent API * await ffmpeg.create() * .input('/path/to/input.mp4') * .videoCodec('libx264') * .audioBitrate('128k') * .size(1920, 1080) * .output('/path/to/output.mp4') * .run(); * * // Buffer to buffer * const outputBuffer = await ffmpeg.create() * .input(inputBuffer, { format: 'mp4' }) * .videoCodec('libx264') * .toBuffer('webm'); * * // With progress tracking * await ffmpeg.create() * .input('/path/to/input.mp4') * .videoCodec('libx264') * .onProgress(p => console.log(`${p.percent?.toFixed(1)}%`)) * .output('/path/to/output.mp4') * .run(); * * // Stream output * const stream = ffmpeg.create() * .input('/path/to/input.mp4') * .videoCodec('libx264') * .toStream('mp4'); * ``` * * @example Legacy API (still supported) * ```typescript * await ffmpeg.convert('input.mp4', 'output.webm', { * videoCodec: 'libvpx-vp9', * audioBitrate: '128k' * }); * ``` */ export class SmartFfmpeg { private ffmpegPath: string; private ffprobePath: string; constructor() { this.ffmpegPath = plugins.ffmpegBinaryPath; this.ffprobePath = plugins.ffprobeBinaryPath; } // ==================== BUILDER API ==================== /** * Create a new FfmpegCommand builder for fluent API usage * * @example * ```typescript * await ffmpeg.create() * .input('/path/to/input.mp4') * .videoCodec('libx264') * .crf(23) * .output('/path/to/output.mp4') * .run(); * ``` */ create(): FfmpegCommand { return new FfmpegCommand(this.ffmpegPath, this.ffprobePath); } /** * Shorthand to create a command with input already set * * @example * ```typescript * // File input * await ffmpeg.input('/path/to/input.mp4') * .videoCodec('libx264') * .output('/path/to/output.mp4') * .run(); * * // Buffer input * const output = await ffmpeg.input(buffer, { format: 'mp4' }) * .videoCodec('libx264') * .toBuffer('webm'); * ``` */ input(source: TFfmpegInput, options?: IInputOptions): FfmpegCommand { return this.create().input(source, options); } // ==================== LEGACY API ==================== /** * Get media file information using ffprobe */ public async getMediaInfo(inputPath: string): Promise { const args = [ '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', inputPath ]; const result = await this.runProcess(this.ffprobePath, args); const data = JSON.parse(result.stdout); return { format: { filename: data.format.filename, formatName: data.format.format_name, formatLongName: data.format.format_long_name, duration: parseFloat(data.format.duration) || 0, size: parseInt(data.format.size, 10) || 0, bitrate: parseInt(data.format.bit_rate, 10) || 0, }, streams: (data.streams || []).map((stream: any) => this.parseStreamInfo(stream)), }; } /** * Convert media file with specified options */ public async convert( inputPath: string, outputPath: string, options: interfaces.IConversionOptions = {} ): Promise { const args = this.buildConversionArgs(inputPath, outputPath, options); await this.runProcess(this.ffmpegPath, args); } /** * Convert media file with progress reporting */ public async convertWithProgress( inputPath: string, outputPath: string, options: interfaces.IConversionOptions = {}, onProgress?: TProgressCallback ): Promise { // Get duration for progress percentage calculation let totalDuration: number | undefined; try { const info = await this.getMediaInfo(inputPath); totalDuration = info.format.duration; } catch { // Continue without duration info } const args = ['-progress', 'pipe:1', ...this.buildConversionArgs(inputPath, outputPath, options)]; return new Promise((resolve, reject) => { const process = plugins.child_process.spawn(this.ffmpegPath, args); let progressData: Partial = {}; process.stdout?.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 (totalDuration && progressData.time) { progressData.percent = Math.min(100, (progressData.time / totalDuration) * 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') { if (onProgress && progressData.frame !== undefined) { onProgress(progressData as interfaces.IProgressInfo); } } break; } } }); let stderr = ''; process.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); process.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`FFmpeg exited with code ${code}: ${stderr}`)); } }); process.on('error', (err) => { reject(err); }); }); } /** * Extract audio from video file */ public async extractAudio( inputPath: string, outputPath: string, options: Pick = {} ): Promise { const conversionOptions: interfaces.IConversionOptions = { ...options, noVideo: true, }; await this.convert(inputPath, outputPath, conversionOptions); } /** * Remove audio from video file */ public async removeAudio( inputPath: string, outputPath: string, options: Pick = {} ): Promise { const conversionOptions: interfaces.IConversionOptions = { ...options, noAudio: true, }; await this.convert(inputPath, outputPath, conversionOptions); } /** * Take a screenshot at a specific time */ public async screenshot( inputPath: string, outputPath: string, options: interfaces.IScreenshotOptions ): Promise { const args: string[] = [ '-y', '-ss', this.formatTime(options.time), '-i', inputPath, '-vframes', '1', ]; if (options.width || options.height) { const scale = this.buildScaleFilter(options.width, options.height); args.push('-vf', scale); } if (options.format === 'jpg' && options.quality) { args.push('-q:v', String(Math.round((100 - options.quality) / 100 * 31))); } else if (options.format === 'webp' && options.quality) { args.push('-quality', String(options.quality)); } args.push(outputPath); await this.runProcess(this.ffmpegPath, args); } /** * Generate multiple thumbnails from video */ public async generateThumbnails( inputPath: string, outputDir: string, options: interfaces.IThumbnailOptions ): Promise { const info = await this.getMediaInfo(inputPath); const duration = info.format.duration; const interval = duration / (options.count + 1); const pattern = options.filenamePattern || 'thumb_%d'; const ext = options.format || 'png'; const outputPaths: string[] = []; for (let i = 1; i <= options.count; i++) { const time = interval * i; const filename = pattern.replace('%d', String(i).padStart(3, '0')) + '.' + ext; const outputPath = plugins.path.join(outputDir, filename); await this.screenshot(inputPath, outputPath, { time, width: options.width, height: options.height, format: options.format, }); outputPaths.push(outputPath); } return outputPaths; } /** * Resize video */ public async resize( inputPath: string, outputPath: string, width?: number, height?: number, options: Omit = {} ): Promise { await this.convert(inputPath, outputPath, { ...options, width, height }); } /** * Change video frame rate */ public async changeFrameRate( inputPath: string, outputPath: string, fps: number, options: Omit = {} ): Promise { await this.convert(inputPath, outputPath, { ...options, fps }); } /** * Trim media file */ public async trim( inputPath: string, outputPath: string, startTime: number | string, duration: number | string, options: Omit = {} ): Promise { await this.convert(inputPath, outputPath, { ...options, startTime, duration }); } /** * Convert to GIF */ public async toGif( inputPath: string, outputPath: string, options: { width?: number; height?: number; fps?: number; startTime?: number | string; duration?: number | string; } = {} ): Promise { const args: string[] = ['-y']; if (options.startTime !== undefined) { args.push('-ss', this.formatTime(options.startTime)); } args.push('-i', inputPath); if (options.duration !== undefined) { args.push('-t', this.formatTime(options.duration)); } // Build filter for GIF optimization const filters: string[] = []; if (options.fps) { filters.push(`fps=${options.fps}`); } if (options.width || options.height) { filters.push(this.buildScaleFilter(options.width, options.height)); } // Split filter for palette generation (better GIF quality) filters.push('split[s0][s1]'); filters.push('[s0]palettegen[p]'); filters.push('[s1][p]paletteuse'); args.push('-filter_complex', filters.join(',')); args.push(outputPath); await this.runProcess(this.ffmpegPath, args); } /** * Concatenate multiple media files */ public async concat( inputPaths: string[], outputPath: string, options: interfaces.IConversionOptions = {} ): Promise { // Create concat file content const concatContent = inputPaths .map(p => `file '${p.replace(/'/g, "'\\''")}'`) .join('\n'); // Write to temp file const tempFile = plugins.path.join( plugins.path.dirname(outputPath), `.concat_${Date.now()}.txt` ); // Write concat file using SmartFs const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode()); await fs.file(tempFile).write(concatContent); try { const args: string[] = [ '-y', '-f', 'concat', '-safe', '0', '-i', tempFile, ]; if (options.videoCodec) { args.push('-c:v', options.videoCodec); } else { args.push('-c', 'copy'); } if (options.audioCodec) { args.push('-c:a', options.audioCodec); } args.push(outputPath); await this.runProcess(this.ffmpegPath, args); } finally { // Clean up temp file await fs.file(tempFile).delete(); } } /** * Add audio to video */ public async addAudio( videoPath: string, audioPath: string, outputPath: string, options: { videoCodec?: interfaces.TVideoCodec; audioCodec?: interfaces.TAudioCodec; audioBitrate?: string; shortest?: boolean; overwrite?: boolean; } = {} ): Promise { const args: string[] = [ '-y', '-i', videoPath, '-i', audioPath, '-c:v', options.videoCodec || 'copy', '-c:a', options.audioCodec || 'aac', ]; if (options.audioBitrate) { args.push('-b:a', options.audioBitrate); } if (options.shortest) { args.push('-shortest'); } args.push('-map', '0:v:0', '-map', '1:a:0'); args.push(outputPath); await this.runProcess(this.ffmpegPath, args); } /** * Get available encoders */ public async getEncoders(): Promise { const result = await this.runProcess(this.ffmpegPath, ['-encoders', '-hide_banner']); const lines = result.stdout.split('\n'); const encoders: string[] = []; let started = false; for (const line of lines) { if (line.includes('------')) { started = true; continue; } if (started && line.trim()) { const match = line.match(/^\s*[A-Z.]+\s+(\S+)/); if (match) { encoders.push(match[1]); } } } return encoders; } /** * Get available decoders */ public async getDecoders(): Promise { const result = await this.runProcess(this.ffmpegPath, ['-decoders', '-hide_banner']); const lines = result.stdout.split('\n'); const decoders: string[] = []; let started = false; for (const line of lines) { if (line.includes('------')) { started = true; continue; } if (started && line.trim()) { const match = line.match(/^\s*[A-Z.]+\s+(\S+)/); if (match) { decoders.push(match[1]); } } } return decoders; } /** * Get available formats */ public async getFormats(): Promise { const result = await this.runProcess(this.ffmpegPath, ['-formats', '-hide_banner']); const lines = result.stdout.split('\n'); const formats: string[] = []; let started = false; for (const line of lines) { if (line.includes('--')) { started = true; continue; } if (started && line.trim()) { const match = line.match(/^\s*[DE ]+\s+(\S+)/); if (match) { formats.push(match[1]); } } } return formats; } /** * Run ffmpeg with raw arguments */ public async runRaw(args: string[]): Promise<{ stdout: string; stderr: string }> { return this.runProcess(this.ffmpegPath, args); } /** * Run ffprobe with raw arguments */ public async runProbeRaw(args: string[]): Promise<{ stdout: string; stderr: string }> { return this.runProcess(this.ffprobePath, args); } // ============ Private Methods ============ private buildConversionArgs( inputPath: string, outputPath: string, options: interfaces.IConversionOptions ): string[] { const args: string[] = []; // Overwrite flag if (options.overwrite !== false) { args.push('-y'); } // Input seeking (before -i for fast seeking) if (options.startTime !== undefined) { args.push('-ss', this.formatTime(options.startTime)); } args.push('-i', inputPath); // Duration (after -i) if (options.duration !== undefined) { args.push('-t', this.formatTime(options.duration)); } // Video options if (options.noVideo) { args.push('-vn'); } else { if (options.videoCodec) { args.push('-c:v', options.videoCodec); } if (options.videoBitrate) { args.push('-b:v', options.videoBitrate); } if (options.crf !== undefined) { args.push('-crf', String(options.crf)); } if (options.preset) { args.push('-preset', options.preset); } if (options.fps) { args.push('-r', String(options.fps)); } if (options.width || options.height) { args.push('-vf', this.buildScaleFilter(options.width, options.height)); } } // Audio options if (options.noAudio) { args.push('-an'); } else { if (options.audioCodec) { args.push('-c:a', options.audioCodec); } if (options.audioBitrate) { args.push('-b:a', options.audioBitrate); } if (options.sampleRate) { args.push('-ar', String(options.sampleRate)); } if (options.audioChannels) { args.push('-ac', String(options.audioChannels)); } } // Output format if (options.format) { args.push('-f', options.format); } // Extra arguments if (options.extraArgs) { args.push(...options.extraArgs); } args.push(outputPath); return args; } private buildScaleFilter(width?: number, height?: number): string { const w = width ? String(width) : '-1'; const h = height ? String(height) : '-1'; // Use -2 instead of -1 to ensure even dimensions (required for some codecs) return `scale=${w === '-1' ? '-2' : w}:${h === '-1' ? '-2' : h}`; } private formatTime(time: number | string): string { if (typeof time === 'string') { return time; } // Convert seconds to HH:MM:SS.mmm format 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 parseStreamInfo(stream: any): interfaces.IStreamInfo { const info: interfaces.IStreamInfo = { index: stream.index, codecName: stream.codec_name, codecLongName: stream.codec_long_name, codecType: stream.codec_type, }; if (stream.codec_type === 'video') { info.width = stream.width; info.height = stream.height; if (stream.r_frame_rate) { const [num, den] = stream.r_frame_rate.split('/').map(Number); info.frameRate = den ? num / den : num; } if (stream.bit_rate) { info.bitrate = parseInt(stream.bit_rate, 10); } } if (stream.codec_type === 'audio') { info.sampleRate = parseInt(stream.sample_rate, 10); info.channels = stream.channels; if (stream.bit_rate) { info.bitrate = parseInt(stream.bit_rate, 10); } } if (stream.duration) { info.duration = parseFloat(stream.duration); } return info; } private runProcess(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const process = plugins.child_process.spawn(command, args); let stdout = ''; let stderr = ''; process.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); }); process.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); process.on('close', (code) => { if (code === 0) { resolve({ stdout, stderr }); } else { reject(new Error(`Process exited with code ${code}: ${stderr}`)); } }); process.on('error', (err) => { reject(err); }); }); } }