mes-engine
Version:
A powerful and flexible video processing framework for Node.js with support for multiple processing engines, adaptive streaming, and intelligent caching.
174 lines (151 loc) • 7.06 kB
text/typescript
// src/processor.ts
import { VideoEngine } from './core/VideoEngine';
import { EventEmitter } from 'events';
import { StorageProvider } from './storage/StorageProvider';
import { StreamManager } from './streaming/StreamManager';
import { VideoConfig, VideoManifest, VideoChunk, ProcessingOptions } from './core/types';
import { join, dirname } from 'path';
import { promises as fs } from 'fs';
import { Readable } from 'stream';
import { VideoEvent } from './core/events';
/**
* Main orchestrator for video processing.
* Handles chunking, transcoding (via engines), and manifest generation.
*/
export class VideoProcessor extends EventEmitter {
private engine: VideoEngine;
private streamManager: StreamManager;
private config: VideoConfig;
constructor(
engine: VideoEngine,
storage: StorageProvider,
config: VideoConfig
) {
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: string,
options?: ProcessingOptions
): Promise<VideoManifest> {
const videoId = await this.generateVideoId(inputPath);
const manifest: VideoManifest = {
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 fs.mkdir(baseDir, { recursive: true });
await fs.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 fs.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: VideoChunk = {
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 fs.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 fs.writeFile(masterPath, masterM3u8);
}
// Save JSON Manifest
const manifestPath = join(baseDir, 'manifest.json');
await fs.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: string,
quality: number,
chunkNumber: number,
range?: { start: number; end: number }
): Promise<Readable> {
const chunkPath = this.getChunkPath(videoId, quality, chunkNumber);
return this.streamManager.createStream(chunkPath, range);
}
private getChunkPath(videoId: string, quality: number, chunkNumber: number): string {
return join(this.config.cacheDir, videoId, `${quality}p`, `chunk_${chunkNumber}.mp4`);
}
private getScreenshotPath(videoId: string, chunkNumber: number): string {
return join(this.config.cacheDir, videoId, 'screenshots', `chunk_${chunkNumber}.jpg`);
}
private async generateVideoId(inputPath: string): Promise<string> {
const stats = await fs.stat(inputPath);
const fileName = inputPath.split(/[\\/]/).pop()?.split('.')[0];
return `${fileName}_${Math.floor(stats.mtimeMs)}`;
}
}