diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index 32d2cc9..3d33577 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -83,7 +83,7 @@ const initMediaPlayer = async (resource: File | string) => { pause(); } - const dirHandle = await showDirectoryPicker({ mode: 'read' }); + // const dirHandle = await showDirectoryPicker({ mode: 'read' }); void videoFrameIterator?.return(); void audioBufferIterator?.return(); @@ -103,8 +103,9 @@ const initMediaPlayer = async (resource: File | string) => { let audioTrack: InputAudioTrack | null = null; if (true || typeof resource === 'string' && resource.includes('.m3u8')) { const input = new Input({ - entryPath: 'master.m3u8', + entryPath: resource, // 'master.m3u8', source: async ({ path }) => { + return new UrlSource(path); const fileHandle = await dirHandle.getFileHandle(path); const file = await fileHandle.getFile(); return new BlobSource(file); @@ -113,23 +114,24 @@ const initMediaPlayer = async (resource: File | string) => { }); // const variant = (await manifestInput.getVariants())[0]!; - console.log(await input.getFormat(), await input.getTracks()); + // console.log(await input.getFormat(), await input.getTracks()); // return; await input.getVideoTracks({ - filter: track => track.hasPairableAudioTrack(), - sortBy: track => asc(track.bitrate), + filter: desc => desc.hasPairableAudioTrack(), + sortBy: desc => asc(desc.bitrate), }); videoTrack = await input.getPrimaryVideoTrack({ - filter: async track => (await track.resolve('displayHeight')) < 1080, + filter: async desc => (desc.displayHeight ?? (await desc.getTrack()).displayHeight) < 720, + // filter: async track => (await track.resolve('displayHeight')) < 1080, }); audioTrack = await input.getPrimaryAudioTrack({ - sortBy: track => prefer(track.canBePairedWith(videoTrack)), + sortBy: desc => prefer(desc.canBePairedWith(videoTrack)), }); - await videoTrack?.hydrate(); - await audioTrack?.hydrate(); + // await videoTrack?.hydrate(); + // await audioTrack?.hydrate(); totalDuration = Math.max( await videoTrack?.computeDuration({ skipLiveWait: true }) ?? 0, @@ -770,9 +772,11 @@ document.addEventListener('dragover', (event) => { event.dataTransfer!.dropEffect = 'copy'; }); +/* document.addEventListener('click', () => { void initMediaPlayer(); }, { once: true }); +*/ document.addEventListener('drop', (event) => { event.preventDefault(); diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index ed3fc4f..79957cf 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -15,7 +15,7 @@ import { readId3V2Header, } from '../id3'; import { Input } from '../input'; -import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; +import { InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { @@ -51,7 +51,7 @@ export class AdtsDemuxer extends Demuxer { loadedSamples: Sample[] = []; metadataTags: MetadataTags | null = null; - tracks: InputAudioTrack[] = []; + trackBackings: AdtsAudioTrackBacking[] = []; readingMutex = new AsyncMutex(); lastSampleLoaded = false; @@ -75,7 +75,7 @@ export class AdtsDemuxer extends Demuxer { assert(this.firstFrameHeader); // Create the single audio track - this.tracks = [new InputAudioTrack(this.input, new AdtsAudioTrackBacking(this))]; + this.trackBackings = [new AdtsAudioTrackBacking(this)]; })(); } @@ -151,9 +151,9 @@ export class AdtsDemuxer extends Demuxer { return 'audio/aac'; } - async getTracks() { + async getTrackBackings() { await this.readMetadata(); - return this.tracks; + return this.trackBackings; } async getMetadataTags() { @@ -198,6 +198,10 @@ export class AdtsDemuxer extends Demuxer { class AdtsAudioTrackBacking implements InputAudioTrackBacking { constructor(public demuxer: AdtsDemuxer) {} + getType() { + return 'audio' as const; + } + getId() { return 1; } diff --git a/src/conversion.ts b/src/conversion.ts index cb90ec1..2829490 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -894,10 +894,6 @@ export class Conversion { /** @internal */ async _processVideoTrack(track: InputVideoTrack, trackOptions: ConversionVideoOptions) { - if (!track.isHydrated) { - await track.hydrate(); - } - const sourceCodec = track.codec; if (!sourceCodec) { this.discardedTracks.push({ @@ -1348,10 +1344,6 @@ export class Conversion { /** @internal */ async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) { - if (!track.isHydrated) { - await track.hydrate(); - } - const sourceCodec = track.codec; if (!sourceCodec) { this.discardedTracks.push({ diff --git a/src/demuxer.ts b/src/demuxer.ts index 9e5d22a..8cac71d 100644 --- a/src/demuxer.ts +++ b/src/demuxer.ts @@ -7,7 +7,7 @@ */ import { Input } from './input'; -import { InputTrack } from './input-track'; +import { InputTrackBacking } from './input-track'; import { MetadataTags } from './metadata'; /** @@ -33,7 +33,7 @@ export abstract class Demuxer { this.input = input; } - abstract getTracks(): Promise; + abstract getTrackBackings(): Promise; abstract getMimeType(): Promise; abstract getMetadataTags(): Promise; abstract getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise; diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index d7d3366..7617058 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -9,7 +9,7 @@ import { FlacBlockType, readVorbisComments } from '../codec-data'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; -import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; +import { InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, @@ -70,7 +70,7 @@ export class FlacDemuxer extends Demuxer { loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos metadataPromise: Promise | null = null; - track: InputAudioTrack | null = null; + trackBacking: FlacAudioTrackBacking | null = null; metadataTags: MetadataTags = {}; audioInfo: FlacAudioInfo | null = null; @@ -91,10 +91,10 @@ export class FlacDemuxer extends Demuxer { return this.metadataTags; } - async getTracks() { + async getTrackBackings() { await this.readMetadata(); - assert(this.track); - return [this.track]; + assert(this.trackBacking); + return [this.trackBacking]; } async getDurationFromMetadata(): Promise { @@ -194,7 +194,7 @@ export class FlacDemuxer extends Demuxer { description, }; - this.track = new InputAudioTrack(this.input, new FlacAudioTrackBacking(this)); + this.trackBacking = new FlacAudioTrackBacking(this); break; } case FlacBlockType.VORBIS_COMMENT: { @@ -559,6 +559,10 @@ export class FlacDemuxer extends Demuxer { class FlacAudioTrackBacking implements InputAudioTrackBacking { constructor(public demuxer: FlacDemuxer) {} + getType() { + return 'audio' as const; + } + getId() { return 1; } diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 015d4fd..29e66d1 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -10,16 +10,14 @@ import { AUDIO_CODECS, AudioCodec, inferCodecFromCodecString, MediaCodec, VIDEO_ import { Demuxer, DurationMetadataRequestOptions } from '../demuxer'; import { Input } from '../input'; import { - InputAudioTrack, InputAudioTrackBacking, InputTrack, InputTrackBacking, - InputVideoTrack, InputVideoTrackBacking, - TrackNotHydratedError, } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata'; +import { TrackType } from '../output'; import { assert, joinPaths, Rotation, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket } from '../packet'; import { readAllLines } from '../reader'; @@ -29,7 +27,6 @@ import { HlsSegmentedInput } from './hls-segmented-input'; type InternalTrack = { id: number; demuxer: HlsDemuxer; - inputTrack: InputTrack | null; backingTrack: InputTrack | null; default: boolean; autoselect: boolean; @@ -58,7 +55,8 @@ type InternalAudioTrack = InternalTrack & { info: { type: 'audio' } }; export class HlsDemuxer extends Demuxer { metadataPromise: Promise | null = null; - tracks: InputTrack[] = []; + trackBackings: InputTrackBacking[] = []; + internalTracks: InternalTrack[] = []; segmentedInputs: HlsSegmentedInput[] = []; hasMasterPlaylist = true; @@ -166,7 +164,8 @@ export class HlsDemuxer extends Demuxer { this.hasMasterPlaylist = false; const input = segmentedInput.toInput(); - this.tracks = await input.getTracks(); + const demuxer = await input._getDemuxer(); + this.trackBackings = await demuxer.getTrackBackings(); return; } @@ -344,7 +343,6 @@ export class HlsDemuxer extends Demuxer { result.push({ id: -1, demuxer: this, - inputTrack: null, backingTrack: null, default: true, autoselect: true, @@ -395,7 +393,6 @@ export class HlsDemuxer extends Demuxer { result.push({ id: -1, demuxer: this, - inputTrack: null, backingTrack: null, default: getMediaTagDefault(mediaTag.attributes), // Autoselect is inferred to be true if the default is true @@ -439,7 +436,6 @@ export class HlsDemuxer extends Demuxer { result.push({ id: -1, demuxer: this, - inputTrack: null, backingTrack: null, default: true, autoselect: true, @@ -487,7 +483,6 @@ export class HlsDemuxer extends Demuxer { result.push({ id: -1, demuxer: this, - inputTrack: null, backingTrack: null, default: getMediaTagDefault(mediaTag.attributes), // Autoselect is inferred to be true if the default is true @@ -564,25 +559,24 @@ export class HlsDemuxer extends Demuxer { internalTracks.sort((a, b) => a.lineNumber - b.lineNumber); for (const internalTrack of internalTracks) { - const inputTrack = internalTrack.info.type === 'video' - ? new InputVideoTrack( - this.input, + if (internalTrack.info.type === 'video') { + this.trackBackings.push( new HlsInputVideoTrackBacking(internalTrack as InternalVideoTrack), - ) - : new InputAudioTrack( - this.input, + ); + } else { + this.trackBackings.push( new HlsInputAudioTrackBacking(internalTrack as InternalAudioTrack), ); - - internalTrack.inputTrack = inputTrack; - this.tracks.push(inputTrack); + } } + + this.internalTracks = internalTracks; })(); } - async getTracks(): Promise { + async getTrackBackings() { await this.readMetadata(); - return this.tracks; + return this.trackBackings; } getSegmentedInputForPath(path: string) { @@ -622,6 +616,9 @@ export class HlsDemuxer extends Demuxer { abstract class HlsInputTrackBacking implements InputTrackBacking { constructor(public internalTrack: InternalTrack) {} + abstract getType(): TrackType; + abstract getDecoderConfig(): Promise; + isHydrated(): boolean { return !!this.internalTrack.backingTrack; } @@ -646,8 +643,9 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { throw new Error('Could not find matching track in underlying media data.'); } - if (!track.isHydrated) { - await track.hydrate(); // Just in case, typically not needed except for cursed shit like recursive .m3u8 + if (!(track._backing.isHydrated?.() ?? true)) { + // Just in case, typically not needed except for cursed shit like recursive .m3u8 + await track.input._hydrateBacking(track._backing); } this.internalTrack.backingTrack = track; @@ -687,13 +685,14 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { } getNumber(): number { + const trackType = this.internalTrack.info.type; let number = 0; - for (const track of this.internalTrack.demuxer.tracks) { - if (track.type === this.internalTrack.inputTrack!.type) { + for (const track of this.internalTrack.demuxer.internalTracks) { + if (track.info.type === trackType) { number++; } - if (track === this.internalTrack.inputTrack) { + if (track === this.internalTrack) { break; } } @@ -701,20 +700,12 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { return number; } - getTimeResolution(): number { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.internalTrack.backingTrack._backing.getTimeResolution(); + getTimeResolution(): number | undefined { + return this.internalTrack.backingTrack?._backing.getTimeResolution(); } - isRelativeToUnixEpoch(): boolean { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.internalTrack.backingTrack._backing.isRelativeToUnixEpoch(); + isRelativeToUnixEpoch(): boolean | undefined { + return this.internalTrack.backingTrack?._backing.isRelativeToUnixEpoch(); } getBitrate(): number | null { @@ -726,18 +717,12 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { } async getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - + assert(this.internalTrack.backingTrack); return this.internalTrack.backingTrack._backing.getDurationFromMetadata(options); } async getLiveRefreshInterval(): Promise { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - + assert(this.internalTrack.backingTrack); return this.internalTrack.backingTrack._backing.getLiveRefreshInterval(); } @@ -746,42 +731,27 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { } getFirstPacket(options: PacketRetrievalOptions): Promise { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - + assert(this.internalTrack.backingTrack); return this.internalTrack.backingTrack._backing.getFirstPacket(options); } getPacket(timestamp: number, options: PacketRetrievalOptions): Promise { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - + assert(this.internalTrack.backingTrack); return this.internalTrack.backingTrack._backing.getPacket(timestamp, options); } getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - + assert(this.internalTrack.backingTrack); return this.internalTrack.backingTrack._backing.getKeyPacket(timestamp, options); } getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - + assert(this.internalTrack.backingTrack); return this.internalTrack.backingTrack._backing.getNextPacket(packet, options); } getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { - if (!this.internalTrack.backingTrack) { - throw new TrackNotHydratedError(); - } - + assert(this.internalTrack.backingTrack); return this.internalTrack.backingTrack._backing.getNextKeyPacket(packet, options); } } @@ -795,8 +765,13 @@ class HlsInputVideoTrackBacking super(internalTrack); } - get backingTrack() { - return this.internalTrack.backingTrack as InputVideoTrack | null; + getType() { + return 'video' as const; + } + + get backingVideoTrack() { + const bt = this.internalTrack.backingTrack; + return bt?.isVideoTrack() ? bt : null; } override getCodec(): VideoCodec | null { @@ -804,92 +779,59 @@ class HlsInputVideoTrackBacking return inferredCodec as VideoCodec; } - getCodedWidth(): number { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getCodedWidth(); + getCodedWidth(): number | undefined { + return this.backingVideoTrack?._backing.getCodedWidth(); } - getCodedHeight(): number { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getCodedHeight(); + getCodedHeight(): number | undefined { + return this.backingVideoTrack?._backing.getCodedHeight(); } - getSquarePixelWidth(): number { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getSquarePixelWidth(); + getSquarePixelWidth(): number | undefined { + return this.backingVideoTrack?._backing.getSquarePixelWidth(); } - getSquarePixelHeight(): number { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getSquarePixelHeight(); + getSquarePixelHeight(): number | undefined { + return this.backingVideoTrack?._backing.getSquarePixelHeight(); } - getDisplayWidth(): number | null { - if (this.backingTrack) { + getMetadataDisplayWidth(): number | null { + if (this.backingVideoTrack) { return null; } return this.internalTrack.info.width; } - getDisplayHeight(): number | null { - if (this.backingTrack) { + getMetadataDisplayHeight(): number | null { + if (this.backingVideoTrack) { return null; } return this.internalTrack.info.height; } - getRotation(): Rotation { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getRotation(); + getRotation(): Rotation | undefined { + return this.backingVideoTrack?._backing.getRotation(); } getColorSpace(): Promise { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getColorSpace(); + assert(this.backingVideoTrack); + return this.backingVideoTrack._backing.getColorSpace(); } canBeTransparent(): Promise { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.canBeTransparent(); + assert(this.backingVideoTrack); + return this.backingVideoTrack._backing.canBeTransparent(); } - getCodecParameterString(): Promise { - if (!this.backingTrack) { - return Promise.resolve(this.internalTrack.fullCodecString); - } - - return this.backingTrack.getCodecParameterString(); + getCodecParameterString(): string { + return this.internalTrack.fullCodecString; } getDecoderConfig(): Promise { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getDecoderConfig(); + assert(this.backingVideoTrack); + return this.backingVideoTrack._backing.getDecoderConfig(); } } @@ -902,8 +844,13 @@ class HlsInputAudioTrackBacking super(internalTrack); } - get backingTrack() { - return this.internalTrack.backingTrack as InputAudioTrack | null; + getType() { + return 'audio' as const; + } + + get backingAudioTrack() { + const bt = this.internalTrack.backingTrack; + return bt?.isAudioTrack() ? bt : null; } override getCodec(): AudioCodec | null { @@ -911,40 +858,25 @@ class HlsInputAudioTrackBacking return inferredCodec as AudioCodec; } - getNumberOfChannels(): number { + getNumberOfChannels(): number | undefined { if (this.internalTrack.info.numberOfChannels !== null) { return this.internalTrack.info.numberOfChannels; } - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getNumberOfChannels(); + return this.backingAudioTrack?._backing.getNumberOfChannels(); } - getSampleRate(): number { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getSampleRate(); + getSampleRate(): number | undefined { + return this.backingAudioTrack?._backing.getSampleRate(); } - getCodecParameterString(): Promise { - if (!this.backingTrack) { - return Promise.resolve(this.internalTrack.fullCodecString); - } - - return this.backingTrack.getCodecParameterString(); + getCodecParameterString(): string { + return this.internalTrack.fullCodecString; } getDecoderConfig(): Promise { - if (!this.backingTrack) { - throw new TrackNotHydratedError(); - } - - return this.backingTrack._backing.getDecoderConfig(); + assert(this.backingAudioTrack); + return this.backingAudioTrack._backing.getDecoderConfig(); } } diff --git a/src/index.ts b/src/index.ts index 8713f55..02a2729 100644 --- a/src/index.ts +++ b/src/index.ts @@ -202,8 +202,12 @@ export { InputVideoTrack, InputAudioTrack, PacketStats, - TrackNotHydratedError, } from './input-track'; +export { + InputTrackDescriptor, + InputVideoTrackDescriptor, + InputAudioTrackDescriptor, +} from './input-track-descriptor'; export { EncodedPacket, EncodedPacketSideData, diff --git a/src/input-track-descriptor.ts b/src/input-track-descriptor.ts new file mode 100644 index 0000000..a54772b --- /dev/null +++ b/src/input-track-descriptor.ts @@ -0,0 +1,439 @@ +/*! + * Copyright (c) 2026-present, Vanilagy and contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { AudioCodec, MediaCodec, VideoCodec } from './codec'; +import { Input } from './input'; +import { + InputAudioTrack, + InputAudioTrackBacking, + InputTrack, + InputTrackBacking, + InputVideoTrack, + InputVideoTrackBacking, +} from './input-track'; +import { TrackDisposition } from './metadata'; +import { MaybePromise } from './misc'; +import { TrackType } from './output'; + +export abstract class InputTrackDescriptor { + /** The input file this descriptor belongs to. */ + readonly input: Input; + /** @internal */ + _backing: InputTrackBacking; + + /** @internal */ + constructor(input: Input, backing: InputTrackBacking) { + this.input = input; + this._backing = backing; + } + + /** The unique ID of this track in the input file. */ + get id() { + return this._backing.getId(); + } + + /** The 1-based index of this track among all tracks of the same type in the input file. */ + get number() { + return this._backing.getNumber(); + } + + /** The type of the track. */ + abstract get type(): TrackType; + + /** Returns true if and only if this is a video track descriptor. */ + isVideoTrackDescriptor(): this is InputVideoTrackDescriptor { + return this instanceof InputVideoTrackDescriptor; + } + + /** Returns true if and only if this is an audio track descriptor. */ + isAudioTrackDescriptor(): this is InputAudioTrackDescriptor { + return this instanceof InputAudioTrackDescriptor; + } + + /** The codec of the track's packets, `undefined` if not yet known. */ + abstract get codec(): MediaCodec | null | undefined; + + /** + * The full codec parameter string (e.g. `'avc1.64001f'`), `undefined` if not yet known. + * This is typically available from HLS master playlists. + */ + get codecParameterString(): string | null | undefined { + return this._backing.getCodecParameterString?.(); + } + + /** The ISO 639-2/T language code for this track, `undefined` if not yet known. */ + get languageCode(): string | undefined { + return this._backing.getLanguageCode(); + } + + /** A user-defined name for this track, `undefined` if not yet known. */ + get name(): string | null | undefined { + return this._backing.getName(); + } + + /** The track's disposition, i.e. information about its intended usage, `undefined` if not yet known. */ + get disposition(): TrackDisposition | undefined { + return this._backing.getDisposition(); + } + + get bitrate(): number | null | undefined { + return this._backing.getBitrate(); + } + + get averageBitrate(): number | null | undefined { + return this._backing.getAverageBitrate(); + } + + /** Whether the track metadata says that this track only contains key packets, `undefined` if not yet known. */ + abstract get hasOnlyKeyPackets(): boolean | undefined; + + canBePairedWith(other: InputTrackDescriptor | InputTrack | null) { + if (!(other instanceof InputTrack || other instanceof InputTrackDescriptor || other === null)) { + throw new TypeError('other must be an InputTrack, InputTrackDescriptor, or null.'); + } + + if (!other) { + return true; + } + + if (this.input !== other.input || this === other) { + return false; + } + + return (this._backing.getPairingMask() & other._backing.getPairingMask()) !== 0n; + } + + async getPairableDescriptors(query?: TrackDescriptorQuery) { + query &&= toValidatedTrackDescriptorQuery(query); + + const descriptors = await this.input.getTrackDescriptors(); + return queryTrackDescriptors( + descriptors.filter(x => this.canBePairedWith(x)), + query, + ); + } + + async getPairableVideoTrackDescriptors(query?: TrackDescriptorQuery) { + query &&= toValidatedTrackDescriptorQuery(query); + + const descriptors = await this.getPairableDescriptors(); + return queryTrackDescriptors( + descriptors.filter((x): x is InputVideoTrackDescriptor => x.isVideoTrackDescriptor()), + query, + ); + } + + async getPairableAudioTrackDescriptors(query?: TrackDescriptorQuery) { + query &&= toValidatedTrackDescriptorQuery(query); + + const descriptors = await this.getPairableDescriptors(); + return queryTrackDescriptors( + descriptors.filter((x): x is InputAudioTrackDescriptor => x.isAudioTrackDescriptor()), + query, + ); + } + + hasPairableDescriptor(predicate?: (descriptor: InputTrackDescriptor) => boolean) { + const descriptors = [...this.input._backingToDescriptor.values()]; + return descriptors.some(x => this.canBePairedWith(x) && (!predicate || predicate(x))); + } + + hasPairableVideoTrack(predicate?: (descriptor: InputVideoTrackDescriptor) => boolean) { + return this.hasPairableDescriptor(x => + x.isVideoTrackDescriptor() && (!predicate || predicate(x)), + ); + } + + hasPairableAudioTrack(predicate?: (descriptor: InputAudioTrackDescriptor) => boolean) { + return this.hasPairableDescriptor(x => + x.isAudioTrackDescriptor() && (!predicate || predicate(x)), + ); + } + + /** + * Loads the full media data for this track and returns a fully-loaded {@link InputTrack}. Calling this + * multiple times returns the same instance. + */ + async getTrack(): Promise { + return this.input._getTrackForBacking(this._backing); + } +} + +/** + * A lightweight descriptor for a video track. + * + * @group Input files & tracks + * @public + */ +export class InputVideoTrackDescriptor extends InputTrackDescriptor { + /** @internal */ + override _backing: InputVideoTrackBacking; + + /** @internal */ + constructor(input: Input, backing: InputVideoTrackBacking) { + super(input, backing); + this._backing = backing; + } + + get type(): TrackType { + return 'video'; + } + + get hasOnlyKeyPackets(): boolean | undefined { + return this._backing.getHasOnlyKeyPackets?.() ?? false; + } + + get codec(): VideoCodec | null | undefined { + return this._backing.getCodec(); + } + + /** The display width in pixels from metadata, `undefined` if not yet known. */ + get displayWidth(): number | undefined { + const metadataWidth = this._backing.getMetadataDisplayWidth?.() ?? null; + if (metadataWidth !== null) { + return metadataWidth; + } + + const rotation = this._backing.getRotation(); + const squarePixelWidth = this._backing.getSquarePixelWidth(); + const squarePixelHeight = this._backing.getSquarePixelHeight(); + + if (rotation === undefined || squarePixelWidth === undefined || squarePixelHeight === undefined) { + return undefined; + } + + return rotation % 180 === 0 ? squarePixelWidth : squarePixelHeight; + } + + /** The display height in pixels from metadata, `undefined` if not yet known. */ + get displayHeight(): number | undefined { + const metadataHeight = this._backing.getMetadataDisplayHeight?.() ?? null; + if (metadataHeight !== null) { + return metadataHeight; + } + + const rotation = this._backing.getRotation(); + const squarePixelWidth = this._backing.getSquarePixelWidth(); + const squarePixelHeight = this._backing.getSquarePixelHeight(); + + if (rotation === undefined || squarePixelWidth === undefined || squarePixelHeight === undefined) { + return undefined; + } + + return rotation % 180 === 0 ? squarePixelHeight : squarePixelWidth; + } + + override async getTrack(): Promise { + return super.getTrack() as Promise; + } +} + +/** + * A lightweight descriptor for an audio track. + * + * @group Input files & tracks + * @public + */ +export class InputAudioTrackDescriptor extends InputTrackDescriptor { + /** @internal */ + override _backing: InputAudioTrackBacking; + + /** @internal */ + constructor(input: Input, backing: InputAudioTrackBacking) { + super(input, backing); + this._backing = backing; + } + + get hasOnlyKeyPackets(): boolean | undefined { + return this._backing.getHasOnlyKeyPackets?.() ?? true; + } + + get type(): TrackType { + return 'audio'; + } + + get codec(): AudioCodec | null | undefined { + return this._backing.getCodec(); + } + + /** The number of audio channels, `undefined` if not yet known. */ + get numberOfChannels(): number | undefined { + return this._backing.getNumberOfChannels(); + } + + /** The audio sample rate in hertz, `undefined` if not yet known. */ + get sampleRate(): number | undefined { + return this._backing.getSampleRate(); + } + + override async getTrack(): Promise { + return super.getTrack() as Promise; + } +} + +export type TrackDescriptorQuery = { + filter?: (descriptor: T) => MaybePromise; + sortBy?: (descriptor: T) => MaybePromise; +}; + +export const toValidatedTrackDescriptorQuery = ( + query: TrackDescriptorQuery, +): TrackDescriptorQuery => { + if (typeof query !== 'object' || !query) { + throw new TypeError('query must be an object.'); + } + if (query.filter !== undefined && typeof query.filter !== 'function') { + throw new TypeError('query.filter, when provided, must be a function.'); + } + if (query.sortBy !== undefined && typeof query.sortBy !== 'function') { + throw new TypeError('query.sortBy, when provided, must be a function.'); + } + + // Instead of validating the return types of the functions everywhere the query is used, simply return a new query + // which wraps the old one while validating it. + return { + filter: query.filter + ? (desc) => { + const handle = (bool: boolean) => { + if (typeof bool !== 'boolean') { + throw new TypeError('query.filter must return or resolve to a boolean.'); + } + + return bool; + }; + + const result = query.filter!(desc); + if (result instanceof Promise) { + return result.then(handle); + } else { + return handle(result); + } + } + : undefined, + sortBy: query.sortBy + ? (desc) => { + const handle = (value: number | number[]) => { + if ( + typeof value !== 'number' + && (!Array.isArray(value) || !value.every(x => typeof x === 'number')) + ) { + throw new TypeError( + 'query.sortBy must return or resolve to a number or an array of numbers.', + ); + } + + return value; + }; + + const result = query.sortBy!(desc); + if (result instanceof Promise) { + return result.then(handle); + } else { + return handle(result); + } + } + : undefined, + }; +}; + +export const mergeTrackDescriptorQueries = ( + queryA: TrackDescriptorQuery | undefined, + queryB: TrackDescriptorQuery | undefined, +): TrackDescriptorQuery => { + return { + filter: queryA?.filter || queryB?.filter + ? (descriptor) => { + const resultA = queryA?.filter?.(descriptor) ?? true; + const handleResultA = (resultA: boolean) => { + if (resultA === false) { + return false; + } + + return queryB?.filter?.(descriptor) ?? true; + }; + + if (resultA instanceof Promise) { + return resultA.then(handleResultA); + } else { + return handleResultA(resultA); + } + } + : undefined, + sortBy: queryA?.sortBy || queryB?.sortBy + ? (descriptor) => { + const resultA = queryA?.sortBy?.(descriptor) ?? []; + const resultB = queryB?.sortBy?.(descriptor) ?? []; + + type Result = Awaited; + const join = (resultA: Result, resultB: Result) => { + return [ + ...(Array.isArray(resultA) ? resultA : [resultA]), + ...(Array.isArray(resultB) ? resultB : [resultB]), + ]; + }; + + if (resultA instanceof Promise || resultB instanceof Promise) { + return Promise.all([resultA, resultB]).then(([resultA, resultB]) => { + return join(resultA, resultB); + }); + } else { + return join(resultA, resultB); + } + } + : undefined, + }; +}; + +export const queryTrackDescriptors = async ( + descriptors: T[], + query?: TrackDescriptorQuery, +): Promise => { + let matched = descriptors; + if (query?.filter) { + const filterMatches = descriptors.map(d => query.filter!(d)); + const hasAsyncFilter = filterMatches.some(x => x instanceof Promise); + if (hasAsyncFilter) { + // eslint-disable-next-line @typescript-eslint/await-thenable + const resolvedFilterMatches = await Promise.all(filterMatches); + matched = descriptors.filter((_, i) => resolvedFilterMatches[i]); + } else { + matched = descriptors.filter((_, i) => filterMatches[i] as boolean); + } + } + + if (!query?.sortBy) { + return matched; + } + + const sortValues = matched.map(d => query.sortBy!(d)); + const hasAsyncSort = sortValues.some(x => x instanceof Promise); + const resolvedSortValues = hasAsyncSort + // eslint-disable-next-line @typescript-eslint/await-thenable + ? await Promise.all(sortValues) + : sortValues as (number | number[])[]; + + return matched + .map((descriptor, i) => ({ descriptor, sortValue: resolvedSortValues[i] })) + .sort((a, b) => { + const aValues = Array.isArray(a.sortValue) ? a.sortValue : [a.sortValue]; + const bValues = Array.isArray(b.sortValue) ? b.sortValue : [b.sortValue]; + const maxLength = Math.max(aValues.length, bValues.length); + + for (let i = 0; i < maxLength; i++) { + const aValue = aValues[i] ?? 0; + const bValue = bValues[i] ?? 0; + if (aValue === bValue) { + continue; + } + return aValue - bValue; + } + + return 0; + }) + .map(x => x.descriptor); +}; diff --git a/src/input-track.ts b/src/input-track.ts index 34feb95..acc152a 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -11,11 +11,18 @@ import { determineVideoPacketType } from './codec-data'; import { customAudioDecoders, customVideoDecoders } from './custom-coder'; import { Input } from './input'; import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink'; -import { assert, MaybePromise, NonFunctionKeys, Rational, Rotation, roundToDivisor, simplifyRational } from './misc'; +import { assert, Rational, Rotation, roundToDivisor, simplifyRational } from './misc'; import { TrackType } from './output'; import { EncodedPacket, PacketType } from './packet'; import { TrackDisposition } from './metadata'; import { DurationMetadataRequestOptions } from './demuxer'; +import { + InputTrackDescriptor, + mergeTrackDescriptorQueries, + type InputVideoTrackDescriptor, + type InputAudioTrackDescriptor, + type TrackDescriptorQuery, +} from './input-track-descriptor'; /** * Contains aggregate statistics about the encoded packets of a track. @@ -32,14 +39,15 @@ export type PacketStats = { }; export interface InputTrackBacking { + getType(): TrackType; getId(): number; getNumber(): number; getCodec(): MediaCodec | null; getInternalCodecId(): string | number | Uint8Array | null; getName(): string | null; getLanguageCode(): string; - getTimeResolution(): number; - isRelativeToUnixEpoch(): boolean; + getTimeResolution(): number | undefined; + isRelativeToUnixEpoch(): boolean | undefined; getDisposition(): TrackDisposition; getPairingMask(): bigint; getBitrate(): number | null; @@ -47,6 +55,8 @@ export interface InputTrackBacking { getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise; getLiveRefreshInterval(): Promise; getHasOnlyKeyPackets?(): boolean | null; + getDecoderConfig(): Promise; + getCodecParameterString?(): string | null; getFirstPacket(options: PacketRetrievalOptions): Promise; getPacket(timestamp: number, options: PacketRetrievalOptions): Promise; @@ -68,9 +78,6 @@ export abstract class InputTrack { readonly input: Input; /** @internal */ _backing: InputTrackBacking; - /** @internal */ - _hydrationPromise: Promise | null = null; - /** @internal */ constructor(input: Input, backing: InputTrackBacking) { this.input = input; @@ -151,7 +158,9 @@ export abstract class InputTrack { * integer multiples of 1/x. */ get timeResolution() { - return this._backing.getTimeResolution(); + const value = this._backing.getTimeResolution(); + assert(value !== undefined); + return value; } /** @@ -159,7 +168,9 @@ export abstract class InputTrack { * each timestamp maps to a definitive point in time. */ get isRelativeToUnixEpoch() { - return this._backing.isRelativeToUnixEpoch(); + const value = this._backing.isRelativeToUnixEpoch(); + assert(value !== undefined); + return value; } /** The track's disposition, i.e. information about its intended usage. */ @@ -181,10 +192,6 @@ export abstract class InputTrack { * with a negative timestamp should not be presented. */ async getFirstTimestamp() { - if (!this.isHydrated) { - await this.hydrate(); - } - const firstPacket = await this._backing.getFirstPacket({ metadataOnly: true }); return firstPacket?.timestamp ?? 0; } @@ -197,10 +204,6 @@ export abstract class InputTrack { * in the options. */ async computeDuration(options?: PacketRetrievalOptions) { - if (!this.isHydrated) { - await this.hydrate(); - } - const lastPacket = await this._backing.getPacket(Infinity, { metadataOnly: true, ...options }); const result = (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0); @@ -218,10 +221,6 @@ export abstract class InputTrack { * {@link DurationMetadataRequestOptions.skipLiveWait} to `true` in the options. */ async getDurationFromMetadata(options: DurationMetadataRequestOptions = {}) { - if (!this.isHydrated) { - await this.hydrate(); - } - return this._backing.getDurationFromMetadata(options); } @@ -277,136 +276,129 @@ export abstract class InputTrack { } async getLiveRefreshInterval() { - if (!this.isHydrated) { - await this.hydrate(); - } - return this._backing.getLiveRefreshInterval(); } - get isHydrated() { - return this._backing.isHydrated?.() ?? true; - } - - hydrate(): Promise { - if (this.isHydrated || !this._backing.hydrate) { - return Promise.resolve(); + canBePairedWith(other: InputTrack | InputTrackDescriptor | null) { + if (!(other instanceof InputTrack || other instanceof InputTrackDescriptor || other === null)) { + throw new TypeError('other must be an InputTrack, InputTrackDescriptor, or null.'); } - return this._hydrationPromise ??= this._backing.hydrate(); - } - - canBePairedWith(otherTrack: InputTrack | null) { - if (!otherTrack) { + if (!other) { return true; } - if (this.input !== otherTrack.input || this === otherTrack) { + if (this.input !== other.input || this === other) { return false; } - return (this._backing.getPairingMask() & otherTrack._backing.getPairingMask()) !== 0n; + return (this._backing.getPairingMask() & other._backing.getPairingMask()) !== 0n; } - async getPairableTracks(query?: TrackQuery) { - const tracks = await this.input.getTracks(); - return queryTracks(tracks.filter(x => this.canBePairedWith(x)), query); + async getPairableTracks(query?: TrackDescriptorQuery) { + const descriptors = await this.input.getTrackDescriptors(mergeTrackDescriptorQueries({ + filter: d => d.canBePairedWith(this), + }, query)); + + return Promise.all(descriptors.map(d => d.getTrack())); } - async pluckPairableTrack(query?: TrackQuery) { - return (await this.getPairableTracks(query))[0]; + async pluckPairableTrack(query?: TrackDescriptorQuery) { + return (await this.getPairableTracks(query))[0] ?? null; } - async getPairableVideoTracks(query?: TrackQuery) { - const tracks = await this.getPairableTracks({ - filter: track => track.isVideoTrack(), - }); - return queryTracks(tracks as InputVideoTrack[], query); + async getPairableVideoTracks(query?: TrackDescriptorQuery) { + const descriptors = await this.input.getVideoTrackDescriptors(mergeTrackDescriptorQueries({ + filter: d => d.canBePairedWith(this), + }, query)); + + return Promise.all(descriptors.map(d => d.getTrack())); } - async pluckPairableVideoTrack(query?: TrackQuery) { + async pluckPairableVideoTrack(query?: TrackDescriptorQuery) { return (await this.getPairableVideoTracks(query))[0] ?? null; } - async getPairableAudioTracks(query?: TrackQuery) { - const tracks = await this.getPairableTracks({ - filter: track => track.isAudioTrack(), - }); - return queryTracks(tracks as InputAudioTrack[], query); + async getPairableAudioTracks(query?: TrackDescriptorQuery) { + const descriptors = await this.input.getAudioTrackDescriptors(mergeTrackDescriptorQueries({ + filter: d => d.canBePairedWith(this), + }, query)); + + return Promise.all(descriptors.map(d => d.getTrack())); } - async pluckPairableAudioTrack(query?: TrackQuery) { + async pluckPairableAudioTrack(query?: TrackDescriptorQuery) { return (await this.getPairableAudioTracks(query))[0] ?? null; } - async getPrimaryPairableVideoTrack(query?: TrackQuery) { - return this.input.getPrimaryVideoTrack(mergeTrackQueries({ - filter: track => track.canBePairedWith(this), + async getPrimaryPairableVideoTrack(query?: TrackDescriptorQuery) { + return this.input.getPrimaryVideoTrack(mergeTrackDescriptorQueries({ + filter: d => d.canBePairedWith(this), }, query)); } - async getPrimaryPairableAudioTrack(query?: TrackQuery) { - return this.input.getPrimaryAudioTrack(mergeTrackQueries({ - filter: track => track.canBePairedWith(this), + async getPrimaryPairableAudioTrack(query?: TrackDescriptorQuery) { + return this.input.getPrimaryAudioTrack(mergeTrackDescriptorQueries({ + filter: d => d.canBePairedWith(this), }, query)); } - hasPairableTrack(predicate?: (track: InputTrack) => boolean) { - assert(this.input._tracksCache); - return this.input._tracksCache.some(x => this.canBePairedWith(x) && (!predicate || predicate(x))); - } + hasPairableTrack(predicate?: (descriptor: InputTrackDescriptor) => boolean) { + predicate &&= toValidatedPredicate(predicate); - hasPairableVideoTrack(predictate?: (track: InputVideoTrack) => boolean): boolean { - return this.hasPairableTrack(x => - x.isVideoTrack() && (!predictate || predictate(x)), + const descriptors = [...this.input._backingToDescriptor.values()]; + return descriptors.some(x => + this.canBePairedWith(x) && (!predicate || predicate(x)), ); } - hasPairableAudioTrack(predictate?: (track: InputAudioTrack) => boolean): boolean { + hasPairableVideoTrack(predicate?: (descriptor: InputVideoTrackDescriptor) => boolean) { + predicate &&= toValidatedPredicate(predicate); + return this.hasPairableTrack(x => - x.isAudioTrack() && (!predictate || predictate(x)), + x.isVideoTrackDescriptor() && (!predicate || predicate(x)), ); } - get>(key: K): this[K] | null { - try { - return this[key]; - } catch (error) { - if (error instanceof TrackNotHydratedError) { - return null; - } + hasPairableAudioTrack(predicate?: (descriptor: InputAudioTrackDescriptor) => boolean) { + predicate &&= toValidatedPredicate(predicate); - throw error; - } - } - - async resolve>(key: K): Promise { - try { - return this[key]; - } catch (error) { - if (error instanceof TrackNotHydratedError) { - await this.hydrate(); - return this[key]; - } - - throw error; - } + return this.hasPairableTrack(x => + x.isAudioTrackDescriptor() && (!predicate || predicate(x)), + ); } } +const toValidatedPredicate = (predicate?: (descriptor: T) => boolean) => { + if (predicate !== undefined && typeof predicate !== 'function') { + throw new TypeError('predicate, when provided, must be a function.'); + } + + return predicate + ? (desc: T) => { + const result = predicate(desc); + if (typeof result !== 'boolean') { + throw new TypeError('predicate must return a boolean value.'); + } + + return result; + } + : undefined; +}; + export interface InputVideoTrackBacking extends InputTrackBacking { + getType(): 'video'; getCodec(): VideoCodec | null; - getCodedWidth(): number; - getCodedHeight(): number; - getSquarePixelWidth(): number; - getSquarePixelHeight(): number; - getDisplayWidth?(): number | null; - getDisplayHeight?(): number | null; - getRotation(): Rotation; + getCodedWidth(): number | undefined; + getCodedHeight(): number | undefined; + getSquarePixelWidth(): number | undefined; + getSquarePixelHeight(): number | undefined; + getMetadataDisplayWidth?(): number | null; + getMetadataDisplayHeight?(): number | null; + getRotation(): Rotation | undefined; getColorSpace(): Promise; canBeTransparent(): Promise; getDecoderConfig(): Promise; - getCodecParameterString?(): Promise; } /** @@ -441,27 +433,37 @@ export class InputVideoTrack extends InputTrack { /** The width in pixels of the track's coded samples, before any transformations or rotations. */ get codedWidth() { - return this._backing.getCodedWidth(); + const value = this._backing.getCodedWidth(); + assert(value !== undefined); + return value; } /** The height in pixels of the track's coded samples, before any transformations or rotations. */ get codedHeight() { - return this._backing.getCodedHeight(); + const value = this._backing.getCodedHeight(); + assert(value !== undefined); + return value; } /** The angle in degrees by which the track's frames should be rotated (clockwise). */ get rotation() { - return this._backing.getRotation(); + const value = this._backing.getRotation(); + assert(value !== undefined); + return value; } /** The width of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation. */ get squarePixelWidth() { - return this._backing.getSquarePixelWidth(); + const value = this._backing.getSquarePixelWidth(); + assert(value !== undefined); + return value; } /** The height of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation. */ get squarePixelHeight() { - return this._backing.getSquarePixelHeight(); + const value = this._backing.getSquarePixelHeight(); + assert(value !== undefined); + return value; } /** @@ -470,39 +472,23 @@ export class InputVideoTrack extends InputTrack { */ get pixelAspectRatio() { return this._pixelAspectRatioCache ??= simplifyRational({ - num: this._backing.getSquarePixelWidth() * this._backing.getCodedHeight(), - den: this._backing.getSquarePixelHeight() * this._backing.getCodedWidth(), + num: this.squarePixelWidth * this.codedHeight, + den: this.squarePixelHeight * this.codedWidth, }); } /** The display width of the track's frames in pixels, after aspect ratio adjustment and rotation. */ get displayWidth() { - const customValue = this._backing.getDisplayWidth?.() ?? null; - if (customValue !== null) { - return customValue; - } - - const rotation = this._backing.getRotation(); - return rotation % 180 === 0 ? this.squarePixelWidth : this.squarePixelHeight; + return this.rotation % 180 === 0 ? this.squarePixelWidth : this.squarePixelHeight; } /** The display height of the track's frames in pixels, after aspect ratio adjustment and rotation. */ get displayHeight() { - const customValue = this._backing.getDisplayHeight?.() ?? null; - if (customValue !== null) { - return customValue; - } - - const rotation = this._backing.getRotation(); - return rotation % 180 === 0 ? this.squarePixelHeight : this.squarePixelWidth; + return this.rotation % 180 === 0 ? this.squarePixelHeight : this.squarePixelWidth; } /** Returns the color space of the track's samples. */ async getColorSpace() { - if (!this.isHydrated) { - await this.hydrate(); - } - return this._backing.getColorSpace(); } @@ -517,10 +503,6 @@ export class InputVideoTrack extends InputTrack { /** Checks if this track may contain transparent samples with alpha data. */ async canBeTransparent() { - if (!this.isHydrated) { - await this.hydrate(); - } - return this._backing.canBeTransparent(); } @@ -530,32 +512,16 @@ export class InputVideoTrack extends InputTrack { * null if the track's codec is unknown. */ async getDecoderConfig() { - if (!this.isHydrated) { - await this.hydrate(); - } - return this._backing.getDecoderConfig(); } async getCodecParameterString() { - if (this._backing.getCodecParameterString) { - return this._backing.getCodecParameterString(); - } - - if (!this.isHydrated) { - await this.hydrate(); - } - const decoderConfig = await this._backing.getDecoderConfig(); return decoderConfig?.codec ?? null; } async canDecode() { try { - if (!this.isHydrated) { - await this.hydrate(); - } - const decoderConfig = await this._backing.getDecoderConfig(); if (!decoderConfig) { return false; @@ -600,11 +566,11 @@ export class InputVideoTrack extends InputTrack { } export interface InputAudioTrackBacking extends InputTrackBacking { + getType(): 'audio'; getCodec(): AudioCodec | null; - getNumberOfChannels(): number; - getSampleRate(): number; + getNumberOfChannels(): number | undefined; + getSampleRate(): number | undefined; getDecoderConfig(): Promise; - getCodecParameterString?(): Promise; } /** @@ -637,12 +603,16 @@ export class InputAudioTrack extends InputTrack { /** The number of audio channels in the track. */ get numberOfChannels() { - return this._backing.getNumberOfChannels(); + const value = this._backing.getNumberOfChannels(); + assert(value !== undefined); + return value; } /** The track's audio sample rate in hertz. */ get sampleRate() { - return this._backing.getSampleRate(); + const value = this._backing.getSampleRate(); + assert(value !== undefined); + return value; } /** @@ -651,32 +621,16 @@ export class InputAudioTrack extends InputTrack { * null if the track's codec is unknown. */ async getDecoderConfig() { - if (!this.isHydrated) { - await this.hydrate(); - } - return this._backing.getDecoderConfig(); } async getCodecParameterString() { - if (this._backing.getCodecParameterString) { - return this._backing.getCodecParameterString(); - } - - if (!this.isHydrated) { - await this.hydrate(); - } - const decoderConfig = await this._backing.getDecoderConfig(); return decoderConfig?.codec ?? null; } async canDecode() { try { - if (!this.isHydrated) { - await this.hydrate(); - } - const decoderConfig = await this._backing.getDecoderConfig(); if (!decoderConfig) { return false; @@ -717,112 +671,3 @@ export class InputAudioTrack extends InputTrack { return 'key'; // No audio codec with delta packets } } - -export class TrackNotHydratedError extends Error { - /** Creates a new {@link InputDisposedError}. */ - constructor( - message = 'InputTrack is not hydrated; please call hydrate() first, or use the resolve() or get() methods.', - ) { - super(message); - this.name = 'TrackNotHydratedError'; - } -} - -export type TrackQuery = { - filter?: (track: T) => MaybePromise; - sortBy?: (track: T) => MaybePromise; -}; - -export const mergeTrackQueries = ( - queryA: TrackQuery | undefined, - queryB: TrackQuery | undefined, -): TrackQuery => { - return { - filter: queryA?.filter || queryB?.filter - ? (track) => { - const resultA = queryA?.filter?.(track) ?? true; - const handleResultA = (resultA: boolean) => { - if (resultA === false) { - return false; - } - - return queryB?.filter?.(track) ?? true; - }; - - if (resultA instanceof Promise) { - return resultA.then(handleResultA); - } else { - return handleResultA(resultA); - } - } - : undefined, - sortBy: queryA?.sortBy || queryB?.sortBy - ? (track) => { - const resultA = queryA?.sortBy?.(track) ?? []; - const resultB = queryB?.sortBy?.(track) ?? []; - - type Result = Awaited; - const join = (resultA: Result, resultB: Result) => { - return [ - ...(Array.isArray(resultA) ? resultA : [resultA]), - ...(Array.isArray(resultB) ? resultB : [resultB]), - ]; - }; - - if (resultA instanceof Promise || resultB instanceof Promise) { - return Promise.all([resultA, resultB]).then(([resultA, resultB]) => { - return join(resultA, resultB); - }); - } else { - return join(resultA, resultB); - } - } - : undefined, - }; -}; - -export const queryTracks = async (tracks: T[], query?: TrackQuery): Promise => { - let matchedTracks = tracks; - if (query?.filter) { - const filterMatches = tracks.map(track => query.filter!(track)); - const hasAsyncFilter = filterMatches.some(x => x instanceof Promise); - if (hasAsyncFilter) { - // eslint-disable-next-line @typescript-eslint/await-thenable - const resolvedFilterMatches = await Promise.all(filterMatches); - matchedTracks = tracks.filter((_, i) => resolvedFilterMatches[i]); - } else { - matchedTracks = tracks.filter((_, i) => filterMatches[i] as boolean); - } - } - - if (!query?.sortBy) { - return matchedTracks; - } - - const sortValues = matchedTracks.map(track => query.sortBy!(track)); - const hasAsyncSort = sortValues.some(x => x instanceof Promise); - const resolvedSortValues = hasAsyncSort - // eslint-disable-next-line @typescript-eslint/await-thenable - ? await Promise.all(sortValues) - : sortValues as (number | number[])[]; - - return matchedTracks - .map((track, i) => ({ track, sortValue: resolvedSortValues[i] })) - .sort((a, b) => { - const aValues = Array.isArray(a.sortValue) ? a.sortValue : [a.sortValue]; - const bValues = Array.isArray(b.sortValue) ? b.sortValue : [b.sortValue]; - const maxLength = Math.max(aValues.length, bValues.length); - - for (let i = 0; i < maxLength; i++) { - const aValue = aValues[i] ?? 0; - const bValue = bValues[i] ?? 0; - if (aValue === bValue) { - continue; - } - return aValue - bValue; - } - - return 0; - }) - .map(x => x.track); -}; diff --git a/src/input.ts b/src/input.ts index e885feb..6a58255 100644 --- a/src/input.ts +++ b/src/input.ts @@ -10,12 +10,21 @@ import { Demuxer, DurationMetadataRequestOptions } from './demuxer'; import { InputFormat } from './input-format'; import { InputAudioTrack, + InputAudioTrackBacking, InputTrack, + InputTrackBacking, InputVideoTrack, - mergeTrackQueries, - queryTracks, - TrackQuery, + InputVideoTrackBacking, } from './input-track'; +import { + InputAudioTrackDescriptor, + InputTrackDescriptor, + InputVideoTrackDescriptor, + mergeTrackDescriptorQueries, + queryTrackDescriptors, + toValidatedTrackDescriptorQuery, + TrackDescriptorQuery, +} from './input-track-descriptor'; import { PacketRetrievalOptions } from './media-sink'; import { arrayArgmin, @@ -100,7 +109,13 @@ export class Input extends EventEmitter /** @internal */ _reader!: Reader; /** @internal */ - _tracksCache: InputTrack[] | null = null; + _trackBackingsCache: InputTrackBacking[] | null = null; + /** @internal */ + _backingToTrack = new Map(); + /** @internal */ + _backingToDescriptor = new Map(); + /** @internal */ + _hydrationPromises = new Map>(); /** @internal */ _disposed = false; /** @internal */ @@ -375,60 +390,193 @@ export class Input extends EventEmitter return demuxer.getDurationFromMetadata(options); } - /** Returns the list of all tracks of this input file in the order in which they appear in the file. */ - async getTracks(query?: TrackQuery) { - const demuxer = await this._getDemuxer(); - const tracks = this._tracksCache ??= await demuxer.getTracks(); - return queryTracks(tracks, query); + /** + * Returns the list of all tracks of this input file in the order in which they appear in the file. + * A query can be provided to filter/sort tracks; the query operates on lightweight descriptors and only + * the matching tracks are fully loaded. + */ + async getTracks(query?: TrackDescriptorQuery): Promise { + const descriptors = await this.getTrackDescriptors(query); + return Promise.all(descriptors.map(x => x.getTrack())); } - async pluckTrack(query?: TrackQuery) { - return (await this.getTracks(query))[0]; + async pluckTrack(query?: TrackDescriptorQuery): Promise { + const descriptors = await this.getTrackDescriptors(query); + return descriptors[0]?.getTrack() ?? null; } /** Returns the list of all video tracks of this input file. */ - async getVideoTracks(query?: TrackQuery) { - const tracks = await this.getTracks(); - return queryTracks(tracks.filter(x => x.isVideoTrack()) as InputVideoTrack[], query); + async getVideoTracks(query?: TrackDescriptorQuery): Promise { + const descriptors = await this.getVideoTrackDescriptors(query); + return Promise.all(descriptors.map(x => x.getTrack())); } - async pluckVideoTrack(query?: TrackQuery) { - return (await this.getVideoTracks(query))[0] ?? null; + async pluckVideoTrack(query?: TrackDescriptorQuery): Promise { + const descriptors = await this.getVideoTrackDescriptors(query); + return descriptors[0]?.getTrack() ?? null; } /** Returns the list of all audio tracks of this input file. */ - async getAudioTracks(query?: TrackQuery) { - const tracks = await this.getTracks(); - return queryTracks(tracks.filter(x => x.isAudioTrack()) as InputAudioTrack[], query); + async getAudioTracks(query?: TrackDescriptorQuery): Promise { + const descriptors = await this.getAudioTrackDescriptors(query); + return Promise.all(descriptors.map(x => x.getTrack())); } - async pluckAudioTrack(query?: TrackQuery) { - return (await this.getAudioTracks(query))[0] ?? null; + async pluckAudioTrack(query?: TrackDescriptorQuery): Promise { + const descriptors = await this.getAudioTrackDescriptors(query); + return descriptors[0]?.getTrack() ?? null; } /** Returns the primary video track of this input file, or null if there are no video tracks. */ - getPrimaryVideoTrack(query?: TrackQuery) { - return this.pluckVideoTrack(mergeTrackQueries(query, { - sortBy: track => [ - prefer(track.disposition.default), - prefer(track.hasPairableAudioTrack()), - prefer(!track.hasOnlyKeyPackets), - desc(track.bitrate), - ], - })); + async getPrimaryVideoTrack( + query?: TrackDescriptorQuery, + ): Promise { + query &&= toValidatedTrackDescriptorQuery(query); + + const descriptor = await this.getPrimaryVideoDescriptor(query); + return descriptor?.getTrack() ?? null; } /** Returns the primary audio track of this input file, or null if there are no audio tracks. */ - async getPrimaryAudioTrack(query?: TrackQuery) { - const videoTrack = await this.getPrimaryVideoTrack(); + async getPrimaryAudioTrack( + query?: TrackDescriptorQuery, + ): Promise { + query &&= toValidatedTrackDescriptorQuery(query); - return this.pluckAudioTrack(mergeTrackQueries(query, { - sortBy: track => [ - prefer(track.canBePairedWith(videoTrack)), - prefer(track.disposition.default), - desc(track.bitrate), + const descriptor = await this.getPrimaryAudioDescriptor(query); + return descriptor?.getTrack() ?? null; + } + + /** + * Returns lightweight track descriptors without loading full media data. Useful for querying and filtering + * tracks (e.g. from an HLS master playlist) before committing to loading any specific track. + */ + async getTrackDescriptors(query?: TrackDescriptorQuery) { + query &&= toValidatedTrackDescriptorQuery(query); + + const backings = await this._getTrackBackings(); + const descriptors = backings.map(backing => this._wrapBackingAsDescriptor(backing)); + return queryTrackDescriptors(descriptors, query); + } + + async getVideoTrackDescriptors( + query?: TrackDescriptorQuery, + ): Promise { + query &&= toValidatedTrackDescriptorQuery(query); + + const descriptors = await this.getTrackDescriptors(); + const videoDescriptors = descriptors.filter( + (x): x is InputVideoTrackDescriptor => x.isVideoTrackDescriptor(), + ); + return queryTrackDescriptors(videoDescriptors, query); + } + + async getAudioTrackDescriptors( + query?: TrackDescriptorQuery, + ): Promise { + query &&= toValidatedTrackDescriptorQuery(query); + + const descriptors = await this.getTrackDescriptors(); + const audioDescriptors = descriptors.filter( + (x): x is InputAudioTrackDescriptor => x.isAudioTrackDescriptor(), + ); + return queryTrackDescriptors(audioDescriptors, query); + } + + async getPrimaryVideoDescriptor( + query?: TrackDescriptorQuery, + ): Promise { + query &&= toValidatedTrackDescriptorQuery(query); + + const merged = mergeTrackDescriptorQueries(query, { + sortBy: d => [ + prefer(d.disposition?.default ?? false), + prefer(d.hasPairableAudioTrack()), + prefer(!d.hasOnlyKeyPackets), + desc(d.bitrate ?? null), ], - })); + }); + + const sorted = await this.getVideoTrackDescriptors(merged); + return sorted[0] ?? null; + } + + async getPrimaryAudioDescriptor( + query?: TrackDescriptorQuery, + ): Promise { + query &&= toValidatedTrackDescriptorQuery(query); + + const primaryVideoDescriptor = await this.getPrimaryVideoDescriptor(); + + const merged = mergeTrackDescriptorQueries(query, { + sortBy: d => [ + prefer(d.canBePairedWith(primaryVideoDescriptor)), + prefer(d.disposition?.default ?? false), + desc(d.bitrate ?? null), + ], + }); + + const sorted = await this.getAudioTrackDescriptors(merged); + return sorted[0] ?? null; + } + + /** @internal */ + async _getTrackBackings() { + const demuxer = await this._getDemuxer(); + return this._trackBackingsCache ??= await demuxer.getTrackBackings(); + } + + /** @internal */ + _wrapBackingAsTrack(backing: InputTrackBacking): InputTrack { + const existing = this._backingToTrack.get(backing); + if (existing) { + return existing; + } + + const type = backing.getType(); + const track = type === 'video' + ? new InputVideoTrack(this, backing as InputVideoTrackBacking) + : new InputAudioTrack(this, backing as InputAudioTrackBacking); + + this._backingToTrack.set(backing, track); + return track; + } + + /** @internal */ + _wrapBackingAsDescriptor(backing: InputTrackBacking): InputTrackDescriptor { + const existing = this._backingToDescriptor.get(backing); + if (existing) { + return existing; + } + + const type = backing.getType(); + const descriptor = type === 'video' + ? new InputVideoTrackDescriptor(this, backing as InputVideoTrackBacking) + : new InputAudioTrackDescriptor(this, backing as InputAudioTrackBacking); + + this._backingToDescriptor.set(backing, descriptor); + return descriptor; + } + + /** @internal */ + _hydrateBacking(backing: InputTrackBacking): Promise { + if (!backing.hydrate || (backing.isHydrated?.() ?? true)) { + return Promise.resolve(); + } + + let promise = this._hydrationPromises.get(backing); + if (!promise) { + promise = backing.hydrate(); + this._hydrationPromises.set(backing, promise); + } + + return promise; + } + + /** @internal */ + async _getTrackForBacking(backing: InputTrackBacking): Promise { + await this._hydrateBacking(backing); + return this._wrapBackingAsTrack(backing); } /** Returns the full MIME type of this input file, including track codecs. */ @@ -446,16 +594,6 @@ export class Input extends EventEmitter return demuxer.getMetadataTags(); } - async allTracksAreHydrated() { - const tracks = await this.getTracks(); - return tracks.every(x => x.isHydrated); - } - - async hydrateAllTracks() { - const tracks = await this.getTracks(); - await Promise.all(tracks.map(x => x.hydrate())); - } - /** * Disposes this input and frees connected resources. When an input is disposed, ongoing read operations will be * canceled, all future read operations will fail, any open decoders will be closed, and all ongoing media sink diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index e139e85..e1b4b3d 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { TrackType } from '../output'; import { parseAacAudioSpecificConfig } from '../../shared/aac-misc'; import { AacCodecInfo, @@ -35,12 +36,8 @@ import { import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { - InputAudioTrack, InputAudioTrackBacking, - InputTrack, InputTrackBacking, - - InputVideoTrack, InputVideoTrackBacking, } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; @@ -97,7 +94,7 @@ import { Bitstream } from '../../shared/bitstream'; type InternalTrack = { id: number; demuxer: IsobmffDemuxer; - inputTrack: InputTrack | null; + trackBacking: InputTrackBacking | null; disposition: TrackDisposition; timescale: number; durationInMovieTimescale: number; @@ -273,15 +270,18 @@ export class IsobmffDemuxer extends Demuxer { this.reader = input._reader; } - override async getTracks() { + override async getTrackBackings() { await this.readMetadata(); - return this.tracks.map(track => track.inputTrack!); + return this.tracks.map(track => track.trackBacking!); } override async getMimeType() { await this.readMetadata(); - const codecStrings = await Promise.all(this.tracks.map(x => x.inputTrack!.getCodecParameterString())); + const backings = await this.getTrackBackings(); + const codecStrings = await Promise.all(backings.map( + x => x.getDecoderConfig().then(c => c?.codec ?? null), + )); return buildIsobmffMimeType({ isQuickTime: this.isQuickTime, @@ -365,7 +365,7 @@ export class IsobmffDemuxer extends Demuxer { const track: InternalTrack = { id: foreignTrack.id, demuxer: this, - inputTrack: null, + trackBacking: null, disposition: foreignTrack.disposition, timescale: foreignTrack.timescale, durationInMediaTimescale: foreignTrack.durationInMediaTimescale, @@ -384,22 +384,20 @@ export class IsobmffDemuxer extends Demuxer { info: foreignTrack.info, }; - if (foreignTrack.inputTrack) { + if (foreignTrack.trackBacking) { assert(track.info); if (track.info.type === 'video' && track.info.width !== -1) { const videoTrack = track as InternalVideoTrack; - track.inputTrack - = new InputVideoTrack(this.input, new IsobmffVideoTrackBacking(videoTrack)); + track.trackBacking = new IsobmffVideoTrackBacking(videoTrack); this.tracks.push(track); } else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) { const audioTrack = track as InternalAudioTrack; - track.inputTrack - = new InputAudioTrack(this.input, new IsobmffAudioTrackBacking(audioTrack)); + track.trackBacking = new IsobmffAudioTrackBacking(audioTrack); this.tracks.push(track); } } else { - // The track didn't have enough info to warrant an input track + // The track didn't have enough info to warrant a backing } } @@ -460,7 +458,10 @@ export class IsobmffDemuxer extends Demuxer { let endTimestamp = this.movieDurationInTimescale / this.movieTimescale; if (this.tracks.length > 0) { const minFirstTimestamp = Math.min( - ...(await Promise.all(this.tracks.map(x => x.inputTrack!.getFirstTimestamp()))), + ...(await Promise.all(this.tracks.map(async (x) => { + const p = await x.trackBacking!.getFirstPacket({ metadataOnly: true }); + return p?.timestamp ?? 0; + }))), ); endTimestamp += minFirstTimestamp; } @@ -760,7 +761,7 @@ export class IsobmffDemuxer extends Demuxer { const track = { id: -1, demuxer: this, - inputTrack: null, + trackBacking: null, disposition: { ...DEFAULT_TRACK_DISPOSITION, primary: false, @@ -788,11 +789,11 @@ export class IsobmffDemuxer extends Demuxer { if (track.id !== -1 && track.timescale !== -1 && track.info !== null) { if (track.info.type === 'video' && track.info.width !== -1) { const videoTrack = track as InternalVideoTrack; - track.inputTrack = new InputVideoTrack(this.input, new IsobmffVideoTrackBacking(videoTrack)); + track.trackBacking = new IsobmffVideoTrackBacking(videoTrack); this.tracks.push(track); } else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) { const audioTrack = track as InternalAudioTrack; - track.inputTrack = new InputAudioTrack(this.input, new IsobmffAudioTrackBacking(audioTrack)); + track.trackBacking = new IsobmffAudioTrackBacking(audioTrack); this.tracks.push(track); } } @@ -2515,18 +2516,20 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { constructor(public internalTrack: InternalTrack) {} + abstract getType(): TrackType; + abstract getDecoderConfig(): Promise; + getId() { return this.internalTrack.id; } getNumber() { const demuxer = this.internalTrack.demuxer; - const inputTrack = this.internalTrack.inputTrack!; - const trackType = inputTrack.type; + const trackType = this.internalTrack.trackBacking!.getType(); let number = 0; for (const track of demuxer.tracks) { - if (track.inputTrack!.type === trackType) { + if (track.trackBacking!.getType() === trackType) { number++; } @@ -2584,9 +2587,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { return null; } - assert(track.inputTrack); + assert(track.trackBacking); - return (await track.inputTrack.getFirstTimestamp()) + track.durationInMediaTimescale / track.timescale; + const firstPacket = await track.trackBacking.getFirstPacket({ metadataOnly: true }); + return (firstPacket?.timestamp ?? 0) + track.durationInMediaTimescale / track.timescale; } async getLiveRefreshInterval() { @@ -3028,6 +3032,10 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo this.internalTrack = internalTrack; } + getType() { + return 'video' as const; + } + override getCodec(): VideoCodec | null { return this.internalTrack.info.codec; } @@ -3109,6 +3117,10 @@ class IsobmffAudioTrackBacking extends IsobmffTrackBacking implements InputAudio this.internalTrack = internalTrack; } + getType() { + return 'audio' as const; + } + override getCodec(): AudioCodec | null { return this.internalTrack.info.codec; } diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 8b43768..2945945 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { TrackType } from '../output'; import { extractAv1CodecInfoFromPacket, extractAvcDecoderConfigurationRecord, @@ -24,12 +25,8 @@ import { import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { - InputAudioTrack, InputAudioTrackBacking, - InputTrack, InputTrackBacking, - - InputVideoTrack, InputVideoTrackBacking, } from '../input-track'; import { AttachedFile, DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata'; @@ -190,7 +187,7 @@ type InternalTrack = { cuePoints: CuePoint[]; disposition: TrackDisposition; - inputTrack: InputTrack | null; + trackBacking: InputTrackBacking | null; codecId: string | null; codecPrivate: Uint8Array | null; defaultDuration: number | null; @@ -273,16 +270,18 @@ export class MatroskaDemuxer extends Demuxer { this.reader = input._reader; } - async getTracks() { + async getTrackBackings() { await this.readMetadata(); - return this.segments.flatMap(segment => segment.tracks.map(track => track.inputTrack!)); + return this.segments.flatMap(segment => segment.tracks.map(track => track.trackBacking!)); } override async getMimeType() { await this.readMetadata(); - const tracks = await this.getTracks(); - const codecStrings = await Promise.all(tracks.map(x => x.getCodecParameterString())); + const backings = await this.getTrackBackings(); + const codecStrings = await Promise.all(backings.map( + x => x.getDecoderConfig().then(c => c?.codec ?? null), + )); return buildMatroskaMimeType({ isWebM: this.isWebM, @@ -328,7 +327,10 @@ export class MatroskaDemuxer extends Demuxer { if (segment.tracks.length > 0) { const minFirstTimestamp = Math.min( - ...await Promise.all(segment.tracks.map(x => x.inputTrack!.getFirstTimestamp())), + ...await Promise.all(segment.tracks.map(x => + x.trackBacking!.getFirstPacket({ metadataOnly: true }) + .then(p => p?.timestamp ?? 0), + )), ); endTimestamp += minFirstTimestamp; @@ -1020,7 +1022,7 @@ export class MatroskaDemuxer extends Demuxer { ...DEFAULT_TRACK_DISPOSITION, primary: false, }, - inputTrack: null, + trackBacking: null, codecId: null, codecPrivate: null, defaultDuration: null, @@ -1101,8 +1103,7 @@ export class MatroskaDemuxer extends Demuxer { } const videoTrack = this.currentTrack as InternalVideoTrack; - const inputTrack = new InputVideoTrack(this.input, new MatroskaVideoTrackBacking(videoTrack)); - this.currentTrack.inputTrack = inputTrack; + this.currentTrack.trackBacking = new MatroskaVideoTrackBacking(videoTrack); this.currentSegment.tracks.push(this.currentTrack); } else if (this.currentTrack.info.type === 'audio') { if (codecIdWithoutSuffix === CODEC_STRING_MAP.aac) { @@ -1159,8 +1160,7 @@ export class MatroskaDemuxer extends Demuxer { } const audioTrack = this.currentTrack as InternalAudioTrack; - const inputTrack = new InputAudioTrack(this.input, new MatroskaAudioTrackBacking(audioTrack)); - this.currentTrack.inputTrack = inputTrack; + this.currentTrack.trackBacking = new MatroskaAudioTrackBacking(audioTrack); this.currentSegment.tracks.push(this.currentTrack); } } @@ -1926,19 +1926,21 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { constructor(public internalTrack: InternalTrack) {} + abstract getType(): TrackType; + abstract getDecoderConfig(): Promise; + getId() { return this.internalTrack.id; } getNumber() { const demuxer = this.internalTrack.demuxer; - const inputTrack = this.internalTrack.inputTrack!; - const trackType = inputTrack.type; + const trackType = this.internalTrack.trackBacking!.getType(); let number = 0; for (const segment of demuxer.segments) { for (const track of segment.tracks) { - if (track.inputTrack!.type === trackType) { + if (track.trackBacking!.getType() === trackType) { number++; } @@ -2409,6 +2411,10 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid this.internalTrack = internalTrack; } + getType() { + return 'video' as const; + } + override getCodec(): VideoCodec | null { return this.internalTrack.info.codec; } @@ -2514,6 +2520,10 @@ class MatroskaAudioTrackBacking extends MatroskaTrackBacking implements InputAud this.internalTrack = internalTrack; } + getType() { + return 'audio' as const; + } + override getCodec(): AudioCodec | null { return this.internalTrack.info.codec; } diff --git a/src/media-sink.ts b/src/media-sink.ts index e12f9ae..1078733 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -157,10 +157,6 @@ export class EncodedPacketSink { throw new InputDisposedError(); } - if (!this._track.isHydrated) { - await this._track.hydrate(); - } - return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options); } @@ -197,10 +193,6 @@ export class EncodedPacketSink { throw new InputDisposedError(); } - if (!this._track.isHydrated) { - await this._track.hydrate(); - } - return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options); } @@ -218,10 +210,6 @@ export class EncodedPacketSink { throw new InputDisposedError(); } - if (!this._track.isHydrated) { - await this._track.hydrate(); - } - return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options); } @@ -244,10 +232,6 @@ export class EncodedPacketSink { throw new InputDisposedError(); } - if (!this._track.isHydrated) { - await this._track.hydrate(); - } - if (!options.verifyKeyPackets) { return this._track._backing.getKeyPacket(timestamp, options); } @@ -283,10 +267,6 @@ export class EncodedPacketSink { throw new InputDisposedError(); } - if (!this._track.isHydrated) { - await this._track.hydrate(); - } - if (!options.verifyKeyPackets) { return this._track._backing.getNextKeyPacket(packet, options); } diff --git a/src/misc.ts b/src/misc.ts index a715043..6b5712f 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -959,12 +959,12 @@ export const validateRectangle = (rect: Rectangle, propertyPath: string) => { } }; -export const asc = (value: number | null) => { - return value ?? Infinity; // nulls last +export const asc = (value: number | null | undefined) => { + return value ?? Infinity; // nulls and undefined last }; -export const desc = (value: number | null) => { - return -(value ?? -Infinity); // nulls last +export const desc = (value: number | null | undefined) => { + return -(value ?? -Infinity); // nulls and undefined last }; export const prefer = (value: boolean) => { diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index 81e8631..646b313 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -9,7 +9,7 @@ import { AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; -import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; +import { InputAudioTrackBacking } from '../input-track'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { PacketRetrievalOptions } from '../media-sink'; import { @@ -59,7 +59,7 @@ export class Mp3Demuxer extends Demuxer { fileSize: number | null; } | null = null; - tracks: InputAudioTrack[] = []; + trackBackings: Mp3AudioTrackBacking[] = []; readingMutex = new AsyncMutex(); lastSampleLoaded = false; @@ -83,7 +83,7 @@ export class Mp3Demuxer extends Demuxer { throw new Error('No valid MP3 frame found.'); } - this.tracks = [new InputAudioTrack(this.input, new Mp3AudioTrackBacking(this))]; + this.trackBackings = [new Mp3AudioTrackBacking(this)]; })(); } @@ -214,9 +214,9 @@ export class Mp3Demuxer extends Demuxer { return 'audio/mpeg'; } - async getTracks() { + async getTrackBackings() { await this.readMetadata(); - return this.tracks; + return this.trackBackings; } async getMetadataTags() { @@ -276,6 +276,10 @@ export class Mp3Demuxer extends Demuxer { class Mp3AudioTrackBacking implements InputAudioTrackBacking { constructor(public demuxer: Mp3Demuxer) {} + getType() { + return 'audio' as const; + } + getId() { return 1; } diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 9cdcd3b..260fd68 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { TrackType } from '../output'; import { SAMPLES_PER_AAC_FRAME } from '../adts/adts-demuxer'; import { MAX_ADTS_FRAME_HEADER_SIZE, readAdtsFrameHeader } from '../adts/adts-reader'; import { aacChannelMap, aacFrequencyTable } from '../../shared/aac-misc'; @@ -41,12 +42,8 @@ import { import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { - InputAudioTrack, InputAudioTrackBacking, - InputTrack, InputTrackBacking, - - InputVideoTrack, InputVideoTrackBacking, } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; @@ -146,7 +143,7 @@ export class MpegTsDemuxer extends Demuxer { metadataPromise: Promise | null = null; elementaryStreams: ElementaryStream[] = []; - tracks: InputTrack[] = []; + trackBackingEntries: InputTrackBacking[] = []; packetOffset = 0; packetStride = -1; sectionEndPositions: number[] = []; @@ -700,23 +697,21 @@ export class MpegTsDemuxer extends Demuxer { for (const stream of this.elementaryStreams) { if (stream.info.type === 'video') { - this.tracks.push(new InputVideoTrack( - this.input, - new MpegTsVideoTrackBacking(stream as ElementaryVideoStream)), + this.trackBackingEntries.push( + new MpegTsVideoTrackBacking(stream as ElementaryVideoStream), ); } else { - this.tracks.push(new InputAudioTrack( - this.input, - new MpegTsAudioTrackBacking(stream as ElementaryAudioStream)), + this.trackBackingEntries.push( + new MpegTsAudioTrackBacking(stream as ElementaryAudioStream), ); } } })(); } - async getTracks(): Promise { + async getTrackBackings() { await this.readMetadata(); - return this.tracks; + return this.trackBackingEntries; } async getDurationFromMetadata(): Promise { @@ -730,8 +725,9 @@ export class MpegTsDemuxer extends Demuxer { async getMimeType(): Promise { await this.readMetadata(); - const tracks = await this.getTracks(); - const codecStrings = await Promise.all(tracks.map(x => x.getCodecParameterString())); + const codecStrings = await Promise.all(this.trackBackingEntries.map( + x => x.getDecoderConfig().then(c => c?.codec ?? null), + )); return buildMpegTsMimeType(codecStrings); } @@ -1046,6 +1042,9 @@ abstract class MpegTsTrackBacking implements InputTrackBacking { constructor(public elementaryStream: ElementaryStream) {} + abstract getType(): TrackType; + abstract getDecoderConfig(): Promise; + getId() { return this.elementaryStream.pid; } @@ -1055,13 +1054,13 @@ abstract class MpegTsTrackBacking implements InputTrackBacking { const trackType = this.elementaryStream.info.type; let number = 0; - for (const track of demuxer.tracks) { - if (track.type === trackType) { + for (const backing of demuxer.trackBackingEntries) { + if (backing.getType() === trackType) { number++; } - assert(track._backing instanceof MpegTsTrackBacking); - if (track._backing.elementaryStream === this.elementaryStream) { + assert(backing instanceof MpegTsTrackBacking); + if (backing.elementaryStream === this.elementaryStream) { break; } } @@ -1657,6 +1656,10 @@ abstract class MpegTsTrackBacking implements InputTrackBacking { class MpegTsVideoTrackBacking extends MpegTsTrackBacking implements InputVideoTrackBacking { override elementaryStream!: ElementaryVideoStream; + getType() { + return 'video' as const; + } + override getCodec(): VideoCodec { return this.elementaryStream.info.codec; } @@ -1706,6 +1709,10 @@ class MpegTsVideoTrackBacking extends MpegTsTrackBacking implements InputVideoTr class MpegTsAudioTrackBacking extends MpegTsTrackBacking implements InputAudioTrackBacking { override elementaryStream!: ElementaryAudioStream; + getType() { + return 'audio' as const; + } + override getCodec(): AudioCodec { return this.elementaryStream.info.codec; } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index bb9d80a..d54482e 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -10,7 +10,7 @@ import { OPUS_SAMPLE_RATE } from '../codec'; import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader, readVorbisComments } from '../codec-data'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; -import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; +import { InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata'; import { @@ -57,7 +57,7 @@ export class OggDemuxer extends Demuxer { metadataPromise: Promise | null = null; bitstreams: LogicalBitstream[] = []; - tracks: InputAudioTrack[] = []; + trackBackings: OggAudioTrackBacking[] = []; metadataTags: MetadataTags = {}; constructor(input: Input) { @@ -138,7 +138,7 @@ export class OggDemuxer extends Demuxer { } if (bitstream.codecInfo.codec !== null) { - this.tracks.push(new InputAudioTrack(this.input, new OggAudioTrackBacking(bitstream, this))); + this.trackBackings.push(new OggAudioTrackBacking(bitstream, this)); } } })(); @@ -379,7 +379,9 @@ export class OggDemuxer extends Demuxer { async getMimeType() { await this.readMetadata(); - const codecStrings = await Promise.all(this.tracks.map(x => x.getCodecParameterString())); + const codecStrings = await Promise.all(this.trackBackings.map( + x => x.getDecoderConfig().then(c => c?.codec ?? null), + )); return buildOggMimeType({ codecStrings: codecStrings.filter(Boolean) as string[], @@ -390,9 +392,9 @@ export class OggDemuxer extends Demuxer { return null; // Not stored anywhere } - async getTracks() { + async getTrackBackings() { await this.readMetadata(); - return this.tracks; + return this.trackBackings; } async getMetadataTags() { @@ -422,14 +424,18 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { : bitstream.sampleRate; } + getType() { + return 'audio' as const; + } + getId() { return this.bitstream.serialNumber; } getNumber() { // All Ogg tracks are audio, so the track's index + 1 is its number - const index = this.demuxer.tracks.findIndex( - t => (t._backing as OggAudioTrackBacking).bitstream === this.bitstream, + const index = this.demuxer.trackBackings.findIndex( + x => x.bitstream === this.bitstream, ); assert(index !== -1); diff --git a/src/segmented-input.ts b/src/segmented-input.ts index 9dcb7d0..0846b76 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { TrackType } from './output'; import { AudioCodec, MediaCodec, VideoCodec } from './codec'; import { Demuxer, DurationMetadataRequestOptions } from './demuxer'; import { Input } from './input'; @@ -15,7 +16,6 @@ import { InputAudioTrackBacking, InputTrack, InputTrackBacking, - InputVideoTrack, InputVideoTrackBacking, } from './input-track'; @@ -107,7 +107,7 @@ export abstract class SegmentedInput { class SegmentedInputDemuxer extends Demuxer { segmentedInput: SegmentedInput; - tracksPromise: Promise | null = null; + trackBackingsPromise: Promise | null = null; firstSegment: Segment | null = null; firstSegmentFirstTimestamps = new WeakMap(); @@ -129,8 +129,8 @@ class SegmentedInputDemuxer extends Demuxer { throw new Error('Unreachable'); } - async getTracks(): Promise { - return this.tracksPromise ??= (async () => { + async getTrackBackings(): Promise { + return this.trackBackingsPromise ??= (async () => { this.firstSegment = await this.segmentedInput.getFirstSegment({}); if (!this.firstSegment) { return []; @@ -139,26 +139,24 @@ class SegmentedInputDemuxer extends Demuxer { const input = this.segmentedInput.getInputForSegment(this.firstSegment); const inputTracks = await input.getTracks(); - const tracks: InputTrack[] = []; + const backings: InputTrackBacking[] = []; for (const track of inputTracks) { if (track.type === 'video') { - const number = arrayCount(tracks, x => x.type === 'video') + 1; + const number = arrayCount(backings, x => x.getType() === 'video') + 1; - tracks.push(new InputVideoTrack( - this.input, + backings.push( new SegmentedInputInputVideoTrackBacking(track, this, number), - )); + ); } else if (track.type === 'audio') { - const number = arrayCount(tracks, x => x.type === 'audio') + 1; + const number = arrayCount(backings, x => x.getType() === 'audio') + 1; - tracks.push(new InputAudioTrack( - this.input, + backings.push( new SegmentedInputInputAudioTrackBacking(track, this, number), - )); + ); } } - return tracks; + return backings; })(); } @@ -215,6 +213,14 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { this.number = number; } + getType(): TrackType { + return this.firstInputTrack._backing.getType(); + } + + getDecoderConfig() { + return this.firstInputTrack._backing.getDecoderConfig(); + } + getHasOnlyKeyPackets() { return this.firstInputTrack.hasOnlyKeyPackets; } @@ -252,7 +258,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { } getTimeResolution(): number { - return this.firstInputTrack._backing.getTimeResolution(); + return this.firstInputTrack._backing.getTimeResolution()!; } isRelativeToUnixEpoch(): boolean { @@ -428,28 +434,32 @@ class SegmentedInputInputVideoTrackBacking implements InputVideoTrackBacking { override firstInputTrack!: InputVideoTrack; + override getType() { + return 'video' as const; + } + override getCodec(): VideoCodec | null { return this.firstInputTrack._backing.getCodec(); } getCodedWidth(): number { - return this.firstInputTrack._backing.getCodedWidth(); + return this.firstInputTrack._backing.getCodedWidth()!; } getCodedHeight(): number { - return this.firstInputTrack._backing.getCodedHeight(); + return this.firstInputTrack._backing.getCodedHeight()!; } getSquarePixelWidth(): number { - return this.firstInputTrack._backing.getSquarePixelWidth(); + return this.firstInputTrack._backing.getSquarePixelWidth()!; } getSquarePixelHeight(): number { - return this.firstInputTrack._backing.getSquarePixelHeight(); + return this.firstInputTrack._backing.getSquarePixelHeight()!; } getRotation(): Rotation { - return this.firstInputTrack._backing.getRotation(); + return this.firstInputTrack._backing.getRotation()!; } getColorSpace(): Promise { @@ -460,7 +470,7 @@ class SegmentedInputInputVideoTrackBacking return this.firstInputTrack._backing.canBeTransparent(); } - getDecoderConfig(): Promise { + override getDecoderConfig(): Promise { return this.firstInputTrack._backing.getDecoderConfig(); } } @@ -470,19 +480,23 @@ class SegmentedInputInputAudioTrackBacking implements InputAudioTrackBacking { override firstInputTrack!: InputAudioTrack; + override getType() { + return 'audio' as const; + } + override getCodec(): AudioCodec | null { return this.firstInputTrack._backing.getCodec(); } getNumberOfChannels(): number { - return this.firstInputTrack._backing.getNumberOfChannels(); + return this.firstInputTrack._backing.getNumberOfChannels()!; } getSampleRate(): number { - return this.firstInputTrack._backing.getSampleRate(); + return this.firstInputTrack._backing.getSampleRate()!; } - getDecoderConfig(): Promise { + override getDecoderConfig(): Promise { return this.firstInputTrack._backing.getDecoderConfig(); } } diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index a642f19..c9f6545 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -9,7 +9,7 @@ import { AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; -import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; +import { InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { assert, UNDETERMINED_LANGUAGE } from '../misc'; @@ -39,7 +39,7 @@ export class WaveDemuxer extends Demuxer { blockSizeInBytes: number; } | null = null; - tracks: InputAudioTrack[] = []; + trackBackings: WaveAudioTrackBacking[] = []; lastKnownPacketIndex = 0; metadataTags: MetadataTags = {}; @@ -130,7 +130,7 @@ export class WaveDemuxer extends Demuxer { const blockSize = this.audioInfo.blockSizeInBytes; this.dataSize = Math.floor(this.dataSize / blockSize) * blockSize; - this.tracks.push(new InputAudioTrack(this.input, new WaveAudioTrackBacking(this))); + this.trackBackings.push(new WaveAudioTrackBacking(this)); })(); } @@ -339,9 +339,9 @@ export class WaveDemuxer extends Demuxer { return 'audio/wav'; } - async getTracks() { + async getTrackBackings() { await this.readMetadata(); - return this.tracks; + return this.trackBackings; } async getMetadataTags() { @@ -355,6 +355,10 @@ const PACKET_SIZE_IN_FRAMES = 2048; class WaveAudioTrackBacking implements InputAudioTrackBacking { constructor(public demuxer: WaveDemuxer) {} + getType() { + return 'audio' as const; + } + getId() { return 1; } diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index d1627cb..1126167 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -23,120 +23,107 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { expect(sourceCount).toBe(1); + // Test descriptors (unhydrated metadata from master playlist) + const descriptors = await input.getTrackDescriptors(); + const videoDescriptors = descriptors.filter(x => x.isVideoTrackDescriptor()); + const audioDescriptors = descriptors.filter(x => x.isAudioTrackDescriptor()); + + expect(videoDescriptors).toHaveLength(5); + expect(audioDescriptors).toHaveLength(5); + + expect(videoDescriptors[0]!.codec).toBe('avc'); + expect(videoDescriptors[0]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[0]!.displayWidth).toBe(1280); + expect(videoDescriptors[0]!.displayHeight).toBe(720); + expect(videoDescriptors[0]!.bitrate).toBe(2149280); + expect(videoDescriptors[0]!.name).toBe('720'); + expect(videoDescriptors[0]!.id).toBe(2); + + expect(videoDescriptors[1]!.codec).toBe('avc'); + expect(videoDescriptors[1]!.codecParameterString).toBe('avc1.42000d'); + expect(videoDescriptors[1]!.displayWidth).toBe(320); + expect(videoDescriptors[1]!.displayHeight).toBe(184); + expect(videoDescriptors[1]!.bitrate).toBe(246440); + expect(videoDescriptors[1]!.name).toBe('240'); + expect(videoDescriptors[1]!.id).toBe(4); + + expect(videoDescriptors[2]!.codec).toBe('avc'); + expect(videoDescriptors[2]!.codecParameterString).toBe('avc1.420016'); + expect(videoDescriptors[2]!.displayWidth).toBe(512); + expect(videoDescriptors[2]!.displayHeight).toBe(288); + expect(videoDescriptors[2]!.bitrate).toBe(460560); + expect(videoDescriptors[2]!.name).toBe('380'); + expect(videoDescriptors[2]!.id).toBe(6); + + expect(videoDescriptors[3]!.codec).toBe('avc'); + expect(videoDescriptors[3]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[3]!.displayWidth).toBe(848); + expect(videoDescriptors[3]!.displayHeight).toBe(480); + expect(videoDescriptors[3]!.bitrate).toBe(836280); + expect(videoDescriptors[3]!.name).toBe('480'); + expect(videoDescriptors[3]!.id).toBe(8); + + expect(videoDescriptors[4]!.codec).toBe('avc'); + expect(videoDescriptors[4]!.codecParameterString).toBe('avc1.640028'); + expect(videoDescriptors[4]!.displayWidth).toBe(1920); + expect(videoDescriptors[4]!.displayHeight).toBe(1080); + expect(videoDescriptors[4]!.bitrate).toBe(6221600); + expect(videoDescriptors[4]!.name).toBe('1080'); + expect(videoDescriptors[4]!.id).toBe(10); + + expect(audioDescriptors[0]!.codec).toBe('aac'); + expect(audioDescriptors[0]!.codecParameterString).toBe('mp4a.40.2'); + expect(audioDescriptors[0]!.bitrate).toBe(2149280); + expect(audioDescriptors[0]!.name).toBe('720'); + expect(audioDescriptors[0]!.id).toBe(1); + expect(audioDescriptors[0]!.numberOfChannels).toBeUndefined(); + expect(audioDescriptors[0]!.sampleRate).toBeUndefined(); + + expect(audioDescriptors[1]!.codec).toBe('aac'); + expect(audioDescriptors[1]!.codecParameterString).toBe('mp4a.40.5'); + expect(audioDescriptors[1]!.bitrate).toBe(246440); + expect(audioDescriptors[1]!.name).toBe('240'); + expect(audioDescriptors[1]!.id).toBe(3); + + expect(audioDescriptors[2]!.codec).toBe('aac'); + expect(audioDescriptors[2]!.codecParameterString).toBe('mp4a.40.5'); + expect(audioDescriptors[2]!.bitrate).toBe(460560); + expect(audioDescriptors[2]!.name).toBe('380'); + expect(audioDescriptors[2]!.id).toBe(5); + + expect(audioDescriptors[3]!.codec).toBe('aac'); + expect(audioDescriptors[3]!.codecParameterString).toBe('mp4a.40.2'); + expect(audioDescriptors[3]!.bitrate).toBe(836280); + expect(audioDescriptors[3]!.name).toBe('480'); + expect(audioDescriptors[3]!.id).toBe(7); + + expect(audioDescriptors[4]!.codec).toBe('aac'); + expect(audioDescriptors[4]!.codecParameterString).toBe('mp4a.40.2'); + expect(audioDescriptors[4]!.bitrate).toBe(6221600); + expect(audioDescriptors[4]!.name).toBe('1080'); + expect(audioDescriptors[4]!.id).toBe(9); + + for (let i = 0; i < videoDescriptors.length - 1; i++) { + for (let j = i + 1; j < videoDescriptors.length; j++) { + expect(videoDescriptors[i]!.canBePairedWith(videoDescriptors[j]!)).toBe(false); + } + } + + for (let i = 0; i < videoDescriptors.length; i++) { + for (let j = 0; j < audioDescriptors.length; j++) { + expect(videoDescriptors[i]!.canBePairedWith(audioDescriptors[j]!)).toBe(i === j); + } + } + + expect(sourceCount).toBe(1); + + // Hydrate all tracks const tracks = await input.getTracks(); const videoTracks = tracks.filter(x => x.isVideoTrack()); const audioTracks = tracks.filter(x => x.isAudioTrack()); - expect(videoTracks).toHaveLength(5); - expect(audioTracks).toHaveLength(5); - - expect(videoTracks[0]!.codec).toBe('avc'); - expect(await videoTracks[0]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(videoTracks[0]!.displayWidth).toBe(1280); - expect(videoTracks[0]!.displayHeight).toBe(720); - expect(videoTracks[0]!.bitrate).toBe(2149280); - expect(videoTracks[0]!.name).toBe('720'); - expect(videoTracks[0]!.isHydrated).toBe(false); - expect(videoTracks[0]!.id).toBe(2); - expect(() => videoTracks[0]?.codedWidth).toThrow(); - - expect(videoTracks[1]!.codec).toBe('avc'); - expect(await videoTracks[1]!.getCodecParameterString()).toBe('avc1.42000d'); - expect(videoTracks[1]!.displayWidth).toBe(320); - expect(videoTracks[1]!.displayHeight).toBe(184); - expect(videoTracks[1]!.bitrate).toBe(246440); - expect(videoTracks[1]!.name).toBe('240'); - expect(videoTracks[1]!.isHydrated).toBe(false); - expect(videoTracks[1]!.id).toBe(4); - - expect(videoTracks[2]!.codec).toBe('avc'); - expect(await videoTracks[2]!.getCodecParameterString()).toBe('avc1.420016'); - expect(videoTracks[2]!.displayWidth).toBe(512); - expect(videoTracks[2]!.displayHeight).toBe(288); - expect(videoTracks[2]!.bitrate).toBe(460560); - expect(videoTracks[2]!.name).toBe('380'); - expect(videoTracks[2]!.isHydrated).toBe(false); - expect(videoTracks[2]!.id).toBe(6); - - expect(videoTracks[3]!.codec).toBe('avc'); - expect(await videoTracks[3]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(videoTracks[3]!.displayWidth).toBe(848); - expect(videoTracks[3]!.displayHeight).toBe(480); - expect(videoTracks[3]!.bitrate).toBe(836280); - expect(videoTracks[3]!.name).toBe('480'); - expect(videoTracks[3]!.isHydrated).toBe(false); - expect(videoTracks[3]!.id).toBe(8); - - expect(videoTracks[4]!.codec).toBe('avc'); - expect(await videoTracks[4]!.getCodecParameterString()).toBe('avc1.640028'); - expect(videoTracks[4]!.displayWidth).toBe(1920); - expect(videoTracks[4]!.displayHeight).toBe(1080); - expect(videoTracks[4]!.bitrate).toBe(6221600); - expect(videoTracks[4]!.name).toBe('1080'); - expect(videoTracks[4]!.isHydrated).toBe(false); - expect(videoTracks[4]!.id).toBe(10); - - expect(audioTracks[0]!.codec).toBe('aac'); - expect(await audioTracks[0]!.getCodecParameterString()).toBe('mp4a.40.2'); - expect(audioTracks[0]!.bitrate).toBe(2149280); - expect(audioTracks[0]!.name).toBe('720'); - expect(audioTracks[0]!.isHydrated).toBe(false); - expect(audioTracks[0]!.id).toBe(1); - expect(() => audioTracks[0]?.numberOfChannels).toThrow(); - expect(() => audioTracks[0]?.sampleRate).toThrow(); - - expect(audioTracks[1]!.codec).toBe('aac'); - expect(await audioTracks[1]!.getCodecParameterString()).toBe('mp4a.40.5'); - expect(audioTracks[1]!.bitrate).toBe(246440); - expect(audioTracks[1]!.name).toBe('240'); - expect(audioTracks[1]!.isHydrated).toBe(false); - expect(audioTracks[1]!.id).toBe(3); - - expect(audioTracks[2]!.codec).toBe('aac'); - expect(await audioTracks[2]!.getCodecParameterString()).toBe('mp4a.40.5'); - expect(audioTracks[2]!.bitrate).toBe(460560); - expect(audioTracks[2]!.name).toBe('380'); - expect(audioTracks[2]!.isHydrated).toBe(false); - expect(audioTracks[2]!.id).toBe(5); - - expect(audioTracks[3]!.codec).toBe('aac'); - expect(await audioTracks[3]!.getCodecParameterString()).toBe('mp4a.40.2'); - expect(audioTracks[3]!.bitrate).toBe(836280); - expect(audioTracks[3]!.name).toBe('480'); - expect(audioTracks[3]!.isHydrated).toBe(false); - expect(audioTracks[3]!.id).toBe(7); - - expect(audioTracks[4]!.codec).toBe('aac'); - expect(await audioTracks[4]!.getCodecParameterString()).toBe('mp4a.40.2'); - expect(audioTracks[4]!.bitrate).toBe(6221600); - expect(audioTracks[4]!.name).toBe('1080'); - expect(audioTracks[4]!.isHydrated).toBe(false); - expect(audioTracks[4]!.id).toBe(9); - - for (let i = 0; i < videoTracks.length - 1; i++) { - for (let j = i + 1; j < videoTracks.length; j++) { - expect(videoTracks[i]!.canBePairedWith(videoTracks[j]!)).toBe(false); - } - } - - for (let i = 0; i < videoTracks.length; i++) { - for (let j = 0; j < audioTracks.length; j++) { - expect(videoTracks[i]!.canBePairedWith(audioTracks[j]!)).toBe(i === j); - } - } - - const primaryVideoTrack = await input.getPrimaryVideoTrack(); - const primaryAudioTrack = await input.getPrimaryAudioTrack(); - - // Since they're the highest-bitrate option - expect(primaryVideoTrack).toBe(videoTracks[4]); - expect(primaryAudioTrack).toBe(audioTracks[4]); - - expect(sourceCount).toBe(1); - await input.hydrateAllTracks(); expect(sourceCount).toBe(1 + 5 + 5); - expect(tracks.every(x => x.isHydrated)).toBe(true); expect(tracks.every(x => !x.isRelativeToUnixEpoch)).toBe(true); for (const track of tracks) { @@ -157,6 +144,18 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { expect(videoTracks[4]!.codedWidth).toBe(1920); expect(videoTracks[4]!.codedHeight).toBe(1080); + // Ensure that these are still avaiable even after track backing hydration + expect(videoDescriptors[0]!.displayWidth).toBe(1280); + expect(videoDescriptors[0]!.displayHeight).toBe(720); + expect(videoDescriptors[1]!.displayWidth).toBe(320); + expect(videoDescriptors[1]!.displayHeight).toBe(184); + expect(videoDescriptors[2]!.displayWidth).toBe(512); + expect(videoDescriptors[2]!.displayHeight).toBe(288); + expect(videoDescriptors[3]!.displayWidth).toBe(848); + expect(videoDescriptors[3]!.displayHeight).toBe(480); + expect(videoDescriptors[4]!.displayWidth).toBe(1920); + expect(videoDescriptors[4]!.displayHeight).toBe(1080); + expect(await videoTracks[0]!.getCodecParameterString()).toEqual('avc1.64001f'); expect(await videoTracks[1]!.getCodecParameterString()).toEqual('avc1.42c00d'); // Slightly altered expect(await videoTracks[2]!.getCodecParameterString()).toEqual('avc1.42c016'); // Slightly altered @@ -206,6 +205,16 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { } expect(packetCount).toBeCloseTo(18 * 60, -1); // Since 60 FPS + + const primaryVideoTrack = await input.getPrimaryVideoTrack(); + const primaryAudioTrack = await input.getPrimaryAudioTrack(); + + assert(primaryVideoTrack); + assert(primaryAudioTrack); + + // Since they're the highest-bitrate option + expect(primaryVideoTrack).toBe(videoTracks[4]); + expect(primaryAudioTrack).toBe(audioTracks[4]); }); test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () => { @@ -219,12 +228,10 @@ test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () = expect(tracks).toHaveLength(2); const audioTrack = tracks[0]! as InputAudioTrack; - expect(audioTrack.isHydrated).toBe(true); expect(audioTrack.isAudioTrack()).toBe(true); expect(audioTrack.codec).toBe('aac'); const videoTrack = tracks[1] as InputVideoTrack; - expect(videoTrack.isHydrated).toBe(true); expect(videoTrack.isVideoTrack()).toBe(true); expect(videoTrack.codec).toBe('avc'); }); @@ -236,10 +243,10 @@ test.concurrent('Codec-less (underspecified) master playlist', { timeout: 15_000 formats: ALL_FORMATS, }); - const tracks = await input.getTracks(); - expect(tracks).toHaveLength(12); + const descriptors = await input.getTrackDescriptors(); + expect(descriptors).toHaveLength(12); - const codecs = new Set(tracks.map(x => x.codec)); + const codecs = new Set(descriptors.map(x => x.codec)); expect(codecs.size).toBe(2); expect(codecs.has('avc')).toBe(true); expect(codecs.has('aac')).toBe(true); @@ -257,7 +264,6 @@ test.concurrent('AES and discontinuities', { timeout: 15_000 }, async () => { const videoTrack = await input.getPrimaryVideoTrack(); assert(videoTrack); - expect(videoTrack.isHydrated).toBe(true); expect(sourceCount).toBe(3); // Entry, first segment, and encryption key @@ -291,7 +297,6 @@ test.concurrent('Range requests', { timeout: 15_000 }, async () => { const tracks = await input.getTracks(); expect(tracks).toHaveLength(2); - expect(tracks.every(x => x.isHydrated)).toBe(true); const videoTrack = await input.getPrimaryVideoTrack(); assert(videoTrack); @@ -320,7 +325,6 @@ test.concurrent('Custom IV', { timeout: 15_000 }, async () => { const tracks = await input.getTracks(); expect(tracks).toHaveLength(2); - expect(tracks.every(x => x.isHydrated)).toBe(true); expect(sourceCount).toBe(3); // Entry, first segment, and encryption key }); @@ -332,18 +336,19 @@ test.concurrent('Out-of-band audio track via ADTS', { timeout: 15_000 }, async ( formats: ALL_FORMATS, }); - const tracks = await input.getTracks(); - expect(tracks.some(x => x.isVideoTrack() && x.hasOnlyKeyPackets)).toBe(true); + const descriptors = await input.getTrackDescriptors(); + expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.hasOnlyKeyPackets)).toBe(true); - const audioTrack = tracks.find(x => x.isAudioTrack()); - assert(audioTrack); - expect(audioTrack.hasOnlyKeyPackets).toBe(true); + const audioDescriptor = descriptors.find(x => x.isAudioTrackDescriptor()); + assert(audioDescriptor); + expect(audioDescriptor.hasOnlyKeyPackets).toBe(true); - expect(await audioTrack.getPairableVideoTracks()).toHaveLength(1); // Since the I-frame one isn't pairable - const videoTrack = await audioTrack.pluckPairableVideoTrack(); + expect(await audioDescriptor.getPairableVideoTrackDescriptors()).toHaveLength(1); // Since the I-frame one isn't pairable + const videoTrack = await (await audioDescriptor.getPairableVideoTrackDescriptors())[0]!.getTrack(); assert(videoTrack); expect(videoTrack.hasOnlyKeyPackets).toBe(false); + const audioTrack = await audioDescriptor.getTrack(); let lastTimestamp = -Infinity; const sink = new EncodedPacketSink(audioTrack); for await (const packet of sink.packets()) { @@ -363,10 +368,9 @@ test.concurrent('MP3 audio only', { timeout: 15_000 }, async () => { formats: ALL_FORMATS, }); - const audioTrack = await input.getPrimaryAudioTrack(); - assert(audioTrack); - expect(audioTrack.isHydrated).toBe(false); - expect(audioTrack.codec).toBe('mp3'); + const audioDescriptor = (await input.getAudioTrackDescriptors())[0]; + assert(audioDescriptor); + expect(audioDescriptor.codec).toBe('mp3'); }); test.concurrent('fMP4', { timeout: 15_000 }, async () => { @@ -385,9 +389,6 @@ test.concurrent('fMP4', { timeout: 15_000 }, async () => { assert(videoTrack); assert(audioTrack); - await videoTrack.hydrate(); - await audioTrack.hydrate(); - expect(videoTrack.codedWidth).toBe(768); expect(videoTrack.codedHeight).toBe(576); expect(audioTrack.numberOfChannels).toBe(2); @@ -409,37 +410,37 @@ test.concurrent('Track disposition & metadata', { timeout: 15_000 }, async () => formats: ALL_FORMATS, }); - const audioTracks = await input.getAudioTracks(); + const audioDescriptors = await input.getAudioTrackDescriptors(); - expect(audioTracks).toHaveLength(6); + expect(audioDescriptors).toHaveLength(6); - expect(audioTracks[0]!.languageCode).toBe('en'); - expect(audioTracks[1]!.languageCode).toBe('de'); - expect(audioTracks[2]!.languageCode).toBe('it'); - expect(audioTracks[3]!.languageCode).toBe('fr'); - expect(audioTracks[4]!.languageCode).toBe('es'); - expect(audioTracks[5]!.languageCode).toBe('en'); + expect(audioDescriptors[0]!.languageCode).toBe('en'); + expect(audioDescriptors[1]!.languageCode).toBe('de'); + expect(audioDescriptors[2]!.languageCode).toBe('it'); + expect(audioDescriptors[3]!.languageCode).toBe('fr'); + expect(audioDescriptors[4]!.languageCode).toBe('es'); + expect(audioDescriptors[5]!.languageCode).toBe('en'); - expect(audioTracks[0]!.disposition.primary).toBe(true); - expect(audioTracks[1]!.disposition.primary).toBe(false); - expect(audioTracks[2]!.disposition.primary).toBe(false); - expect(audioTracks[3]!.disposition.primary).toBe(false); - expect(audioTracks[4]!.disposition.primary).toBe(false); - expect(audioTracks[5]!.disposition.primary).toBe(false); + expect(audioDescriptors[0]!.disposition!.primary).toBe(true); + expect(audioDescriptors[1]!.disposition!.primary).toBe(false); + expect(audioDescriptors[2]!.disposition!.primary).toBe(false); + expect(audioDescriptors[3]!.disposition!.primary).toBe(false); + expect(audioDescriptors[4]!.disposition!.primary).toBe(false); + expect(audioDescriptors[5]!.disposition!.primary).toBe(false); - expect(audioTracks[0]!.disposition.default).toBe(true); - expect(audioTracks[1]!.disposition.default).toBe(true); - expect(audioTracks[2]!.disposition.default).toBe(true); - expect(audioTracks[3]!.disposition.default).toBe(true); - expect(audioTracks[4]!.disposition.default).toBe(true); - expect(audioTracks[5]!.disposition.default).toBe(false); + expect(audioDescriptors[0]!.disposition!.default).toBe(true); + expect(audioDescriptors[1]!.disposition!.default).toBe(true); + expect(audioDescriptors[2]!.disposition!.default).toBe(true); + expect(audioDescriptors[3]!.disposition!.default).toBe(true); + expect(audioDescriptors[4]!.disposition!.default).toBe(true); + expect(audioDescriptors[5]!.disposition!.default).toBe(false); - expect(audioTracks[0]!.name).toBe('stream_5'); - expect(audioTracks[1]!.name).toBe('stream_4'); - expect(audioTracks[2]!.name).toBe('stream_8'); - expect(audioTracks[3]!.name).toBe('stream_7'); - expect(audioTracks[4]!.name).toBe('stream_9'); - expect(audioTracks[5]!.name).toBe('stream_6'); + expect(audioDescriptors[0]!.name).toBe('stream_5'); + expect(audioDescriptors[1]!.name).toBe('stream_4'); + expect(audioDescriptors[2]!.name).toBe('stream_8'); + expect(audioDescriptors[3]!.name).toBe('stream_7'); + expect(audioDescriptors[4]!.name).toBe('stream_9'); + expect(audioDescriptors[5]!.name).toBe('stream_6'); }); test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => { @@ -462,16 +463,16 @@ test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => { let sourceCount = 0; input.on('source', () => sourceCount++); - const tracks = await input.getTracks(); - expect(tracks.filter(x => x.isVideoTrack())).toHaveLength(6); - expect(tracks.filter(x => x.isAudioTrack())).toHaveLength(1); + const descriptors = await input.getTrackDescriptors(); + expect(descriptors.filter(x => x.isVideoTrackDescriptor())).toHaveLength(6); + expect(descriptors.filter(x => x.isAudioTrackDescriptor())).toHaveLength(1); - expect(tracks.some(x => x.isVideoTrack() && x.displayWidth === 320 && x.displayHeight === 180)).toBe(true); - expect(tracks.some(x => x.isVideoTrack() && x.displayWidth === 480 && x.displayHeight === 270)).toBe(true); - expect(tracks.some(x => x.isVideoTrack() && x.displayWidth === 640 && x.displayHeight === 360)).toBe(true); - expect(tracks.some(x => x.isVideoTrack() && x.displayWidth === 960 && x.displayHeight === 540)).toBe(true); - expect(tracks.some(x => x.isVideoTrack() && x.displayWidth === 1280 && x.displayHeight === 720)).toBe(true); - expect(tracks.some(x => x.isVideoTrack() && x.displayWidth === 1920 && x.displayHeight === 1080)).toBe(true); + expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.displayWidth === 320 && x.displayHeight === 180)).toBe(true); + expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.displayWidth === 480 && x.displayHeight === 270)).toBe(true); + expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.displayWidth === 640 && x.displayHeight === 360)).toBe(true); + expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.displayWidth === 960 && x.displayHeight === 540)).toBe(true); + expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.displayWidth === 1280 && x.displayHeight === 720)).toBe(true); + expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.displayWidth === 1920 && x.displayHeight === 1080)).toBe(true); const videoTrack = await input.pluckVideoTrack(); assert(videoTrack); @@ -490,7 +491,7 @@ test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { }); const tracks = await input.getTracks(); - expect(tracks.every(x => x.isHydrated && x.isRelativeToUnixEpoch)).toBe(true); + expect(tracks.every(x => x.isRelativeToUnixEpoch)).toBe(true); const track = tracks[0]!; const firstTimestamp = await track.getFirstTimestamp(); @@ -565,8 +566,6 @@ test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); - await audioTrack.hydrate(); - expect(audioTrack.isRelativeToUnixEpoch).toBe(false); }); @@ -577,11 +576,11 @@ test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { formats: ALL_FORMATS, }); - const tracks = await input.getTracks(); - expect(tracks).toHaveLength(2); + const descriptors = await input.getTrackDescriptors(); + expect(descriptors).toHaveLength(2); - expect(tracks[0]).toMatchObject({ type: 'audio', languageCode: 'en', name: 'English' }); - expect(tracks[1]).toMatchObject({ type: 'audio', languageCode: 'dubbing', name: 'Dubbing' }); + expect(descriptors[0]).toMatchObject({ type: 'audio', languageCode: 'en', name: 'English' }); + expect(descriptors[1]).toMatchObject({ type: 'audio', languageCode: 'dubbing', name: 'Dubbing' }); }); test.concurrent('Advanced Apple HLS', { timeout: 30_000 }, async () => { @@ -591,115 +590,115 @@ test.concurrent('Advanced Apple HLS', { timeout: 30_000 }, async () => { formats: ALL_FORMATS, }); - const tracks = await input.getTracks(); - const videoTracks = tracks.filter(x => x.isVideoTrack()); - const audioTracks = tracks.filter(x => x.isAudioTrack()); + const descriptors = await input.getTrackDescriptors(); + const videoDescriptors = descriptors.filter(x => x.isVideoTrackDescriptor()); + const audioDescriptors = descriptors.filter(x => x.isAudioTrackDescriptor()); - expect(audioTracks).toHaveLength(3); - expect(videoTracks).toHaveLength(28); + expect(audioDescriptors).toHaveLength(3); + expect(videoDescriptors).toHaveLength(28); - expect(audioTracks[0]).toMatchObject({ codec: 'aac', name: 'English', languageCode: 'en' }); - expect(audioTracks[1]).toMatchObject({ codec: 'ac3', name: 'English', languageCode: 'en' }); - expect(audioTracks[2]).toMatchObject({ codec: 'eac3', name: 'English', languageCode: 'en' }); + expect(audioDescriptors[0]).toMatchObject({ codec: 'aac', name: 'English', languageCode: 'en' }); + expect(audioDescriptors[1]).toMatchObject({ codec: 'ac3', name: 'English', languageCode: 'en' }); + expect(audioDescriptors[2]).toMatchObject({ codec: 'eac3', name: 'English', languageCode: 'en' }); - expect(await audioTracks[0]!.getCodecParameterString()).toBe('mp4a.40.2'); - expect(await audioTracks[1]!.getCodecParameterString()).toBe('ac-3'); - expect(await audioTracks[2]!.getCodecParameterString()).toBe('ec-3'); + expect(audioDescriptors[0]!.codecParameterString).toBe('mp4a.40.2'); + expect(audioDescriptors[1]!.codecParameterString).toBe('ac-3'); + expect(audioDescriptors[2]!.codecParameterString).toBe('ec-3'); - expect(await audioTracks[0]!.getPairableVideoTracks()).toHaveLength(18); - expect(await audioTracks[1]!.getPairableVideoTracks()).toHaveLength(18); - expect(await audioTracks[2]!.getPairableVideoTracks()).toHaveLength(18); + expect(await audioDescriptors[0]!.getPairableVideoTrackDescriptors()).toHaveLength(18); + expect(await audioDescriptors[1]!.getPairableVideoTrackDescriptors()).toHaveLength(18); + expect(await audioDescriptors[2]!.getPairableVideoTrackDescriptors()).toHaveLength(18); // I-FRAME AVC - expect(videoTracks[0]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 1015727, hasOnlyKeyPackets: true }); - expect(videoTracks[1]).toMatchObject({ codec: 'avc', displayWidth: 1280, displayHeight: 720, bitrate: 760174, hasOnlyKeyPackets: true }); - expect(videoTracks[2]).toMatchObject({ codec: 'avc', displayWidth: 960, displayHeight: 540, bitrate: 520162, hasOnlyKeyPackets: true }); - expect(videoTracks[3]).toMatchObject({ codec: 'avc', displayWidth: 640, displayHeight: 360, bitrate: 186651, hasOnlyKeyPackets: true }); - expect(videoTracks[4]).toMatchObject({ codec: 'avc', displayWidth: 480, displayHeight: 270, bitrate: 95410, hasOnlyKeyPackets: true }); + expect(videoDescriptors[0]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 1015727, hasOnlyKeyPackets: true }); + expect(videoDescriptors[1]).toMatchObject({ codec: 'avc', displayWidth: 1280, displayHeight: 720, bitrate: 760174, hasOnlyKeyPackets: true }); + expect(videoDescriptors[2]).toMatchObject({ codec: 'avc', displayWidth: 960, displayHeight: 540, bitrate: 520162, hasOnlyKeyPackets: true }); + expect(videoDescriptors[3]).toMatchObject({ codec: 'avc', displayWidth: 640, displayHeight: 360, bitrate: 186651, hasOnlyKeyPackets: true }); + expect(videoDescriptors[4]).toMatchObject({ codec: 'avc', displayWidth: 480, displayHeight: 270, bitrate: 95410, hasOnlyKeyPackets: true }); - expect(await videoTracks[0]!.getCodecParameterString()).toBe('avc1.640028'); - expect(await videoTracks[1]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(await videoTracks[2]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(await videoTracks[3]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(await videoTracks[4]!.getCodecParameterString()).toBe('avc1.64001f'); + expect(videoDescriptors[0]!.codecParameterString).toBe('avc1.640028'); + expect(videoDescriptors[1]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[2]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[3]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[4]!.codecParameterString).toBe('avc1.64001f'); for (let i = 0; i <= 4; i++) { - const videoTrack = videoTracks[i]!; - expect(audioTracks.some(x => x.canBePairedWith(videoTrack))).toBe(false); + const videoDescriptor = videoDescriptors[i]!; + expect(audioDescriptors.some(x => x.canBePairedWith(videoDescriptor))).toBe(false); } // AVC - expect(videoTracks[5]).toMatchObject({ codec: 'avc', displayWidth: 960, displayHeight: 540, bitrate: 2746096, hasOnlyKeyPackets: false }); - expect(videoTracks[6]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 10095767, hasOnlyKeyPackets: false }); - expect(videoTracks[7]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 7540836, hasOnlyKeyPackets: false }); - expect(videoTracks[8]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 5644219, hasOnlyKeyPackets: false }); - expect(videoTracks[9]).toMatchObject({ codec: 'avc', displayWidth: 1280, displayHeight: 720, bitrate: 3833756, hasOnlyKeyPackets: false }); - expect(videoTracks[10]).toMatchObject({ codec: 'avc', displayWidth: 768, displayHeight: 432, bitrate: 1698402, hasOnlyKeyPackets: false }); - expect(videoTracks[11]).toMatchObject({ codec: 'avc', displayWidth: 640, displayHeight: 360, bitrate: 1240204, hasOnlyKeyPackets: false }); - expect(videoTracks[12]).toMatchObject({ codec: 'avc', displayWidth: 480, displayHeight: 270, bitrate: 805319, hasOnlyKeyPackets: false }); - expect(videoTracks[13]).toMatchObject({ codec: 'avc', displayWidth: 416, displayHeight: 234, bitrate: 561903, hasOnlyKeyPackets: false }); + expect(videoDescriptors[5]).toMatchObject({ codec: 'avc', displayWidth: 960, displayHeight: 540, bitrate: 2746096, hasOnlyKeyPackets: false }); + expect(videoDescriptors[6]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 10095767, hasOnlyKeyPackets: false }); + expect(videoDescriptors[7]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 7540836, hasOnlyKeyPackets: false }); + expect(videoDescriptors[8]).toMatchObject({ codec: 'avc', displayWidth: 1920, displayHeight: 1080, bitrate: 5644219, hasOnlyKeyPackets: false }); + expect(videoDescriptors[9]).toMatchObject({ codec: 'avc', displayWidth: 1280, displayHeight: 720, bitrate: 3833756, hasOnlyKeyPackets: false }); + expect(videoDescriptors[10]).toMatchObject({ codec: 'avc', displayWidth: 768, displayHeight: 432, bitrate: 1698402, hasOnlyKeyPackets: false }); + expect(videoDescriptors[11]).toMatchObject({ codec: 'avc', displayWidth: 640, displayHeight: 360, bitrate: 1240204, hasOnlyKeyPackets: false }); + expect(videoDescriptors[12]).toMatchObject({ codec: 'avc', displayWidth: 480, displayHeight: 270, bitrate: 805319, hasOnlyKeyPackets: false }); + expect(videoDescriptors[13]).toMatchObject({ codec: 'avc', displayWidth: 416, displayHeight: 234, bitrate: 561903, hasOnlyKeyPackets: false }); - expect(await videoTracks[5]!.getCodecParameterString()).toBe('avc1.640020'); - expect(await videoTracks[6]!.getCodecParameterString()).toBe('avc1.64002a'); - expect(await videoTracks[7]!.getCodecParameterString()).toBe('avc1.64002a'); - expect(await videoTracks[8]!.getCodecParameterString()).toBe('avc1.64002a'); - expect(await videoTracks[9]!.getCodecParameterString()).toBe('avc1.640020'); - expect(await videoTracks[10]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(await videoTracks[11]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(await videoTracks[12]!.getCodecParameterString()).toBe('avc1.64001f'); - expect(await videoTracks[13]!.getCodecParameterString()).toBe('avc1.64001f'); + expect(videoDescriptors[5]!.codecParameterString).toBe('avc1.640020'); + expect(videoDescriptors[6]!.codecParameterString).toBe('avc1.64002a'); + expect(videoDescriptors[7]!.codecParameterString).toBe('avc1.64002a'); + expect(videoDescriptors[8]!.codecParameterString).toBe('avc1.64002a'); + expect(videoDescriptors[9]!.codecParameterString).toBe('avc1.640020'); + expect(videoDescriptors[10]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[11]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[12]!.codecParameterString).toBe('avc1.64001f'); + expect(videoDescriptors[13]!.codecParameterString).toBe('avc1.64001f'); for (let i = 5; i <= 13; i++) { - const videoTrack = videoTracks[i]!; - expect(audioTracks.every(x => x.canBePairedWith(videoTrack))).toBe(true); + const videoDescriptor = videoDescriptors[i]!; + expect(audioDescriptors.every(x => x.canBePairedWith(videoDescriptor))).toBe(true); } // I-FRAME HEVC - expect(videoTracks[14]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 328352, hasOnlyKeyPackets: true }); - expect(videoTracks[15]).toMatchObject({ codec: 'hevc', displayWidth: 1280, displayHeight: 720, bitrate: 226274, hasOnlyKeyPackets: true }); - expect(videoTracks[16]).toMatchObject({ codec: 'hevc', displayWidth: 960, displayHeight: 540, bitrate: 159037, hasOnlyKeyPackets: true }); - expect(videoTracks[17]).toMatchObject({ codec: 'hevc', displayWidth: 640, displayHeight: 360, bitrate: 92800, hasOnlyKeyPackets: true }); - expect(videoTracks[18]).toMatchObject({ codec: 'hevc', displayWidth: 480, displayHeight: 270, bitrate: 51760, hasOnlyKeyPackets: true }); + expect(videoDescriptors[14]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 328352, hasOnlyKeyPackets: true }); + expect(videoDescriptors[15]).toMatchObject({ codec: 'hevc', displayWidth: 1280, displayHeight: 720, bitrate: 226274, hasOnlyKeyPackets: true }); + expect(videoDescriptors[16]).toMatchObject({ codec: 'hevc', displayWidth: 960, displayHeight: 540, bitrate: 159037, hasOnlyKeyPackets: true }); + expect(videoDescriptors[17]).toMatchObject({ codec: 'hevc', displayWidth: 640, displayHeight: 360, bitrate: 92800, hasOnlyKeyPackets: true }); + expect(videoDescriptors[18]).toMatchObject({ codec: 'hevc', displayWidth: 480, displayHeight: 270, bitrate: 51760, hasOnlyKeyPackets: true }); - expect(await videoTracks[14]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[15]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[16]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[17]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[18]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[14]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[15]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[16]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[17]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[18]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); for (let i = 14; i <= 18; i++) { - const videoTrack = videoTracks[i]!; - expect(audioTracks.some(x => x.canBePairedWith(videoTrack))).toBe(false); + const videoDescriptor = videoDescriptors[i]!; + expect(audioDescriptors.some(x => x.canBePairedWith(videoDescriptor))).toBe(false); } // HEVC - expect(videoTracks[19]).toMatchObject({ codec: 'hevc', displayWidth: 960, displayHeight: 540, bitrate: 2386827, hasOnlyKeyPackets: false }); - expect(videoTracks[20]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 6886727, hasOnlyKeyPackets: false }); - expect(videoTracks[21]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 5650398, hasOnlyKeyPackets: false }); - expect(videoTracks[22]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 4302269, hasOnlyKeyPackets: false }); - expect(videoTracks[23]).toMatchObject({ codec: 'hevc', displayWidth: 1280, displayHeight: 720, bitrate: 2987200, hasOnlyKeyPackets: false }); - expect(videoTracks[24]).toMatchObject({ codec: 'hevc', displayWidth: 768, displayHeight: 432, bitrate: 1448754, hasOnlyKeyPackets: false }); - expect(videoTracks[25]).toMatchObject({ codec: 'hevc', displayWidth: 640, displayHeight: 360, bitrate: 1124269, hasOnlyKeyPackets: false }); - expect(videoTracks[26]).toMatchObject({ codec: 'hevc', displayWidth: 480, displayHeight: 270, bitrate: 771426, hasOnlyKeyPackets: false }); - expect(videoTracks[27]).toMatchObject({ codec: 'hevc', displayWidth: 416, displayHeight: 234, bitrate: 563212, hasOnlyKeyPackets: false }); + expect(videoDescriptors[19]).toMatchObject({ codec: 'hevc', displayWidth: 960, displayHeight: 540, bitrate: 2386827, hasOnlyKeyPackets: false }); + expect(videoDescriptors[20]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 6886727, hasOnlyKeyPackets: false }); + expect(videoDescriptors[21]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 5650398, hasOnlyKeyPackets: false }); + expect(videoDescriptors[22]).toMatchObject({ codec: 'hevc', displayWidth: 1920, displayHeight: 1080, bitrate: 4302269, hasOnlyKeyPackets: false }); + expect(videoDescriptors[23]).toMatchObject({ codec: 'hevc', displayWidth: 1280, displayHeight: 720, bitrate: 2987200, hasOnlyKeyPackets: false }); + expect(videoDescriptors[24]).toMatchObject({ codec: 'hevc', displayWidth: 768, displayHeight: 432, bitrate: 1448754, hasOnlyKeyPackets: false }); + expect(videoDescriptors[25]).toMatchObject({ codec: 'hevc', displayWidth: 640, displayHeight: 360, bitrate: 1124269, hasOnlyKeyPackets: false }); + expect(videoDescriptors[26]).toMatchObject({ codec: 'hevc', displayWidth: 480, displayHeight: 270, bitrate: 771426, hasOnlyKeyPackets: false }); + expect(videoDescriptors[27]).toMatchObject({ codec: 'hevc', displayWidth: 416, displayHeight: 234, bitrate: 563212, hasOnlyKeyPackets: false }); - expect(await videoTracks[19]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[20]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[21]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[22]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[23]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[24]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[25]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[26]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); - expect(await videoTracks[27]!.getCodecParameterString()).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[19]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[20]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[21]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[22]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[23]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[24]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[25]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[26]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); + expect(videoDescriptors[27]!.codecParameterString).toBe('hvc1.2.4.L123.B0'); for (let i = 19; i <= 27; i++) { - const videoTrack = videoTracks[i]!; - expect(audioTracks.every(x => x.canBePairedWith(videoTrack))).toBe(true); + const videoDescriptor = videoDescriptors[i]!; + expect(audioDescriptors.every(x => x.canBePairedWith(videoDescriptor))).toBe(true); } }); @@ -716,12 +715,6 @@ test.concurrent('Live HLS', { timeout: 30_000 }, async () => { const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); - await expect(videoTrack.isLive()).rejects.toThrow(); - await expect(audioTrack.isLive()).rejects.toThrow(); - - await videoTrack.hydrate(); - await audioTrack.hydrate(); - expect(await videoTrack.isLive()).toBe(true); expect(await audioTrack.isLive()).toBe(true); diff --git a/todo.txt b/todo.txt index 45cf5c7..1be1c02 100644 --- a/todo.txt +++ b/todo.txt @@ -2,6 +2,7 @@ idea: discontinuities with extra decoder config on the packet. Also, why not just have the packet metadata on the packet? I think that would make things easier overall. - keep input/demuxer.isSupported()? -- getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field? -- conversion behavior for HLS; only take primary tracks by default? \ No newline at end of file +- conversion behavior for HLS; only take primary tracks by default? + +easier way to create input \ No newline at end of file