From a7386fc6c380a66eb4bc16c98d56928a173d1bd9 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:14:37 +0200 Subject: [PATCH] Add file compression example --- docs/examples.md | 6 +- examples/file-compression/file-compression.ts | 137 +++++++++ examples/file-compression/index.html | 54 ++++ examples/media-player/index.html | 2 +- examples/metadata-extraction/index.html | 2 +- examples/thumbnail-generation/index.html | 2 +- src/codec.ts | 34 ++- src/conversion.ts | 120 ++++---- src/input.ts | 4 +- src/media-source.ts | 261 +++++++++--------- 10 files changed, 413 insertions(+), 209 deletions(-) create mode 100644 examples/file-compression/file-compression.ts create mode 100644 examples/file-compression/index.html diff --git a/docs/examples.md b/docs/examples.md index 967b44e..c5d7354 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -18,8 +18,10 @@ features: details: "A full video & audio media player, implemented from scratch with Mediakit, with microsecond playback accuracy." link: /examples/media-player target: _self - - title: Feature A - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit + - title: File compression + details: Convert an input file to a highly-compressed MP4 file. + link: /examples/file-compression + target: _self - title: Feature B details: Lorem ipsum dolor sit amet, consectetur adipiscing elit - title: Feature C diff --git a/examples/file-compression/file-compression.ts b/examples/file-compression/file-compression.ts new file mode 100644 index 0000000..a3e9c46 --- /dev/null +++ b/examples/file-compression/file-compression.ts @@ -0,0 +1,137 @@ +import { + Input, + ALL_FORMATS, + BlobSource, + Output, + BufferTarget, + Mp4OutputFormat, + Conversion, + QUALITY_VERY_LOW, +} from 'mediakit'; + +const selectMediaButton = document.querySelector('button') as HTMLButtonElement; +const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement; +const horizontalRule = document.querySelector('hr') as HTMLHRElement; +const progressBarContainer = document.querySelector('#progress-bar-container') as HTMLDivElement; +const progressBar = document.querySelector('#progress-bar') as HTMLDivElement; +const speedometer = document.querySelector('#speedometer') as HTMLParagraphElement; +const videoElement = document.querySelector('video') as HTMLVideoElement; +const compressionFacts = document.querySelector('#compression-facts') as HTMLParagraphElement; +const errorElement = document.querySelector('#error-element') as HTMLParagraphElement; + +let currentConversion: Conversion | null = null; +let currentIntervalId = -1; + +const compressFile = async (file: File) => { + clearInterval(currentIntervalId); + await currentConversion?.cancel(); + + fileNameElement.textContent = file.name; + horizontalRule.style.display = ''; + progressBarContainer.style.display = ''; + speedometer.style.display = ''; + speedometer.textContent = 'Speed: -'; + videoElement.style.display = 'none'; + videoElement.src = ''; + errorElement.textContent = ''; + + try { + // Create a new input from the file + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, // Accept all formats + }); + + // Define the output file + const output = new Output({ + target: new BufferTarget(), + format: new Mp4OutputFormat(), + }); + + // Initialize the conversion process + currentConversion = await Conversion.init({ + input, + output, + video: { + width: 320, // Height will be deduced automatically to retain aspect ratio + bitrate: QUALITY_VERY_LOW, + }, + audio: { + bitrate: 32e3, + }, + }); + + // Keep track of progress + let progress = 0; + currentConversion.onProgress = newProgress => progress = newProgress; + + const fileDuration = await input.computeDuration(); + const startTime = performance.now(); + + const updateProgress = () => { + progressBar.style.width = `${progress * 100}%`; + + const now = performance.now(); + const elapsedSeconds = (now - startTime) / 1000; + const factor = fileDuration / (elapsedSeconds / progress); + speedometer.textContent = `Speed: ~${factor.toPrecision(3)}x real time`; + }; + + // Update the progress indicator regularly + currentIntervalId = window.setInterval(updateProgress, 1000 / 60); + + // Start the conversion process + await currentConversion.execute(); + + clearInterval(currentIntervalId); + updateProgress(); + + // Display the final media file + videoElement.style.display = ''; + videoElement.src = URL.createObjectURL(new Blob([output.target.buffer!])); + void videoElement.play(); + + compressionFacts.style.display = ''; + compressionFacts.textContent + = `${(output.target.buffer!.byteLength / file.size * 100).toPrecision(3)}% of original size`; + } catch (error) { + errorElement.textContent = String(error); + clearInterval(currentIntervalId); + + progressBarContainer.style.display = 'none'; + speedometer.style.display = 'none'; + compressionFacts.style.display = 'none'; + videoElement.style.display = 'none'; + } +}; + +/** === FILE SELECTION LOGIC === */ + +selectMediaButton.addEventListener('click', () => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) { + return; + } + + void compressFile(file); + }); + + fileInput.click(); +}); + +document.addEventListener('dragover', (event) => { + event.preventDefault(); + event.dataTransfer!.dropEffect = 'copy'; +}); + +document.addEventListener('drop', (event) => { + event.preventDefault(); + const files = event.dataTransfer?.files; + const file = files && files.length > 0 ? files[0] : undefined; + if (file) { + void compressFile(file); + } +}); diff --git a/examples/file-compression/index.html b/examples/file-compression/index.html new file mode 100644 index 0000000..8e667c5 --- /dev/null +++ b/examples/file-compression/index.html @@ -0,0 +1,54 @@ + + + + + + + File compression example | Mediakit + + + + + + +

File compression example

+

Select or drop a media file, and Mediakit will convert it to a heavily-compressed MP4 file.

+ +
+ + + + + +
+

+ + + +

+ + + + + + + +
+ +

Mediakit

+
+ + + +

View source code

+
+ + diff --git a/examples/media-player/index.html b/examples/media-player/index.html index 14d0861..bba598c 100644 --- a/examples/media-player/index.html +++ b/examples/media-player/index.html @@ -25,7 +25,7 @@

- +

- +

diff --git a/examples/thumbnail-generation/index.html b/examples/thumbnail-generation/index.html index ae16da7..7725c5a 100644 --- a/examples/thumbnail-generation/index.html +++ b/examples/thumbnail-generation/index.html @@ -25,7 +25,7 @@

- +

diff --git a/src/codec.ts b/src/codec.ts index 34aa50f..b772b17 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -1143,7 +1143,7 @@ export const canEncode = (codec: MediaCodec) => { export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = 720, bitrate = 1e6 }: { width?: number; height?: number; - bitrate?: number; + bitrate?: number | Quality; } = {}) => { if (!VIDEO_CODECS.includes(codec)) { return false; @@ -1154,21 +1154,25 @@ export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = if (!Number.isInteger(height) || height <= 0) { throw new TypeError('height must be a positive integer.'); } - if (!Number.isInteger(bitrate) || bitrate <= 0) { - throw new TypeError('bitrate 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, - bitrate, + resolvedBitrate, ), width, height, - bitrate, + bitrate: resolvedBitrate, ...getVideoEncoderConfigExtension(codec), }; @@ -1183,10 +1187,10 @@ export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = } const support = await VideoEncoder.isConfigSupported({ - codec: buildVideoCodecString(codec, width, height, bitrate), + codec: buildVideoCodecString(codec, width, height, resolvedBitrate), width, height, - bitrate, + bitrate: resolvedBitrate, ...getVideoEncoderConfigExtension(codec), }); @@ -1200,7 +1204,7 @@ export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, sampleRate = 48000, bitrate = 128e3 }: { numberOfChannels?: number; sampleRate?: number; - bitrate?: number; + bitrate?: number | Quality; } = {}) => { if (!AUDIO_CODECS.includes(codec)) { return false; @@ -1211,10 +1215,14 @@ export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, if (!Number.isInteger(sampleRate) || sampleRate <= 0) { throw new TypeError('sampleRate must be a positive integer.'); } - if (!Number.isInteger(bitrate) || bitrate <= 0) { + 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( @@ -1224,7 +1232,7 @@ export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, ), numberOfChannels, sampleRate, - bitrate, + bitrate: resolvedBitrate, ...getAudioEncoderConfigExtension(codec), }; @@ -1246,7 +1254,7 @@ export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, codec: buildAudioCodecString(codec, numberOfChannels, sampleRate), numberOfChannels, sampleRate, - bitrate, + bitrate: resolvedBitrate, ...getAudioEncoderConfigExtension(codec), }); @@ -1288,7 +1296,7 @@ export const getEncodableVideoCodecs = async ( options?: { width?: number; height?: number; - bitrate?: number; + bitrate?: number | Quality; }, ): Promise => { const bools = await Promise.all(checkedCodecs.map(codec => canEncodeVideo(codec, options))); @@ -1304,7 +1312,7 @@ export const getEncodableAudioCodecs = async ( options?: { numberOfChannels?: number; sampleRate?: number; - bitrate?: number; + bitrate?: number | Quality; }, ): Promise => { const bools = await Promise.all(checkedCodecs.map(codec => canEncodeAudio(codec, options))); diff --git a/src/conversion.ts b/src/conversion.ts index 03e8f07..b488336 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -100,12 +100,6 @@ export type ConversionOptions = { /** The time in the input file at which the output file should end. Must be greater than `start`. */ end: number; }; - - /** - * When set to true, the current progress of the conversion will be computed and kept up to date in the `progress` - * field of the Conversion instance. - */ - computeProgress?: boolean; }; const FALLBACK_NUMBER_OF_CHANNELS = 2; @@ -160,16 +154,17 @@ export class Conversion { _canceled = false; /** - * A number between 0 and 1, indicating the completion of the conversion. If the `computeProgress` option is not - * enabled, this value will be stuck at 0. Note that a progress of 1 doesn't necessarily mean the conversion is - * complete; the conversion is complete once `execute` resolves. - */ - progress = 0; - /** - * A callback that is fired whenever the conversion progresses. Only called if the `computeProgress` option - * is enabled. + * A callback that is fired whenever the conversion progresses. Returns a number between 0 and 1, indicating the + * completion of the conversion. Note that a progress of 1 doesn't necessarily mean the conversion is complete; + * the conversion is complete once `execute` resolves. + * + * In order for progress to be computed, this property must be set before `execute` is called. */ onProgress?: (progress: number) => unknown = undefined; + /** @internal */ + _computeProgress = false; + /** @internal */ + _lastProgress = 0; /** The list of tracks that are included in the output file. */ utilizedTracks: InputTrack[] = []; @@ -179,12 +174,12 @@ export class Conversion { track: InputTrack; /** The reason for discarding the track. */ reason: - | 'discardedByUser' - | 'maxTrackCountReached' - | 'maxTrackCountOfTypeReached' - | 'unknownSourceCodec' - | 'undecodableSourceCodec' - | 'noEncodableTargetCodec'; + | 'discarded_by_user' + | 'max_track_count_reached' + | 'max_track_count_of_type_reached' + | 'unknown_source_codec' + | 'undecodable_source_codec' + | 'no_encodable_target_codec'; }[] = []; /** Initializes a new conversion process without starting the conversion. */ @@ -302,9 +297,6 @@ export class Conversion { && options.trim.start >= options.trim.end) { throw new TypeError('options.trim.start must be less than options.trim.end.'); } - if (options.computeProgress !== undefined && typeof options.computeProgress !== 'boolean') { - throw new TypeError('options.computeProgress, when provided, must be a boolean.'); - } this._options = options; this._input = options.input; @@ -327,7 +319,7 @@ export class Conversion { if (track.isVideoTrack() && this._options.video?.discard) { this.discardedTracks.push({ track, - reason: 'discardedByUser', + reason: 'discarded_by_user', }); continue; } @@ -335,7 +327,7 @@ export class Conversion { if (track.isAudioTrack() && this._options.audio?.discard) { this.discardedTracks.push({ track, - reason: 'discardedByUser', + reason: 'discarded_by_user', }); continue; } @@ -343,7 +335,7 @@ export class Conversion { if (this._totalTrackCount === outputTrackCounts.total.max) { this.discardedTracks.push({ track, - reason: 'maxTrackCountReached', + reason: 'max_track_count_reached', }); continue; } @@ -351,7 +343,7 @@ export class Conversion { if (this._addedCounts[track.type] === outputTrackCounts[track.type].max) { this.discardedTracks.push({ track, - reason: 'maxTrackCountOfTypeReached', + reason: 'max_track_count_of_type_reached', }); continue; } @@ -363,22 +355,14 @@ export class Conversion { } } - const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discardedByUser'); + const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user'); if (unintentionallyDiscardedTracks.length > 0) { // Let's give the user a notice/warning about discarded tracks so they aren't confused console.warn('Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks); } - - if (this._options.computeProgress) { - this._totalDuration = Math.min( - await this._input.computeDuration() - this._startTimestamp, - this._endTimestamp - this._startTimestamp, - ); - this.onProgress?.(this.progress); - } } - /** Starts the conversion process. */ + /** Executes the conversion process. Resolves once conversion is complete. */ async execute() { if (this._executed) { throw new Error('Conversion cannot be executed twice.'); @@ -386,10 +370,28 @@ export class Conversion { this._executed = true; + if (this.onProgress) { + this._computeProgress = true; + this._totalDuration = Math.min( + await this._input.computeDuration() - this._startTimestamp, + this._endTimestamp - this._startTimestamp, + ); + this.onProgress?.(0); + } + await this._output.start(); this._start(); - await Promise.all(this._trackPromises); + try { + await Promise.all(this._trackPromises); + } catch (error) { + if (!this._canceled) { + // Make sure to cancel to stop other encoding processes and clean up resources + await this.cancel(); + } + + throw error; + } if (this._canceled) { await new Promise(() => {}); // Never resolve @@ -397,9 +399,8 @@ export class Conversion { await this._output.finalize(); - if (this._options.computeProgress && this.progress !== 1) { - this.progress = 1; - this.onProgress?.(this.progress); + if (this._computeProgress) { + this.onProgress?.(1); } } @@ -424,7 +425,7 @@ export class Conversion { if (!sourceCodec) { this.discardedTracks.push({ track, - reason: 'unknownSourceCodec', + reason: 'unknown_source_codec', }); return; } @@ -508,7 +509,7 @@ export class Conversion { if (!canDecode) { this.discardedTracks.push({ track, - reason: 'undecodableSourceCodec', + reason: 'undecodable_source_codec', }); return; } @@ -517,18 +518,20 @@ export class Conversion { videoCodecs = videoCodecs.filter(codec => codec === this._options.video?.codec); } - const encodableCodecs = await getEncodableVideoCodecs(videoCodecs, { width, height }); + const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH; + + const encodableCodecs = await getEncodableVideoCodecs(videoCodecs, { width, height, bitrate }); if (encodableCodecs.length === 0) { this.discardedTracks.push({ track, - reason: 'noEncodableTargetCodec', + reason: 'no_encodable_target_codec', }); return; } const encodingConfig: VideoEncodingConfig = { codec: encodableCodecs[0]!, - bitrate: this._options.video?.bitrate ?? QUALITY_HIGH, + bitrate, onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), }; @@ -612,7 +615,7 @@ export class Conversion { if (!sourceCodec) { this.discardedTracks.push({ track, - reason: 'unknownSourceCodec', + reason: 'unknown_source_codec', }); return; } @@ -677,7 +680,7 @@ export class Conversion { if (!canDecode) { this.discardedTracks.push({ track, - reason: 'undecodableSourceCodec', + reason: 'undecodable_source_codec', }); return; } @@ -688,9 +691,12 @@ export class Conversion { audioCodecs = audioCodecs.filter(codec => codec === this._options.audio!.codec); } + const bitrate = this._options.audio?.bitrate ?? QUALITY_HIGH; + const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, { numberOfChannels, sampleRate, + bitrate, }); if ( @@ -705,6 +711,7 @@ export class Conversion { const encodableCodecsWithDefaultParams = await getEncodableAudioCodecs(audioCodecs, { numberOfChannels: FALLBACK_NUMBER_OF_CHANNELS, sampleRate: FALLBACK_SAMPLE_RATE, + bitrate, }); if ( @@ -724,17 +731,17 @@ export class Conversion { if (codecOfChoice === null) { this.discardedTracks.push({ track, - reason: 'noEncodableTargetCodec', + reason: 'no_encodable_target_codec', }); return; } if (needsResample) { - audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate); + audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate, bitrate); } else { const source = new AudioSampleSource({ codec: codecOfChoice, - bitrate: this._options.audio?.bitrate ?? QUALITY_HIGH, + bitrate, onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration), }); audioSource = source; @@ -777,10 +784,11 @@ export class Conversion { codec: AudioCodec, targetNumberOfChannels: number, targetSampleRate: number, + bitrate: number | Quality, ) { const source = new AudioSampleSource({ codec, - bitrate: this._options.audio?.bitrate ?? QUALITY_HIGH, + bitrate, onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration), }); @@ -823,7 +831,7 @@ export class Conversion { /** @internal */ _reportProgress(trackId: number, endTimestamp: number) { - if (!this._options.computeProgress) { + if (!this._computeProgress) { return; } assert(this._totalDuration !== null); @@ -838,9 +846,9 @@ export class Conversion { const averageTimestamp = totalTimestamps / this._totalTrackCount; const newProgress = clamp(averageTimestamp / this._totalDuration, 0, 1); - if (newProgress !== this.progress) { - this.progress = newProgress; - this.onProgress?.(this.progress); + if (newProgress !== this._lastProgress) { + this._lastProgress = newProgress; + this.onProgress?.(newProgress); } } } diff --git a/src/input.ts b/src/input.ts index 8a12fc2..eb724c2 100644 --- a/src/input.ts +++ b/src/input.ts @@ -84,8 +84,8 @@ export class Input { } /** - * Computes the duration of the longest track in this input file, in seconds. More precisely, returns the largest - * end timestamp among all tracks. + * Computes the duration of the input file, in seconds. More precisely, returns the largest end timestamp among + * all tracks. */ async computeDuration() { const demuxer = await this._getDemuxer(); diff --git a/src/media-source.ts b/src/media-source.ts index 3269d23..009fd7d 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -16,7 +16,7 @@ import { VideoCodec, } from './codec'; import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from './output'; -import { assert, CallSerializer, clamp, promiseWithResolvers, setInt24, setUint24 } from './misc'; +import { assert, CallSerializer, clamp, setInt24, setUint24 } from './misc'; import { Muxer } from './muxer'; import { SubtitleParser } from './subtitles'; import { toAlaw, toUlaw } from './pcm'; @@ -345,85 +345,82 @@ class VideoEncoderWrapper { return; } - const { promise, resolve } = promiseWithResolvers(); - this.ensureEncoderPromise = promise; + 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 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, + const encoderConfig: VideoEncoderConfig = { + codec: this.encodingConfig.fullCodecString ?? buildVideoCodecString( + this.encodingConfig.codec, + width, + height, + bitrate, + ), width, height, bitrate, - ), - width, - height, - bitrate, - 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( - this.encodingConfig.codec, - encoderConfig, - )); - - if (MatchingCustomEncoder) { - // @ts-expect-error "Can't create instance of abstract class 🤓" - this.customEncoder = new MatchingCustomEncoder() as CustomVideoEncoder; - this.customEncoder.codec = this.encodingConfig.codec; - this.customEncoder.config = encoderConfig; - this.customEncoder.onPacket = (packet, meta) => { - if (!(packet instanceof EncodedPacket)) { - throw new TypeError('The first argument passed to onPacket must be an EncodedPacket.'); - } - if (meta !== undefined && (!meta || typeof meta !== 'object')) { - throw new TypeError('The second argument passed to onPacket must be an object or undefined.'); - } - - this.encodingConfig.onEncodedPacket?.(packet, meta); - void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta); + framerate: this.source._connectedTrack?.metadata.frameRate, + latencyMode: this.encodingConfig.latencyMode, + ...getVideoEncoderConfigExtension(this.encodingConfig.codec), }; + this.encodingConfig.onEncoderConfig?.(encoderConfig); - await this.customEncoder.init(); - } else { - if (typeof VideoEncoder === 'undefined') { - throw new Error('VideoEncoder is not supported by this browser.'); - } + const MatchingCustomEncoder = customVideoEncoders.find(x => x.supports( + this.encodingConfig.codec, + encoderConfig, + )); - 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 packet = EncodedPacket.fromEncodedChunk(chunk); + if (MatchingCustomEncoder) { + // @ts-expect-error "Can't create instance of abstract class 🤓" + this.customEncoder = new MatchingCustomEncoder() as CustomVideoEncoder; + this.customEncoder.codec = this.encodingConfig.codec; + this.customEncoder.config = encoderConfig; + this.customEncoder.onPacket = (packet, meta) => { + if (!(packet instanceof EncodedPacket)) { + throw new TypeError('The first argument passed to onPacket must be an EncodedPacket.'); + } + if (meta !== undefined && (!meta || typeof meta !== 'object')) { + throw new TypeError('The second argument passed to onPacket must be an object or undefined.'); + } this.encodingConfig.onEncodedPacket?.(packet, meta); void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta); - }, - error: this.encodingConfig.onEncoderError ?? (error => console.error('VideoEncoder error:', error)), - }); - this.encoder.configure(encoderConfig); - } + }; - assert(this.source._connectedTrack); - this.muxer = this.source._connectedTrack.output._muxer; + await this.customEncoder.init(); + } else { + if (typeof VideoEncoder === 'undefined') { + throw new Error('VideoEncoder is not supported by this browser.'); + } - this.encoderInitialized = true; + 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.', + ); + } - resolve(); + this.encoder = new VideoEncoder({ + output: (chunk, meta) => { + const packet = EncodedPacket.fromEncodedChunk(chunk); + + this.encodingConfig.onEncodedPacket?.(packet, meta); + void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta); + }, + error: this.encodingConfig.onEncoderError ?? (error => console.error('VideoEncoder error:', error)), + }); + this.encoder.configure(encoderConfig); + } + + assert(this.source._connectedTrack); + this.muxer = this.source._connectedTrack.output._muxer; + + this.encoderInitialized = true; + })(); } async flushAndClose() { @@ -891,87 +888,85 @@ class AudioEncoderWrapper { } } - private async ensureEncoder(audioSample: AudioSample) { + private ensureEncoder(audioSample: AudioSample) { if (this.encoderInitialized) { return; } - const { promise, resolve } = promiseWithResolvers(); - this.ensureEncoderPromise = promise; + 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 { 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, + const encoderConfig: AudioEncoderConfig = { + codec: this.encodingConfig.fullCodecString ?? buildAudioCodecString( + this.encodingConfig.codec, + numberOfChannels, + sampleRate, + ), numberOfChannels, sampleRate, - ), - numberOfChannels, - sampleRate, - bitrate, - ...getAudioEncoderConfigExtension(this.encodingConfig.codec), - }; - this.encodingConfig.onEncoderConfig?.(encoderConfig); - - const MatchingCustomEncoder = customAudioEncoders.find(x => x.supports( - this.encodingConfig.codec, - encoderConfig, - )); - - if (MatchingCustomEncoder) { - // @ts-expect-error "Can't create instance of abstract class 🤓" - this.customEncoder = new MatchingCustomEncoder() as CustomAudioEncoder; - this.customEncoder.codec = this.encodingConfig.codec; - this.customEncoder.config = encoderConfig; - this.customEncoder.onPacket = (packet, meta) => { - if (!(packet instanceof EncodedPacket)) { - throw new TypeError('The first argument passed to onPacket must be an EncodedPacket.'); - } - if (meta !== undefined && (!meta || typeof meta !== 'object')) { - throw new TypeError('The second argument passed to onPacket must be an object or undefined.'); - } - - this.encodingConfig.onEncodedPacket?.(packet, meta); - void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta); + bitrate, + ...getAudioEncoderConfigExtension(this.encodingConfig.codec), }; + this.encodingConfig.onEncoderConfig?.(encoderConfig); - await this.customEncoder.init(); - } 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 MatchingCustomEncoder = customAudioEncoders.find(x => x.supports( + this.encodingConfig.codec, + encoderConfig, + )); - const support = await AudioEncoder.isConfigSupported(encoderConfig); - if (!support.supported) { - throw new Error( - 'This specific encoder configuration not supported by this browser. Consider using another codec or' - + ' changing your audio parameters.', - ); - } - - this.encoder = new AudioEncoder({ - output: (chunk, meta) => { - const packet = EncodedPacket.fromEncodedChunk(chunk); + if (MatchingCustomEncoder) { + // @ts-expect-error "Can't create instance of abstract class 🤓" + this.customEncoder = new MatchingCustomEncoder() as CustomAudioEncoder; + this.customEncoder.codec = this.encodingConfig.codec; + this.customEncoder.config = encoderConfig; + this.customEncoder.onPacket = (packet, meta) => { + if (!(packet instanceof EncodedPacket)) { + throw new TypeError('The first argument passed to onPacket must be an EncodedPacket.'); + } + if (meta !== undefined && (!meta || typeof meta !== 'object')) { + throw new TypeError('The second argument passed to onPacket must be an object or undefined.'); + } this.encodingConfig.onEncodedPacket?.(packet, meta); void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta); - }, - error: this.encodingConfig.onEncoderError ?? (error => console.error('AudioEncoder error:', error)), - }); - this.encoder.configure(encoderConfig); - } + }; - assert(this.source._connectedTrack); - this.muxer = this.source._connectedTrack.output._muxer; + await this.customEncoder.init(); + } 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.'); + } - this.encoderInitialized = true; - resolve(); + const support = await AudioEncoder.isConfigSupported(encoderConfig); + if (!support.supported) { + throw new Error( + 'This specific encoder configuration not supported by this browser. Consider using another' + + ' codec or changing your audio parameters.', + ); + } + + this.encoder = new AudioEncoder({ + output: (chunk, meta) => { + const packet = EncodedPacket.fromEncodedChunk(chunk); + + this.encodingConfig.onEncodedPacket?.(packet, meta); + void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta); + }, + error: this.encodingConfig.onEncoderError ?? (error => console.error('AudioEncoder error:', error)), + }); + this.encoder.configure(encoderConfig); + } + + assert(this.source._connectedTrack); + this.muxer = this.source._connectedTrack.output._muxer; + + this.encoderInitialized = true; + })(); } private initPcmEncoder() {