Files
dees-wcctools/ts_web/services/ffmpeg.service.ts
2025-12-11 18:03:46 +00:00

315 lines
9.0 KiB
TypeScript

/**
* 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<void> | null = null;
private messageId = 0;
private pendingMessages: Map<number, { resolve: Function; reject: Function }> = 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<void> {
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<void> {
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<WorkerMessage>) => {
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<any> {
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<Blob> {
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<void> {
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;
}