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 {
+
+
+
@@ -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;
+}