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.

522 lines (508 loc) 19.5 kB
import { EventEmitter } from 'events'; import { spawn } from 'child_process'; import fs, { promises } from 'fs'; import { dirname, join } from 'path'; import { Readable } from 'stream'; import fetch from 'node-fetch'; // src/core/events.ts /** * Options: * * - CHUNK_PROCESSED * - QUALITY_PROCESSED * - PROCESSING_COMPLETE * - ERROR */ var VideoEvent; (function (VideoEvent) { VideoEvent["CHUNK_PROCESSED"] = "chunkProcessed"; VideoEvent["QUALITY_PROCESSED"] = "qualityProcessed"; VideoEvent["PROCESSING_COMPLETE"] = "processingComplete"; VideoEvent["ERROR"] = "error"; })(VideoEvent || (VideoEvent = {})); // src/core/VideoEngine.ts /** * Base class for video processing engines (e.g., FFmpeg, GStreamer). */ class VideoEngine extends EventEmitter { } // src/engines/FFmpegEngine.ts /** * FFmpeg implementation of the VideoEngine. * Requires `ffmpeg` and `ffprobe` to be installed on the system path. */ 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, outputPath, startTime, quality) { // 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, outputPath, time) { // 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) { // 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); } }); }); } async getDurationFromBuffer(inputPath) { 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); } parseMp4Duration(buffer) { 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; } parseMediaDuration(buffer) { // 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; } } // src/streaming/StreamManager.ts class StreamManager { constructor(storage) { this.storage = storage; } async createStream(chunkPath, range) { const data = await this.storage.getChunk(chunkPath); const stream = new Readable(); if (range) { stream.push(data.slice(range.start, range.end + 1)); } else { stream.push(data); } stream.push(null); return stream; } } // src/engines/GStreamerEngine.ts class GStreamerEngine extends VideoEngine { async processChunk(inputPath, outputPath, startTime, quality) { return new Promise((resolve, reject) => { const gst = spawn('gst-launch-1.0', [ 'filesrc', `location=${inputPath}`, '!', 'decodebin', '!', 'videoconvert', '!', 'videoscale', '!', `video/x-raw,width=-1,height=${quality.height}`, '!', 'x264enc', `bitrate=${quality.bitrate}`, '!', 'mp4mux', '!', 'filesink', `location=${outputPath}` ]); gst.on('close', code => { code === 0 ? resolve() : reject(new Error(`GStreamer error: ${code}`)); }); }); } async extractScreenshot(inputPath, outputPath, time) { return new Promise((resolve, reject) => { const gst = spawn('gst-launch-1.0', [ 'filesrc', `location=${inputPath}`, '!', 'decodebin', '!', 'videoconvert', '!', 'videorate', '!', `video/x-raw,framerate=1/1`, '!', 'videocut', `starting-time=${time * 1000000000}`, '!', 'jpegenc', '!', 'filesink', `location=${outputPath}` ]); gst.on('close', code => { code === 0 ? resolve() : reject(new Error(`GStreamer screenshot error: ${code}`)); }); }); } async getDuration(inputPath) { return new Promise((resolve, reject) => { const gst = spawn('gst-launch-1.0', [ 'filesrc', `location=${inputPath}`, '!', 'decodebin', '!', 'identity', '-debug', 'duration' ]); let output = ''; gst.stdout.on('data', data => output += data); gst.on('close', code => { code === 0 ? resolve(parseFloat(output)) : reject(new Error(`GStreamer error: ${code}`)); }); }); } } // src/storage/StorageProvider.ts /** * Interface for storage backends (e.g., Local File System, S3, Cloud Storage). */ class StorageProvider { } // src/storage/FileSystemStorage.ts class FileSystemStorage extends StorageProvider { async saveChunk(chunkPath, data) { await promises.writeFile(chunkPath, data); } async getChunk(chunkPath) { return promises.readFile(chunkPath); } async deleteChunk(chunkPath) { await promises.unlink(chunkPath); } } class CacheStrategy { } // cache/LRU.ts class LRU { constructor(maxSize) { this.maxSize = maxSize; this.cache = new Map(); } set(key, value) { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.maxSize) { const oldestKey = this.cache.keys().next().value; if (oldestKey !== undefined) { this.cache.delete(oldestKey); } } this.cache.set(key, value); } get(key) { if (!this.cache.has(key)) return undefined; const value = this.cache.get(key); this.cache.delete(key); this.cache.set(key, value); return value; } clear() { this.cache.clear(); } } // cache/internalCache.ts class InternalCache extends CacheStrategy { constructor(options, storage) { super(); this.options = options; this.cache = new LRU(options.maxSize); this.storage = storage; } async set(key, value) { this.cache.set(key, value); } async get(key) { const cached = this.cache.get(key); if (cached) return cached; try { const data = await this.storage.getChunk(key); await this.set(key, data); return data; } catch { return null; } } async preload(key) { if (this.options.preloadNextChunk) { const nextChunkKey = this.getNextChunkKey(key); if (!this.cache.get(nextChunkKey)) { try { const data = await this.storage.getChunk(nextChunkKey); await this.set(nextChunkKey, data); } catch { // Ignore preload failures } } } } async clear() { this.cache.clear(); } getNextChunkKey(currentKey) { const parts = currentKey.split('_'); const currentChunk = parseInt(parts[parts.length - 1]); parts[parts.length - 1] = (currentChunk + 1).toString(); return parts.join('_'); } } // cache/ExternalCache.ts class ExternalCache extends CacheStrategy { constructor(options) { super(); this.options = options; this.baseUrl = options.externalCacheUrl; } async set(key, value) { await fetch(`${this.baseUrl}/cache/${key}`, { method: 'POST', body: value, headers: { 'Content-Type': 'application/octet-stream', 'TTL': this.options.ttl.toString() } }); } async get(key) { const response = await fetch(`${this.baseUrl}/cache/${key}`); return response.ok ? Buffer.from(await response.arrayBuffer()) : null; } async preload(key) { if (this.options.preloadNextChunk) { const nextChunkKey = this.getNextChunkKey(key); await fetch(`${this.baseUrl}/preload/${nextChunkKey}`); } } async clear() { await fetch(`${this.baseUrl}/cache`, { method: 'DELETE' }); } getNextChunkKey(currentKey) { const parts = currentKey.split('_'); const currentChunk = parseInt(parts[parts.length - 1]); parts[parts.length - 1] = (currentChunk + 1).toString(); return parts.join('_'); } } /** * Main orchestrator for video processing. * Handles chunking, transcoding (via engines), and manifest generation. */ class VideoProcessor extends EventEmitter { constructor(engine, storage, config) { super(); this.engine = engine; this.streamManager = new StreamManager(storage); this.config = config; } /** * Processes an input video file into multiple quality levels and chunks. * Generates HLS playlists and a JSON manifest. * * @param inputPath - Absolute path to the source video file * @param options - Optional metadata and processing instructions * @returns Promise resolving to the generated VideoManifest * @throws Error if processing fails at any stage */ async processVideo(inputPath, options) { const videoId = await this.generateVideoId(inputPath); const manifest = { videoId, qualities: this.config.defaultQualities, chunks: [], metadata: { title: options?.title, description: options?.overallDescription, createdAt: new Date().toISOString() } }; // Create base directory structure const baseDir = join(this.config.cacheDir, videoId); const screenshotDir = join(baseDir, 'screenshots'); await promises.mkdir(baseDir, { recursive: true }); await promises.mkdir(screenshotDir, { recursive: true }); const duration = await this.engine.getDuration(inputPath); const chunks = Math.ceil(duration / this.config.chunkSize); for (const quality of this.config.defaultQualities) { // Create quality-specific directory const qualityDir = join(baseDir, `${quality.height}p`); await promises.mkdir(qualityDir, { recursive: true }); let m3u8Content = '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:' + this.config.chunkSize + '\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n'; for (let i = 0; i < chunks; i++) { const chunkPath = this.getChunkPath(videoId, quality.height, i); const screenshotPath = this.getScreenshotPath(videoId, i); try { // Process chunk await this.engine.processChunk(inputPath, chunkPath, i * this.config.chunkSize, quality); // Extract screenshot (only once per chunk number, e.g., for the first quality) if (quality.height === this.config.defaultQualities[0].height) { await this.engine.extractScreenshot(inputPath, screenshotPath, i * this.config.chunkSize + 1 // 1 second into the chunk ); } const chunk = { quality: quality.height, number: i, path: chunkPath, screenshotPath: screenshotPath, description: options?.descriptions?.[i] }; manifest.chunks.push(chunk); // Add to M3U8 m3u8Content += `#EXTINF:${this.config.chunkSize}.0,\nchunk_${i}.mp4\n`; this.emit(VideoEvent.CHUNK_PROCESSED, { quality, chunkNumber: i }); } catch (error) { this.emit(VideoEvent.ERROR, error); throw error; } } m3u8Content += '#EXT-X-ENDLIST'; // Save M3U8 for this quality const m3u8Path = join(qualityDir, 'playlist.m3u8'); await promises.writeFile(m3u8Path, m3u8Content); this.emit(VideoEvent.QUALITY_PROCESSED, quality); } // Generate Master M3U8 if (this.config.defaultQualities.length > 1) { let masterM3u8 = '#EXTM3U\n'; for (const quality of this.config.defaultQualities) { masterM3u8 += `#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrate.replace('k', '000')},RESOLUTION=-1x${quality.height}\n${quality.height}p/playlist.m3u8\n`; } const masterPath = join(baseDir, 'master.m3u8'); await promises.writeFile(masterPath, masterM3u8); } // Save JSON Manifest const manifestPath = join(baseDir, 'manifest.json'); await promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); this.emit(VideoEvent.PROCESSING_COMPLETE, manifest); return manifest; } /** * Creates a readable stream for a specific video chunk. * Supported for on-demand delivery of processed segments. * * @param videoId - ID of the processed video * @param quality - Target quality (height) * @param chunkNumber - Sequential index of the chunk * @param range - Optional byte range for partial content * @returns Promise resolving to a Readable stream */ async streamChunk(videoId, quality, chunkNumber, range) { const chunkPath = this.getChunkPath(videoId, quality, chunkNumber); return this.streamManager.createStream(chunkPath, range); } getChunkPath(videoId, quality, chunkNumber) { return join(this.config.cacheDir, videoId, `${quality}p`, `chunk_${chunkNumber}.mp4`); } getScreenshotPath(videoId, chunkNumber) { return join(this.config.cacheDir, videoId, 'screenshots', `chunk_${chunkNumber}.jpg`); } async generateVideoId(inputPath) { const stats = await promises.stat(inputPath); const fileName = inputPath.split(/[\\/]/).pop()?.split('.')[0]; return `${fileName}_${Math.floor(stats.mtimeMs)}`; } } export { CacheStrategy, ExternalCache, FFmpegEngine, FileSystemStorage, GStreamerEngine, InternalCache, StorageProvider, StreamManager, VideoEngine, VideoEvent, VideoProcessor }; //# sourceMappingURL=index.js.map