BREAKING CHANGE(recorder): Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness
This commit is contained in:
13
changelog.md
13
changelog.md
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
Add demo wrapper utilities, improve recording trim behavior, and harden property panel element detection; update documentation
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@design.estate/dees-domtools": "^2.3.6",
|
"@design.estate/dees-domtools": "^2.3.6",
|
||||||
"@design.estate/dees-element": "^2.1.3",
|
"@design.estate/dees-element": "^2.1.3",
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
|
||||||
"@ffmpeg/util": "^0.12.1",
|
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"lit": "^3.3.1"
|
"lit": "^3.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -14,12 +14,6 @@ importers:
|
|||||||
'@design.estate/dees-element':
|
'@design.estate/dees-element':
|
||||||
specifier: ^2.1.3
|
specifier: ^2.1.3
|
||||||
version: 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':
|
'@push.rocks/smartdelay':
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.0.5
|
version: 3.0.5
|
||||||
@@ -455,18 +449,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@fortawesome/fontawesome-common-types@7.1.0':
|
||||||
resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==}
|
resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -4691,14 +4673,6 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.27.1':
|
'@esbuild/win32-x64@0.27.1':
|
||||||
optional: true
|
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-common-types@7.1.0': {}
|
||||||
|
|
||||||
'@fortawesome/fontawesome-svg-core@7.1.0':
|
'@fortawesome/fontawesome-svg-core@7.1.0':
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ enum ETestEnum {
|
|||||||
|
|
||||||
@customElement('test-demoelement')
|
@customElement('test-demoelement')
|
||||||
export class TestDemoelement extends DeesElement {
|
export class TestDemoelement extends DeesElement {
|
||||||
public static demo = () => html`<test-demoelement>This is a slot text</test-demoelement>`;
|
public static demo = [
|
||||||
|
() => html`<test-demoelement>This is demo 1</test-demoelement>`,
|
||||||
|
() => html`<test-demoelement>This is demo 2</test-demoelement>`,
|
||||||
|
() => html`<test-demoelement>This is demo 2</test-demoelement>`,
|
||||||
|
]
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
accessor notTyped = 'hello';
|
accessor notTyped = 'hello';
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-wcctools',
|
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.'
|
description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
|
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
|
||||||
import { RecorderService } from '../services/recorder.service.js';
|
import { RecorderService } from '../services/recorder.service.js';
|
||||||
import { getFFmpegService, type IConversionProgress } from '../services/ffmpeg.service.js';
|
|
||||||
import type { WccDashboard } from './wcc-dashboard.js';
|
import type { WccDashboard } from './wcc-dashboard.js';
|
||||||
|
|
||||||
@customElement('wcc-recording-panel')
|
@customElement('wcc-recording-panel')
|
||||||
@@ -52,15 +51,6 @@ export class WccRecordingPanel extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor isExporting: boolean = false;
|
accessor isExporting: boolean = false;
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor outputFormat: 'mp4' | 'webm' = 'mp4';
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor conversionProgress: IConversionProgress | null = null;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor isConverting: boolean = false;
|
|
||||||
|
|
||||||
// Service instance
|
// Service instance
|
||||||
private recorderService: RecorderService;
|
private recorderService: RecorderService;
|
||||||
|
|
||||||
@@ -563,94 +553,6 @@ export class WccRecordingPanel extends DeesElement {
|
|||||||
to { transform: rotate(360deg); }
|
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 {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Format Selection Section -->
|
|
||||||
<div class="format-section">
|
|
||||||
<div class="format-section-header">
|
|
||||||
<span class="format-section-title">Output Format</span>
|
|
||||||
</div>
|
|
||||||
<div class="format-buttons">
|
|
||||||
<button
|
|
||||||
class="format-btn ${this.outputFormat === 'mp4' ? 'selected' : ''}"
|
|
||||||
@click=${() => this.outputFormat = 'mp4'}
|
|
||||||
>
|
|
||||||
<span class="format-icon">📱</span>
|
|
||||||
MP4 (Universal)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="format-btn ${this.outputFormat === 'webm' ? 'selected' : ''}"
|
|
||||||
@click=${() => this.outputFormat = 'webm'}
|
|
||||||
>
|
|
||||||
<span class="format-icon">🌐</span>
|
|
||||||
WebM (Web Only)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
${this.outputFormat === 'mp4' ? html`
|
|
||||||
<div class="format-note">
|
|
||||||
<span class="note-icon">ℹ️</span>
|
|
||||||
MP4 requires conversion (~31MB download on first use)
|
|
||||||
</div>
|
|
||||||
` : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-modal-actions">
|
<div class="preview-modal-actions">
|
||||||
<button class="preview-btn secondary" @click=${() => this.discardRecording()}>Discard</button>
|
<button class="preview-btn secondary" @click=${() => this.discardRecording()}>Discard</button>
|
||||||
<button
|
<button
|
||||||
class="preview-btn primary"
|
class="preview-btn primary"
|
||||||
?disabled=${this.isExporting || this.isConverting}
|
?disabled=${this.isExporting}
|
||||||
@click=${() => this.downloadRecording()}
|
@click=${() => this.downloadRecording()}
|
||||||
>
|
>
|
||||||
${this.isConverting ? html`
|
${this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : 'Download WebM'}
|
||||||
<div class="conversion-progress">
|
|
||||||
<span class="export-spinner"></span>
|
|
||||||
<span class="progress-text">${this.conversionProgress?.message || 'Converting...'}</span>
|
|
||||||
${this.conversionProgress?.progress !== undefined ? html`
|
|
||||||
<span class="progress-percent">${this.conversionProgress.progress}%</span>
|
|
||||||
` : null}
|
|
||||||
</div>
|
|
||||||
` : this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : `Download ${this.outputFormat.toUpperCase()}`}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,7 +817,7 @@ export class WccRecordingPanel extends DeesElement {
|
|||||||
try {
|
try {
|
||||||
let blobToDownload: Blob;
|
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;
|
const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1;
|
||||||
|
|
||||||
if (needsTrim) {
|
if (needsTrim) {
|
||||||
@@ -965,43 +831,9 @@ export class WccRecordingPanel extends DeesElement {
|
|||||||
blobToDownload = recordedBlob;
|
blobToDownload = recordedBlob;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Convert to MP4 if selected
|
// Trigger download
|
||||||
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 timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
const extension = this.outputFormat;
|
const filename = `wcctools-recording-${timestamp}.webm`;
|
||||||
const filename = `wcctools-recording-${timestamp}.${extension}`;
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blobToDownload);
|
const url = URL.createObjectURL(blobToDownload);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -1016,8 +848,6 @@ export class WccRecordingPanel extends DeesElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error exporting video:', error);
|
console.error('Error exporting video:', error);
|
||||||
this.isExporting = false;
|
this.isExporting = false;
|
||||||
this.isConverting = false;
|
|
||||||
this.conversionProgress = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -333,5 +333,9 @@ export class WccSidebar extends DeesElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.dashboardRef.buildUrl();
|
this.dashboardRef.buildUrl();
|
||||||
|
|
||||||
|
// Force re-render to update demo child selection indicator
|
||||||
|
// (needed when switching between demos of the same element)
|
||||||
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { TTemplateFactory } from './elements/wcctools.helpers.js';
|
|||||||
|
|
||||||
// Export recording components and service
|
// Export recording components and service
|
||||||
export { RecorderService, type IRecorderEvents, type IRecordingOptions } from './services/recorder.service.js';
|
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 { WccRecordButton } from './elements/wcc-record-button.js';
|
||||||
export { WccRecordingPanel } from './elements/wcc-recording-panel.js';
|
export { WccRecordingPanel } from './elements/wcc-recording-panel.js';
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> | null = null;
|
|
||||||
private messageId = 0;
|
|
||||||
private pendingMessages: Map<number, { resolve: Function; reject: Function }> = 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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<WorkerMessage>) => {
|
|
||||||
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<any> {
|
|
||||||
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<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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<void> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -235,9 +235,11 @@ export class RecorderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRecordingComplete(): void {
|
private async handleRecordingComplete(): Promise<void> {
|
||||||
// Create blob from recorded chunks
|
// 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
|
// Stop all tracks
|
||||||
if (this.currentStream) {
|
if (this.currentStream) {
|
||||||
|
|||||||
Reference in New Issue
Block a user