/** * FFmpegService - Handles client-side video format conversion using FFmpeg.wasm * Uses a custom worker implementation to bypass COEP/CORS issues with the standard library */ 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; } // Message types for worker communication type WorkerMessageType = 'LOAD' | 'EXEC' | 'WRITE_FILE' | 'READ_FILE' | 'DELETE_FILE' | 'LOG' | 'PROGRESS' | 'ERROR'; interface WorkerMessage { id: number; type: WorkerMessageType; data?: any; } export class FFmpegService { private worker: Worker | null = null; private core: any = null; private loadPromise: Promise | null = null; private messageId = 0; private pendingMessages: Map = new Map(); private onLog?: (message: string) => void; private onProgress?: (progress: number) => void; /** * Lazy load FFmpeg.wasm from CDN using custom worker */ async ensureLoaded(onProgress?: (progress: IConversionProgress) => void): Promise { if (this.worker && this.core) return; if (this.loadPromise) { await this.loadPromise; return; } this.loadPromise = this.loadFFmpeg(onProgress); await this.loadPromise; } private async loadFFmpeg(onProgress?: (progress: IConversionProgress) => void): Promise { console.log('[FFmpeg] Starting FFmpeg load with custom worker...'); onProgress?.({ stage: 'loading', progress: 0, message: 'Loading FFmpeg library...' }); // Import toBlobURL utility const { toBlobURL } = await import('@ffmpeg/util'); // Use jsdelivr CDN (has proper CORS/CORP headers) const coreBaseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/umd'; onProgress?.({ stage: 'loading', progress: 10, message: 'Downloading FFmpeg core (~31MB)...' }); console.log('[FFmpeg] Creating blob URLs...'); const coreURL = await toBlobURL(`${coreBaseURL}/ffmpeg-core.js`, 'text/javascript'); const wasmURL = await toBlobURL(`${coreBaseURL}/ffmpeg-core.wasm`, 'application/wasm'); console.log('[FFmpeg] Blob URLs created'); onProgress?.({ stage: 'loading', progress: 50, message: 'Initializing FFmpeg...' }); // Create custom worker code that bypasses @ffmpeg/ffmpeg wrapper issues const workerCode = ` let ffmpeg = null; self.onmessage = async (e) => { const { id, type, data } = e.data; try { switch (type) { case 'LOAD': { const { coreURL, wasmURL } = data; console.log('[FFmpeg Worker] Loading core...'); importScripts(coreURL); console.log('[FFmpeg Worker] Initializing with WASM...'); ffmpeg = await self.createFFmpegCore({ mainScriptUrlOrBlob: coreURL + '#' + btoa(JSON.stringify({ wasmURL })) }); // Set up logging ffmpeg.setLogger((log) => { self.postMessage({ type: 'LOG', data: log }); }); // Set up progress ffmpeg.setProgress((progress) => { self.postMessage({ type: 'PROGRESS', data: progress }); }); console.log('[FFmpeg Worker] Core initialized successfully'); self.postMessage({ id, type: 'LOAD', data: true }); break; } case 'EXEC': { const { args, timeout = -1 } = data; ffmpeg.setTimeout(timeout); ffmpeg.exec(...args); const ret = ffmpeg.ret; ffmpeg.reset(); self.postMessage({ id, type: 'EXEC', data: ret }); break; } case 'WRITE_FILE': { const { path, fileData } = data; ffmpeg.FS.writeFile(path, fileData); self.postMessage({ id, type: 'WRITE_FILE', data: true }); break; } case 'READ_FILE': { const { path } = data; const fileData = ffmpeg.FS.readFile(path); self.postMessage({ id, type: 'READ_FILE', data: fileData }, [fileData.buffer]); break; } case 'DELETE_FILE': { const { path } = data; ffmpeg.FS.unlink(path); self.postMessage({ id, type: 'DELETE_FILE', data: true }); break; } default: throw new Error('Unknown message type: ' + type); } } catch (err) { console.error('[FFmpeg Worker] Error:', err); self.postMessage({ id, type: 'ERROR', data: err.message }); } }; `; // Create worker from blob const workerBlob = new Blob([workerCode], { type: 'text/javascript' }); const workerURL = URL.createObjectURL(workerBlob); this.worker = new Worker(workerURL); // Set up message handler this.worker.onmessage = (e: MessageEvent) => { const { id, type, data } = e.data; if (type === 'LOG') { console.log('[FFmpeg Log]', data); this.onLog?.(data.message || data); return; } if (type === 'PROGRESS') { this.onProgress?.(data); return; } const pending = this.pendingMessages.get(id); if (pending) { this.pendingMessages.delete(id); if (type === 'ERROR') { pending.reject(new Error(data)); } else { pending.resolve(data); } } }; this.worker.onerror = (e) => { console.error('[FFmpeg] Worker error:', e); }; // Initialize FFmpeg in worker console.log('[FFmpeg] Initializing worker...'); await this.sendMessage('LOAD', { coreURL, wasmURL }); this.core = true; // Mark as loaded console.log('[FFmpeg] Worker initialized successfully'); onProgress?.({ stage: 'loading', progress: 100, message: 'FFmpeg loaded successfully' }); } private sendMessage(type: WorkerMessageType, data?: any): Promise { return new Promise((resolve, reject) => { const id = ++this.messageId; this.pendingMessages.set(id, { resolve, reject }); this.worker!.postMessage({ id, type, data }); }); } /** * 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'); } // Set up progress callback this.onProgress = (progress: number) => { onProgress?.({ stage: 'converting', progress: Math.round(progress * 100), message: `Converting video... ${Math.round(progress * 100)}%` }); }; await this.ensureLoaded(onProgress); onProgress?.({ stage: 'converting', progress: 0, message: 'Preparing video for conversion...' }); // Read input blob as Uint8Array const inputData = new Uint8Array(await inputBlob.arrayBuffer()); // Write input file to virtual filesystem await this.sendMessage('WRITE_FILE', { path: 'input.webm', fileData: inputData }); // Execute conversion with optimized settings for web playback await this.sendMessage('EXEC', { args: [ '-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 outputData: Uint8Array = await this.sendMessage('READ_FILE', { path: 'output.mp4' }); // Clean up virtual filesystem await this.sendMessage('DELETE_FILE', { path: 'input.webm' }); await this.sendMessage('DELETE_FILE', { path: 'output.mp4' }); onProgress?.({ stage: 'finalizing', progress: 100, message: 'Conversion complete!' }); return new Blob([new Uint8Array(outputData)], { type: 'video/mp4' }); } /** * Check if FFmpeg is currently loaded */ get isLoaded(): boolean { return this.worker !== null && this.core !== null; } /** * Terminate FFmpeg worker to free resources */ async terminate(): Promise { if (this.worker) { this.worker.terminate(); this.worker = null; this.core = null; this.loadPromise = null; this.pendingMessages.clear(); } } } // Singleton instance for caching let ffmpegServiceInstance: FFmpegService | null = null; export function getFFmpegService(): FFmpegService { if (!ffmpegServiceInstance) { ffmpegServiceInstance = new FFmpegService(); } return ffmpegServiceInstance; }