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;
} }
+117 -38
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,40 +580,57 @@ 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);
// Let's get these from the sample instead of the frame, as the frame has no innate timing info // Let's get these from the sample instead of the frame, as the frame has no innate timing info
// (unlike AudioData), so the sample will always be more accurate. // (unlike AudioData), so the sample will always be more accurate.
const timestamp = sample.timestamp; const timestamp = sample.timestamp;
const duration = sample.duration; const duration = sample.duration;
onFrame({ onFrame({
frame, frame,
sample, sample,
timestamp, timestamp,
duration, duration,
}); });
}, };
error: onError,
}); const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
this.decoder.configure(decoderConfig); if (MatchingCustomDecoder) {
this.customDecoder = new MatchingCustomDecoder(codec, decoderConfig, frameHandler);
} else {
this.decoder = new VideoDecoder({
output: frameHandler,
error: onError,
});
this.decoder.configure(decoderConfig);
}
} }
getDecodeQueueSize() { getDecodeQueueSize() {
return this.decoder.decodeQueueSize; if (this.customDecoder) {
return this.customDecoderQueueSize;
} else {
assert(this.decoder);
return this.decoder.decodeQueueSize;
}
} }
decode(sample: EncodedVideoSample) { decode(sample: EncodedVideoSample) {
@@ -620,15 +638,35 @@ class VideoDecoderWrapper extends DecoderWrapper<EncodedVideoSample, VideoFrame>
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);
this.decoder.decode(sample.toEncodedVideoChunk()); 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());
}
} }
flush() { flush() {
return this.decoder.flush(); if (this.customDecoder) {
return this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
return this.decoder.flush();
}
} }
close() { close() {
this.decoder.close(); if (this.customDecoder) {
void this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
this.decoder.close();
}
} }
} }
@@ -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,12 +916,27 @@ class AudioDecoderWrapper extends DecoderWrapper<EncodedAudioSample, AudioData>
timestamp, timestamp,
duration, duration,
}); });
}, error: onError }); };
this.decoder.configure(decoderConfig);
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);
}
} }
getDecodeQueueSize() { getDecodeQueueSize() {
return this.decoder.decodeQueueSize; if (this.customDecoder) {
return this.customDecoderQueueSize;
} else {
assert(this.decoder);
return this.decoder.decodeQueueSize;
}
} }
decode(sample: EncodedAudioSample) { decode(sample: EncodedAudioSample) {
@@ -886,15 +944,35 @@ class AudioDecoderWrapper extends DecoderWrapper<EncodedAudioSample, AudioData>
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);
this.decoder.decode(sample.toEncodedAudioChunk()); 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());
}
} }
flush() { flush() {
return this.decoder.flush(); if (this.customDecoder) {
return this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
return this.decoder.flush();
}
} }
close() { close() {
this.decoder.close(); if (this.customDecoder) {
void this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
this.decoder.close();
}
} }
} }
@@ -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);
} }
} }
+156 -58
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,22 +225,43 @@ 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,
}); };
if (shouldClose) {
videoFrame.close();
}
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval; this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
// We need to do this after sending the frame to the encoder as the frame otherwise might be closed if (this.customEncoder) {
if (this.encoder.encodeQueueSize >= 4) { this.customEncoderQueueSize++;
await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true })); 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) {
videoFrame.close();
}
// We need to do this after sending the frame to the encoder as the frame otherwise might be closed
if (this.encoder.encodeQueueSize >= 4) {
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,33 +295,58 @@ class VideoEncoderWrapper {
latencyMode: this.encodingConfig.latencyMode, latencyMode: this.encodingConfig.latencyMode,
...getVideoEncoderConfigExtension(this.encodingConfig.codec), ...getVideoEncoderConfigExtension(this.encodingConfig.codec),
}; };
const support = await VideoEncoder.isConfigSupported(encoderConfig);
if (!support.supported) { const MatchingCustomEncoder = customVideoEncoders.find(x => x.supports(
throw new Error( this.encodingConfig.codec,
'This specific encoder configuration is not supported by this browser. Consider using another codec or' encoderConfig,
+ ' changing your video parameters.', ));
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);
if (!support.supported) {
throw new Error(
'This specific encoder configuration is not supported by this browser. Consider using another codec'
+ ' or changing your video parameters.',
);
}
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
const sample = EncodedVideoSample.fromEncodedVideoChunk(chunk);
this.encodingConfig.onEncodedSample?.(sample, meta);
void this.muxer!.addEncodedVideoSample(this.source._connectedTrack!, sample, meta);
},
error: this.encodingConfig.onEncodingError ?? (error => console.error('VideoEncoder error:', error)),
});
this.encoder.configure(encoderConfig);
} }
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
const sample = EncodedVideoSample.fromEncodedVideoChunk(chunk);
this.encodingConfig.onEncodedSample?.(sample, meta);
void this.muxer!.addEncodedVideoSample(this.source._connectedTrack!, sample, meta);
},
error: this.encodingConfig.onEncodingError ?? (error => console.error('VideoEncoder error:', error)),
});
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,29 +714,44 @@ 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)) { const { numberOfChannels, sampleRate } = audioData;
const bitrate = this.encodingConfig.bitrate instanceof Quality
? this.encodingConfig.bitrate._toAudioBitrate(this.encodingConfig.codec)
: this.encodingConfig.bitrate;
const encoderConfig: AudioEncoderConfig = {
codec: buildAudioCodecString(
this.encodingConfig.codec,
numberOfChannels,
sampleRate,
),
numberOfChannels,
sampleRate,
bitrate,
...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(); this.initPcmEncoder();
} else { } else {
if (typeof AudioEncoder === 'undefined') { if (typeof AudioEncoder === 'undefined') {
throw new Error('AudioEncoder is not supported by this browser.'); throw new Error('AudioEncoder is not supported by this browser.');
} }
const { numberOfChannels, sampleRate } = audioData;
const bitrate = this.encodingConfig.bitrate instanceof Quality
? this.encodingConfig.bitrate._toAudioBitrate(this.encodingConfig.codec)
: this.encodingConfig.bitrate;
const encoderConfig: AudioEncoderConfig = {
codec: buildAudioCodecString(
this.encodingConfig.codec,
numberOfChannels,
sampleRate,
),
numberOfChannels,
sampleRate,
bitrate,
...getAudioEncoderConfigExtension(this.encodingConfig.codec),
};
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);