update
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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 {
|
||||
</button>
|
||||
</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 class="preview-modal-actions">
|
||||
<button class="preview-btn secondary" @click=${() => this.discardRecording()}>Discard</button>
|
||||
<button
|
||||
class="preview-btn primary"
|
||||
?disabled=${this.isExporting}
|
||||
?disabled=${this.isExporting || this.isConverting}
|
||||
@click=${() => this.downloadRecording()}
|
||||
>
|
||||
${this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : 'Download'}
|
||||
${this.isConverting ? html`
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
174
ts_web/services/ffmpeg.service.ts
Normal file
174
ts_web/services/ffmpeg.service.ts
Normal file
@@ -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<void> | null = null;
|
||||
|
||||
/**
|
||||
* Lazy load FFmpeg.wasm from CDN
|
||||
* Uses toBlobURL to bypass CORS restrictions
|
||||
*/
|
||||
async ensureLoaded(onProgress?: (progress: IConversionProgress) => void): Promise<void> {
|
||||
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<void> {
|
||||
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<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');
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user