diff --git a/package.json b/package.json
index 14a07c4..f351093 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"@design.estate/dees-domtools": "^2.5.4",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/smartdelay": "^3.0.5",
- "lit": "^3.3.2"
+ "lit": "^3.3.2",
+ "mediabunny": "^1.40.1"
},
"devDependencies": {
"@api.global/typedserver": "^8.4.6",
@@ -59,5 +60,11 @@
"element testing",
"page development"
],
- "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
+ "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
+ "pnpm": {
+ "overrides": {
+ "@types/dom-webcodecs": "./ts_web/types/dom-webcodecs-stub",
+ "@types/dom-mediacapture-transform": "./ts_web/types/dom-mediacapture-stub"
+ }
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 28377c9..2f8f06a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ '@types/dom-webcodecs': ./ts_web/types/dom-webcodecs-stub
+ '@types/dom-mediacapture-transform': ./ts_web/types/dom-mediacapture-stub
+
importers:
.:
@@ -20,6 +24,9 @@ importers:
lit:
specifier: ^3.3.2
version: 3.3.2
+ mediabunny:
+ specifier: ^1.40.1
+ version: 1.40.1
devDependencies:
'@api.global/typedserver':
specifier: ^8.4.6
@@ -3115,6 +3122,9 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+ mediabunny@1.40.1:
+ resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
+
memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -8621,6 +8631,11 @@ snapshots:
mdurl@2.0.0: {}
+ mediabunny@1.40.1:
+ dependencies:
+ '@types/dom-mediacapture-transform': link:ts_web/types/dom-mediacapture-stub
+ '@types/dom-webcodecs': link:ts_web/types/dom-webcodecs-stub
+
memory-pager@1.5.0: {}
micromark-core-commonmark@2.0.3:
diff --git a/readme.hints.md b/readme.hints.md
index 3290a46..aa3f139 100644
--- a/readme.hints.md
+++ b/readme.hints.md
@@ -1,5 +1,13 @@
# Project Hints and Findings
+## Mediabunny / @types/dom-webcodecs Override (2026-04-12)
+
+The `mediabunny` package depends on `@types/dom-webcodecs` and `@types/dom-mediacapture-transform`, which conflict with TypeScript 6's built-in WebCodecs types in `lib.dom.d.ts`. We override both via `pnpm.overrides` in `package.json`, pointing them to local stubs in `ts_web/types/`:
+- `dom-webcodecs-stub/` — empty, since TS6 provides these types natively
+- `dom-mediacapture-stub/` — provides `MediaStreamVideoTrack` and `MediaStreamAudioTrack` interfaces (not yet in `lib.dom.d.ts`)
+
+If mediabunny drops these `@types` dependencies in a future version, the overrides can be removed.
+
## TypeScript 6.0 & Build Tooling (2026-04-12)
### TypeScript 6.0 Strict Defaults
diff --git a/ts_web/elements/wcc-recording-panel.ts b/ts_web/elements/wcc-recording-panel.ts
index 90873cc..f6fb88d 100644
--- a/ts_web/elements/wcc-recording-panel.ts
+++ b/ts_web/elements/wcc-recording-panel.ts
@@ -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 {
+
+
Format
+
+
+
+
+
+
Audio
@@ -716,7 +737,9 @@ export class WccRecordingPanel extends DeesElement {
?disabled=${this.isExporting}
@click=${() => this.downloadRecording()}
>
- ${this.isExporting ? html`Exporting...` : 'Download WebM'}
+ ${this.isExporting
+ ? html`${this.outputFormat === 'mp4' ? 'Converting to MP4...' : 'Exporting...'}`
+ : `Download ${this.outputFormat === 'mp4' ? 'MP4' : 'WebM'}`}
@@ -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');
diff --git a/ts_web/index.ts b/ts_web/index.ts
index fc66d35..aa918a0 100644
--- a/ts_web/index.ts
+++ b/ts_web/index.ts
@@ -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';
diff --git a/ts_web/services/recorder.service.ts b/ts_web/services/recorder.service.ts
index dc919e9..efb7a24 100644
--- a/ts_web/services/recorder.service.ts
+++ b/ts_web/services/recorder.service.ts
@@ -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 {
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 {
- // 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 {
+ 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,
diff --git a/ts_web/types/dom-mediacapture-stub/index.d.ts b/ts_web/types/dom-mediacapture-stub/index.d.ts
new file mode 100644
index 0000000..cccefeb
--- /dev/null
+++ b/ts_web/types/dom-mediacapture-stub/index.d.ts
@@ -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;
+}
diff --git a/ts_web/types/dom-mediacapture-stub/package.json b/ts_web/types/dom-mediacapture-stub/package.json
new file mode 100644
index 0000000..821ede5
--- /dev/null
+++ b/ts_web/types/dom-mediacapture-stub/package.json
@@ -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"
+}
diff --git a/ts_web/types/dom-webcodecs-stub/index.d.ts b/ts_web/types/dom-webcodecs-stub/index.d.ts
new file mode 100644
index 0000000..2f0d9ca
--- /dev/null
+++ b/ts_web/types/dom-webcodecs-stub/index.d.ts
@@ -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.
diff --git a/ts_web/types/dom-webcodecs-stub/package.json b/ts_web/types/dom-webcodecs-stub/package.json
new file mode 100644
index 0000000..a957503
--- /dev/null
+++ b/ts_web/types/dom-webcodecs-stub/package.json
@@ -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"
+}