/** * FFmpegService - Handles client-side video format conversion using FFmpeg.wasm * Implements lazy loading to minimize initial bundle impact */ export interface IConversionProgress { stage: 'loading' | 'converting' | 'finalizing'; progress: number; // 0-100 message: string; } export interface IConversionOptions { inputBlob: Blob; outputFormat: 'mp4' | 'webm'; onProgress?: (progress: IConversionProgress) => void; } export class FFmpegService { private ffmpeg: any = null; private isLoading: boolean = false; private loadPromise: Promise | null = null; /** * Lazy load FFmpeg.wasm from CDN * Uses toBlobURL to bypass CORS restrictions */ async ensureLoaded(onProgress?: (progress: IConversionProgress) => void): Promise { if (this.ffmpeg?.loaded) return; if (this.loadPromise) { await this.loadPromise; return; } this.isLoading = true; this.loadPromise = this.loadFFmpeg(onProgress); await this.loadPromise; this.isLoading = false; } private async loadFFmpeg(onProgress?: (progress: IConversionProgress) => void): Promise { onProgress?.({ stage: 'loading', progress: 0, message: 'Loading FFmpeg library...' }); // Dynamic import to enable code splitting const { FFmpeg } = await import('@ffmpeg/ffmpeg'); const { toBlobURL } = await import('@ffmpeg/util'); this.ffmpeg = new FFmpeg(); // Set up progress listener this.ffmpeg.on('progress', ({ progress }: { progress: number }) => { onProgress?.({ stage: 'converting', progress: Math.round(progress * 100), message: `Converting video... ${Math.round(progress * 100)}%` }); }); const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; onProgress?.({ stage: 'loading', progress: 10, message: 'Downloading FFmpeg core (~31MB)...' }); await this.ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), }); onProgress?.({ stage: 'loading', progress: 100, message: 'FFmpeg loaded successfully' }); } /** * Convert WebM blob to MP4 */ async convertToMp4(options: IConversionOptions): Promise { const { inputBlob, onProgress } = options; // Check file size limit (2GB) const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; if (inputBlob.size > MAX_FILE_SIZE) { throw new Error('File size exceeds 2GB limit for conversion'); } await this.ensureLoaded(onProgress); const { fetchFile } = await import('@ffmpeg/util'); onProgress?.({ stage: 'converting', progress: 0, message: 'Preparing video for conversion...' }); // Write input file to virtual filesystem await this.ffmpeg.writeFile('input.webm', await fetchFile(inputBlob)); // Execute conversion with optimized settings for web playback // -c:v libx264 - H.264 video codec (universal compatibility) // -preset fast - Balance between speed and compression // -crf 23 - Quality level (lower = better quality, larger file) // -c:a aac - AAC audio codec // -movflags +faststart - Enable streaming playback await this.ffmpeg.exec([ '-i', 'input.webm', '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', 'output.mp4' ]); onProgress?.({ stage: 'finalizing', progress: 95, message: 'Finalizing video...' }); // Read output file const data = await this.ffmpeg.readFile('output.mp4'); // Clean up virtual filesystem await this.ffmpeg.deleteFile('input.webm'); await this.ffmpeg.deleteFile('output.mp4'); onProgress?.({ stage: 'finalizing', progress: 100, message: 'Conversion complete!' }); return new Blob([data.buffer], { type: 'video/mp4' }); } /** * Check if FFmpeg is currently loaded */ get isLoaded(): boolean { return this.ffmpeg?.loaded ?? false; } /** * Terminate FFmpeg worker to free resources */ async terminate(): Promise { if (this.ffmpeg) { await this.ffmpeg.terminate(); this.ffmpeg = null; this.loadPromise = null; } } } // Singleton instance for caching let ffmpegServiceInstance: FFmpegService | null = null; export function getFFmpegService(): FFmpegService { if (!ffmpegServiceInstance) { ffmpegServiceInstance = new FFmpegService(); } return ffmpegServiceInstance; }