175 lines
4.5 KiB
TypeScript
175 lines
4.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<void> | null = null;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Lazy load FFmpeg.wasm from CDN
|
||
|
|
* Uses toBlobURL to bypass CORS restrictions
|
||
|
|
*/
|
||
|
|
async ensureLoaded(onProgress?: (progress: IConversionProgress) => void): Promise<void> {
|
||
|
|
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<void> {
|
||
|
|
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<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');
|
||
|
|
}
|
||
|
|
|
||
|
|
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<void> {
|
||
|
|
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;
|
||
|
|
}
|