From 3eeb9dc46f8e3eb4c56ab728be8b6c71010d99b3 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 12 Apr 2026 23:17:21 +0000 Subject: [PATCH] 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. --- package.json | 11 ++- pnpm-lock.yaml | 15 ++++ readme.hints.md | 8 ++ ts_web/elements/wcc-recording-panel.ts | 39 +++++++-- ts_web/index.ts | 2 +- ts_web/services/recorder.service.ts | 80 ++++++++++++++++--- ts_web/types/dom-mediacapture-stub/index.d.ts | 12 +++ .../types/dom-mediacapture-stub/package.json | 6 ++ ts_web/types/dom-webcodecs-stub/index.d.ts | 2 + ts_web/types/dom-webcodecs-stub/package.json | 6 ++ 10 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 ts_web/types/dom-mediacapture-stub/index.d.ts create mode 100644 ts_web/types/dom-mediacapture-stub/package.json create mode 100644 ts_web/types/dom-webcodecs-stub/index.d.ts create mode 100644 ts_web/types/dom-webcodecs-stub/package.json 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" +}