diff --git a/docs/guide/media-sources.md b/docs/guide/media-sources.md index 9b281f7..57b8cbe 100644 --- a/docs/guide/media-sources.md +++ b/docs/guide/media-sources.md @@ -47,9 +47,13 @@ All video sources that handle encoding internally require you to specify a `Vide type VideoEncodingConfig = { codec: VideoCodec; bitrate: number | Quality; + bitrateMode?: 'constant' | 'variable'; latencyMode?: 'quality' | 'realtime'; keyFrameInterval?: number; fullCodecString?: string; + hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software'; + scalabilityMode?: string; + contentHint?: string; onEncodedPacket?: ( packet: EncodedPacket, @@ -62,9 +66,13 @@ type VideoEncodingConfig = { ``` - `codec`: The [video codec](./supported-formats-and-codecs#video-codecs) used for encoding. - `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities). +- `bitrateMode`: Can be used to control constant vs. variable bitrate. - `latencyMode`: The latency mode as specified by the WebCodecs API. Browsers default to `quality`. Media stream-driven video sources will automatically use the `realtime` setting. - `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 5 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks. - `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically. +- `hardwareAcceleration`: A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`. +- `scalabilityMode`: An encoding scalability mode identifier as defined by [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*). +- `contentHint`: An encoding video content hint as defined by [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints). - `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress. - `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string. @@ -75,6 +83,7 @@ All audio sources that handle encoding internally require you to specify an `Aud type AudioEncodingConfig = { codec: AudioCodec; bitrate?: number | Quality; + bitrateMode?: 'constant' | 'variable'; fullCodecString?: string; onEncodedPacket?: ( @@ -88,6 +97,7 @@ type AudioEncodingConfig = { ``` - `codec`: The [audio codec](./supported-formats-and-codecs#audio-codecs) used for encoding. Can be omitted for uncompressed PCM codecs. - `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities). +- `bitrateMode`: Can be used to control constant vs. variable bitrate. - `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically. - `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress. - `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string. diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md index a945c5d..5b9e08c 100644 --- a/docs/guide/supported-formats-and-codecs.md +++ b/docs/guide/supported-formats-and-codecs.md @@ -120,6 +120,8 @@ canEncodeAudio('aac', { }); // => Promise ``` +Additionally, most properties of [`VideoEncodingConfig`](./media-sources#video-encoding-config) and [`AudioEncodingConfig`](./media-sources#audio-encoding-config) can be used here as well. + --- In addition, you can use the following functions to check encodability for multiple codecs at once, getting back a list of supported codecs: diff --git a/src/codec.ts b/src/codec.ts index 1b7416f..8e4740e 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -12,7 +12,6 @@ import { HevcDecoderConfigurationRecord, Vp9CodecInfo, } from './codec-data'; -import { customAudioEncoders, customVideoEncoders } from './custom-coder'; import { Bitstream, COLOR_PRIMARIES_MAP, @@ -1124,271 +1123,3 @@ export const validateSubtitleMetadata = (metadata: SubtitleMetadata | undefined) throw new TypeError('Subtitle metadata config description must be a string.'); } }; - -/** - * Checks if the browser is able to encode the given codec. - * @public - */ -export const canEncode = (codec: MediaCodec) => { - if ((VIDEO_CODECS as readonly string[]).includes(codec)) { - return canEncodeVideo(codec as VideoCodec); - } else if ((AUDIO_CODECS as readonly string[]).includes(codec)) { - return canEncodeAudio(codec as AudioCodec); - } else if ((SUBTITLE_CODECS as readonly string[]).includes(codec)) { - return canEncodeSubtitles(codec as SubtitleCodec); - } - - throw new TypeError(`Unknown codec '${codec}'.`); -}; - -/** - * Checks if the browser is able to encode the given video codec with the given parameters. - * @public - */ -export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = 720, bitrate = 1e6 }: { - width?: number; - height?: number; - bitrate?: number | Quality; -} = {}) => { - if (!VIDEO_CODECS.includes(codec)) { - return false; - } - if (!Number.isInteger(width) || width <= 0) { - throw new TypeError('width must be a positive integer.'); - } - if (!Number.isInteger(height) || height <= 0) { - throw new TypeError('height must be a positive integer.'); - } - if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) { - throw new TypeError('bitrate must be a positive integer or a quality.'); - } - - const resolvedBitrate = bitrate instanceof Quality - ? bitrate._toVideoBitrate(codec, width, height) - : bitrate; - - if (customVideoEncoders.length > 0) { - const encoderConfig: VideoEncoderConfig = { - codec: buildVideoCodecString( - codec, - width, - height, - resolvedBitrate, - ), - width, - height, - bitrate: resolvedBitrate, - ...getVideoEncoderConfigExtension(codec), - }; - - if (customVideoEncoders.some(x => x.supports(codec, encoderConfig))) { - // There's a custom encoder - return true; - } - } - - if (typeof VideoEncoder === 'undefined') { - return false; - } - - const support = await VideoEncoder.isConfigSupported({ - codec: buildVideoCodecString(codec, width, height, resolvedBitrate), - width, - height, - bitrate: resolvedBitrate, - ...getVideoEncoderConfigExtension(codec), - }); - - return support.supported === true; -}; - -/** - * Checks if the browser is able to encode the given audio codec with the given parameters. - * @public - */ -export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, sampleRate = 48000, bitrate = 128e3 }: { - numberOfChannels?: number; - sampleRate?: number; - bitrate?: number | Quality; -} = {}) => { - if (!AUDIO_CODECS.includes(codec)) { - return false; - } - if (!Number.isInteger(numberOfChannels) || numberOfChannels <= 0) { - throw new TypeError('numberOfChannels must be a positive integer.'); - } - if (!Number.isInteger(sampleRate) || sampleRate <= 0) { - throw new TypeError('sampleRate must be a positive integer.'); - } - if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) { - throw new TypeError('bitrate must be a positive integer.'); - } - - const resolvedBitrate = bitrate instanceof Quality - ? bitrate._toAudioBitrate(codec) - : bitrate; - - if (customAudioEncoders.length > 0) { - const encoderConfig: AudioEncoderConfig = { - codec: buildAudioCodecString( - codec, - numberOfChannels, - sampleRate, - ), - numberOfChannels, - sampleRate, - bitrate: resolvedBitrate, - ...getAudioEncoderConfigExtension(codec), - }; - - if (customAudioEncoders.some(x => x.supports(codec, encoderConfig))) { - // There's a custom encoder - return true; - } - } - - if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) { - return true; // Because we encode these ourselves - } - - if (typeof AudioEncoder === 'undefined') { - return false; - } - - const support = await AudioEncoder.isConfigSupported({ - codec: buildAudioCodecString(codec, numberOfChannels, sampleRate), - numberOfChannels, - sampleRate, - bitrate: resolvedBitrate, - ...getAudioEncoderConfigExtension(codec), - }); - - return support.supported === true; -}; - -/** - * Checks if the browser is able to encode the given subtitle codec. - * @public - */ -export const canEncodeSubtitles = async (codec: SubtitleCodec) => { - if (!SUBTITLE_CODECS.includes(codec)) { - return false; - } - - return true; -}; - -/** - * Returns the list of all media codecs that can be encoded by the browser. - * @public - */ -export const getEncodableCodecs = async (): Promise => { - const [videoCodecs, audioCodecs, subtitleCodecs] = await Promise.all([ - getEncodableVideoCodecs(), - getEncodableAudioCodecs(), - getEncodableSubtitleCodecs(), - ]); - - return [...videoCodecs, ...audioCodecs, ...subtitleCodecs]; -}; - -/** - * Returns the list of all video codecs that can be encoded by the browser. - * @public - */ -export const getEncodableVideoCodecs = async ( - checkedCodecs = VIDEO_CODECS as unknown as VideoCodec[], - options?: { - width?: number; - height?: number; - bitrate?: number | Quality; - }, -): Promise => { - const bools = await Promise.all(checkedCodecs.map(codec => canEncodeVideo(codec, options))); - return checkedCodecs.filter((_, i) => bools[i]); -}; - -/** - * Returns the list of all audio codecs that can be encoded by the browser. - * @public - */ -export const getEncodableAudioCodecs = async ( - checkedCodecs = AUDIO_CODECS as unknown as AudioCodec[], - options?: { - numberOfChannels?: number; - sampleRate?: number; - bitrate?: number | Quality; - }, -): Promise => { - const bools = await Promise.all(checkedCodecs.map(codec => canEncodeAudio(codec, options))); - return checkedCodecs.filter((_, i) => bools[i]); -}; - -/** - * Returns the list of all subtitle codecs that can be encoded by the browser. - * @public - */ -export const getEncodableSubtitleCodecs = async ( - checkedCodecs = SUBTITLE_CODECS as unknown as SubtitleCodec[], -): Promise => { - const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles)); - return checkedCodecs.filter((_, i) => bools[i]); -}; - -/** - * Returns the first video codec from the given list that can be encoded by the browser. - * @public - */ -export const getFirstEncodableVideoCodec = async ( - checkedCodecs: VideoCodec[], - options?: { - width?: number; - height?: number; - bitrate?: number | Quality; - }, -): Promise => { - for (const codec of checkedCodecs) { - if (await canEncodeVideo(codec, options)) { - return codec; - } - } - - return null; -}; - -/** - * Returns the first audio codec from the given list that can be encoded by the browser. - * @public - */ -export const getFirstEncodableAudioCodec = async ( - checkedCodecs: AudioCodec[], - options?: { - numberOfChannels?: number; - sampleRate?: number; - bitrate?: number | Quality; - }, -): Promise => { - for (const codec of checkedCodecs) { - if (await canEncodeAudio(codec, options)) { - return codec; - } - } - - return null; -}; - -/** - * Returns the first subtitle codec from the given list that can be encoded by the browser. - * @public - */ -export const getFirstEncodableSubtitleCodec = async ( - checkedCodecs: SubtitleCodec[], -): Promise => { - for (const codec of checkedCodecs) { - if (await canEncodeSubtitles(codec)) { - return codec; - } - } - - return null; -}; diff --git a/src/conversion.ts b/src/conversion.ts index 6c95f14..8075e9f 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -9,14 +9,18 @@ import { AUDIO_CODECS, AudioCodec, - getFirstEncodableVideoCodec, - getEncodableAudioCodecs, NON_PCM_AUDIO_CODECS, Quality, QUALITY_HIGH, VIDEO_CODECS, VideoCodec, } from './codec'; +import { + AudioEncodingConfig, + getEncodableAudioCodecs, + getFirstEncodableVideoCodec, + VideoEncodingConfig, +} from './encode'; import { Input } from './input'; import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track'; import { @@ -26,11 +30,9 @@ import { VideoSampleSink, } from './media-sink'; import { - AudioEncodingConfig, AudioSource, EncodedVideoPacketSource, EncodedAudioPacketSource, - VideoEncodingConfig, VideoSource, VideoSampleSource, AudioSampleSource, diff --git a/src/encode.ts b/src/encode.ts new file mode 100644 index 0000000..e3ea32e --- /dev/null +++ b/src/encode.ts @@ -0,0 +1,546 @@ +/*! + * Copyright (c) 2025-present, Vanilagy and contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { + AUDIO_CODECS, + AudioCodec, + buildAudioCodecString, + buildVideoCodecString, + getAudioEncoderConfigExtension, + getVideoEncoderConfigExtension, + inferCodecFromCodecString, + MediaCodec, + PCM_AUDIO_CODECS, + Quality, + SUBTITLE_CODECS, + SubtitleCodec, + VIDEO_CODECS, + VideoCodec, +} from './codec'; +import { customAudioEncoders, customVideoEncoders } from './custom-coder'; +import { EncodedPacket } from './packet'; + +/** + * Configuration object that controls video encoding. Can be used to set codec, quality, and more. + * @public + */ +export type VideoEncodingConfig = { + /** The video codec that should be used for encoding the video samples (frames). */ + codec: VideoCodec; + /** + * The target bitrate for the encoded video, in bits per second. Alternatively, a subjective Quality can + * be provided. + */ + bitrate: number | Quality; + /** + * The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key + * frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them + * all the same key frame interval. + */ + keyFrameInterval?: number; + + /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ + onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown; + /** Called when the internal encoder config, as used by the WebCodecs API, is created. */ + onEncoderConfig?: (config: VideoEncoderConfig) => unknown; +} & VideoEncodingAdditionalOptions; + +export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => { + if (!config || typeof config !== 'object') { + throw new TypeError('Encoding config must be an object.'); + } + if (!VIDEO_CODECS.includes(config.codec)) { + throw new TypeError(`Invalid video codec '${config.codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`); + } + if (!(config.bitrate instanceof Quality) && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) { + throw new TypeError('config.bitrate must be a positive integer or a quality.'); + } + if ( + config.keyFrameInterval !== undefined + && (!Number.isFinite(config.keyFrameInterval) || config.keyFrameInterval < 0) + ) { + throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.'); + } + if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { + throw new TypeError('config.onEncodedChunk, 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.'); + } + + validateVideoEncodingAdditionalOptions(config.codec, config); +}; + +/** + * Additional options that control audio encoding. + * @public + */ +export type VideoEncodingAdditionalOptions = { + /** Configures the bitrate mode. */ + bitrateMode?: 'constant' | 'variable'; + /** The latency mode used by the encoder; controls the performance-quality tradeoff. */ + latencyMode?: VideoEncoderConfig['latencyMode']; + /** + * The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec + * specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library. + */ + fullCodecString?: string; + /** A hint that configures the hardware acceleration method of this codec. This is best left on 'no-preference'. */ + hardwareAcceleration?: VideoEncoderConfig['hardwareAcceleration']; + /** + * An encoding scalability mode identifier as defined by + * [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*). + */ + scalabilityMode?: VideoEncoderConfig['scalabilityMode']; + /** + * An encoding video content hint as defined by + * [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints). + */ + contentHint?: VideoEncoderConfig['contentHint']; +}; + +export const validateVideoEncodingAdditionalOptions = (codec: VideoCodec, options: VideoEncodingAdditionalOptions) => { + if (!options || typeof options !== 'object') { + throw new TypeError('Encoding options must be an object.'); + } + if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) { + throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.'); + } + if (options.latencyMode !== undefined && !['quality', 'realtime'].includes(options.latencyMode)) { + throw new TypeError('latencyMode, when provided, must be \'quality\' or \'realtime\'.'); + } + if (options.fullCodecString !== undefined && typeof options.fullCodecString !== 'string') { + throw new TypeError('fullCodecString, when provided, must be a string.'); + } + if (options.fullCodecString !== undefined && inferCodecFromCodecString(options.fullCodecString) !== codec) { + throw new TypeError( + `fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`, + ); + } + if ( + options.hardwareAcceleration !== undefined + && !['no-preference', 'prefer-hardware', 'prefer-software'].includes(options.hardwareAcceleration) + ) { + throw new TypeError( + 'hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or' + + ' \'prefer-software\'.', + ); + } + if (options.scalabilityMode !== undefined && typeof options.scalabilityMode !== 'string') { + throw new TypeError('scalabilityMode, when provided, must be a string.'); + } + if (options.contentHint !== undefined && typeof options.contentHint !== 'string') { + throw new TypeError('contentHint, when provided, must be a string.'); + } +}; + +export const buildVideoEncoderConfig = (options: { + codec: VideoCodec; + width: number; + height: number; + bitrate: number | Quality; + framerate: number | undefined; +} & VideoEncodingAdditionalOptions): VideoEncoderConfig => { + const resolvedBitrate = options.bitrate instanceof Quality + ? options.bitrate._toVideoBitrate(options.codec, options.width, options.height) + : options.bitrate; + + return { + codec: options.fullCodecString ?? buildVideoCodecString( + options.codec, + options.width, + options.height, + resolvedBitrate, + ), + width: options.width, + height: options.height, + bitrate: resolvedBitrate, + bitrateMode: options.bitrateMode, + framerate: options.framerate, // this.source._connectedTrack?.metadata.frameRate, + latencyMode: options.latencyMode, + hardwareAcceleration: options.hardwareAcceleration, + scalabilityMode: options.scalabilityMode, + contentHint: options.contentHint, + ...getVideoEncoderConfigExtension(options.codec), + }; +}; + +/** + * Configuration object that controls audio encoding. Can be used to set codec, quality, and more. + * @public + */ +export type AudioEncodingConfig = { + /** The audio codec that should be used for encoding the audio samples. */ + codec: AudioCodec; + /** + * The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective Quality can + * be provided. Required for compressed audio codecs, unused for PCM codecs. + */ + bitrate?: number | Quality; + + /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ + onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown; + /** Called when the internal encoder config, as used by the WebCodecs API, is created. */ + onEncoderConfig?: (config: AudioEncoderConfig) => unknown; +} & AudioEncodingAdditionalOptions; + +export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => { + if (!config || typeof config !== 'object') { + throw new TypeError('Encoding config must be an object.'); + } + if (!AUDIO_CODECS.includes(config.codec)) { + throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`); + } + if ( + config.bitrate === undefined + && (!(PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac') + ) { + throw new TypeError('config.bitrate must be provided for compressed audio codecs.'); + } + if ( + config.bitrate !== undefined + && !(config.bitrate instanceof Quality) + && (!Number.isInteger(config.bitrate) || config.bitrate <= 0) + ) { + throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.'); + } + if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { + throw new TypeError('config.onEncodedChunk, 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.'); + } + + validateAudioEncodingAdditionalOptions(config.codec, config); +}; + +/** + * Additional options that control audio encoding. + * @public + */ +export type AudioEncodingAdditionalOptions = { + /** Configures the bitrate mode. */ + bitrateMode?: 'constant' | 'variable'; + /** + * The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec + * specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library. + */ + fullCodecString?: string; +}; + +export const validateAudioEncodingAdditionalOptions = (codec: AudioCodec, options: AudioEncodingAdditionalOptions) => { + if (!options || typeof options !== 'object') { + throw new TypeError('Encoding options must be an object.'); + } + if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) { + throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.'); + } + if (options.fullCodecString !== undefined && typeof options.fullCodecString !== 'string') { + throw new TypeError('fullCodecString, when provided, must be a string.'); + } + if (options.fullCodecString !== undefined && inferCodecFromCodecString(options.fullCodecString) !== codec) { + throw new TypeError( + `fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`, + ); + } +}; + +export const buildAudioEncoderConfig = (options: { + codec: AudioCodec; + numberOfChannels: number; + sampleRate: number; + bitrate?: number | Quality; +} & AudioEncodingAdditionalOptions): AudioEncoderConfig => { + const resolvedBitrate = options.bitrate instanceof Quality + ? options.bitrate._toAudioBitrate(options.codec) + : options.bitrate; + + return { + codec: options.fullCodecString ?? buildAudioCodecString( + options.codec, + options.numberOfChannels, + options.sampleRate, + ), + numberOfChannels: options.numberOfChannels, + sampleRate: options.sampleRate, + bitrate: resolvedBitrate, + bitrateMode: options.bitrateMode, + ...getAudioEncoderConfigExtension(options.codec), + }; +}; + +/** + * Checks if the browser is able to encode the given codec. + * @public + */ +export const canEncode = (codec: MediaCodec) => { + if ((VIDEO_CODECS as readonly string[]).includes(codec)) { + return canEncodeVideo(codec as VideoCodec); + } else if ((AUDIO_CODECS as readonly string[]).includes(codec)) { + return canEncodeAudio(codec as AudioCodec); + } else if ((SUBTITLE_CODECS as readonly string[]).includes(codec)) { + return canEncodeSubtitles(codec as SubtitleCodec); + } + + throw new TypeError(`Unknown codec '${codec}'.`); +}; + +/** + * Checks if the browser is able to encode the given video codec with the given parameters. + * @public + */ +export const canEncodeVideo = async (codec: VideoCodec, { + width = 1280, + height = 720, + bitrate = 1e6, + ...restOptions +}: { + width?: number; + height?: number; + bitrate?: number | Quality; +} & VideoEncodingAdditionalOptions = {}) => { + if (!VIDEO_CODECS.includes(codec)) { + return false; + } + if (!Number.isInteger(width) || width <= 0) { + throw new TypeError('width must be a positive integer.'); + } + if (!Number.isInteger(height) || height <= 0) { + throw new TypeError('height must be a positive integer.'); + } + if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) { + throw new TypeError('bitrate must be a positive integer or a quality.'); + } + validateVideoEncodingAdditionalOptions(codec, restOptions); + + let encoderConfig: VideoEncoderConfig | null = null; + + if (customVideoEncoders.length > 0) { + encoderConfig ??= buildVideoEncoderConfig({ + codec, + width, + height, + bitrate, + framerate: undefined, + ...restOptions, + }); + + if (customVideoEncoders.some(x => x.supports(codec, encoderConfig!))) { + // There's a custom encoder + return true; + } + } + + if (typeof VideoEncoder === 'undefined') { + return false; + } + + encoderConfig ??= buildVideoEncoderConfig({ + codec, + width, + height, + bitrate, + framerate: undefined, + ...restOptions, + }); + + const support = await VideoEncoder.isConfigSupported(encoderConfig); + return support.supported === true; +}; + +/** + * Checks if the browser is able to encode the given audio codec with the given parameters. + * @public + */ +export const canEncodeAudio = async (codec: AudioCodec, { + numberOfChannels = 2, + sampleRate = 48000, + bitrate = 128e3, + ...restOptions +}: { + numberOfChannels?: number; + sampleRate?: number; + bitrate?: number | Quality; +} & AudioEncodingAdditionalOptions = {}) => { + if (!AUDIO_CODECS.includes(codec)) { + return false; + } + if (!Number.isInteger(numberOfChannels) || numberOfChannels <= 0) { + throw new TypeError('numberOfChannels must be a positive integer.'); + } + if (!Number.isInteger(sampleRate) || sampleRate <= 0) { + throw new TypeError('sampleRate must be a positive integer.'); + } + if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) { + throw new TypeError('bitrate must be a positive integer.'); + } + validateAudioEncodingAdditionalOptions(codec, restOptions); + + let encoderConfig: AudioEncoderConfig | null = null; + + if (customAudioEncoders.length > 0) { + encoderConfig ??= buildAudioEncoderConfig({ + codec, + numberOfChannels, + sampleRate, + bitrate, + ...restOptions, + }); + + if (customAudioEncoders.some(x => x.supports(codec, encoderConfig!))) { + // There's a custom encoder + return true; + } + } + + if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) { + return true; // Because we encode these ourselves + } + + if (typeof AudioEncoder === 'undefined') { + return false; + } + + encoderConfig ??= buildAudioEncoderConfig({ + codec, + numberOfChannels, + sampleRate, + bitrate, + ...restOptions, + }); + + const support = await AudioEncoder.isConfigSupported(encoderConfig); + return support.supported === true; +}; + +/** + * Checks if the browser is able to encode the given subtitle codec. + * @public + */ +export const canEncodeSubtitles = async (codec: SubtitleCodec) => { + if (!SUBTITLE_CODECS.includes(codec)) { + return false; + } + + return true; +}; + +/** + * Returns the list of all media codecs that can be encoded by the browser. + * @public + */ +export const getEncodableCodecs = async (): Promise => { + const [videoCodecs, audioCodecs, subtitleCodecs] = await Promise.all([ + getEncodableVideoCodecs(), + getEncodableAudioCodecs(), + getEncodableSubtitleCodecs(), + ]); + + return [...videoCodecs, ...audioCodecs, ...subtitleCodecs]; +}; + +/** + * Returns the list of all video codecs that can be encoded by the browser. + * @public + */ +export const getEncodableVideoCodecs = async ( + checkedCodecs = VIDEO_CODECS as unknown as VideoCodec[], + options?: { + width?: number; + height?: number; + bitrate?: number | Quality; + }, +): Promise => { + const bools = await Promise.all(checkedCodecs.map(codec => canEncodeVideo(codec, options))); + return checkedCodecs.filter((_, i) => bools[i]); +}; + +/** + * Returns the list of all audio codecs that can be encoded by the browser. + * @public + */ +export const getEncodableAudioCodecs = async ( + checkedCodecs = AUDIO_CODECS as unknown as AudioCodec[], + options?: { + numberOfChannels?: number; + sampleRate?: number; + bitrate?: number | Quality; + }, +): Promise => { + const bools = await Promise.all(checkedCodecs.map(codec => canEncodeAudio(codec, options))); + return checkedCodecs.filter((_, i) => bools[i]); +}; + +/** + * Returns the list of all subtitle codecs that can be encoded by the browser. + * @public + */ +export const getEncodableSubtitleCodecs = async ( + checkedCodecs = SUBTITLE_CODECS as unknown as SubtitleCodec[], +): Promise => { + const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles)); + return checkedCodecs.filter((_, i) => bools[i]); +}; + +/** + * Returns the first video codec from the given list that can be encoded by the browser. + * @public + */ +export const getFirstEncodableVideoCodec = async ( + checkedCodecs: VideoCodec[], + options?: { + width?: number; + height?: number; + bitrate?: number | Quality; + }, +): Promise => { + for (const codec of checkedCodecs) { + if (await canEncodeVideo(codec, options)) { + return codec; + } + } + + return null; +}; + +/** + * Returns the first audio codec from the given list that can be encoded by the browser. + * @public + */ +export const getFirstEncodableAudioCodec = async ( + checkedCodecs: AudioCodec[], + options?: { + numberOfChannels?: number; + sampleRate?: number; + bitrate?: number | Quality; + }, +): Promise => { + for (const codec of checkedCodecs) { + if (await canEncodeAudio(codec, options)) { + return codec; + } + } + + return null; +}; + +/** + * Returns the first subtitle codec from the given list that can be encoded by the browser. + * @public + */ +export const getFirstEncodableSubtitleCodec = async ( + checkedCodecs: SubtitleCodec[], +): Promise => { + for (const codec of checkedCodecs) { + if (await canEncodeSubtitles(codec)) { + return codec; + } + } + + return null; +}; diff --git a/src/index.ts b/src/index.ts index fbceb47..9354d29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,8 +41,6 @@ export { InclusiveIntegerRange, } from './output-format'; export { - VideoEncodingConfig, - AudioEncodingConfig, MediaSource, VideoSource, EncodedVideoPacketSource, @@ -73,6 +71,12 @@ export { QUALITY_MEDIUM, QUALITY_HIGH, QUALITY_VERY_HIGH, +} from './codec'; +export { + VideoEncodingConfig, + VideoEncodingAdditionalOptions, + AudioEncodingConfig, + AudioEncodingAdditionalOptions, canEncode, canEncodeVideo, canEncodeAudio, @@ -84,7 +88,7 @@ export { getFirstEncodableVideoCodec, getFirstEncodableAudioCodec, getFirstEncodableSubtitleCodec, -} from './codec'; +} from './encode'; export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target'; export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc'; export { diff --git a/src/media-source.ts b/src/media-source.ts index ce44e75..96d9f9e 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -9,15 +9,9 @@ import { AUDIO_CODECS, AudioCodec, - buildAudioCodecString, - buildVideoCodecString, - getAudioEncoderConfigExtension, - getVideoEncoderConfigExtension, - inferCodecFromCodecString, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, - Quality, SUBTITLE_CODECS, SubtitleCodec, VIDEO_CODECS, @@ -36,6 +30,14 @@ import { } from './custom-coder'; import { EncodedPacket } from './packet'; import { AudioSample, VideoSample } from './sample'; +import { + AudioEncodingConfig, + buildAudioEncoderConfig, + buildVideoEncoderConfig, + validateAudioEncodingConfig, + validateVideoEncodingConfig, + VideoEncodingConfig, +} from './encode'; /** * Base class for media sources. Media sources are used to add media samples to an output file. @@ -183,74 +185,6 @@ export class EncodedVideoPacketSource extends VideoSource { } } -/** - * Configuration object that controls video encoding. Can be used to set codec, quality, and more. - * @public - */ -export type VideoEncodingConfig = { - /** The video codec that should be used for encoding the video samples (frames). */ - codec: VideoCodec; - /** - * The target bitrate for the encoded video, in bits per second. Alternatively, a subjective Quality can - * be provided. - */ - bitrate: number | Quality; - /** The latency mode used by the encoder; controls the performance-quality tradeoff. */ - latencyMode?: VideoEncoderConfig['latencyMode']; - /** - * The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key - * frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them - * all the same key frame interval. - */ - keyFrameInterval?: number; - /** - * The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec - * specified in `codec`. When not set, 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 the internal encoder config, as used by the WebCodecs API, is created. */ - onEncoderConfig?: (config: VideoEncoderConfig) => unknown; -}; - -const validateVideoEncodingConfig = (config: VideoEncodingConfig) => { - if (!config || typeof config !== 'object') { - throw new TypeError('Encoding config must be an object.'); - } - if (!VIDEO_CODECS.includes(config.codec)) { - throw new TypeError(`Invalid video codec '${config.codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`); - } - if (!(config.bitrate instanceof Quality) && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) { - throw new TypeError('config.bitrate must be a positive integer or a quality.'); - } - if (config.latencyMode !== undefined && !['quality', 'realtime'].includes(config.latencyMode)) { - throw new TypeError('config.latencyMode, when provided, must be \'quality\' or \'realtime\'.'); - } - if ( - config.keyFrameInterval !== undefined - && (!Number.isFinite(config.keyFrameInterval) || config.keyFrameInterval < 0) - ) { - 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.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { - throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); - } -}; - class VideoEncoderWrapper { private ensureEncoderPromise: Promise | null = null; private encoderInitialized = false; @@ -370,26 +304,12 @@ class VideoEncoderWrapper { } return this.ensureEncoderPromise = (async () => { - const width = videoSample.codedWidth; - const height = videoSample.codedHeight; - const bitrate = this.encodingConfig.bitrate instanceof Quality - ? this.encodingConfig.bitrate._toVideoBitrate(this.encodingConfig.codec, width, height) - : this.encodingConfig.bitrate; - - const encoderConfig: VideoEncoderConfig = { - codec: this.encodingConfig.fullCodecString ?? buildVideoCodecString( - this.encodingConfig.codec, - width, - height, - bitrate, - ), - width, - height, - bitrate, + const encoderConfig = buildVideoEncoderConfig({ + width: videoSample.codedWidth, + height: videoSample.codedHeight, + ...this.encodingConfig, framerate: this.source._connectedTrack?.metadata.frameRate, - latencyMode: this.encodingConfig.latencyMode, - ...getVideoEncoderConfigExtension(this.encodingConfig.codec), - }; + }); this.encodingConfig.onEncoderConfig?.(encoderConfig); const MatchingCustomEncoder = customVideoEncoders.find(x => x.supports( @@ -813,67 +733,6 @@ export class EncodedAudioPacketSource extends AudioSource { } } -/** - * Configuration object that controls audio encoding. Can be used to set codec, quality, and more. - * @public - */ -export type AudioEncodingConfig = { - /** The audio codec that should be used for encoding the audio samples. */ - codec: AudioCodec; - /** - * The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective Quality can - * be provided. Required for compressed audio codecs, unused for PCM codecs. - */ - bitrate?: number | Quality; - /** - * The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec - * specified in `codec`. When not set, 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 the internal encoder config, as used by the WebCodecs API, is created. */ - onEncoderConfig?: (config: AudioEncoderConfig) => unknown; -}; - -const validateAudioEncodingConfig = (config: AudioEncodingConfig) => { - if (!config || typeof config !== 'object') { - throw new TypeError('Encoding config must be an object.'); - } - if (!AUDIO_CODECS.includes(config.codec)) { - throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`); - } - if ( - config.bitrate === undefined - && (!(PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac') - ) { - throw new TypeError('config.bitrate must be provided for compressed audio codecs.'); - } - if ( - config.bitrate !== undefined - && !(config.bitrate instanceof Quality) - && (!Number.isInteger(config.bitrate) || config.bitrate <= 0) - ) { - throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.'); - } - 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.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { - throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); - } -}; - class AudioEncoderWrapper { private ensureEncoderPromise: Promise | null = null; private encoderInitialized = false; @@ -1061,21 +920,12 @@ class AudioEncoderWrapper { return this.ensureEncoderPromise = (async () => { const { numberOfChannels, sampleRate } = audioSample; - const bitrate = this.encodingConfig.bitrate instanceof Quality - ? this.encodingConfig.bitrate._toAudioBitrate(this.encodingConfig.codec) - : this.encodingConfig.bitrate; - const encoderConfig: AudioEncoderConfig = { - codec: this.encodingConfig.fullCodecString ?? buildAudioCodecString( - this.encodingConfig.codec, - numberOfChannels, - sampleRate, - ), + const encoderConfig = buildAudioEncoderConfig({ numberOfChannels, sampleRate, - bitrate, - ...getAudioEncoderConfigExtension(this.encodingConfig.codec), - }; + ...this.encodingConfig, + }); this.encodingConfig.onEncoderConfig?.(encoderConfig); const MatchingCustomEncoder = customAudioEncoders.find(x => x.supports(