diff --git a/package.json b/package.json index 5724c56..5481c67 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "dependencies": { "@design.estate/dees-domtools": "^2.3.6", "@design.estate/dees-element": "^2.1.3", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.1", "@push.rocks/smartdelay": "^3.0.5", "lit": "^3.3.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f64b74..c6d0656 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@design.estate/dees-element': specifier: ^2.1.3 version: 2.1.3 + '@ffmpeg/ffmpeg': + specifier: ^0.12.15 + version: 0.12.15 + '@ffmpeg/util': + specifier: ^0.12.1 + version: 0.12.2 '@push.rocks/smartdelay': specifier: ^3.0.5 version: 3.0.5 @@ -449,6 +455,18 @@ packages: cpu: [x64] os: [win32] + '@ffmpeg/ffmpeg@0.12.15': + resolution: {integrity: sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==} + engines: {node: '>=18.x'} + + '@ffmpeg/types@0.12.4': + resolution: {integrity: sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==} + engines: {node: '>=16.x'} + + '@ffmpeg/util@0.12.2': + resolution: {integrity: sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==} + engines: {node: '>=18.x'} + '@fortawesome/fontawesome-common-types@7.1.0': resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==} engines: {node: '>=6'} @@ -4673,6 +4691,14 @@ snapshots: '@esbuild/win32-x64@0.27.1': optional: true + '@ffmpeg/ffmpeg@0.12.15': + dependencies: + '@ffmpeg/types': 0.12.4 + + '@ffmpeg/types@0.12.4': {} + + '@ffmpeg/util@0.12.2': {} + '@fortawesome/fontawesome-common-types@7.1.0': {} '@fortawesome/fontawesome-svg-core@7.1.0': diff --git a/ts_web/elements/wcc-recording-panel.ts b/ts_web/elements/wcc-recording-panel.ts index 4abe107..b21e708 100644 --- a/ts_web/elements/wcc-recording-panel.ts +++ b/ts_web/elements/wcc-recording-panel.ts @@ -1,5 +1,6 @@ import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element'; import { RecorderService } from '../services/recorder.service.js'; +import { getFFmpegService, type IConversionProgress } from '../services/ffmpeg.service.js'; import type { WccDashboard } from './wcc-dashboard.js'; @customElement('wcc-recording-panel') @@ -51,6 +52,15 @@ export class WccRecordingPanel extends DeesElement { @state() accessor isExporting: boolean = false; + @state() + accessor outputFormat: 'mp4' | 'webm' = 'mp4'; + + @state() + accessor conversionProgress: IConversionProgress | null = null; + + @state() + accessor isConverting: boolean = false; + // Service instance private recorderService: RecorderService; @@ -552,6 +562,95 @@ export class WccRecordingPanel extends DeesElement { @keyframes spin { to { transform: rotate(360deg); } } + + /* Format Selection Styles */ + .format-section { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); + } + + .format-section-header { + margin-bottom: 0.75rem; + } + + .format-section-title { + font-size: 0.75rem; + font-weight: 500; + color: #888; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .format-buttons { + display: flex; + gap: 0.5rem; + } + + .format-btn { + flex: 1; + padding: 0.6rem 0.75rem; + background: var(--input); + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: #999; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + } + + .format-btn:hover { + border-color: var(--primary); + color: #ccc; + } + + .format-btn.selected { + background: rgba(59, 130, 246, 0.15); + border-color: var(--primary); + color: var(--primary); + } + + .format-icon { + font-size: 1rem; + } + + .format-note { + margin-top: 0.5rem; + padding: 0.5rem; + background: rgba(59, 130, 246, 0.1); + border-radius: var(--radius-sm); + font-size: 0.7rem; + color: #888; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .note-icon { + font-size: 0.9rem; + } + + /* Conversion Progress Styles */ + .conversion-progress { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .progress-text { + font-size: 0.75rem; + } + + .progress-percent { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.7); + } ` ]; @@ -706,15 +805,52 @@ export class WccRecordingPanel extends DeesElement { + + +
+
+ Output Format +
+
+ + +
+ ${this.outputFormat === 'mp4' ? html` +
+ â„šī¸ + MP4 requires conversion (~31MB download on first use) +
+ ` : null} +
@@ -815,6 +951,7 @@ export class WccRecordingPanel extends DeesElement { try { let blobToDownload: Blob; + // Step 1: Handle trimming if needed const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1; if (needsTrim) { @@ -828,8 +965,43 @@ export class WccRecordingPanel extends DeesElement { blobToDownload = recordedBlob; } + // Step 2: Convert to MP4 if selected + if (this.outputFormat === 'mp4') { + this.isConverting = true; + this.isExporting = false; // Switch to conversion state + + try { + const ffmpegService = getFFmpegService(); + blobToDownload = await ffmpegService.convertToMp4({ + inputBlob: blobToDownload, + outputFormat: 'mp4', + onProgress: (progress) => { + this.conversionProgress = progress; + } + }); + } catch (conversionError) { + console.error('MP4 conversion failed:', conversionError); + // Offer WebM fallback + const useFallback = confirm( + 'MP4 conversion failed. Would you like to download as WebM instead?' + ); + if (!useFallback) { + this.isConverting = false; + this.conversionProgress = null; + return; + } + // Continue with original WebM blob (already set) + this.outputFormat = 'webm'; + } + + this.isConverting = false; + this.conversionProgress = null; + } + + // Step 3: Trigger download const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const filename = `wcctools-recording-${timestamp}.webm`; + const extension = this.outputFormat; + const filename = `wcctools-recording-${timestamp}.${extension}`; const url = URL.createObjectURL(blobToDownload); const a = document.createElement('a'); @@ -844,6 +1016,8 @@ export class WccRecordingPanel extends DeesElement { } catch (error) { console.error('Error exporting video:', error); this.isExporting = false; + this.isConverting = false; + this.conversionProgress = null; } } diff --git a/ts_web/index.ts b/ts_web/index.ts index 384379d..bbd0189 100644 --- a/ts_web/index.ts +++ b/ts_web/index.ts @@ -4,6 +4,7 @@ import type { TTemplateFactory } from './elements/wcctools.helpers.js'; // Export recording components and service export { RecorderService, type IRecorderEvents, type IRecordingOptions } from './services/recorder.service.js'; +export { FFmpegService, getFFmpegService, type IConversionProgress, type IConversionOptions } from './services/ffmpeg.service.js'; export { WccRecordButton } from './elements/wcc-record-button.js'; export { WccRecordingPanel } from './elements/wcc-recording-panel.js'; diff --git a/ts_web/services/ffmpeg.service.ts b/ts_web/services/ffmpeg.service.ts new file mode 100644 index 0000000..6188b5a --- /dev/null +++ b/ts_web/services/ffmpeg.service.ts @@ -0,0 +1,174 @@ +/** + * 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; +}