feat(recording): add output format options and MP4 conversion support
- Introduced `outputFormat` state in `WccRecordingPanel` for selecting between MP4 and WebM formats. - Updated `RecorderService` to handle MP4 conversion using mediabunny. - Added type stubs for `MediaStreamAudioTrack` and `MediaStreamVideoTrack` to ensure type safety. - Updated documentation to reflect changes in output format handling.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { RecorderService } from '../services/recorder.service.js';
|
||||
import { RecorderService, type TOutputFormat } from '../services/recorder.service.js';
|
||||
import type { WccDashboard } from './wcc-dashboard.js';
|
||||
|
||||
@customElement('wcc-recording-panel')
|
||||
@@ -16,6 +16,9 @@ export class WccRecordingPanel extends DeesElement {
|
||||
@state()
|
||||
accessor recordingMode: 'viewport' | 'screen' = 'viewport';
|
||||
|
||||
@state()
|
||||
accessor outputFormat: TOutputFormat = 'mp4';
|
||||
|
||||
@state()
|
||||
accessor audioEnabled: boolean = false;
|
||||
|
||||
@@ -591,6 +594,24 @@ export class WccRecordingPanel extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recording-option-group">
|
||||
<div class="recording-option-label">Format</div>
|
||||
<div class="recording-mode-buttons">
|
||||
<button
|
||||
class="recording-mode-btn ${this.outputFormat === 'mp4' ? 'selected' : ''}"
|
||||
@click=${() => this.outputFormat = 'mp4'}
|
||||
>
|
||||
MP4 (H.264)
|
||||
</button>
|
||||
<button
|
||||
class="recording-mode-btn ${this.outputFormat === 'webm' ? 'selected' : ''}"
|
||||
@click=${() => this.outputFormat = 'webm'}
|
||||
>
|
||||
WebM (VP9)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recording-option-group">
|
||||
<div class="recording-option-label">Audio</div>
|
||||
<div class="audio-toggle">
|
||||
@@ -716,7 +737,9 @@ export class WccRecordingPanel extends DeesElement {
|
||||
?disabled=${this.isExporting}
|
||||
@click=${() => this.downloadRecording()}
|
||||
>
|
||||
${this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : 'Download WebM'}
|
||||
${this.isExporting
|
||||
? html`<span class="export-spinner"></span>${this.outputFormat === 'mp4' ? 'Converting to MP4...' : 'Exporting...'}`
|
||||
: `Download ${this.outputFormat === 'mp4' ? 'MP4' : 'WebM'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -764,7 +787,7 @@ export class WccRecordingPanel extends DeesElement {
|
||||
await this.recorderService.startRecording({
|
||||
mode: this.recordingMode,
|
||||
audioDeviceId: this.audioEnabled ? this.selectedMicrophoneId : undefined,
|
||||
viewportElement
|
||||
viewportElement,
|
||||
});
|
||||
|
||||
this.panelState = 'recording';
|
||||
@@ -817,7 +840,7 @@ export class WccRecordingPanel extends DeesElement {
|
||||
try {
|
||||
let blobToDownload: Blob;
|
||||
|
||||
// Handle trimming if needed
|
||||
// Handle trimming if needed — always produces WebM
|
||||
const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1;
|
||||
|
||||
if (needsTrim) {
|
||||
@@ -831,9 +854,15 @@ export class WccRecordingPanel extends DeesElement {
|
||||
blobToDownload = recordedBlob;
|
||||
}
|
||||
|
||||
// Convert WebM → MP4 if MP4 format selected
|
||||
if (this.outputFormat === 'mp4') {
|
||||
blobToDownload = await this.recorderService.convertToMp4(blobToDownload);
|
||||
}
|
||||
|
||||
// Trigger download
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `wcctools-recording-${timestamp}.webm`;
|
||||
const ext = this.outputFormat === 'mp4' ? 'mp4' : 'webm';
|
||||
const filename = `wcctools-recording-${timestamp}.${ext}`;
|
||||
|
||||
const url = URL.createObjectURL(blobToDownload);
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { TTemplateFactory } from './elements/wcctools.helpers.js';
|
||||
import type { IWccConfig, IWccSection } from './wcctools.interfaces.js';
|
||||
|
||||
// Export recording components and service
|
||||
export { RecorderService, type IRecorderEvents, type IRecordingOptions } from './services/recorder.service.js';
|
||||
export { RecorderService, type IRecorderEvents, type IRecordingOptions, type TOutputFormat } from './services/recorder.service.js';
|
||||
export { WccRecordButton } from './elements/wcc-record-button.js';
|
||||
export { WccRecordingPanel } from './elements/wcc-recording-panel.js';
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic
|
||||
* RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic.
|
||||
* Recording always uses MediaRecorder → WebM (the reliable browser path).
|
||||
* MP4 output is produced by converting WebM → MP4 via mediabunny at export time.
|
||||
*/
|
||||
|
||||
export type TOutputFormat = 'webm' | 'mp4';
|
||||
|
||||
export interface IRecorderEvents {
|
||||
onDurationUpdate?: (duration: number) => void;
|
||||
onRecordingComplete?: (blob: Blob) => void;
|
||||
@@ -14,6 +18,7 @@ export interface IRecordingOptions {
|
||||
mode: 'viewport' | 'screen';
|
||||
audioDeviceId?: string;
|
||||
viewportElement?: HTMLElement;
|
||||
outputFormat?: TOutputFormat;
|
||||
}
|
||||
|
||||
export class RecorderService {
|
||||
@@ -24,6 +29,7 @@ export class RecorderService {
|
||||
private _duration: number = 0;
|
||||
private _recordedBlob: Blob | null = null;
|
||||
private _isRecording: boolean = false;
|
||||
private _outputFormat: TOutputFormat = 'webm';
|
||||
|
||||
// Audio monitoring state
|
||||
private audioContext: AudioContext | null = null;
|
||||
@@ -56,6 +62,10 @@ export class RecorderService {
|
||||
return this._recordedBlob;
|
||||
}
|
||||
|
||||
get outputFormat(): TOutputFormat {
|
||||
return this._outputFormat;
|
||||
}
|
||||
|
||||
// Update event callbacks
|
||||
setEvents(events: IRecorderEvents): void {
|
||||
this.events = { ...this.events, ...events };
|
||||
@@ -132,6 +142,8 @@ export class RecorderService {
|
||||
|
||||
async startRecording(options: IRecordingOptions): Promise<void> {
|
||||
try {
|
||||
this._outputFormat = options.outputFormat || 'webm';
|
||||
|
||||
// Stop audio monitoring before recording
|
||||
this.stopAudioMonitoring();
|
||||
|
||||
@@ -182,7 +194,15 @@ export class RecorderService {
|
||||
// Store stream for cleanup
|
||||
this.currentStream = combinedStream;
|
||||
|
||||
// Create MediaRecorder
|
||||
// Handle stream ending (user clicks "Stop sharing")
|
||||
videoStream.getVideoTracks()[0].onended = () => {
|
||||
if (this._isRecording) {
|
||||
this.stopRecording();
|
||||
this.events.onStreamEnded?.();
|
||||
}
|
||||
};
|
||||
|
||||
// Always record as WebM — conversion to MP4 happens at export time
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: 'video/webm';
|
||||
@@ -198,14 +218,6 @@ export class RecorderService {
|
||||
|
||||
this.mediaRecorder.onstop = () => this.handleRecordingComplete();
|
||||
|
||||
// Handle stream ending (user clicks "Stop sharing")
|
||||
videoStream.getVideoTracks()[0].onended = () => {
|
||||
if (this._isRecording) {
|
||||
this.stopRecording();
|
||||
this.events.onStreamEnded?.();
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(1000); // Capture in 1-second chunks
|
||||
|
||||
// Start duration timer
|
||||
@@ -236,9 +248,7 @@ export class RecorderService {
|
||||
}
|
||||
|
||||
private async handleRecordingComplete(): Promise<void> {
|
||||
// Create blob from recorded chunks
|
||||
const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
|
||||
|
||||
this._recordedBlob = blob;
|
||||
|
||||
// Stop all tracks
|
||||
@@ -251,7 +261,51 @@ export class RecorderService {
|
||||
this.events.onRecordingComplete?.(this._recordedBlob);
|
||||
}
|
||||
|
||||
// ==================== Trim & Export ====================
|
||||
// ==================== Conversion & Export ====================
|
||||
|
||||
/**
|
||||
* Converts a WebM blob to MP4 using mediabunny's Conversion API.
|
||||
* Uses WebCodecs for hardware-accelerated H.264 encoding.
|
||||
*/
|
||||
async convertToMp4(webmBlob: Blob): Promise<Blob> {
|
||||
const {
|
||||
Input, Output, Conversion, BlobSource, BufferTarget, Mp4OutputFormat, WEBM, QUALITY_HIGH,
|
||||
} = await import('mediabunny');
|
||||
|
||||
const input = new Input({
|
||||
source: new BlobSource(webmBlob),
|
||||
formats: [WEBM],
|
||||
});
|
||||
|
||||
const target = new BufferTarget();
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
|
||||
target,
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
// Force transcoding from VP9 → H.264 and Opus → AAC
|
||||
video: {
|
||||
codec: 'avc',
|
||||
bitrate: QUALITY_HIGH,
|
||||
fit: 'contain',
|
||||
},
|
||||
audio: {
|
||||
codec: 'aac',
|
||||
bitrate: QUALITY_HIGH,
|
||||
},
|
||||
});
|
||||
await conversion.execute();
|
||||
|
||||
const buffer = target.buffer;
|
||||
if (!buffer || buffer.byteLength === 0) {
|
||||
throw new Error('MP4 conversion produced empty output');
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: 'video/mp4' });
|
||||
}
|
||||
|
||||
async exportTrimmedVideo(
|
||||
videoElement: HTMLVideoElement,
|
||||
|
||||
12
ts_web/types/dom-mediacapture-stub/index.d.ts
vendored
Normal file
12
ts_web/types/dom-mediacapture-stub/index.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Minimal type stubs for MediaCapture Transform API types not yet in lib.dom.d.ts.
|
||||
// These specialize MediaStreamTrack for audio/video so mediabunny's API is type-safe.
|
||||
|
||||
interface MediaStreamAudioTrack extends MediaStreamTrack {
|
||||
readonly kind: 'audio';
|
||||
clone(): MediaStreamAudioTrack;
|
||||
}
|
||||
|
||||
interface MediaStreamVideoTrack extends MediaStreamTrack {
|
||||
readonly kind: 'video';
|
||||
clone(): MediaStreamVideoTrack;
|
||||
}
|
||||
6
ts_web/types/dom-mediacapture-stub/package.json
Normal file
6
ts_web/types/dom-mediacapture-stub/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@types/dom-mediacapture-transform",
|
||||
"version": "0.0.0",
|
||||
"description": "Minimal stub providing MediaStreamVideoTrack/MediaStreamAudioTrack for TS 6",
|
||||
"types": "index.d.ts"
|
||||
}
|
||||
2
ts_web/types/dom-webcodecs-stub/index.d.ts
vendored
Normal file
2
ts_web/types/dom-webcodecs-stub/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Empty stub: TypeScript 6 includes WebCodecs types in lib.dom.d.ts natively.
|
||||
// This prevents @types/dom-webcodecs from conflicting with the built-in types.
|
||||
6
ts_web/types/dom-webcodecs-stub/package.json
Normal file
6
ts_web/types/dom-webcodecs-stub/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@types/dom-webcodecs",
|
||||
"version": "0.0.0",
|
||||
"description": "Empty stub — TypeScript 6 provides WebCodecs types natively in lib.dom.d.ts",
|
||||
"types": "index.d.ts"
|
||||
}
|
||||
Reference in New Issue
Block a user