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
JavaScript
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