From 12c85fa4cba25993e782c0cb41d9d0506343eafa Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 11 Dec 2025 19:02:02 +0000 Subject: [PATCH] BREAKING CHANGE(recorder): Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness --- changelog.md | 13 + package.json | 2 - pnpm-lock.yaml | 26 -- test/elements/test-demoelement.ts | 6 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/wcc-recording-panel.ts | 180 +------------- ts_web/elements/wcc-sidebar.ts | 4 + ts_web/index.ts | 1 - ts_web/services/ffmpeg.service.ts | 314 ------------------------- ts_web/services/recorder.service.ts | 6 +- 10 files changed, 32 insertions(+), 522 deletions(-) delete mode 100644 ts_web/services/ffmpeg.service.ts diff --git a/changelog.md b/changelog.md index 6f52f66..125488b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2025-12-11 - 2.0.0 - BREAKING CHANGE(recorder) +Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness + +- Removed FFmpegService and all client-side MP4 conversion logic — exports are now WebM-only (MP4 conversion and related UI/controls removed). +- ts_web/elements/wcc-recording-panel: dropped outputFormat and conversion states/UI; download flow simplified to always export WebM. +- ts_web/index.ts: removed FFmpegService exports and conversion types from public API. +- package.json: removed @ffmpeg/* dependencies. +- RecorderService: handleRecordingComplete is now async and fixes recorded blob assignment and cleanup timing. +- wcc-properties: improved element detection and robustness — recursive search through light/shadow DOM with retry/delay, plus an advanced JSON editor for Object/Array props (supports multiple open editors and frame resize events). +- wcc-sidebar: force re-render after selecting demos to ensure child demo selection indicators update correctly. +- dees-demowrapper: ensure slotted content is rendered before calling runAfterRender (small timing/stability improvements). +- Test update: demo definitions can be arrays (multiple demos) — test-demoelement updated to use multiple demo entries. + ## 2025-12-11 - 1.3.0 - feat(recording-panel) Add demo wrapper utilities, improve recording trim behavior, and harden property panel element detection; update documentation diff --git a/package.json b/package.json index f9510ca..f19abb1 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "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 6766c2b..e2d04c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,6 @@ 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 @@ -455,18 +449,6 @@ 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'} @@ -4691,14 +4673,6 @@ 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/test/elements/test-demoelement.ts b/test/elements/test-demoelement.ts index 9c9ae6e..8eb073e 100644 --- a/test/elements/test-demoelement.ts +++ b/test/elements/test-demoelement.ts @@ -18,7 +18,11 @@ enum ETestEnum { @customElement('test-demoelement') export class TestDemoelement extends DeesElement { - public static demo = () => html`This is a slot text`; + public static demo = [ + () => html`This is demo 1`, + () => html`This is demo 2`, + () => html`This is demo 2`, + ] @property() accessor notTyped = 'hello'; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 4b3a3c8..f522843 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-wcctools', - version: '1.3.0', + version: '2.0.0', description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.' } diff --git a/ts_web/elements/wcc-recording-panel.ts b/ts_web/elements/wcc-recording-panel.ts index b21e708..90873cc 100644 --- a/ts_web/elements/wcc-recording-panel.ts +++ b/ts_web/elements/wcc-recording-panel.ts @@ -1,6 +1,5 @@ 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') @@ -52,15 +51,6 @@ 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; @@ -563,94 +553,6 @@ export class WccRecordingPanel extends DeesElement { 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); - } ` ]; @@ -806,51 +708,15 @@ export class WccRecordingPanel extends DeesElement { - -
-
- Output Format -
-
- - -
- ${this.outputFormat === 'mp4' ? html` -
- â„šī¸ - MP4 requires conversion (~31MB download on first use) -
- ` : null} -
@@ -951,7 +817,7 @@ export class WccRecordingPanel extends DeesElement { try { let blobToDownload: Blob; - // Step 1: Handle trimming if needed + // Handle trimming if needed const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1; if (needsTrim) { @@ -965,43 +831,9 @@ 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 + // Trigger download const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const extension = this.outputFormat; - const filename = `wcctools-recording-${timestamp}.${extension}`; + const filename = `wcctools-recording-${timestamp}.webm`; const url = URL.createObjectURL(blobToDownload); const a = document.createElement('a'); @@ -1016,8 +848,6 @@ 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/elements/wcc-sidebar.ts b/ts_web/elements/wcc-sidebar.ts index bb5a83f..e2c169c 100644 --- a/ts_web/elements/wcc-sidebar.ts +++ b/ts_web/elements/wcc-sidebar.ts @@ -333,5 +333,9 @@ export class WccSidebar extends DeesElement { ); this.dashboardRef.buildUrl(); + + // Force re-render to update demo child selection indicator + // (needed when switching between demos of the same element) + this.requestUpdate(); } } diff --git a/ts_web/index.ts b/ts_web/index.ts index bbd0189..384379d 100644 --- a/ts_web/index.ts +++ b/ts_web/index.ts @@ -4,7 +4,6 @@ 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 deleted file mode 100644 index b3aeb9e..0000000 --- a/ts_web/services/ffmpeg.service.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * 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 | 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 using custom worker - */ - async ensureLoaded(onProgress?: (progress: IConversionProgress) => void): Promise { - 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 { - 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) => { - 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 { - 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 { - 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 { - 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; -} diff --git a/ts_web/services/recorder.service.ts b/ts_web/services/recorder.service.ts index be552ab..dc919e9 100644 --- a/ts_web/services/recorder.service.ts +++ b/ts_web/services/recorder.service.ts @@ -235,9 +235,11 @@ export class RecorderService { } } - private handleRecordingComplete(): void { + private async handleRecordingComplete(): Promise { // Create blob from recorded chunks - this._recordedBlob = new Blob(this.recordedChunks, { type: 'video/webm' }); + const blob = new Blob(this.recordedChunks, { type: 'video/webm' }); + + this._recordedBlob = blob; // Stop all tracks if (this.currentStream) {