UNPKG

mes-engine

Version:

A powerful and flexible video processing framework for Node.js with support for multiple processing engines, adaptive streaming, and intelligent caching.

192 lines (164 loc) 6.88 kB
// src/engines/FFmpegEngine.ts import { VideoEngine } from '../core/VideoEngine'; import { QualityLevel } from '../core/types'; import { spawn } from 'child_process'; import fs from 'fs'; import { dirname } from 'path'; /** * FFmpeg implementation of the VideoEngine. * Requires `ffmpeg` and `ffprobe` to be installed on the system path. */ export class FFmpegEngine extends VideoEngine { /** * Processes a chunk of video using FFmpeg. * * @param inputPath - The path to the input video file. * @param outputPath - The path where the processed video chunk will be saved. * @param startTime - The start time (in seconds) of the chunk to process. * @param quality - The desired quality level for the output video. * @returns A Promise that resolves when the chunk is processed, or rejects on error. */ async processChunk( inputPath: string, outputPath: string, startTime: number, quality: QualityLevel ): Promise<void> { // Ensure output directory exists await fs.promises.mkdir(dirname(outputPath), { recursive: true }); return new Promise((resolve, reject) => { const args = [ '-i', inputPath, '-ss', startTime.toString(), '-t', '10', // Force dimensions divisible by 2 for H.264 encoding // The scale filter with -2 rounds to the nearest even number '-vf', `scale=-2:${quality.height}`, '-c:v', 'libx264', '-b:v', quality.bitrate, '-c:a', 'aac', '-b:a', '128k', '-preset', 'fast', // Use yuv420p for maximum compatibility '-pix_fmt', 'yuv420p', '-y', outputPath ]; const ffmpegProcess = spawn('ffmpeg', args); let stderr = ''; ffmpegProcess.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpegProcess.on('error', (err) => { reject(new Error(`Failed to spawn FFmpeg: ${err.message}`)); }); ffmpegProcess.on('close', (code) => { if (code === 0) { resolve(); } else { console.error('FFmpeg stderr:', stderr); reject(new Error(`FFmpeg error: ${code}\nCommand: ffmpeg ${args.join(' ')}\nStderr: ${stderr}`)); } }); }); } async extractScreenshot( inputPath: string, outputPath: string, time: number ): Promise<void> { // Ensure output directory exists await fs.promises.mkdir(dirname(outputPath), { recursive: true }); return new Promise((resolve, reject) => { const args = [ '-ss', time.toString(), '-i', inputPath, '-vframes', '1', '-q:v', '2', '-y', outputPath ]; const ffmpegProcess = spawn('ffmpeg', args); let stderr = ''; ffmpegProcess.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpegProcess.on('error', (err) => { reject(new Error(`Failed to spawn FFmpeg for screenshot: ${err.message}`)); }); ffmpegProcess.on('close', (code) => { if (code === 0) { resolve(); } else { console.error('FFmpeg screenshot stderr:', stderr); reject(new Error(`FFmpeg screenshot error: ${code}\nCommand: ffmpeg ${args.join(' ')}\nStderr: ${stderr}`)); } }); }); } async getDuration(inputPath: string): Promise<number> { // Use ffprobe for more reliable duration detection return new Promise((resolve, reject) => { const ffprobeProcess = spawn('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', inputPath ]); let stdout = ''; let stderr = ''; ffprobeProcess.stdout.on('data', (data) => { stdout += data.toString(); }); ffprobeProcess.stderr.on('data', (data) => { stderr += data.toString(); }); ffprobeProcess.on('error', (err) => { // Fallback to buffer parsing if ffprobe not available console.warn('ffprobe not available, falling back to buffer parsing'); this.getDurationFromBuffer(inputPath) .then(resolve) .catch(reject); }); ffprobeProcess.on('close', (code) => { if (code === 0) { const duration = parseFloat(stdout.trim()); if (!isNaN(duration)) { resolve(duration); } else { reject(new Error('Could not parse duration')); } } else { // Fallback to buffer parsing this.getDurationFromBuffer(inputPath) .then(resolve) .catch(reject); } }); }); } private async getDurationFromBuffer(inputPath: string): Promise<number> { const buffer = await fs.promises.readFile(inputPath); // Parse MP4 moov atom for duration if (inputPath.endsWith('.mp4')) { return this.parseMp4Duration(buffer); } // For other formats, extract from file metadata return this.parseMediaDuration(buffer); } private parseMp4Duration(buffer: Buffer): number { const moovStart = buffer.indexOf(Buffer.from('moov')); if (moovStart === -1) return 0; const mvhdStart = buffer.indexOf(Buffer.from('mvhd'), moovStart); if (mvhdStart === -1) return 0; const timeScale = buffer.readUInt32BE(mvhdStart + 12); const duration = buffer.readUInt32BE(mvhdStart + 16); return duration / timeScale; } private parseMediaDuration(buffer: Buffer): number { // Look for duration metadata in file headers const durationStr = buffer.toString('utf8', 0, Math.min(1000, buffer.length)); const match = durationStr.match(/duration["\s:]+(\d+\.?\d*)/i); return match ? parseFloat(match[1]) : 0; } }