Add support for custom encoders & decoders

This commit is contained in:
Vanilagy
2025-01-26 17:32:10 +01:00
parent bb9f56b1f0
commit ffd0835e9a
8 changed files with 465 additions and 103 deletions
+5 -1
View File
@@ -43,8 +43,12 @@
codec: 'aac', codec: 'aac',
}, },
*/ */
video: {
codec: 'avc',
},
audio: { audio: {
discard: true codec: 'mp3',
bitrate: 320000
}, },
trim: { trim: {
start: 0, start: 0,
+7 -4
View File
@@ -22,15 +22,18 @@
}); });
const audioTrack = await input.getPrimaryAudioTrack(); const audioTrack = await input.getPrimaryAudioTrack();
const sink = new Metamuxer.EncodedAudioSampleSink(audioTrack); const sink = new Metamuxer.AudioDataSink(audioTrack);
const mediaSource = new Metamuxer.EncodedAudioSampleSource(await audioTrack.getCodec()); const mediaSource = new Metamuxer.AudioDataSource({
codec: 'mp3',
bitrate: 192000
});
output.addAudioTrack(mediaSource); output.addAudioTrack(mediaSource);
output.start(); output.start();
for await (const sample of sink.samples()) { for await (const { data } of sink.data()) {
await mediaSource.digest(sample); await mediaSource.digest(data);
} }
await output.finalize(); await output.finalize();
+47 -1
View File
@@ -1,3 +1,4 @@
import { customAudioEncoders, customVideoEncoders } from './custom-coder';
import { import {
COLOR_PRIMARIES_MAP, COLOR_PRIMARIES_MAP,
MATRIX_COEFFICIENTS_MAP, MATRIX_COEFFICIENTS_MAP,
@@ -1098,7 +1099,13 @@ export class Quality {
} else if (codec === 'opus' || codec === 'vorbis') { } else if (codec === 'opus' || codec === 'vorbis') {
finalBitrate = Math.max(6000, finalBitrate); finalBitrate = Math.max(6000, finalBitrate);
} else if (codec === 'mp3') { } else if (codec === 'mp3') {
finalBitrate = Math.round(finalBitrate / 32000) * 32000; const validRates = [
8000, 16000, 24000, 32000, 40000, 48000, 64000, 80000,
96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000,
];
finalBitrate = validRates.reduce((prev, curr) =>
Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev,
);
} }
return Math.round(finalBitrate / 1000) * 1000; return Math.round(finalBitrate / 1000) * 1000;
@@ -1441,6 +1448,26 @@ export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height =
throw new TypeError('bitrate must be a positive integer.'); throw new TypeError('bitrate must be a positive integer.');
} }
if (customVideoEncoders.length > 0) {
const encoderConfig: VideoEncoderConfig = {
codec: buildVideoCodecString(
codec,
width,
height,
bitrate,
),
width,
height,
bitrate,
...getVideoEncoderConfigExtension(codec),
};
if (customVideoEncoders.some(x => x.supports(codec, encoderConfig))) {
// There's a custom encoder
return true;
}
}
if (typeof VideoEncoder === 'undefined') { if (typeof VideoEncoder === 'undefined') {
return false; return false;
} }
@@ -1475,6 +1502,25 @@ export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2,
throw new TypeError('bitrate must be a positive integer.'); throw new TypeError('bitrate must be a positive integer.');
} }
if (customAudioEncoders.length > 0) {
const encoderConfig: AudioEncoderConfig = {
codec: buildAudioCodecString(
codec,
numberOfChannels,
sampleRate,
),
numberOfChannels,
sampleRate,
bitrate,
...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)) { if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
return true; // Because we encode these ourselves return true; // Because we encode these ourselves
} }
+101
View File
@@ -0,0 +1,101 @@
import { AudioCodec, VideoCodec } from './codec';
import { EncodedAudioSample, EncodedVideoSample } from './sample';
/** @public */
export class CustomVideoDecoder {
constructor(
public codec: VideoCodec,
public config: VideoDecoderConfig,
public onFrame: (frame: VideoFrame) => unknown,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: VideoCodec, config: VideoDecoderConfig): boolean {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
decode(sample: EncodedVideoSample): Promise<void> | void {}
flush(): Promise<void> | void {}
}
/** @public */
export class CustomAudioDecoder {
constructor(
public codec: AudioCodec,
public config: AudioDecoderConfig,
public onData: (data: AudioData) => unknown,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
decode(sample: EncodedAudioSample): Promise<void> | void {}
flush(): Promise<void> | void {}
}
/** @public */
export class CustomVideoEncoder {
constructor(
public codec: VideoCodec,
public config: VideoEncoderConfig,
public onSample: (sample: EncodedVideoSample, meta?: EncodedVideoChunkMetadata) => unknown,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: VideoCodec, config: VideoEncoderConfig): boolean {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
encode(videoFrame: VideoFrame, options: VideoEncoderEncodeOptions): Promise<void> | void {}
flush(): Promise<void> | void {}
}
/** @public */
export class CustomAudioEncoder {
constructor(
public codec: AudioCodec,
public config: AudioEncoderConfig,
public onSample: (sample: EncodedAudioSample, meta?: EncodedAudioChunkMetadata) => unknown,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: AudioCodec, config: AudioEncoderConfig): boolean {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
encode(audioData: AudioData): Promise<void> | void {}
flush(): Promise<void> | void {}
}
export const customVideoDecoders: typeof CustomVideoDecoder[] = [];
export const customAudioDecoders: typeof CustomAudioDecoder[] = [];
export const customVideoEncoders: typeof CustomVideoEncoder[] = [];
export const customAudioEncoders: typeof CustomAudioEncoder[] = [];
/** @public */
export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof CustomAudioDecoder) => {
if (decoder.prototype instanceof CustomVideoDecoder) {
customVideoDecoders.push(decoder as typeof CustomVideoDecoder);
} else if (decoder.prototype instanceof CustomAudioDecoder) {
customAudioDecoders.push(decoder as typeof CustomAudioDecoder);
} else {
throw new TypeError('Decoder must be a CustomVideoDecoder or CustomAudioDecoder.');
}
};
/** @public */
export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof CustomAudioEncoder) => {
if (encoder.prototype instanceof CustomVideoEncoder) {
customVideoEncoders.push(encoder as typeof CustomVideoEncoder);
} else if (encoder.prototype instanceof CustomAudioEncoder) {
customAudioEncoders.push(encoder as typeof CustomAudioEncoder);
} else {
throw new TypeError('Encoder must be a CustomVideoEncoder or CustomAudioEncoder.');
}
};
+8
View File
@@ -108,5 +108,13 @@ export {
WrappedAudioBuffer, WrappedAudioBuffer,
} from './media-sink'; } from './media-sink';
export { convert, ConversionOptions, ConversionInfo } from './conversion'; export { convert, ConversionOptions, ConversionInfo } from './conversion';
export {
CustomVideoDecoder,
CustomAudioDecoder,
CustomVideoEncoder,
CustomAudioEncoder,
registerDecoder,
registerEncoder,
} from './custom-coder';
// 🐡🦔 // 🐡🦔
+24 -1
View File
@@ -1,6 +1,7 @@
import { AudioCodec, MediaCodec, VideoCodec } from './codec'; import { AudioCodec, MediaCodec, VideoCodec } from './codec';
import { customAudioDecoders, customVideoDecoders } from './custom-coder';
import { EncodedAudioSampleSink, EncodedVideoSampleSink, SampleRetrievalOptions } from './media-sink'; import { EncodedAudioSampleSink, EncodedVideoSampleSink, SampleRetrievalOptions } from './media-sink';
import { Rotation } from './misc'; import { assert, Rotation } from './misc';
import { TrackType } from './output'; import { TrackType } from './output';
import { EncodedAudioSample, EncodedVideoSample } from './sample'; import { EncodedAudioSample, EncodedVideoSample } from './sample';
@@ -137,6 +138,17 @@ export class InputVideoTrack extends InputTrack {
return false; return false;
} }
const codec = await this.getCodec();
assert(codec !== null);
if (customVideoDecoders.some(x => x.supports(codec, decoderConfig))) {
return true;
}
if (typeof VideoDecoder === 'undefined') {
return false;
}
const support = await VideoDecoder.isConfigSupported(decoderConfig); const support = await VideoDecoder.isConfigSupported(decoderConfig);
return support.supported === true; return support.supported === true;
} catch (error) { } catch (error) {
@@ -206,9 +218,20 @@ export class InputAudioTrack extends InputTrack {
return false; return false;
} }
const codec = await this.getCodec();
assert(codec !== null);
if (customAudioDecoders.some(x => x.supports(codec, decoderConfig))) {
return true;
}
if (decoderConfig.codec.startsWith('pcm-')) { if (decoderConfig.codec.startsWith('pcm-')) {
return true; // Since we decode it ourselves return true; // Since we decode it ourselves
} else { } else {
if (typeof AudioDecoder === 'undefined') {
return false;
}
const support = await AudioDecoder.isConfigSupported(decoderConfig); const support = await AudioDecoder.isConfigSupported(decoderConfig);
return support.supported === true; return support.supported === true;
} }
+93 -14
View File
@@ -1,4 +1,5 @@
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec } from './codec'; import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder';
import { InputAudioTrack, InputVideoTrack } from './input-track'; import { InputAudioTrack, InputVideoTrack } from './input-track';
import { import {
AnyIterable, AnyIterable,
@@ -579,18 +580,22 @@ export class EncodedVideoSampleSink extends BaseSampleSink<EncodedVideoSample> {
} }
class VideoDecoderWrapper extends DecoderWrapper<EncodedVideoSample, VideoFrame> { class VideoDecoderWrapper extends DecoderWrapper<EncodedVideoSample, VideoFrame> {
decoder: VideoDecoder; decoder: VideoDecoder | null = null;
pendingSamples: EncodedVideoSample[] = []; pendingSamples: EncodedVideoSample[] = [];
customDecoder: CustomVideoDecoder | null = null;
lastCustomDecoderPromise = Promise.resolve();
customDecoderQueueSize = 0;
constructor( constructor(
onFrame: (frame: WrappedMediaFrame<VideoFrame, EncodedVideoSample>) => unknown, onFrame: (frame: WrappedMediaFrame<VideoFrame, EncodedVideoSample>) => unknown,
onError: (error: DOMException) => unknown, onError: (error: DOMException) => unknown,
codec: VideoCodec,
decoderConfig: VideoDecoderConfig, decoderConfig: VideoDecoderConfig,
) { ) {
super(onFrame, onError); super(onFrame, onError);
this.decoder = new VideoDecoder({ const frameHandler = (frame: VideoFrame) => {
output: (frame) => {
const sample = this.pendingSamples.shift(); const sample = this.pendingSamples.shift();
assert(sample); assert(sample);
@@ -605,31 +610,64 @@ class VideoDecoderWrapper extends DecoderWrapper<EncodedVideoSample, VideoFrame>
timestamp, timestamp,
duration, duration,
}); });
}, };
const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
if (MatchingCustomDecoder) {
this.customDecoder = new MatchingCustomDecoder(codec, decoderConfig, frameHandler);
} else {
this.decoder = new VideoDecoder({
output: frameHandler,
error: onError, error: onError,
}); });
this.decoder.configure(decoderConfig); this.decoder.configure(decoderConfig);
} }
}
getDecodeQueueSize() { getDecodeQueueSize() {
if (this.customDecoder) {
return this.customDecoderQueueSize;
} else {
assert(this.decoder);
return this.decoder.decodeQueueSize; return this.decoder.decodeQueueSize;
} }
}
decode(sample: EncodedVideoSample) { decode(sample: EncodedVideoSample) {
// We know the decoder spits out frames in sorted order, so we need to insert the sample in the right place // We know the decoder spits out frames in sorted order, so we need to insert the sample in the right place
const insertionIndex = binarySearchLessOrEqual(this.pendingSamples, sample.timestamp, x => x.timestamp); const insertionIndex = binarySearchLessOrEqual(this.pendingSamples, sample.timestamp, x => x.timestamp);
this.pendingSamples.splice(insertionIndex + 1, 0, sample); this.pendingSamples.splice(insertionIndex + 1, 0, sample);
if (this.customDecoder) {
this.customDecoderQueueSize++;
this.lastCustomDecoderPromise = this.lastCustomDecoderPromise.then(() => {
return this.customDecoder!.decode(sample);
});
void this.lastCustomDecoderPromise.then(() => this.customDecoderQueueSize--);
} else {
assert(this.decoder);
this.decoder.decode(sample.toEncodedVideoChunk()); this.decoder.decode(sample.toEncodedVideoChunk());
} }
}
flush() { flush() {
if (this.customDecoder) {
return this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
return this.decoder.flush(); return this.decoder.flush();
} }
}
close() { close() {
if (this.customDecoder) {
void this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
this.decoder.close(); this.decoder.close();
} }
}
} }
/** @public */ /** @public */
@@ -666,10 +704,11 @@ export class VideoFrameSink extends BaseMediaFrameSink<EncodedVideoSample, Video
); );
} }
const codec = await this._videoTrack.getCodec();
const decoderConfig = await this._videoTrack.getDecoderConfig(); const decoderConfig = await this._videoTrack.getDecoderConfig();
assert(decoderConfig); assert(codec && decoderConfig);
return new VideoDecoderWrapper(onFrame, onError, decoderConfig); return new VideoDecoderWrapper(onFrame, onError, codec, decoderConfig);
} }
/** @internal */ /** @internal */
@@ -848,22 +887,26 @@ export class EncodedAudioSampleSink extends BaseSampleSink<EncodedAudioSample> {
} }
class AudioDecoderWrapper extends DecoderWrapper<EncodedAudioSample, AudioData> { class AudioDecoderWrapper extends DecoderWrapper<EncodedAudioSample, AudioData> {
decoder: AudioDecoder; decoder: AudioDecoder | null = null;
pendingSamples: EncodedAudioSample[] = []; pendingSamples: EncodedAudioSample[] = [];
customDecoder: CustomAudioDecoder | null = null;
lastCustomDecoderPromise = Promise.resolve();
customDecoderQueueSize = 0;
constructor( constructor(
onData: (data: WrappedMediaFrame<AudioData, EncodedAudioSample>) => unknown, onData: (data: WrappedMediaFrame<AudioData, EncodedAudioSample>) => unknown,
onError: (error: DOMException) => unknown, onError: (error: DOMException) => unknown,
codec: AudioCodec,
decoderConfig: AudioDecoderConfig, decoderConfig: AudioDecoderConfig,
) { ) {
super(onData, onError); super(onData, onError);
this.decoder = new AudioDecoder({ output: (data) => { const dataHandler = (data: AudioData) => {
const sample = this.pendingSamples.shift(); const sample = this.pendingSamples.shift();
assert(sample); assert(sample);
// We use the timing information from the data instead of sample as it will be more accurate. However, // We use the timing information from the data instead of sample as it will be more accurate
// we also know these need to be multiple of the sample length, so let's round:
const timestamp = Math.round(data.timestamp / 1e6 * decoderConfig.sampleRate) / decoderConfig.sampleRate; const timestamp = Math.round(data.timestamp / 1e6 * decoderConfig.sampleRate) / decoderConfig.sampleRate;
const duration = Math.round(data.duration / 1e6 * decoderConfig.sampleRate) / decoderConfig.sampleRate; const duration = Math.round(data.duration / 1e6 * decoderConfig.sampleRate) / decoderConfig.sampleRate;
@@ -873,29 +916,64 @@ class AudioDecoderWrapper extends DecoderWrapper<EncodedAudioSample, AudioData>
timestamp, timestamp,
duration, duration,
}); });
}, error: onError }); };
const MatchingCustomDecoder = customAudioDecoders.find(x => x.supports(codec, decoderConfig));
if (MatchingCustomDecoder) {
this.customDecoder = new MatchingCustomDecoder(codec, decoderConfig, dataHandler);
} else {
this.decoder = new AudioDecoder({
output: dataHandler,
error: onError,
});
this.decoder.configure(decoderConfig); this.decoder.configure(decoderConfig);
} }
}
getDecodeQueueSize() { getDecodeQueueSize() {
if (this.customDecoder) {
return this.customDecoderQueueSize;
} else {
assert(this.decoder);
return this.decoder.decodeQueueSize; return this.decoder.decodeQueueSize;
} }
}
decode(sample: EncodedAudioSample) { decode(sample: EncodedAudioSample) {
// We know the decoder spits out data in sorted order, so we need to insert the sample in the right place // We know the decoder spits out data in sorted order, so we need to insert the sample in the right place
const insertionIndex = binarySearchLessOrEqual(this.pendingSamples, sample.timestamp, x => x.timestamp); const insertionIndex = binarySearchLessOrEqual(this.pendingSamples, sample.timestamp, x => x.timestamp);
this.pendingSamples.splice(insertionIndex + 1, 0, sample); this.pendingSamples.splice(insertionIndex + 1, 0, sample);
if (this.customDecoder) {
this.customDecoderQueueSize++;
this.lastCustomDecoderPromise = this.lastCustomDecoderPromise.then(() => {
return this.customDecoder!.decode(sample);
});
void this.lastCustomDecoderPromise.then(() => this.customDecoderQueueSize--);
} else {
assert(this.decoder);
this.decoder.decode(sample.toEncodedAudioChunk()); this.decoder.decode(sample.toEncodedAudioChunk());
} }
}
flush() { flush() {
if (this.customDecoder) {
return this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
return this.decoder.flush(); return this.decoder.flush();
} }
}
close() { close() {
if (this.customDecoder) {
void this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
this.decoder.close(); this.decoder.close();
} }
}
} }
// There are a lot of PCM variants not natively supported by the browser and by AudioData. Therefore we need a simple // There are a lot of PCM variants not natively supported by the browser and by AudioData. Therefore we need a simple
@@ -1102,13 +1180,14 @@ export class AudioDataSink extends BaseMediaFrameSink<EncodedAudioSample, AudioD
); );
} }
const codec = await this._audioTrack.getCodec();
const decoderConfig = await this._audioTrack.getDecoderConfig(); const decoderConfig = await this._audioTrack.getDecoderConfig();
assert(decoderConfig); assert(codec && decoderConfig);
if ((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec)) { if ((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec)) {
return new PcmAudioDecoderWrapper(onData, onError, decoderConfig); return new PcmAudioDecoderWrapper(onData, onError, decoderConfig);
} else { } else {
return new AudioDecoderWrapper(onData, onError, decoderConfig); return new AudioDecoderWrapper(onData, onError, codec, decoderConfig);
} }
} }
+125 -27
View File
@@ -20,6 +20,12 @@ import { Muxer } from './muxer';
import { SubtitleParser } from './subtitles'; import { SubtitleParser } from './subtitles';
import { EncodedAudioSample, EncodedVideoSample } from './sample'; import { EncodedAudioSample, EncodedVideoSample } from './sample';
import { toAlaw, toUlaw } from './pcm'; import { toAlaw, toUlaw } from './pcm';
import {
CustomVideoEncoder,
CustomAudioEncoder,
customVideoEncoders,
customAudioEncoders,
} from './custom-coder';
/** @public */ /** @public */
export abstract class MediaSource { export abstract class MediaSource {
@@ -175,15 +181,18 @@ const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
class VideoEncoderWrapper { class VideoEncoderWrapper {
private ensureEncoderPromise: Promise<void> | null = null; private ensureEncoderPromise: Promise<void> | null = null;
private encoderInitialized = false;
private encoder: VideoEncoder | null = null; private encoder: VideoEncoder | null = null;
private muxer: Muxer | null = null; private muxer: Muxer | null = null;
private lastMultipleOfKeyFrameInterval = -1; private lastMultipleOfKeyFrameInterval = -1;
private lastWidth: number | null = null; private lastWidth: number | null = null;
private lastHeight: number | null = null; private lastHeight: number | null = null;
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) { private customEncoder: CustomVideoEncoder | null = null;
validateVideoEncodingConfig(encodingConfig); private lastCustomEncoderPromise = Promise.resolve();
} private customEncoderQueueSize = 0;
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {}
async digest(videoFrame: VideoFrame, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) { async digest(videoFrame: VideoFrame, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) {
this.source._ensureValidDigest(); this.source._ensureValidDigest();
@@ -201,14 +210,14 @@ class VideoEncoderWrapper {
this.lastHeight = videoFrame.codedHeight; this.lastHeight = videoFrame.codedHeight;
} }
if (!this.encoder) { if (!this.encoderInitialized) {
if (this.ensureEncoderPromise) { if (this.ensureEncoderPromise) {
await this.ensureEncoderPromise; await this.ensureEncoderPromise;
} else { } else {
await this.ensureEncoder(videoFrame); await this.ensureEncoder(videoFrame);
} }
} }
assert(this.encoder); assert(this.encoderInitialized);
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 5; const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 5;
const multipleOfKeyFrameInterval = Math.floor((videoFrame.timestamp / 1e6) / keyFrameInterval); const multipleOfKeyFrameInterval = Math.floor((videoFrame.timestamp / 1e6) / keyFrameInterval);
@@ -216,23 +225,44 @@ class VideoEncoderWrapper {
// Ensure a key frame every KEY_FRAME_INTERVAL seconds. It is important that all video tracks follow the same // Ensure a key frame every KEY_FRAME_INTERVAL seconds. It is important that all video tracks follow the same
// "key frame" rhythm, because aligned key frames are required to start new fragments in ISOBMFF or clusters // "key frame" rhythm, because aligned key frames are required to start new fragments in ISOBMFF or clusters
// in Matroska. // in Matroska.
this.encoder.encode(videoFrame, { const finalEncodeOptions = {
...encodeOptions, ...encodeOptions,
keyFrame: encodeOptions?.keyFrame keyFrame: encodeOptions?.keyFrame
|| keyFrameInterval === 0 || keyFrameInterval === 0
|| multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval, || multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval,
};
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
if (this.customEncoder) {
this.customEncoderQueueSize++;
this.lastCustomEncoderPromise = this.lastCustomEncoderPromise.then(() => {
return this.customEncoder!.encode(videoFrame, finalEncodeOptions);
}); });
void this.lastCustomEncoderPromise.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
videoFrame.close();
}
});
if (this.customEncoderQueueSize >= 4) {
await this.lastCustomEncoderPromise;
}
} else {
assert(this.encoder);
this.encoder.encode(videoFrame, finalEncodeOptions);
if (shouldClose) { if (shouldClose) {
videoFrame.close(); videoFrame.close();
} }
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
// We need to do this after sending the frame to the encoder as the frame otherwise might be closed // We need to do this after sending the frame to the encoder as the frame otherwise might be closed
if (this.encoder.encodeQueueSize >= 4) { if (this.encoder.encodeQueueSize >= 4) {
await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true })); await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true }));
} }
}
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
} }
@@ -242,10 +272,6 @@ class VideoEncoderWrapper {
return; return;
} }
if (typeof VideoEncoder === 'undefined') {
throw new Error('VideoEncoder is not supported by this browser.');
}
const { promise, resolve } = promiseWithResolvers(); const { promise, resolve } = promiseWithResolvers();
this.ensureEncoderPromise = promise; this.ensureEncoderPromise = promise;
@@ -269,11 +295,31 @@ class VideoEncoderWrapper {
latencyMode: this.encodingConfig.latencyMode, latencyMode: this.encodingConfig.latencyMode,
...getVideoEncoderConfigExtension(this.encodingConfig.codec), ...getVideoEncoderConfigExtension(this.encodingConfig.codec),
}; };
const MatchingCustomEncoder = customVideoEncoders.find(x => x.supports(
this.encodingConfig.codec,
encoderConfig,
));
if (MatchingCustomEncoder) {
this.customEncoder = new MatchingCustomEncoder(
this.encodingConfig.codec,
encoderConfig,
(sample, meta) => {
this.encodingConfig.onEncodedSample?.(sample, meta);
void this.muxer!.addEncodedVideoSample(this.source._connectedTrack!, sample, meta);
},
);
} else {
if (typeof VideoEncoder === 'undefined') {
throw new Error('VideoEncoder is not supported by this browser.');
}
const support = await VideoEncoder.isConfigSupported(encoderConfig); const support = await VideoEncoder.isConfigSupported(encoderConfig);
if (!support.supported) { if (!support.supported) {
throw new Error( throw new Error(
'This specific encoder configuration is not supported by this browser. Consider using another codec or' 'This specific encoder configuration is not supported by this browser. Consider using another codec'
+ ' changing your video parameters.', + ' or changing your video parameters.',
); );
} }
@@ -287,15 +333,20 @@ class VideoEncoderWrapper {
error: this.encodingConfig.onEncodingError ?? (error => console.error('VideoEncoder error:', error)), error: this.encodingConfig.onEncodingError ?? (error => console.error('VideoEncoder error:', error)),
}); });
this.encoder.configure(encoderConfig); this.encoder.configure(encoderConfig);
}
assert(this.source._connectedTrack); assert(this.source._connectedTrack);
this.muxer = this.source._connectedTrack.output._muxer; this.muxer = this.source._connectedTrack.output._muxer;
this.encoderInitialized = true;
resolve(); resolve();
} }
async flush() { async flush() {
if (this.encoder) { if (this.customEncoder) {
await this.lastCustomEncoderPromise.then(() => this.customEncoder!.flush());
} else if (this.encoder) {
await this.encoder.flush(); await this.encoder.flush();
this.encoder.close(); this.encoder.close();
} }
@@ -308,6 +359,8 @@ export class VideoFrameSource extends VideoSource {
private _encoder: VideoEncoderWrapper; private _encoder: VideoEncoderWrapper;
constructor(encodingConfig: VideoEncodingConfig) { constructor(encodingConfig: VideoEncodingConfig) {
validateVideoEncodingConfig(encodingConfig);
super(encodingConfig.codec); super(encodingConfig.codec);
this._encoder = new VideoEncoderWrapper(this, encodingConfig); this._encoder = new VideoEncoderWrapper(this, encodingConfig);
} }
@@ -337,6 +390,7 @@ export class CanvasSource extends VideoSource {
if (!(canvas instanceof HTMLCanvasElement)) { if (!(canvas instanceof HTMLCanvasElement)) {
throw new TypeError('canvas must be an HTMLCanvasElement.'); throw new TypeError('canvas must be an HTMLCanvasElement.');
} }
validateVideoEncodingConfig(encodingConfig);
super(encodingConfig.codec); super(encodingConfig.codec);
this._encoder = new VideoEncoderWrapper(this, encodingConfig); this._encoder = new VideoEncoderWrapper(this, encodingConfig);
@@ -382,6 +436,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') { if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') {
throw new TypeError('track must be a video MediaStreamTrack.'); throw new TypeError('track must be a video MediaStreamTrack.');
} }
validateVideoEncodingConfig(encodingConfig);
encodingConfig = { encodingConfig = {
...encodingConfig, ...encodingConfig,
@@ -507,9 +562,11 @@ class AudioEncoderWrapper {
private outputSampleSize: number | null = null; private outputSampleSize: number | null = null;
private writeOutputValue: ((view: DataView, byteOffset: number, value: number) => void) | null = null; private writeOutputValue: ((view: DataView, byteOffset: number, value: number) => void) | null = null;
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) { private customEncoder: CustomAudioEncoder | null = null;
validateAudioEncodingConfig(encodingConfig); private lastCustomEncoderPromise = Promise.resolve();
} private customEncoderQueueSize = 0;
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
async digest(audioData: AudioData, shouldClose: boolean) { async digest(audioData: AudioData, shouldClose: boolean) {
this.source._ensureValidDigest(); this.source._ensureValidDigest();
@@ -540,7 +597,26 @@ class AudioEncoderWrapper {
} }
assert(this.encoderInitialized); assert(this.encoderInitialized);
if (this.isPcmEncoder) { if (this.customEncoder) {
this.customEncoderQueueSize++;
this.lastCustomEncoderPromise = this.lastCustomEncoderPromise.then(() => {
return this.customEncoder!.encode(audioData);
});
void this.lastCustomEncoderPromise.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
audioData.close();
}
});
if (this.customEncoderQueueSize >= 4) {
await this.lastCustomEncoderPromise;
}
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
} else if (this.isPcmEncoder) {
await this.doPcmEncoding(audioData, shouldClose); await this.doPcmEncoding(audioData, shouldClose);
} else { } else {
assert(this.encoder); assert(this.encoder);
@@ -638,13 +714,6 @@ class AudioEncoderWrapper {
const { promise, resolve } = promiseWithResolvers(); const { promise, resolve } = promiseWithResolvers();
this.ensureEncoderPromise = promise; this.ensureEncoderPromise = promise;
if ((PCM_AUDIO_CODECS as readonly string[]).includes(this.encodingConfig.codec)) {
this.initPcmEncoder();
} else {
if (typeof AudioEncoder === 'undefined') {
throw new Error('AudioEncoder is not supported by this browser.');
}
const { numberOfChannels, sampleRate } = audioData; const { numberOfChannels, sampleRate } = audioData;
const bitrate = this.encodingConfig.bitrate instanceof Quality const bitrate = this.encodingConfig.bitrate instanceof Quality
? this.encodingConfig.bitrate._toAudioBitrate(this.encodingConfig.codec) ? this.encodingConfig.bitrate._toAudioBitrate(this.encodingConfig.codec)
@@ -661,6 +730,28 @@ class AudioEncoderWrapper {
bitrate, bitrate,
...getAudioEncoderConfigExtension(this.encodingConfig.codec), ...getAudioEncoderConfigExtension(this.encodingConfig.codec),
}; };
const MatchingCustomEncoder = customAudioEncoders.find(x => x.supports(
this.encodingConfig.codec,
encoderConfig,
));
if (MatchingCustomEncoder) {
this.customEncoder = new MatchingCustomEncoder(
this.encodingConfig.codec,
encoderConfig,
(sample, meta) => {
this.encodingConfig.onEncodedSample?.(sample, meta);
void this.muxer!.addEncodedAudioSample(this.source._connectedTrack!, sample, meta);
},
);
} else if ((PCM_AUDIO_CODECS as readonly string[]).includes(this.encodingConfig.codec)) {
this.initPcmEncoder();
} else {
if (typeof AudioEncoder === 'undefined') {
throw new Error('AudioEncoder is not supported by this browser.');
}
const support = await AudioEncoder.isConfigSupported(encoderConfig); const support = await AudioEncoder.isConfigSupported(encoderConfig);
if (!support.supported) { if (!support.supported) {
throw new Error( throw new Error(
@@ -770,7 +861,9 @@ class AudioEncoderWrapper {
} }
async flush() { async flush() {
if (this.encoder) { if (this.customEncoder) {
await this.lastCustomEncoderPromise.then(() => this.customEncoder!.flush());
} else if (this.encoder) {
await this.encoder.flush(); await this.encoder.flush();
this.encoder.close(); this.encoder.close();
} }
@@ -783,6 +876,8 @@ export class AudioDataSource extends AudioSource {
private _encoder: AudioEncoderWrapper; private _encoder: AudioEncoderWrapper;
constructor(encodingConfig: AudioEncodingConfig) { constructor(encodingConfig: AudioEncodingConfig) {
validateAudioEncodingConfig(encodingConfig);
super(encodingConfig.codec); super(encodingConfig.codec);
this._encoder = new AudioEncoderWrapper(this, encodingConfig); this._encoder = new AudioEncoderWrapper(this, encodingConfig);
} }
@@ -809,6 +904,8 @@ export class AudioBufferSource extends AudioSource {
private _accumulatedFrameCount = 0; private _accumulatedFrameCount = 0;
constructor(encodingConfig: AudioEncodingConfig) { constructor(encodingConfig: AudioEncodingConfig) {
validateAudioEncodingConfig(encodingConfig);
super(encodingConfig.codec); super(encodingConfig.codec);
this._encoder = new AudioEncoderWrapper(this, encodingConfig); this._encoder = new AudioEncoderWrapper(this, encodingConfig);
} }
@@ -884,6 +981,7 @@ export class MediaStreamAudioTrackSource extends AudioSource {
if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') { if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') {
throw new TypeError('track must be an audio MediaStreamTrack.'); throw new TypeError('track must be an audio MediaStreamTrack.');
} }
validateAudioEncodingConfig(encodingConfig);
super(encodingConfig.codec); super(encodingConfig.codec);
this._encoder = new AudioEncoderWrapper(this, encodingConfig); this._encoder = new AudioEncoderWrapper(this, encodingConfig);