diff --git a/dev/mux.html b/dev/mux.html index fb38b1c..55a3558 100644 --- a/dev/mux.html +++ b/dev/mux.html @@ -42,7 +42,7 @@ format = new Metamuxer.Mp4OutputFormat({ fastStart: 'fragmented' }); // new Metamuxer.MkvOutputFormat();// new Metamuxer.Mp4OutputFormat({ fastStart: false }); format = new Metamuxer.OggOutputFormat(); format = new Metamuxer.Mp4OutputFormat({ fastStart: 'fragmented', minimumFragmentDuration: 2 }); - format = new Metamuxer.WebMOutputFormat({ minimumClusterDuration: 2 }); + format = new Metamuxer.MkvOutputFormat({ minimumClusterDuration: 2 }); let target = new Metamuxer.BufferTarget(); /* @@ -87,7 +87,8 @@ */ let videoSource = new Metamuxer.CanvasSource(canvas, { - codec: 'av1', + codec: 'avc', + fullCodecString: 'avc1.42001f', bitrate: 1e6 }); let audioSource = new Metamuxer.AudioBufferSource({ diff --git a/src/codec.ts b/src/codec.ts index 30bba15..3ab8d8f 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -1058,6 +1058,7 @@ export const parseOpusTocByte = (packet: Uint8Array) => { }; const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/; + export const parsePcmCodec = (codec: PcmAudioCodec) => { assert(PCM_AUDIO_CODECS.includes(codec)); @@ -1086,6 +1087,52 @@ export const parsePcmCodec = (codec: PcmAudioCodec) => { return { dataType, sampleSize, littleEndian, silentValue }; }; +export const inferCodecFromCodecString = (codecString: string): MediaCodec | null => { + // Video codecs + if (codecString.startsWith('avc1') || codecString.startsWith('avc3')) { + return 'avc'; + } else if (codecString.startsWith('hev1') || codecString.startsWith('hvc1')) { + return 'hevc'; + } else if (codecString === 'vp8') { + return 'vp8'; + } else if (codecString.startsWith('vp09')) { + return 'vp9'; + } else if (codecString.startsWith('av01')) { + return 'av1'; + } + + // Audio codecs + if (codecString.startsWith('mp4a.40') || codecString === 'mp4a.67') { + return 'aac'; + } else if ( + codecString === 'mp3' + || codecString === 'mp4a.69' + || codecString === 'mp4a.6B' + || codecString === 'mp4a.6b' + ) { + return 'mp3'; + } else if (codecString === 'opus') { + return 'opus'; + } else if (codecString === 'vorbis') { + return 'vorbis'; + } else if (codecString === 'flac') { + return 'flac'; + } else if (codecString === 'ulaw') { + return 'ulaw'; + } else if (codecString === 'alaw') { + return 'alaw'; + } else if (PCM_CODEC_REGEX.test(codecString)) { + return codecString as PcmAudioCodec; + } + + // Subtitle codecs + if (codecString === 'webvtt') { + return 'webvtt'; + } + + return null; +}; + export const getVideoEncoderConfigExtension = (codec: VideoCodec) => { if (codec === 'avc') { return { @@ -1423,7 +1470,13 @@ export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata | } // AAC-specific validation - if (metadata.decoderConfig.codec.startsWith('mp4a')) { + if ( + metadata.decoderConfig.codec.startsWith('mp4a') + // These three refer to MP3: + && metadata.decoderConfig.codec !== 'mp4a.69' + && metadata.decoderConfig.codec !== 'mp4a.6B' + && metadata.decoderConfig.codec !== 'mp4a.6b' + ) { const validStrings = ['mp4a.40.2', 'mp4a.40.02', 'mp4a.40.5', 'mp4a.40.05', 'mp4a.40.29', 'mp4a.67']; if (!validStrings.includes(metadata.decoderConfig.codec)) { throw new TypeError( @@ -1441,14 +1494,22 @@ export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata | } // MP3-specific validation - if (metadata.decoderConfig.codec === 'mp3') { - if (metadata.decoderConfig.codec !== 'mp3') { - throw new TypeError('Audio chunk metadata decoder configuration codec string for MP3 must be "mp3".'); + if (metadata.decoderConfig.codec.startsWith('mp3') || metadata.decoderConfig.codec.startsWith('mp4a')) { + if ( + metadata.decoderConfig.codec !== 'mp3' + && metadata.decoderConfig.codec !== 'mp4a.69' + && metadata.decoderConfig.codec !== 'mp4a.6B' + && metadata.decoderConfig.codec !== 'mp4a.6b' + ) { + throw new TypeError( + 'Audio chunk metadata decoder configuration codec string for MP3 must be "mp3", "mp4a.69" or' + + ' "mp4a.6B".', + ); } } // Opus-specific validation - if (metadata.decoderConfig.codec === 'opus') { + if (metadata.decoderConfig.codec.startsWith('opus')) { if (metadata.decoderConfig.codec !== 'opus') { throw new TypeError('Audio chunk metadata decoder configuration codec string for Opus must be "opus".'); } @@ -1463,7 +1524,7 @@ export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata | } // Vorbis-specific validation - if (metadata.decoderConfig.codec === 'vorbis') { + if (metadata.decoderConfig.codec.startsWith('vorbis')) { if (metadata.decoderConfig.codec !== 'vorbis') { throw new TypeError('Audio chunk metadata decoder configuration codec string for Vorbis must be "vorbis".'); } @@ -1477,7 +1538,7 @@ export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata | } // FLAC-specific validation - if (metadata.decoderConfig.codec === 'flac') { + if (metadata.decoderConfig.codec.startsWith('flac')) { if (metadata.decoderConfig.codec !== 'flac') { throw new TypeError('Audio chunk metadata decoder configuration codec string for FLAC must be "flac".'); } diff --git a/src/media-source.ts b/src/media-source.ts index 938d0f9..f35ef72 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -5,6 +5,7 @@ import { buildVideoCodecString, getAudioEncoderConfigExtension, getVideoEncoderConfigExtension, + inferCodecFromCodecString, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, @@ -186,10 +187,16 @@ export type VideoEncodingConfig = { * all the same key frame interval. */ keyFrameInterval?: number; + /** + * The full codec string as specified in the WebCodecs API Codec Registry. When not specified, a fitting codec + * string will be constructed automatically by the library. + */ + fullCodecString?: string; + /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown; - /** Called when an error occurs during encoding. */ - onEncodingError?: (error: Error) => unknown; + /** Called when an error occurs within VideoEncoder. */ + onEncoderError?: (error: Error) => unknown; /** Called when the internal encoder config, as used by the WebCodecs API, is created. */ onEncoderConfig?: (config: VideoEncoderConfig) => unknown; }; @@ -213,12 +220,24 @@ const validateVideoEncodingConfig = (config: VideoEncodingConfig) => { ) { throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.'); } + if (config.fullCodecString !== undefined && typeof config.fullCodecString !== 'string') { + throw new TypeError('config.fullCodecString, when provided, must be a string.'); + } + if (config.fullCodecString !== undefined && inferCodecFromCodecString(config.fullCodecString) !== config.codec) { + throw new TypeError( + `config.fullCodecString, when provided, must be a string that matches the specified codec` + + ` (${config.codec}).`, + ); + } if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { throw new TypeError('config.onEncodedChunk, when provided, must be a function.'); } - if (config.onEncodingError !== undefined && typeof config.onEncodingError !== 'function') { + if (config.onEncoderError !== undefined && typeof config.onEncoderError !== 'function') { throw new TypeError('config.onEncodingError, when provided, must be a function.'); } + if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { + throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); + } }; class VideoEncoderWrapper { @@ -326,7 +345,7 @@ class VideoEncoderWrapper { : this.encodingConfig.bitrate; const encoderConfig: VideoEncoderConfig = { - codec: buildVideoCodecString( + codec: this.encodingConfig.fullCodecString ?? buildVideoCodecString( this.encodingConfig.codec, width, height, @@ -377,7 +396,7 @@ class VideoEncoderWrapper { this.encodingConfig.onEncodedPacket?.(packet, meta); void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta); }, - error: this.encodingConfig.onEncodingError ?? (error => console.error('VideoEncoder error:', error)), + error: this.encodingConfig.onEncoderError ?? (error => console.error('VideoEncoder error:', error)), }); this.encoder.configure(encoderConfig); } @@ -633,10 +652,16 @@ export type AudioEncodingConfig = { * be provided. Required for compressed audio codecs, unused for PCM codecs. */ bitrate?: number | Quality; + /** + * The full codec string as specified in the WebCodecs API Codec Registry. When not specified, a fitting codec + * string will be constructed automatically by the library. + */ + fullCodecString?: string; + /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown; - /** Called when an error occurs during encoding. */ - onEncodingError?: (error: Error) => unknown; + /** Called when an error occurs within AudioEncoder. */ + onEncoderError?: (error: Error) => unknown; /** Called when the internal encoder config, as used by the WebCodecs API, is created. */ onEncoderConfig?: (config: AudioEncoderConfig) => unknown; }; @@ -661,9 +686,24 @@ const validateAudioEncodingConfig = (config: AudioEncodingConfig) => { ) { throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.'); } - if (config.onEncodingError !== undefined && typeof config.onEncodingError !== 'function') { + if (config.fullCodecString !== undefined && typeof config.fullCodecString !== 'string') { + throw new TypeError('config.fullCodecString, when provided, must be a string.'); + } + if (config.fullCodecString !== undefined && inferCodecFromCodecString(config.fullCodecString) !== config.codec) { + throw new TypeError( + `config.fullCodecString, when provided, must be a string that matches the specified codec` + + ` (${config.codec}).`, + ); + } + if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { + throw new TypeError('config.onEncodedChunk, when provided, must be a function.'); + } + if (config.onEncoderError !== undefined && typeof config.onEncoderError !== 'function') { throw new TypeError('config.onEncodingError, when provided, must be a function.'); } + if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { + throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); + } }; class AudioEncoderWrapper { @@ -837,7 +877,7 @@ class AudioEncoderWrapper { : this.encodingConfig.bitrate; const encoderConfig: AudioEncoderConfig = { - codec: buildAudioCodecString( + codec: this.encodingConfig.fullCodecString ?? buildAudioCodecString( this.encodingConfig.codec, numberOfChannels, sampleRate, @@ -887,7 +927,7 @@ class AudioEncoderWrapper { this.encodingConfig.onEncodedPacket?.(packet, meta); void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta); }, - error: this.encodingConfig.onEncodingError ?? (error => console.error('AudioEncoder error:', error)), + error: this.encodingConfig.onEncoderError ?? (error => console.error('AudioEncoder error:', error)), }); this.encoder.configure(encoderConfig); }