diff --git a/.playwright-mcp/recording-panel.png b/.playwright-mcp/recording-panel.png new file mode 100644 index 0000000..2ce1a87 Binary files /dev/null and b/.playwright-mcp/recording-panel.png differ diff --git a/.playwright-mcp/wcctools-dashboard.png b/.playwright-mcp/wcctools-dashboard.png new file mode 100644 index 0000000..1a15704 Binary files /dev/null and b/.playwright-mcp/wcctools-dashboard.png differ diff --git a/.playwright-mcp/wcctools-with-element.png b/.playwright-mcp/wcctools-with-element.png new file mode 100644 index 0000000..f105784 Binary files /dev/null and b/.playwright-mcp/wcctools-with-element.png differ diff --git a/package.json b/package.json index 5481c67..f9510ca 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@git.zone/tsbundle": "^2.6.3", "@git.zone/tsrun": "^2.0.0", "@git.zone/tstest": "^3.1.3", - "@git.zone/tswatch": "^2.3.10", + "@git.zone/tswatch": "^2.3.11", "@push.rocks/projectinfo": "^5.0.2", "@types/node": "^25.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6d0656..6766c2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ importers: specifier: ^3.1.3 version: 3.1.3(@push.rocks/smartserve@1.4.0)(socks@2.8.7)(typescript@5.9.3) '@git.zone/tswatch': - specifier: ^2.3.10 - version: 2.3.10(@tiptap/pm@2.27.1) + specifier: ^2.3.11 + version: 2.3.11(@tiptap/pm@2.27.1) '@push.rocks/projectinfo': specifier: ^5.0.2 version: 5.0.2 @@ -507,8 +507,8 @@ packages: resolution: {integrity: sha512-t+/cKV21JHK8X7NGAmihs5M/eMm+V+jn4R5rzfwGG97WJFAcP5qE1Os9VYtyZw3tx/NZXA2yA4abo/ELluTuRA==} hasBin: true - '@git.zone/tswatch@2.3.10': - resolution: {integrity: sha512-88bdzD15mYoG0T0AUTg8ATNkV/dN5ecqfiYcQRX1gJHmLrE2yqymFGkb0W0/xWgpcRakc08V+wRbSI7pqg+EOQ==} + '@git.zone/tswatch@2.3.11': + resolution: {integrity: sha512-FJWOsPQ9i0INn1i7uqMD0ECrZ6bwwGQC8oFDEx9PLcaS+qHpGsYj3P9UscpW1N78P+6Yd1WFUfBh9sUQiKm+KA==} hasBin: true '@happy-dom/global-registrator@15.11.7': @@ -4833,7 +4833,7 @@ snapshots: - utf-8-validate - vue - '@git.zone/tswatch@2.3.10(@tiptap/pm@2.27.1)': + '@git.zone/tswatch@2.3.11(@tiptap/pm@2.27.1)': dependencies: '@api.global/typedserver': 7.11.1(@tiptap/pm@2.27.1) '@git.zone/tsbundle': 2.6.3 diff --git a/ts_web/services/ffmpeg.service.ts b/ts_web/services/ffmpeg.service.ts index 6188b5a..b3aeb9e 100644 --- a/ts_web/services/ffmpeg.service.ts +++ b/ts_web/services/ffmpeg.service.ts @@ -1,6 +1,6 @@ /** * FFmpegService - Handles client-side video format conversion using FFmpeg.wasm - * Implements lazy loading to minimize initial bundle impact + * Uses a custom worker implementation to bypass COEP/CORS issues with the standard library */ export interface IConversionProgress { @@ -15,52 +15,53 @@ export interface IConversionOptions { 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 ffmpeg: any = null; - private isLoading: boolean = false; + 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 - * Uses toBlobURL to bypass CORS restrictions + * Lazy load FFmpeg.wasm from CDN using custom worker */ async ensureLoaded(onProgress?: (progress: IConversionProgress) => void): Promise { - if (this.ffmpeg?.loaded) return; + if (this.worker && this.core) 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 { + console.log('[FFmpeg] Starting FFmpeg load with custom worker...'); + onProgress?.({ stage: 'loading', progress: 0, message: 'Loading FFmpeg library...' }); - // Dynamic import to enable code splitting - const { FFmpeg } = await import('@ffmpeg/ffmpeg'); + // Import toBlobURL utility 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'; + // 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', @@ -68,11 +69,133 @@ export class FFmpegService { 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'), + 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, @@ -80,6 +203,14 @@ export class FFmpegService { }); } + 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 */ @@ -92,9 +223,16 @@ export class FFmpegService { throw new Error('File size exceeds 2GB limit for conversion'); } - await this.ensureLoaded(onProgress); + // Set up progress callback + this.onProgress = (progress: number) => { + onProgress?.({ + stage: 'converting', + progress: Math.round(progress * 100), + message: `Converting video... ${Math.round(progress * 100)}%` + }); + }; - const { fetchFile } = await import('@ffmpeg/util'); + await this.ensureLoaded(onProgress); onProgress?.({ stage: 'converting', @@ -102,25 +240,25 @@ export class FFmpegService { 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.ffmpeg.writeFile('input.webm', await fetchFile(inputBlob)); + await this.sendMessage('WRITE_FILE', { path: 'input.webm', fileData: inputData }); // 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' - ]); + 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', @@ -129,11 +267,11 @@ export class FFmpegService { }); // Read output file - const data = await this.ffmpeg.readFile('output.mp4'); + const outputData: Uint8Array = await this.sendMessage('READ_FILE', { path: 'output.mp4' }); // Clean up virtual filesystem - await this.ffmpeg.deleteFile('input.webm'); - await this.ffmpeg.deleteFile('output.mp4'); + await this.sendMessage('DELETE_FILE', { path: 'input.webm' }); + await this.sendMessage('DELETE_FILE', { path: 'output.mp4' }); onProgress?.({ stage: 'finalizing', @@ -141,24 +279,26 @@ export class FFmpegService { message: 'Conversion complete!' }); - return new Blob([data.buffer], { type: 'video/mp4' }); + return new Blob([new Uint8Array(outputData)], { type: 'video/mp4' }); } /** * Check if FFmpeg is currently loaded */ get isLoaded(): boolean { - return this.ffmpeg?.loaded ?? false; + return this.worker !== null && this.core !== null; } /** * Terminate FFmpeg worker to free resources */ async terminate(): Promise { - if (this.ffmpeg) { - await this.ffmpeg.terminate(); - this.ffmpeg = null; + if (this.worker) { + this.worker.terminate(); + this.worker = null; + this.core = null; this.loadPromise = null; + this.pendingMessages.clear(); } } }