From cfb5418fa0a5d11af651a278edf80814e0823881 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:28:26 +0100 Subject: [PATCH] Refactor HLS API: Get rid of specialized Manifest* classes, use Input instead. Add unhydrated InputTracks, InputTrack compatibility, InputTrack queries --- dev/demux.html | 34 +- eslint.config.mjs | 1 + examples/media-player/media-player.ts | 51 +- src/adts/adts-demuxer.ts | 20 +- src/aggregate-demuxer.ts | 196 ---- src/flac/flac-demuxer.ts | 20 +- src/hls/hls-demuxer.ts | 853 ++++++++++++++++++ src/hls/hls-misc.ts | 56 ++ .../hls-segmented-input.ts} | 321 +------ src/index.ts | 12 +- src/input-format.ts | 41 +- src/input-track.ts | 281 +++++- src/input.ts | 163 +++- src/isobmff/isobmff-demuxer.ts | 17 +- src/manifest-input-format.ts | 35 - src/manifest-input.ts | 189 ---- src/manifest-parser.ts | 22 - src/matroska/matroska-demuxer.ts | 23 +- src/misc.ts | 31 + src/mp3/mp3-demuxer.ts | 20 +- src/mpeg-ts/mpeg-ts-demuxer.ts | 16 +- src/ogg/ogg-demuxer.ts | 20 +- src/reader.ts | 13 +- src/{manifest-input-segment.ts => segment.ts} | 37 +- ...st-input-variant.ts => segmented-input.ts} | 96 +- src/wave/wave-demuxer.ts | 16 +- testfiles_temp/entry-apple.m3u8 | 152 ++++ testfiles_temp/entry.m3u8 | 4 + todo.txt | 5 +- 29 files changed, 1841 insertions(+), 904 deletions(-) delete mode 100644 src/aggregate-demuxer.ts create mode 100644 src/hls/hls-demuxer.ts create mode 100644 src/hls/hls-misc.ts rename src/{m3u8/m3u8-parser.ts => hls/hls-segmented-input.ts} (52%) delete mode 100644 src/manifest-input-format.ts delete mode 100644 src/manifest-input.ts delete mode 100644 src/manifest-parser.ts rename src/{manifest-input-segment.ts => segment.ts} (78%) rename src/{manifest-input-variant.ts => segmented-input.ts} (82%) create mode 100644 testfiles_temp/entry-apple.m3u8 diff --git a/dev/demux.html b/dev/demux.html index 3d6109d..f1bdfe0 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -17,22 +17,30 @@ source: new Mediabunny.BlobSource(file), }); - /* - const manifest = new Mediabunny.ManifestInput({ - entryPath: /*'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8' ?? 'https://bitmovin-a.akamaihd.net/content/dataset/multi-codec/hevc/v720p_ts.m3u8', - getSource: path => new Mediabunny.UrlSource(path), - manifestFormats: Mediabunny.ALL_MANIFEST_FORMATS, - mediaFormats: Mediabunny.ALL_FORMATS, + const manifest = new Mediabunny.Input({ + entryPath: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8', + source: ({ path }) => new Mediabunny.UrlSource(path), + formats: Mediabunny.ALL_FORMATS, }); - const variants = await manifest.getVariants(); - const variant = variants[0]; - const input = variant.toInput(); - */ - const videoTrack = await input.getPrimaryVideoTrack(); - const sink = new Mediabunny.EncodedPacketSink(videoTrack); + const tracks = await manifest.getTracks(); + console.log(tracks.map(x => x.languageCode)) + + function transform(n) { + n = BigInt(n); + + let result = 0n; + let place = 1n; + + while (n > 0n) { + result += (n & 1n) * place; // extract lowest bit + n >>= 1n; // shift right + place *= 10n; // next decimal digit + } + + return result; + } - console.log(await sink.getPacket(17.48808888888889)); /* const manifest = new Mediabunny.ManifestInput({ entryPath: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8' diff --git a/eslint.config.mjs b/eslint.config.mjs index fa924e2..2086537 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,6 +30,7 @@ export default tseslint.config( '@typescript-eslint/require-await': 'off', '@stylistic/yield-star-spacing': ['error', { before: false, after: true }], '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-unsafe-unary-minus': 'off', }, }, { diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index 4de5ba4..9d9c1a4 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -1,14 +1,17 @@ import { ALL_FORMATS, - ALL_MANIFEST_FORMATS, AudioBufferSink, BlobSource, CanvasSink, Input, - ManifestInput, + InputAudioTrack, + InputVideoTrack, UrlSource, WrappedAudioBuffer, WrappedCanvas, + asc, + desc, + prefer, } from 'mediabunny'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; @@ -91,17 +94,38 @@ const initMediaPlayer = async (resource: File | string) => { errorElement.textContent = ''; warningElement.textContent = ''; - let input: Input; + let videoTrack: InputVideoTrack | null = null; + let audioTrack: InputAudioTrack | null = null; if (typeof resource === 'string' && resource.includes('.m3u8')) { - const manifestInput = new ManifestInput({ + const input = new Input({ entryPath: resource, - getSource: path => new UrlSource(path), - manifestFormats: ALL_MANIFEST_FORMATS, - mediaFormats: ALL_FORMATS, + source: ({ path }) => new UrlSource(path), + formats: ALL_FORMATS, }); // const variant = (await manifestInput.getVariants())[0]!; - input = await manifestInput.toInput(); + console.log(await input.getFormat(), await input.getTracks()); + // return; + + await input.getVideoTracks({ + filter: track => track.hasPairableAudioTrack(), + sortBy: track => asc(track.bitrate), + }); + + videoTrack = await input.getPrimaryVideoTrack({ + filter: async track => (await track.resolve('displayHeight')) <= 720, + }); + audioTrack = await input.getPrimaryAudioTrack({ + filter: track => !videoTrack || videoTrack.canBePairedWith(track), + }); + + await videoTrack?.hydrate(); + await audioTrack?.hydrate(); + + totalDuration = Math.max( + await videoTrack?.computeDuration() ?? 0, + await audioTrack?.computeDuration() ?? 0, + ); // https://test-streams.mux.dev/test_001/stream.m3u8 // https://test-streams.mux.dev/test_001/stream_1000k_48k_640x360_050.ts @@ -110,10 +134,14 @@ const initMediaPlayer = async (resource: File | string) => { ? new BlobSource(resource) : new UrlSource(resource); - input = new Input({ + const input = new Input({ source, formats: ALL_FORMATS, }); + + totalDuration = await input.computeDuration(); + videoTrack = await input.getPrimaryVideoTrack(); + audioTrack = await input.getPrimaryAudioTrack(); } /* @@ -131,11 +159,8 @@ const initMediaPlayer = async (resource: File | string) => { */ playbackTimeAtStart = 0; - totalDuration = await input.computeDuration(); - durationElement.textContent = formatSeconds(totalDuration); - let videoTrack = await input.getPrimaryVideoTrack(); - let audioTrack = await input.getPrimaryAudioTrack(); + durationElement.textContent = formatSeconds(totalDuration); let problemMessage = ''; diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index e9ecaaf..93fdb5a 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -206,6 +206,22 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking { return sampleRate / SAMPLES_PER_AAC_FRAME; } + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { + return null; + } + + getAverageBitrate() { + return null; + } + getName() { return null; } @@ -233,10 +249,6 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking { return numberOfChannels; } - getVariant() { - return null; - } - getSampleRate() { assert(this.demuxer.firstFrameHeader); diff --git a/src/aggregate-demuxer.ts b/src/aggregate-demuxer.ts deleted file mode 100644 index f1240a3..0000000 --- a/src/aggregate-demuxer.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { VideoCodec, AudioCodec } from './codec'; -import { Demuxer } from './demuxer'; -import { Input } from './input'; -import { - InputTrack, - InputVideoTrack, - InputAudioTrack, - InputTrackBacking, - InputVideoTrackBacking, - InputAudioTrackBacking, -} from './input-track'; -import { ManifestInputVariant } from './manifest-input-variant'; -import { PacketRetrievalOptions } from './media-sink'; -import { MetadataTags, TrackDisposition } from './metadata'; -import { arrayCount, Rotation } from './misc'; -import { EncodedPacket } from './packet'; - -/** A utility demuxer that acts as the union of multiple Inputs. */ -export class InputAggregateDemuxer extends Demuxer { - subInputs: Input[]; - tracksPromise: Promise | null = null; - - constructor(input: Input, subInputs: Input[]) { - super(input); - - this.subInputs = subInputs; - } - - async getMetadataTags(): Promise { - return {}; // todo? - } - - async getMimeType() { - return ''; // todo? - } - - getTracks() { - return this.tracksPromise ??= (async () => { - const subInputTracks = await Promise.all(this.subInputs.map(async (input) => { - const supported = await input.isSupported(); - if (!supported) { - return []; - } - - return input.getTracks(); - })); - - const tracks: InputTrack[] = []; - for (const inputTracks of subInputTracks) { - for (const track of inputTracks) { - if (track.isVideoTrack()) { - const number = arrayCount(tracks, x => x.type === 'video') + 1; - tracks.push(new InputVideoTrack( - this.input, - new InputAggregateVideoTrackBacking(track._backing, number), - )); - } else if (track.isAudioTrack()) { - const number = arrayCount(tracks, x => x.type === 'audio') + 1; - tracks.push(new InputAudioTrack( - this.input, - new InputAggregateAudioTrackBacking(track._backing, number), - )); - } - } - } - - return tracks; - })(); - } -} - -class InputAggregateTrackBacking implements InputTrackBacking { - source: InputTrackBacking; - number: number; - - constructor(source: InputTrackBacking, number: number) { - this.source = source; - this.number = number; - } - - getId() { - return this.source.getId(); - } - - getNumber() { - return this.number; - } - - getCodec() { - return this.source.getCodec(); - } - - getInternalCodecId() { - return this.source.getInternalCodecId(); - } - - getName() { - return this.source.getName(); - } - - getLanguageCode() { - return this.source.getLanguageCode(); - } - - getTimeResolution() { - return this.source.getTimeResolution(); - } - - getDisposition(): TrackDisposition { - return this.source.getDisposition(); - } - - getVariant(): ManifestInputVariant | null { - return this.source.getVariant(); - } - - getFirstPacket(options: PacketRetrievalOptions): Promise { - return this.source.getFirstPacket(options); - } - - getPacket(timestamp: number, options: PacketRetrievalOptions): Promise { - return this.source.getPacket(timestamp, options); - } - - getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { - return this.source.getNextPacket(packet, options); - } - - getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise { - return this.source.getKeyPacket(timestamp, options); - } - - getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { - return this.source.getNextKeyPacket(packet, options); - } -} - -class InputAggregateVideoTrackBacking extends InputAggregateTrackBacking implements InputVideoTrackBacking { - override source!: InputVideoTrackBacking; - - constructor(source: InputVideoTrackBacking, number: number) { - super(source, number); - } - - override getCodec(): VideoCodec | null { - return this.source.getCodec(); - } - - getCodedWidth() { - return this.source.getCodedWidth(); - } - - getCodedHeight() { - return this.source.getCodedHeight(); - } - - getRotation(): Rotation { - return this.source.getRotation(); - } - - getColorSpace() { - return this.source.getColorSpace(); - } - - canBeTransparent() { - return this.source.canBeTransparent(); - } - - getDecoderConfig() { - return this.source.getDecoderConfig(); - } -} - -class InputAggregateAudioTrackBacking extends InputAggregateTrackBacking implements InputAudioTrackBacking { - override source!: InputAudioTrackBacking; - - constructor(source: InputAudioTrackBacking, number: number) { - super(source, number); - } - - override getCodec(): AudioCodec | null { - return this.source.getCodec(); - } - - getNumberOfChannels() { - return this.source.getNumberOfChannels(); - } - - getSampleRate() { - return this.source.getSampleRate(); - } - - getDecoderConfig() { - return this.source.getDecoderConfig(); - } -} diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index 0ccb1cc..68ba366 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -558,16 +558,28 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.audioInfo.sampleRate; } + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { + return null; + } + + getAverageBitrate() { + return null; + } + getDisposition() { return { ...DEFAULT_TRACK_DISPOSITION, }; } - getVariant() { - return null; - } - async getDecoderConfig(): Promise { assert(this.demuxer.audioInfo); diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts new file mode 100644 index 0000000..c9f8dc2 --- /dev/null +++ b/src/hls/hls-demuxer.ts @@ -0,0 +1,853 @@ +import { AUDIO_CODECS, AudioCodec, inferCodecFromCodecString, MediaCodec, VIDEO_CODECS, VideoCodec } from '../codec'; +import { Demuxer } 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 { assert, joinPaths, Rotation, UNDETERMINED_LANGUAGE } from '../misc'; +import { EncodedPacket } from '../packet'; +import { LineReader } from '../reader'; +import { AttributeList, canIgnoreLine } from './hls-misc'; +import { HlsSegmentedInput } from './hls-segmented-input'; + +type InternalTrack = { + id: number; + demuxer: HlsDemuxer; + inputTrack: InputTrack | null; + backingTrack: InputTrack | null; + default: boolean; + languageCode: string; + lineNumber: number; + + fullPath: string; + fullCodecString: string; + groupId: number; + pairingMask: bigint; + peakBitrate: number | null; + averageBitrate: number | null; + name: string | null; + + info: { + type: 'video'; + width: number | null; + height: number | null; + } | { + type: 'audio'; + numberOfChannels: number | null; + }; +}; +type InternalVideoTrack = InternalTrack & { info: { type: 'video' } }; +type InternalAudioTrack = InternalTrack & { info: { type: 'audio' } }; + +export class HlsDemuxer extends Demuxer { + metadataPromise: Promise | null = null; + lineReader: LineReader; + tracks: InputTrack[] = []; + segmentedInputs: HlsSegmentedInput[] = []; + + constructor(input: Input) { + super(input); + this.lineReader = new LineReader(() => input._reader, canIgnoreLine); + } + + readMetadata() { + return this.metadataPromise ??= (async () => { + assert(typeof this.input._source === 'function'); + assert(this.input._entryPath !== null); + + let line = this.lineReader.readNextLine(); + if (line instanceof Promise) line = await line; + + if (line !== '#EXTM3U') { + throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.'); + } + + const variantStreams: { + fullPath: string; + attributes: AttributeList; + lineNumber: number; + }[] = []; + const mediaTags: { + fullPath: string | null; + attributes: AttributeList; + lineNumber: number; + }[] = []; + + while (true) { + let line = this.lineReader.readNextLine(); + if (line instanceof Promise) line = await line; + + if (line === null) { + break; + } + + if (line.startsWith('#EXT-X-STREAM-INF:')) { + const streamInfLineNumber = this.lineReader.currentLineNumber; + let playlistPath = this.lineReader.readNextLine(); + if (playlistPath instanceof Promise) playlistPath = await playlistPath; + + if (playlistPath === null) { + throw new Error('Incorrect M3U8 file; a line must follow the #EXT-X-STREAM-INF tag.'); + } + + const fullPath = joinPaths(this.input._entryPath, playlistPath); + const attributes = new AttributeList(line.slice(18)); + + const bandwidth = attributes.getAsNumber('bandwidth'); + if (bandwidth === null) { + throw new Error( + 'Invalid M3U8 file; #EXT-X-STREAM-INF tag requires a BANDWIDTH attribute with a valid' + + ' number value.', + ); + } + + variantStreams.push({ fullPath: fullPath, attributes, lineNumber: streamInfLineNumber }); + } else if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) { + const attributes = new AttributeList(line.slice(18)); + const playlistPath = attributes.get('uri'); + + if (playlistPath === null) { + throw new Error( + 'Invalid M3U8 file; #EXT-X-I-FRAME-STREAM-INF tag requires a URI attribute.', + ); + } + + const fullPath = joinPaths(this.input._entryPath, playlistPath); + + variantStreams.push({ fullPath, attributes, lineNumber: this.lineReader.currentLineNumber }); + } else if (line.startsWith('#EXT-X-MEDIA:')) { + const attributes = new AttributeList(line.slice(13)); + + const type = attributes.get('type'); + if (type === null) { + throw new Error( + 'Invalid M3U8 file; #EXT-X-MEDIA tag requires a TYPE attribute.', + ); + } + + const groupId = attributes.get('group-id'); + if (groupId === null) { + throw new Error( + 'Invalid M3U8 file; #EXT-X-MEDIA tag requires a GROUP-ID attribute.', + ); + } + + let fullPath: string | null = null; + const uri = attributes.get('uri'); + if (uri !== null) { + fullPath = joinPaths(this.input._entryPath, uri); + } + + mediaTags.push({ fullPath, attributes, lineNumber: this.lineReader.currentLineNumber }); + } else if (line === '#EXT-X-I-FRAMES-ONLY') { + // iFramesOnlyTagFound = true; + } else if (line.startsWith('#EXTINF:')) { + // This is a media playlist, not a master playlist + const segmentedInput = new HlsSegmentedInput(this, this.input._entryPath, this.input._reader); + this.segmentedInputs.push(segmentedInput); + + const input = segmentedInput.toInput(); + this.tracks = await input.getTracks(); + + return; + } + } + + const videoGroupIds = [...new Set( + mediaTags + .filter(tag => tag.attributes.get('type')!.toLowerCase() === 'video') + .map(tag => tag.attributes.get('group-id')!)), + ]; + const audioGroupIds = [...new Set( + mediaTags + .filter(tag => tag.attributes.get('type')!.toLowerCase() === 'audio') + .map(tag => tag.attributes.get('group-id')!)), + ]; + const internalTracks: InternalTrack[] = []; + + const addInternalTrack = (track: InternalTrack, canMerge: boolean) => { + const existingTrack = internalTracks.find(x => + x.fullPath === track.fullPath && x.info.type === track.info.type && x.groupId === track.groupId, + ); + if (existingTrack && canMerge) { + existingTrack.pairingMask |= track.pairingMask; + existingTrack.default ||= track.default; + existingTrack.lineNumber = Math.min(existingTrack.lineNumber, track.lineNumber); + + if (existingTrack.languageCode === UNDETERMINED_LANGUAGE) { + existingTrack.languageCode = track.languageCode; + } + } else { + internalTracks.push(track); + } + }; + + for (let i = 0; i < variantStreams.length; i++) { + const variantStream = variantStreams[i]!; + + const codecsList = variantStream.attributes.get('codecs'); + let codecStrings: string[]; + + if (codecsList) { + codecStrings = codecsList.split(',').map(x => x.trim()); + } else { + const segmentedInput = this.getSegmentedInputForPath(variantStream.fullPath); + const input = segmentedInput.toInput(); + const tracks = await input.getTracks(); + + codecStrings = await Promise.all( + tracks + .filter(x => x.codec !== null) + .map(x => x.getCodecParameterString()), + ) as string[]; + } + + const videoGroupId = variantStream.attributes.get('video'); + const audioGroupId = variantStream.attributes.get('audio'); + + if (videoGroupId !== null) { + if (!videoGroupIds.includes(videoGroupId)) { + throw new Error( + `Invalid M3U8 file; variant stream references video group "${videoGroupId}" which` + + ` is not defined in any #EXT-X-MEDIA tags.`, + ); + } + + for (const mediaTag of mediaTags) { + const groupId = mediaTag.attributes.get('group-id')!; + const type = mediaTag.attributes.get('type')!; + + if (groupId !== videoGroupId || type.toLowerCase() !== 'video') { + continue; + } + + const uri = mediaTag.attributes.get('uri'); + if (uri !== null) { + const fullPath = joinPaths(this.input._entryPath, uri); + const segmentedInput = this.getSegmentedInputForPath(fullPath); + const input = segmentedInput.toInput(); + const videoTrack = await input.getPrimaryVideoTrack(); + + if (videoTrack && videoTrack.codec !== null) { + const codecParameterString = await videoTrack.getCodecParameterString(); + assert(codecParameterString !== null); + + codecStrings.push(codecParameterString); + } + } + } + } + + if (audioGroupId !== null) { + if (!audioGroupIds.includes(audioGroupId)) { + throw new Error( + `Invalid M3U8 file; variant stream references audio group "${audioGroupId}" which` + + ` is not defined in any #EXT-X-MEDIA tags.`, + ); + } + + for (const mediaTag of mediaTags) { + const groupId = mediaTag.attributes.get('group-id')!; + const type = mediaTag.attributes.get('type')!; + + if (groupId !== audioGroupId || type.toLowerCase() !== 'audio') { + continue; + } + + const uri = mediaTag.attributes.get('uri'); + if (uri !== null) { + const fullPath = joinPaths(this.input._entryPath, uri); + const segmentedInput = this.getSegmentedInputForPath(fullPath); + const input = segmentedInput.toInput(); + const audioTrack = await input.getPrimaryAudioTrack(); + + if (audioTrack && audioTrack.codec !== null) { + const codecParameterString = await audioTrack.getCodecParameterString(); + assert(codecParameterString !== null); + + codecStrings.push(codecParameterString); + } + } + } + } + + // Unique that shit + codecStrings = [...new Set(codecStrings)]; + + let videoCodecString: string | null = null; + let audioCodecString: string | null = null; + + for (const codecString of codecStrings) { + const inferredCodec = inferCodecFromCodecString(codecString); + if (inferredCodec === null) { + continue; + } + + if (VIDEO_CODECS.includes(inferredCodec as VideoCodec)) { + if (videoCodecString !== null) { + throw new Error( + 'Unsupported M3U8 file; multiple video codecs found in the CODECS attribute of a' + + ' variant stream.', + ); + } + + videoCodecString = codecString; + } else if (AUDIO_CODECS.includes(inferredCodec as AudioCodec)) { + if (audioCodecString !== null) { + throw new Error( + 'Unsupported M3U8 file; multiple audio codecs found in the CODECS attribute of a' + + ' variant stream.', + ); + } + + audioCodecString = codecString; + } + } + + const bandwidth = variantStream.attributes.getAsNumber('bandwidth'); + assert(bandwidth !== null); + + const averageBandwidth = variantStream.attributes.getAsNumber('average-bandwidth'); + const name = variantStream.attributes.get('name'); + + if (videoCodecString !== null) { + const videoGroupId = variantStream.attributes.get('video'); + + if (videoGroupId === null) { + const resolution = variantStream.attributes.get('resolution'); + let width: number | null = null; + let height: number | null = null; + + if (resolution) { + const match = resolution.match(/^(\d+)x(\d+)$/); + if (match) { + width = Number(match[1]); + height = Number(match[2]); + } + } + + addInternalTrack({ + id: internalTracks.length + 1, + demuxer: this, + inputTrack: null, + backingTrack: null, + default: true, + languageCode: UNDETERMINED_LANGUAGE, + lineNumber: variantStream.lineNumber, + fullPath: variantStream.fullPath, + fullCodecString: videoCodecString, + groupId: 1, + pairingMask: 1n << BigInt(i), + peakBitrate: bandwidth, + averageBitrate: averageBandwidth, + name, + info: { + type: 'video', + width, + height, + }, + }, false); + } else { + if (!videoGroupIds.includes(videoGroupId)) { + throw new Error( + `Invalid M3U8 file; variant stream references video group "${videoGroupId}" which` + + ` is not defined in any #EXT-X-MEDIA tags.`, + ); + } + + for (const mediaTag of mediaTags) { + const groupId = mediaTag.attributes.get('group-id')!; + const type = mediaTag.attributes.get('type')!; + + if (groupId !== videoGroupId || type.toLowerCase() !== 'video') { + continue; + } + + const resolution = mediaTag.attributes.get('resolution') + ?? variantStream.attributes.get('resolution'); + let width: number | null = null; + let height: number | null = null; + + if (resolution) { + const match = resolution.match(/^(\d+)x(\d+)$/); + if (match) { + width = Number(match[1]); + height = Number(match[2]); + } + } + + addInternalTrack({ + id: internalTracks.length + 1, + demuxer: this, + inputTrack: null, + backingTrack: null, + default: getMediaTagDefault(mediaTag.attributes), + languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), + lineNumber: mediaTag.lineNumber, + fullPath: mediaTag.fullPath ?? variantStream.fullPath, + fullCodecString: videoCodecString, + groupId: 3 + videoGroupIds.indexOf(groupId), + pairingMask: 1n << BigInt(i), + peakBitrate: null, + averageBitrate: null, + name: mediaTag.attributes.get('name'), + info: { + type: 'video', + width, + height, + }, + }, true); + } + } + } + + if (audioCodecString !== null) { + const audioGroupId = variantStream.attributes.get('audio'); + + if (audioGroupId === null) { + const channels = variantStream.attributes.get('channels'); + const parsedChannels = channels !== null + ? Number(channels) + : null; + + addInternalTrack({ + id: internalTracks.length + 1, + demuxer: this, + inputTrack: null, + backingTrack: null, + default: true, + languageCode: UNDETERMINED_LANGUAGE, + lineNumber: variantStream.lineNumber, + fullPath: variantStream.fullPath, + fullCodecString: audioCodecString, + groupId: 2, + pairingMask: 1n << BigInt(i), + peakBitrate: bandwidth, + averageBitrate: averageBandwidth, + name, + info: { + type: 'audio', + numberOfChannels: + parsedChannels !== null + && Number.isInteger(parsedChannels) + && parsedChannels > 0 + ? parsedChannels + : null, + }, + }, false); + } else { + if (!audioGroupIds.includes(audioGroupId)) { + throw new Error( + `Invalid M3U8 file; variant stream references audio group "${audioGroupId}" which` + + ` is not defined in any #EXT-X-MEDIA tags.`, + ); + } + + for (const mediaTag of mediaTags) { + const groupId = mediaTag.attributes.get('group-id')!; + const type = mediaTag.attributes.get('type')!; + + if (groupId !== audioGroupId || type.toLowerCase() !== 'audio') { + continue; + } + + const channels = mediaTag.attributes.get('channels') + ?? variantStream.attributes.get('channels'); + const parsedChannels = channels !== null + ? Number(channels) + : null; + + addInternalTrack({ + id: internalTracks.length + 1, + demuxer: this, + inputTrack: null, + backingTrack: null, + default: getMediaTagDefault(mediaTag.attributes), + languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')), + lineNumber: mediaTag.lineNumber, + fullPath: mediaTag.fullPath ?? variantStream.fullPath, + fullCodecString: audioCodecString, + groupId: 3 + videoGroupIds.length + audioGroupIds.indexOf(groupId), + pairingMask: 1n << BigInt(i), + peakBitrate: null, + averageBitrate: null, + name: mediaTag.attributes.get('name'), + info: { + type: 'audio', + numberOfChannels: + parsedChannels !== null + && Number.isInteger(parsedChannels) + && parsedChannels > 0 + ? parsedChannels + : null, + }, + }, true); + } + } + } + } + + // Order tracks by how they appear in the file + internalTracks.sort((a, b) => a.lineNumber - b.lineNumber); + + for (const internalTrack of internalTracks) { + const inputTrack = internalTrack.info.type === 'video' + ? new InputVideoTrack( + this.input, + new HlsInputVideoTrackBacking(internalTrack as InternalVideoTrack), + ) + : new InputAudioTrack( + this.input, + new HlsInputAudioTrackBacking(internalTrack as InternalAudioTrack), + ); + + internalTrack.inputTrack = inputTrack; + this.tracks.push(inputTrack); + } + })(); + } + + async getTracks(): Promise { + await this.readMetadata(); + return this.tracks; + } + + getSegmentedInputForPath(path: string) { + let segmentedInput = this.segmentedInputs.find(x => x.path === path); + if (segmentedInput) { + return segmentedInput; + } + + segmentedInput = new HlsSegmentedInput(this, path, null); + this.segmentedInputs.push(segmentedInput); + + return segmentedInput; + } + + async getMetadataTags(): Promise { + return {}; + } + + async getMimeType(): Promise { + return 'application/vnd.apple.mpegurl'; + } +} + +abstract class HlsInputTrackBacking implements InputTrackBacking { + constructor(public internalTrack: InternalTrack) {} + + isHydrated(): boolean { + return !!this.internalTrack.backingTrack; + } + + async hydrate() { + const segmentedInput = this.internalTrack.demuxer.getSegmentedInputForPath(this.internalTrack.fullPath); + const input = segmentedInput.toInput(); + + let track: InputTrack | null; + if (this instanceof HlsInputVideoTrackBacking) { + track = await input.getPrimaryVideoTrack({ + filter: track => track.codec === this.getCodec(), + }); + } else { + assert(this instanceof HlsInputAudioTrackBacking); + track = await input.getPrimaryAudioTrack({ + filter: track => track.codec === this.getCodec(), + }); + } + + if (!track) { + throw new Error('Could not find matching track in underlying media data.'); + } + + this.internalTrack.backingTrack = track; + } + + getCodec(): MediaCodec | null { + throw new Error('Not implemented on base class.'); + } + + getDisposition(): TrackDisposition { + return { + ...DEFAULT_TRACK_DISPOSITION, + default: this.internalTrack.default, + }; + } + + getId(): number { + return this.internalTrack.id; + } + + getGroupId(): number { + return this.internalTrack.groupId; + } + + getPairingMask(): bigint { + return this.internalTrack.pairingMask; + } + + getInternalCodecId(): string | number | Uint8Array | null { + return null; + } + + getLanguageCode(): string { + return this.internalTrack.languageCode; + } + + getName(): string | null { + return this.internalTrack.name; + } + + getNumber(): number { + let number = 0; + for (const track of this.internalTrack.demuxer.tracks) { + if (track.type === this.internalTrack.inputTrack!.type) { + number++; + } + + if (track === this.internalTrack.inputTrack) { + break; + } + } + + return number; + } + + getTimeResolution(): number { + if (!this.internalTrack.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.internalTrack.backingTrack._backing.getTimeResolution(); + } + + getBitrate(): number | null { + return this.internalTrack.peakBitrate; + } + + getAverageBitrate(): number | null { + return this.internalTrack.averageBitrate; + } + + getFirstPacket(options: PacketRetrievalOptions): Promise { + if (!this.internalTrack.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.internalTrack.backingTrack._backing.getFirstPacket(options); + } + + getPacket(timestamp: number, options: PacketRetrievalOptions): Promise { + if (!this.internalTrack.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.internalTrack.backingTrack._backing.getPacket(timestamp, options); + } + + getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise { + if (!this.internalTrack.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.internalTrack.backingTrack._backing.getKeyPacket(timestamp, options); + } + + getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { + if (!this.internalTrack.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.internalTrack.backingTrack._backing.getNextPacket(packet, options); + } + + getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { + if (!this.internalTrack.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.internalTrack.backingTrack._backing.getNextKeyPacket(packet, options); + } +} + +class HlsInputVideoTrackBacking + extends HlsInputTrackBacking + implements InputVideoTrackBacking { + override internalTrack!: InternalVideoTrack; + + constructor(internalTrack: InternalVideoTrack) { + super(internalTrack); + } + + get backingTrack() { + return this.internalTrack.backingTrack as InputVideoTrack | null; + } + + override getCodec(): VideoCodec | null { + const inferredCodec = inferCodecFromCodecString(this.internalTrack.fullCodecString); + return inferredCodec as VideoCodec; + } + + getCodedWidth(): number { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getCodedWidth(); + } + + getCodedHeight(): number { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getCodedHeight(); + } + + getSquarePixelWidth(): number { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getSquarePixelWidth(); + } + + getSquarePixelHeight(): number { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getSquarePixelHeight(); + } + + getDisplayWidth(): number | null { + return this.internalTrack.info.width; + } + + getDisplayHeight(): number | null { + return this.internalTrack.info.height; + } + + getRotation(): Rotation { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getRotation(); + } + + getColorSpace(): Promise { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getColorSpace(); + } + + canBeTransparent(): Promise { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.canBeTransparent(); + } + + getDecoderConfig(): Promise { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getDecoderConfig(); + } +} + +class HlsInputAudioTrackBacking + extends HlsInputTrackBacking + implements InputAudioTrackBacking { + override internalTrack!: InternalAudioTrack; + + constructor(internalTrack: InternalAudioTrack) { + super(internalTrack); + } + + get backingTrack() { + return this.internalTrack.backingTrack as InputAudioTrack | null; + } + + override getCodec(): AudioCodec | null { + const inferredCodec = inferCodecFromCodecString(this.internalTrack.fullCodecString); + return inferredCodec as AudioCodec; + } + + getNumberOfChannels(): number { + if (this.internalTrack.info.numberOfChannels !== null) { + return this.internalTrack.info.numberOfChannels; + } + + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getNumberOfChannels(); + } + + getSampleRate(): number { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getSampleRate(); + } + + getDecoderConfig(): Promise { + if (!this.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.backingTrack._backing.getDecoderConfig(); + } +} + +const getMediaTagDefault = (attributes: AttributeList) => { + const value = attributes.get('default'); + if (value === null) { + return false; + } + + const normalized = value.toUpperCase(); + if (normalized === 'YES') { + return true; + } + if (normalized === 'NO') { + return false; + } + + throw new Error( + `Invalid M3U8 file; #EXT-X-MEDIA DEFAULT attribute must be YES or NO, got "${value}".`, + ); +}; + +const preprocessLanguageCode = (code: string | null) => { + if (code === null) { + return UNDETERMINED_LANGUAGE; + } + + const languageSubtag = code.split('-')[0]; + if (!languageSubtag) { + return UNDETERMINED_LANGUAGE; + } + + // Technically invalid, for now: The language subtag might be a language code from ISO 639-1, + // ISO 639-2, ISO 639-3, ISO 639-5 or some other thing (source: Wikipedia). But, `languageCode` is + // documented as ISO 639-2. Changing the definition would be a breaking change. This will get + // cleaned up in the future by defining languageCode to be BCP 47 instead. + return languageSubtag; +}; diff --git a/src/hls/hls-misc.ts b/src/hls/hls-misc.ts new file mode 100644 index 0000000..5e47d31 --- /dev/null +++ b/src/hls/hls-misc.ts @@ -0,0 +1,56 @@ +export const canIgnoreLine = (line: string) => line.length === 0 || (line.startsWith('#') && !line.startsWith('#EXT')); + +export class AttributeList { + _attributes: Record = {}; + + constructor(str: string) { + let key = ''; + let value = ''; + let inValue = false; + let inQuotes = false; + + for (let i = 0; i < str.length; i++) { + const char = str[i]!; + + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === '=' && !inValue && !inQuotes) { + inValue = true; + } else if (char === ',' && !inQuotes) { + if (key) { + this._attributes[key.toLowerCase()] = value; + } + + key = ''; + value = ''; + inValue = false; + } else if (inValue) { + value += char; + } else { + key += char; + } + } + + if (key) { + this._attributes[key.toLowerCase()] = value; + } + } + + get(name: string) { + return this._attributes[name.toLowerCase()] ?? null; + } + + getAsNumber(name: string) { + const value = this.get(name); + if (value === null) { + return null; + } + + const num = Number(value); + return Number.isFinite(num) ? num : null; + } + + merge(other: AttributeList) { + Object.assign(this._attributes, other._attributes); + } +} diff --git a/src/m3u8/m3u8-parser.ts b/src/hls/hls-segmented-input.ts similarity index 52% rename from src/m3u8/m3u8-parser.ts rename to src/hls/hls-segmented-input.ts index 3de3532..56d372b 100644 --- a/src/m3u8/m3u8-parser.ts +++ b/src/hls/hls-segmented-input.ts @@ -1,165 +1,17 @@ -/*! - * 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 { AES_128_BLOCK_SIZE } from '../aes'; -import { ManifestInput } from '../manifest-input'; -import { ManifestParser } from '../manifest-parser'; -import { AssociatedGroup, ManifestInputVariant } from '../manifest-input-variant'; -import { AsyncMutex, binarySearchLessOrEqual, joinPaths, last, toDataView } from '../misc'; +import { Segment, SegmentEncryptionInfo, SegmentLocation } from '../segment'; +import { SegmentedInput } from '../segmented-input'; +import { AsyncMutex, binarySearchLessOrEqual, toDataView, joinPaths, last } from '../misc'; import { LineReader, Reader } from '../reader'; -import { ManifestInputSegment, ManifestInputSegmentLocation, SegmentEncryptionInfo } from '../manifest-input-segment'; -import { inferCodecFromCodecString, MediaCodec } from '../codec'; +import { HlsDemuxer } from './hls-demuxer'; +import { AttributeList, canIgnoreLine } from './hls-misc'; const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/; -export class M3u8Parser extends ManifestParser { - metadataPromise: Promise | null = null; - variants: M3u8ManifestVariant[] = []; - lineReader: LineReader; - - constructor(input: ManifestInput) { - super(input); - this.lineReader = new LineReader(() => input._entryReader, canIgnoreLine); - } - - readMetadata() { - return this.metadataPromise ??= (async () => { - let line = this.lineReader.readNextLine(); - if (line instanceof Promise) line = await line; - - if (line !== '#EXTM3U') { - throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.'); - } - - let iFramesOnlyTagFound = false; - - while (true) { - let line = this.lineReader.readNextLine(); - if (line instanceof Promise) line = await line; - - if (line === null) { - break; - } - - if (line.startsWith('#EXT-X-STREAM-INF:')) { - let playlistPath = this.lineReader.readNextLine(); - if (playlistPath instanceof Promise) playlistPath = await playlistPath; - - if (playlistPath === null) { - throw new Error('Incorrect M3U8 file; a line must follow the #EXT-X-STREAM-INF tag.'); - } - - const fullPath = joinPaths(this._input._entryPath, playlistPath); - const attributes = new AttributeList(line.slice(18)); - - this.pushOrMergeVariant( - fullPath, - null, - attributes, - false, - ); - } else if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) { - const attributes = new AttributeList(line.slice(18)); - const playlistPath = attributes.get('uri'); - - if (playlistPath === null) { - throw new Error( - 'Invalid M3U8 file; #EXT-X-I-FRAME-STREAM-INF tag requires a URI attribute.', - ); - } - - const fullPath = joinPaths(this._input._entryPath, playlistPath); - - this.pushOrMergeVariant( - fullPath, - null, - attributes, - true, - ); - } else if (line.startsWith('#EXT-X-MEDIA:')) { - const attributes = new AttributeList(line.slice(13)); - - const groupId = attributes.get('group-id'); - if (groupId === null) { - throw new Error( - 'Invalid M3U8 file; #EXT-X-MEDIA tag requires a GROUP-ID attribute.', - ); - } - - const uri = attributes.get('uri'); - if (uri === null) { - continue; - } - - const fullPath = joinPaths(this._input._entryPath, uri); - - this.pushOrMergeVariant( - fullPath, - null, - attributes, - false, - ); - } else if (line === '#EXT-X-I-FRAMES-ONLY') { - iFramesOnlyTagFound = true; - } else if (line.startsWith('#EXTINF:')) { - // This is a media playlist, not a master playlist - - this.variants = [ - new M3u8ManifestVariant( - this, - this._input._entryPath, - this.lineReader.reader, - new AttributeList(''), - iFramesOnlyTagFound, - ), - ]; - - break; - } - } - })(); - } - - pushOrMergeVariant( - path: string, - reader: Reader | null, - attributes: AttributeList, - isKeyFrameOnly: boolean, - ) { - const existing = this.variants.find(v => v.path === path); - if (existing) { - // Sometimes the same path exists multiple times, so let's just aggregate the data then - // (instead of showing the variant twice) - existing._attributes.merge(attributes); - existing._isKeyFrameOnly = isKeyFrameOnly; - } else { - this.variants.push(new M3u8ManifestVariant( - this, - path, - reader, - attributes, - isKeyFrameOnly, - )); - } - } - - override async getVariants() { - await this.readMetadata(); - return this.variants; - } -} - -export class M3u8ManifestVariant extends ManifestInputVariant { - _attributes: AttributeList; - _isKeyFrameOnly: boolean; - _parser: M3u8Parser; +export class HlsSegmentedInput extends SegmentedInput { + _demuxer: HlsDemuxer; _lineReader: LineReader; - _segments: ManifestInputSegment[] = []; + _segments: Segment[] = []; _nextSegmentDuration: number | null = null; _nextSegmentTitle: string | null = null; _accumulatedTime = 0; @@ -167,107 +19,31 @@ export class M3u8ManifestVariant extends ManifestInputVariant { _mutex = new AsyncMutex(); _currentKey: SegmentEncryptionInfo | null = null; _nextSequenceNumber = 0; - _currentFirstSegment: ManifestInputSegment | null = null; - _currentInitSegment: ManifestInputSegment | null = null; + _currentFirstSegment: Segment | null = null; + _currentInitSegment: Segment | null = null; _lastByteRangeEnd: number | null = null; _nextByteRange: { offset: number; length: number } | null = null; /** @internal */ constructor( - parser: M3u8Parser, + demuxer: HlsDemuxer, path: string, reader: Reader | null, - attributes: AttributeList, - isKeyFrameOnly: boolean, ) { - super(parser._input, path); + super(demuxer.input, path); - this._attributes = attributes; - this._isKeyFrameOnly = isKeyFrameOnly; - this._parser = parser; + this._demuxer = demuxer; if (reader) { this._lineReader = new LineReader(() => reader, canIgnoreLine); } else { this._lineReader = new LineReader(async () => { - const source = await this.input._getSourceUncached(this.path); + const source = await this._demuxer.input._getSourceUncached({ path: this.path }); return new Reader(source); }, canIgnoreLine); } } - get metadata() { - const codecStrings = this._getCodecStrings(); - const codecs = codecStrings.map(x => inferCodecFromCodecString(x)).filter(Boolean) as MediaCodec[]; - - return { - name: this._attributes.get('name'), - bitrate: this._attributes.getAsNumber('bandwidth'), - averageBitrate: this._attributes.getAsNumber('average-bandwidth'), - codecs, - codecStrings, - resolution: this._getResolution(), - frameRate: this._attributes.getAsNumber('frame-rate'), - isKeyFrameOnly: this._isKeyFrameOnly, - }; - } - - get groupId() { - return this._attributes.get('group-id'); - } - - get associatedGroups() { - const groups: AssociatedGroup[] = []; - - const videoGroupId = this._attributes.get('video'); - if (videoGroupId) { - groups.push({ id: videoGroupId, type: 'video' }); - } - - const audioGroupId = this._attributes.get('audio'); - if (audioGroupId) { - groups.push({ id: audioGroupId, type: 'audio' }); - } - - const subtitlesGroupId = this._attributes.get('subtitles'); - if (subtitlesGroupId) { - groups.push({ id: subtitlesGroupId, type: 'subtitles' }); - } - - const closedCaptionsGroupId = this._attributes.get('closed-captions'); - if (closedCaptionsGroupId) { - groups.push({ id: closedCaptionsGroupId, type: 'closed-captions' }); - } - - return groups; - } - - _getCodecStrings() { - const value = this._attributes.get('codecs'); - if (!value) { - return []; - } - - return value.split(',').map(x => x.trim()).filter(x => x); - } - - _getResolution() { - const value = this._attributes.get('resolution'); - if (!value) { - return null; - } - - const match = value.match(/^(\d+)x(\d+)$/); - if (!match) { - return null; - } - - return { - width: Number(match[1]), - height: Number(match[2]), - }; - } - async getFirstSegment() { if (this._segments.length === 0) { await this._readNextSegment(); @@ -287,7 +63,7 @@ export class M3u8ManifestVariant extends ManifestInputVariant { return this._segments[index]!; } - async getNextSegment(segment: ManifestInputSegment) { + async getNextSegment(segment: Segment) { const index = this._segments.indexOf(segment); if (index === -1) { throw new Error('Segment was not created by this variant.'); @@ -305,7 +81,7 @@ export class M3u8ManifestVariant extends ManifestInputVariant { return this._segments[index + 1] ?? null; } - async getPreviousSegment(segment: ManifestInputSegment): Promise { + async getPreviousSegment(segment: Segment): Promise { const index = this._segments.indexOf(segment); if (index === -1) { throw new Error('Segment was not created by this variant.'); @@ -365,13 +141,13 @@ export class M3u8ManifestVariant extends ManifestInputVariant { } const fullPath = joinPaths(this.path, line); - const location: ManifestInputSegmentLocation = { + const location: SegmentLocation = { path: fullPath, offset: this._nextByteRange?.offset ?? 0, length: this._nextByteRange?.length ?? null, }; - const segment = new ManifestInputSegment( + const segment = new Segment( this, location, this._accumulatedTime, @@ -423,7 +199,7 @@ export class M3u8ManifestVariant extends ManifestInputVariant { } const fullPath = joinPaths(this.path, uri); - const location: ManifestInputSegmentLocation = { + const location: SegmentLocation = { path: fullPath, offset: this._nextByteRange?.offset ?? 0, length: this._nextByteRange?.length ?? null, @@ -434,7 +210,7 @@ export class M3u8ManifestVariant extends ManifestInputVariant { throw new Error('IV attribute must be set on #EXT-X-KEY tag preceding the #EXT-X-MAP tag.'); } - const segment = new ManifestInputSegment( + const segment = new Segment( this, location, this._accumulatedTime, @@ -553,60 +329,3 @@ export class M3u8ManifestVariant extends ManifestInputVariant { } } } - -const canIgnoreLine = (line: string) => line.length === 0 || (line.startsWith('#') && !line.startsWith('#EXT')); - -class AttributeList { - _attributes: Record = {}; - - constructor(str: string) { - let key = ''; - let value = ''; - let inValue = false; - let inQuotes = false; - - for (let i = 0; i < str.length; i++) { - const char = str[i]!; - - if (char === '"') { - inQuotes = !inQuotes; - } else if (char === '=' && !inValue && !inQuotes) { - inValue = true; - } else if (char === ',' && !inQuotes) { - if (key) { - this._attributes[key.toLowerCase()] = value; - } - - key = ''; - value = ''; - inValue = false; - } else if (inValue) { - value += char; - } else { - key += char; - } - } - - if (key) { - this._attributes[key.toLowerCase()] = value; - } - } - - get(name: string) { - return this._attributes[name.toLowerCase()] ?? null; - } - - getAsNumber(name: string) { - const value = this.get(name); - if (value === null) { - return null; - } - - const num = Number(value); - return Number.isFinite(num) ? num : null; - } - - merge(other: AttributeList) { - Object.assign(this._attributes, other._attributes); - } -} diff --git a/src/index.ts b/src/index.ts index 4f92765..88d617b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,6 +119,9 @@ export { Rectangle, Rotation, SetRequired, + asc, + desc, + prefer, } from './misc'; export { TrackType, @@ -173,6 +176,7 @@ export { InputVideoTrack, InputAudioTrack, PacketStats, + TrackNotHydratedError, } from './input-track'; export { EncodedPacket, @@ -225,13 +229,5 @@ export { AttachedFile, TrackDisposition, } from './metadata'; -export { - ManifestInput, - ManifestInputOptions, -} from './manifest-input'; -export { - M3U8, - ALL_MANIFEST_FORMATS, -} from './manifest-input-format'; // 🐡🦔 diff --git a/src/input-format.ts b/src/input-format.ts index 804a87b..e7cb1a6 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -32,6 +32,7 @@ import { readAscii, readBytes, readU32Be } from './reader'; import { FlacDemuxer } from './flac/flac-demuxer'; import { MpegTsDemuxer } from './mpeg-ts/mpeg-ts-demuxer'; import { TS_PACKET_SIZE } from './mpeg-ts/mpeg-ts-misc'; +import { HlsDemuxer } from './hls/hls-demuxer'; /** * Base class representing an input media file format. @@ -563,6 +564,37 @@ export class MpegTsInputFormat extends InputFormat { } } +export class HlsInputFormat extends InputFormat { + async _canReadInput(input: Input) { + let slice = input._reader.requestSlice(0, 7); + if (slice instanceof Promise) slice = await slice; + if (!slice) return false; + + const isM3u8 = readAscii(slice, 7) === '#EXTM3U'; + if (!isM3u8) { + return false; + } + + if (typeof input._source !== 'function') { + throw new TypeError('HLS inputs require `InputOptions.source` to be a function.'); + } + + return true; + } + + _createDemuxer(input: Input) { + return new HlsDemuxer(input); + } + + get name() { + return 'HTTP Live Streaming (HLS)'; + } + + get mimeType() { + return 'application/vnd.apple.mpegurl'; + } +} + export class VirtualInputFormat extends InputFormat { /** @internal */ _createDemuxerFn: (input: Input) => Demuxer; @@ -657,10 +689,17 @@ export const FLAC = /* #__PURE__ */ new FlacInputFormat(); */ export const MPEG_TS = /* #__PURE__ */ new MpegTsInputFormat(); +/** + * HLS input format singleton. + * @group Input formats + * @public + */ +export const HLS = /* #__PURE__ */ new HlsInputFormat(); + /** * List of all input format singletons. If you don't need to support all input formats, you should specify the * formats individually for better tree shaking. * @group Input formats * @public */ -export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS, MPEG_TS]; +export const ALL_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS, MPEG_TS]; diff --git a/src/input-track.ts b/src/input-track.ts index 4c31acd..d9030bb 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -11,11 +11,10 @@ import { determineVideoPacketType } from './codec-data'; import { customAudioDecoders, customVideoDecoders } from './custom-coder'; import { Input } from './input'; import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink'; -import { assert, Rational, Rotation, simplifyRational } from './misc'; +import { assert, MaybePromise, NonFunctionKeys, Rational, Rotation, simplifyRational } from './misc'; import { TrackType } from './output'; import { EncodedPacket, PacketType } from './packet'; import { TrackDisposition } from './metadata'; -import { ManifestInputVariant } from './manifest-input-variant'; /** * Contains aggregate statistics about the encoded packets of a track. @@ -40,13 +39,19 @@ export interface InputTrackBacking { getLanguageCode(): string; getTimeResolution(): number; getDisposition(): TrackDisposition; - getVariant(): ManifestInputVariant | null; + getGroupId(): number; + getPairingMask(): bigint; + getBitrate(): number | null; + getAverageBitrate(): number | null; getFirstPacket(options: PacketRetrievalOptions): Promise; getPacket(timestamp: number, options: PacketRetrievalOptions): Promise; getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise; getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise; getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise; + + isHydrated?(): boolean; + hydrate?(): Promise; } /** @@ -59,6 +64,8 @@ export abstract class InputTrack { readonly input: Input; /** @internal */ _backing: InputTrackBacking; + /** @internal */ + _hydrationPromise: Promise | null = null; /** @internal */ constructor(input: Input, backing: InputTrackBacking) { @@ -104,6 +111,14 @@ export abstract class InputTrack { return this._backing.getNumber(); } + get groupId() { + return this._backing.getGroupId(); + } + + get pairingMask() { + return this._backing.getPairingMask(); + } + /** * The identifier of the codec used internally by the container. It is not homogenized by Mediabunny * and depends entirely on the container format. @@ -146,8 +161,12 @@ export abstract class InputTrack { return this._backing.getDisposition(); } - get variant() { - return this._backing.getVariant(); + get bitrate() { + return this._backing.getBitrate(); + } + + get averageBitrate() { + return this._backing.getAverageBitrate(); } /** @@ -208,6 +227,113 @@ export abstract class InputTrack { : 0, }; } + + get isHydrated() { + return this._backing.isHydrated?.() ?? true; + } + + hydrate(): Promise { + if (this.isHydrated || !this._backing.hydrate) { + return Promise.resolve(); + } + + return this._hydrationPromise ??= this._backing.hydrate(); + } + + canBePairedWith(otherTrack: InputTrack | null) { + if (!otherTrack) { + return true; + } + + return this.input === otherTrack.input + && this.groupId !== otherTrack.groupId // This also prevents the track from being paired with itself + && (this.pairingMask & otherTrack.pairingMask) !== 0n; + } + + async getPairableTracks(query?: TrackQuery) { + const tracks = await this.input.getTracks(); + return queryTracks(tracks.filter(x => this.canBePairedWith(x)), query); + } + + async pluckPairableTrack(query?: TrackQuery) { + return (await this.getPairableTracks(query))[0]; + } + + async getPairableVideoTracks(query?: TrackQuery) { + const tracks = await this.getPairableTracks({ + filter: track => track.isVideoTrack(), + }); + return queryTracks(tracks as InputVideoTrack[], query); + } + + async pluckPairableVideoTrack(query?: TrackQuery) { + 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 pluckPairableAudioTrack(query?: TrackQuery) { + return (await this.getPairableAudioTracks(query))[0] ?? null; + } + + async getPrimaryPairableVideoTrack(query?: TrackQuery) { + return this.input.getPrimaryVideoTrack(mergeTrackQueries({ + filter: track => track.canBePairedWith(this), + }, query)); + } + + async getPrimaryPairableAudioTrack(query?: TrackQuery) { + return this.input.getPrimaryAudioTrack(mergeTrackQueries({ + filter: track => track.canBePairedWith(this), + }, query)); + } + + hasPairableTrack(predicate?: (track: InputTrack) => boolean) { + assert(this.input._tracksCache); + return this.input._tracksCache.some(x => this.canBePairedWith(x) && (!predicate || predicate(x))); + } + + hasPairableVideoTrack(predictate?: (track: InputVideoTrack) => boolean): boolean { + return this.hasPairableTrack(x => + x.isVideoTrack() && (!predictate || predictate(x)), + ); + } + + hasPairableAudioTrack(predictate?: (track: InputAudioTrack) => boolean): boolean { + return this.hasPairableTrack(x => + x.isAudioTrack() && (!predictate || predictate(x)), + ); + } + + getUnhydrated>(key: K): this[K] | null { + try { + return this[key]; + } catch (error) { + if (error instanceof TrackNotHydratedError) { + return null; + } + + throw error; + } + } + + resolve>(key: K): MaybePromise { + try { + return this[key]; + } catch (error) { + if (error instanceof TrackNotHydratedError) { + return this.hydrate() + .then(() => this[key]); + } + + throw error; + } + } } export interface InputVideoTrackBacking extends InputTrackBacking { @@ -216,6 +342,8 @@ export interface InputVideoTrackBacking extends InputTrackBacking { getCodedHeight(): number; getSquarePixelWidth(): number; getSquarePixelHeight(): number; + getDisplayWidth?(): number | null; + getDisplayHeight?(): number | null; getRotation(): Rotation; getColorSpace(): Promise; canBeTransparent(): Promise; @@ -230,22 +358,14 @@ export interface InputVideoTrackBacking extends InputTrackBacking { export class InputVideoTrack extends InputTrack { /** @internal */ override _backing: InputVideoTrackBacking; - - /** - * The pixel aspect ratio of the track's frames, as a rational number in its reduced form. Most videos use - * square pixels (1:1). - */ - readonly pixelAspectRatio: Rational; + /** @internal */ + _pixelAspectRatioCache: Rational | null = null; /** @internal */ constructor(input: Input, backing: InputVideoTrackBacking) { super(input, backing); this._backing = backing; - this.pixelAspectRatio = simplifyRational({ - num: this._backing.getSquarePixelWidth() * this._backing.getCodedHeight(), - den: this._backing.getSquarePixelHeight() * this._backing.getCodedWidth(), - }); } get type(): TrackType { @@ -281,14 +401,35 @@ export class InputVideoTrack extends InputTrack { return this._backing.getSquarePixelHeight(); } + /** + * The pixel aspect ratio of the track's frames, as a rational number in its reduced form. Most videos use + * square pixels (1:1). + */ + get pixelAspectRatio() { + return this._pixelAspectRatioCache ??= simplifyRational({ + num: this._backing.getSquarePixelWidth() * this._backing.getCodedHeight(), + den: this._backing.getSquarePixelHeight() * this._backing.getCodedWidth(), + }); + } + /** 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; } /** 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; } @@ -468,3 +609,113 @@ 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 getUnhydrated()' + + ' method.', + ) { + 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 bbf3c7f..deeea26 100644 --- a/src/input.ts +++ b/src/input.ts @@ -8,13 +8,31 @@ import { Demuxer } from './demuxer'; import { InputFormat } from './input-format'; -import { assert, polyfillSymbolDispose } from './misc'; +import { + InputAudioTrack, + InputTrack, + InputVideoTrack, + mergeTrackQueries, + queryTracks, + TrackQuery, +} from './input-track'; +import { arrayArgmin, arrayCount, assert, desc, MaybePromise, polyfillSymbolDispose, prefer } from './misc'; import { Reader } from './reader'; import { Source } from './source'; polyfillSymbolDispose(); const UNSUPPORTED_INPUT_FORMAT_MESSAGE = 'Input has an unsupported or unrecognizable format.'; +export const DEFAULT_SOURCE_CACHE_GROUP = 1; +export const ENCRYPTION_KEY_CACHE_GROUP = 2; + +export type SourceRequest = { + path: string; +}; + +const sourceRequestsAreEqual = (a: SourceRequest, b: SourceRequest) => { + return a.path === b.path; +}; /** * The options for creating an Input object. @@ -25,7 +43,8 @@ export type InputOptions = { /** A list of supported formats. If the source file is not of one of these formats, then it cannot be read. */ formats: InputFormat[]; /** The source from which data will be read. */ - source: S; + source: S | ((request: SourceRequest) => MaybePromise); + entryPath?: string; initInput?: Input; }; @@ -36,19 +55,32 @@ export type InputOptions = { */ export class Input implements Disposable { /** @internal */ - _source: S; + _source: InputOptions['source']; /** @internal */ _formats: InputFormat[]; /** @internal */ _initInput: Input | null; /** @internal */ + _entryPath: string | null; + /** @internal */ _demuxerPromise: Promise | null = null; /** @internal */ _format: InputFormat | null = null; /** @internal */ _reader!: Reader; /** @internal */ + _tracksCache: InputTrack[] | null = null; + /** @internal */ _disposed = false; + /** @internal */ + _nextSourceCacheAge = 0; + /** @internal */ + _sourceCache: { + request: SourceRequest; + sourcePromise: Promise; + age: number; + cacheGroup: number; + }[] = []; /** True if the input has been disposed. */ get disposed() { @@ -66,25 +98,82 @@ export class Input implements Disposable { if (!Array.isArray(options.formats) || options.formats.some(x => !(x instanceof InputFormat))) { throw new TypeError('options.formats must be an array of InputFormat.'); } - if (!(options.source instanceof Source)) { - throw new TypeError('options.source must be a Source.'); + if (!(options.source instanceof Source) && typeof options.source !== 'function') { + throw new TypeError('options.source must be a Source or a function that returns a Source.'); } - if (options.source._disposed) { + if (options.source instanceof Source && options.source._disposed) { throw new TypeError('options.source must not be disposed.'); } + if (typeof options.source === 'function' && options.entryPath === undefined) { + throw new TypeError('options.entryPath must be provided when options.source is a function.'); + } if (options.initInput !== undefined && !(options.initInput instanceof Input)) { throw new TypeError('options.initInput, when provided, must be an Input.'); } + if (options.entryPath !== undefined && typeof options.entryPath !== 'string') { + throw new TypeError('options.entryPath, when provided, must be a string.'); + } this._formats = options.formats; this._source = options.source; this._initInput = options.initInput ?? null; + this._entryPath = options.entryPath ?? null; + } + + async _getSourceUncached(request: SourceRequest) { + assert(typeof this._source === 'function'); + + const source = await this._source(request); + if (!(source instanceof Source)) { + throw new TypeError('The source function must return a Source.'); + } + if (source._disposed) { + throw new TypeError('The returned Source must not be disposed.'); + } + + return source; + } + + _getSourceCached(request: SourceRequest, cacheGroup = DEFAULT_SOURCE_CACHE_GROUP) { + const cachedEntry = this._sourceCache.find(x => + x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request), + ); + if (cachedEntry) { + cachedEntry.age++; + return cachedEntry.sourcePromise; + } + + const sourcePromise = Promise.resolve(this._getSourceUncached(request)); + this._sourceCache.push({ + request, + sourcePromise, + age: this._nextSourceCacheAge++, + cacheGroup, + }); + + const MAX_SOURCE_CACHE_SIZE = 4; + const count = arrayCount(this._sourceCache, x => x.cacheGroup === cacheGroup); + + if (count > MAX_SOURCE_CACHE_SIZE) { + const minAgeIndex = arrayArgmin(this._sourceCache, x => x.cacheGroup === cacheGroup ? x.age : Infinity); + this._sourceCache.splice(minAgeIndex, 1); + } + + return sourcePromise; } /** @internal */ _getDemuxer() { return this._demuxerPromise ??= (async () => { - this._reader = new Reader(this._source); + let source: Source; + if (this._source instanceof Source) { + source = this._source; + } else { + assert(this._entryPath !== null); + source = await this._getSourceUncached({ path: this._entryPath }); + } + + this._reader = new Reader(source); for (const format of this._formats) { const canRead = await format._canReadInput(this); @@ -103,6 +192,7 @@ export class Input implements Disposable { * constructor. */ get source() { + // TODO throw if function or some shit? return this._source; } @@ -159,33 +249,58 @@ export class Input implements Disposable { } /** Returns the list of all tracks of this input file. */ - async getTracks() { + async getTracks(query?: TrackQuery) { const demuxer = await this._getDemuxer(); - return demuxer.getTracks(); + const tracks = this._tracksCache ??= await demuxer.getTracks(); + return queryTracks(tracks, query); + } + + async pluckTrack(query?: TrackQuery) { + return (await this.getTracks(query))[0]; } /** Returns the list of all video tracks of this input file. */ - async getVideoTracks() { + async getVideoTracks(query?: TrackQuery) { const tracks = await this.getTracks(); - return tracks.filter(x => x.isVideoTrack()); + return queryTracks(tracks.filter(x => x.isVideoTrack()) as InputVideoTrack[], query); + } + + async pluckVideoTrack(query?: TrackQuery) { + return (await this.getVideoTracks(query))[0] ?? null; } /** Returns the list of all audio tracks of this input file. */ - async getAudioTracks() { + async getAudioTracks(query?: TrackQuery) { const tracks = await this.getTracks(); - return tracks.filter(x => x.isAudioTrack()); + return queryTracks(tracks.filter(x => x.isAudioTrack()) as InputAudioTrack[], query); + } + + async pluckAudioTrack(query?: TrackQuery) { + return (await this.getAudioTracks(query))[0] ?? null; } /** Returns the primary video track of this input file, or null if there are no video tracks. */ - async getPrimaryVideoTrack() { - const tracks = await this.getTracks(); - return tracks.find(x => x.isVideoTrack()) ?? null; + getPrimaryVideoTrack(query?: TrackQuery) { + return this.pluckVideoTrack(mergeTrackQueries(query, { + sortBy: track => [ + prefer(track.disposition.default), + prefer(track.hasPairableAudioTrack()), + desc(track.bitrate), + ], + })); } /** Returns the primary audio track of this input file, or null if there are no audio tracks. */ - async getPrimaryAudioTrack() { - const tracks = await this.getTracks(); - return tracks.find(x => x.isAudioTrack()) ?? null; + async getPrimaryAudioTrack(query?: TrackQuery) { + const videoTrack = await this.getPrimaryVideoTrack(); + + return this.pluckAudioTrack(mergeTrackQueries(query, { + sortBy: track => [ + prefer(track.canBePairedWith(videoTrack)), + prefer(track.disposition.default), + desc(track.bitrate), + ], + })); } /** Returns the full MIME type of this input file, including track codecs. */ @@ -218,8 +333,14 @@ export class Input implements Disposable { this._disposed = true; - this._source._disposed = true; - this._source._dispose(); + if (this._source instanceof Source) { + this._source._disposed = true; + this._source._dispose(); + } else { + // TODO + // TODO + throw new Error('TODO'); + } } /** diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 638205f..52f8f0b 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -324,9 +324,6 @@ export class IsobmffDemuxer extends Demuxer { this.moovSlice = moovSlice; this.readContiguousBoxes(this.moovSlice); - // Put default tracks first - this.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default)); - for (const track of this.tracks) { // Modify the edit list offset based on the previous segment durations. They are in different // timescales, so we first convert to seconds and then into the track timescale. @@ -2543,7 +2540,19 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { return this.internalTrack.disposition; } - getVariant() { + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { + return null; + } + + getAverageBitrate() { return null; } diff --git a/src/manifest-input-format.ts b/src/manifest-input-format.ts deleted file mode 100644 index da0e316..0000000 --- a/src/manifest-input-format.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * 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 { M3u8Parser } from './m3u8/m3u8-parser'; -import { ManifestInput } from './manifest-input'; -import { ManifestParser } from './manifest-parser'; -import { readAscii } from './reader'; - -export abstract class ManifestInputFormat { - abstract _canReadManifestInput(input: ManifestInput): Promise; - abstract _createParser(input: ManifestInput): ManifestParser; -} - -export class M3u8ManifestInputFormat extends ManifestInputFormat { - async _canReadManifestInput(input: ManifestInput) { - let slice = input._entryReader.requestSlice(0, 7); - if (slice instanceof Promise) slice = await slice; - if (!slice) return false; - - return readAscii(slice, 7) === '#EXTM3U'; - } - - _createParser(input: ManifestInput) { - return new M3u8Parser(input); - } -} - -export const M3U8 = /* #__PURE__ */ new M3u8ManifestInputFormat(); - -export const ALL_MANIFEST_FORMATS = [M3U8]; diff --git a/src/manifest-input.ts b/src/manifest-input.ts deleted file mode 100644 index 923f897..0000000 --- a/src/manifest-input.ts +++ /dev/null @@ -1,189 +0,0 @@ -/*! - * 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 { Input } from './input'; -import { InputFormat, VirtualInputFormat } from './input-format'; -import { ManifestInputFormat } from './manifest-input-format'; -import { ManifestParser } from './manifest-parser'; -import { arrayArgmin, assert, MaybePromise, polyfillSymbolDispose } from './misc'; -import { Reader } from './reader'; -import { NullSource, Source } from './source'; -import { ManifestInputVariant } from './manifest-input-variant'; -import { InputAggregateDemuxer } from './aggregate-demuxer'; - -polyfillSymbolDispose(); - -export type ManifestInputOptions = { - entryPath: string; - getSource: (path: string) => MaybePromise; - manifestFormats: ManifestInputFormat[]; - mediaFormats: InputFormat[]; -}; - -export class ManifestInput implements Disposable { - _entryPath: string; - _getSourceUncached: (path: string) => MaybePromise; - _manifestFormats: ManifestInputFormat[]; - _mediaFormats: InputFormat[]; - _parserPromise: Promise | null = null; - _format: ManifestInputFormat | null = null; - _entryReader!: Reader; - _disposed = false; - _encryptionKeyReaders = new Map>(); - _nextSourceCacheAge = 0; - _sourceCache: { - path: string; - sourcePromise: Promise; - age: number; - }[] = []; - - get disposed() { - return this._disposed; - } - - constructor(options: ManifestInputOptions) { - if (!options || typeof options !== 'object') { - throw new TypeError('options must be an object.'); - } - if (typeof options.entryPath !== 'string') { - throw new TypeError('options.entryPath must be a string.'); - } - if (typeof options.getSource !== 'function') { - throw new TypeError('options.getSource must be a function.'); - } - if ( - !Array.isArray(options.manifestFormats) - || options.manifestFormats.some(x => !(x instanceof ManifestInputFormat)) - ) { - throw new TypeError('options.manifestFormats must be an array of ManifestInputFormat.'); - } - if ( - !Array.isArray(options.mediaFormats) - || options.mediaFormats.some(x => !(x instanceof InputFormat)) - ) { - throw new TypeError('options.mediaFormats must be an array of InputFormat.'); - } - - this._entryPath = options.entryPath; - this._manifestFormats = options.manifestFormats; - this._mediaFormats = options.mediaFormats; - - this._getSourceUncached = async (path) => { - const source = await options.getSource(path); - if (!(source instanceof Source)) { - throw new TypeError('The getSource function must return a Source.'); - } - if (source._disposed) { - throw new TypeError('The Source returned by getSource must not be disposed.'); - } - - return source; - }; - } - - _getParser() { - return this._parserPromise ??= (async () => { - const entrySource = await this._getSourceUncached(this._entryPath); - this._entryReader = new Reader(entrySource); - - for (const format of this._manifestFormats) { - const canRead = await format._canReadManifestInput(this); - if (canRead) { - this._format = format; - return format._createParser(this); - } - } - - throw new Error('Manifest input has an unsupported or unrecognizable format.'); - })(); - } - - async getFormat() { - await this._getParser(); - assert(this._format!); - return this._format; - } - - async getVariants(): Promise { - const parser = await this._getParser(); - const variants = await parser.getVariants(); - - const sorted = [...variants] - .sort((a, b) => { - // Variants with unknown bitrate come last - return (b.metadata.bitrate ?? b.metadata.averageBitrate ?? -Infinity) - - (a.metadata.bitrate ?? a.metadata.averageBitrate ?? -Infinity); - }); - - return sorted; - } - - async getPrimaryVariant() { - const variants = await this.getVariants(); - return variants[0] ?? null; - } - - async toInput() { - // Create one "mega input" that contains all tracks from all variants - const variants = await this.getVariants(); - const subInputs = variants.map(v => v.toInput()); - - return new Input({ - source: new NullSource(), - formats: [new VirtualInputFormat(input => new InputAggregateDemuxer(input, subInputs))], - }); - } - - _getSourceCached(path: string) { - const cachedEntry = this._sourceCache.find(x => x.path === path); - if (cachedEntry) { - cachedEntry.age++; - return cachedEntry.sourcePromise; - } - - const sourcePromise = Promise.resolve(this._getSourceUncached(path)); - this._sourceCache.push({ - path, - sourcePromise, - age: this._nextSourceCacheAge++, - }); - - const MAX_SOURCE_CACHE_SIZE = 4; - if (this._sourceCache.length > MAX_SOURCE_CACHE_SIZE) { - const minAgeIndex = arrayArgmin(this._sourceCache, x => x.age); - this._sourceCache.splice(minAgeIndex, 1); - } - - return sourcePromise; - } - - _getEncryptionKeyReader(path: string) { - let cachedEntry = this._encryptionKeyReaders.get(path); - if (cachedEntry) { - return cachedEntry; - } - - cachedEntry = Promise.resolve(this._getSourceUncached(path)) - .then(keySource => new Reader(keySource)); - this._encryptionKeyReaders.set(path, cachedEntry); - - return cachedEntry; - } - - dispose() { - if (this._disposed) { - return; - } - - this._disposed = true; - } - - [Symbol.dispose]() { - this.dispose(); - } -} diff --git a/src/manifest-parser.ts b/src/manifest-parser.ts deleted file mode 100644 index c72f148..0000000 --- a/src/manifest-parser.ts +++ /dev/null @@ -1,22 +0,0 @@ -/*! - * 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 { ManifestInput } from './manifest-input'; -import { ManifestInputVariant } from './manifest-input-variant'; - -export class ManifestParser { - _input: ManifestInput; - - constructor(input: ManifestInput) { - this._input = input; - } - - getVariants(): Promise { - throw new Error('Not implemented.'); - } -} diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 5d5902d..06da2d6 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -543,9 +543,6 @@ export class MatroskaDemuxer extends Demuxer { } } - // Put default tracks first - this.currentSegment.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default)); - // Now, let's distribute the cue points to the tracks const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x])); @@ -1945,10 +1942,6 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { return this.internalTrack.languageCode; } - getVariant() { - return null; - } - getTimeResolution() { return this.internalTrack.segment.timestampFactor; } @@ -1957,6 +1950,22 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { return this.internalTrack.disposition; } + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { + return null; + } + + getAverageBitrate() { + return null; + } + async getFirstPacket(options: PacketRetrievalOptions) { return this.performClusterLookup( null, diff --git a/src/misc.ts b/src/misc.ts index 301e978..387aeb8 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -932,6 +932,21 @@ export const arrayArgmin = (array: T[], getValue: (item: T) => number): numbe return minIndex; }; +export const arrayArgmax = (array: T[], getValue: (item: T) => number): number => { + let maxIndex = -1; + let maxValue = -Infinity; + + for (let i = 0; i < array.length; i++) { + const value = getValue(array[i]!); + if (value > maxValue) { + maxValue = value; + maxIndex = i; + } + } + + return maxIndex; +}; + /** * A rational number; a ratio of two integers. * @group Miscellaneous @@ -997,3 +1012,19 @@ export const validateRectangle = (rect: Rectangle, propertyPath: string) => { throw new TypeError(`${propertyPath}.height must be a non-negative integer.`); } }; + +export const asc = (value: number | null) => { + return value ?? Infinity; // nulls last +}; + +export const desc = (value: number | null) => { + return -(value ?? -Infinity); // nulls last +}; + +export const prefer = (value: boolean) => { + return -value; +}; + +export type NonFunctionKeys = { + [K in keyof T]-?: T[K] extends ((...args: never[]) => unknown) ? never : K +}[keyof T]; diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index 0f7f6ae..39a455b 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -217,6 +217,22 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame; } + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { + return null; + } + + getAverageBitrate() { + return null; + } + getName() { return null; } @@ -249,10 +265,6 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { }; } - getVariant() { - return null; - } - async getDecoderConfig(): Promise { assert(this.demuxer.firstFrameHeader); diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 3e9a2da..d5ea43e 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -1025,7 +1025,7 @@ const readPesPacket = ( } as T extends true ? TimestampedPesPacket : PesPacket; }; -export abstract class MpegTsTrackBacking implements InputTrackBacking { +abstract class MpegTsTrackBacking implements InputTrackBacking { packetBuffers = new WeakMap(); /** Used for recreating PacketBuffers if necessary. */ packetSectionStarts = new WeakMap(); @@ -1079,7 +1079,19 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking { return TIMESCALE; } - getVariant() { + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { + return null; + } + + getAverageBitrate() { return null; } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 6100a62..15a260d 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -444,6 +444,22 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return this.bitstream.sampleRate; } + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { + return null; + } + + getAverageBitrate() { + return null; + } + getCodec() { return this.bitstream.codecInfo.codec; } @@ -452,10 +468,6 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return null; } - getVariant() { - return null; - } - async getDecoderConfig(): Promise { assert(this.bitstream.codecInfo.codec); diff --git a/src/reader.ts b/src/reader.ts index 7040e15..7c0c006 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -338,6 +338,7 @@ export class LineReader { ignore?: (line: string) => boolean; reader: Reader | null = null; textDecoder = new TextDecoder(); + currentLineNumber = 0; // 1-based readPos = 0; reachedEnd = false; lineBuffer = ''; @@ -372,8 +373,17 @@ export class LineReader { if (!slice || slice.length === 0) { this.reachedEnd = true; const line = this.lineBuffer.trim(); + this.lineBuffer = ''; - return line || null; + if (line) { + this.currentLineNumber++; + } + + if (!line || this.ignore?.(line)) { + return null; + } + + return line; } const bytes = readBytes(slice, slice.length); @@ -400,6 +410,7 @@ export class LineReader { const line = this.lineBuffer.slice(0, newlineIndex).trim(); this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1); + this.currentLineNumber++; if (this.ignore?.(line)) { continue; diff --git a/src/manifest-input-segment.ts b/src/segment.ts similarity index 78% rename from src/manifest-input-segment.ts rename to src/segment.ts index 7b9f4b5..0cb4d22 100644 --- a/src/manifest-input-segment.ts +++ b/src/segment.ts @@ -1,8 +1,7 @@ import { AES_128_BLOCK_SIZE, createAesDecryptStream } from './aes'; -import { Input } from './input'; -import { ManifestInputVariant } from './manifest-input-variant'; +import { ENCRYPTION_KEY_CACHE_GROUP, Input } from './input'; +import { SegmentedInput } from './segmented-input'; import { arrayArgmin, assert } from './misc'; -import { fs } from './node'; import { readBytes, Reader } from './reader'; import { ReadableStreamSource, Source } from './source'; @@ -13,31 +12,31 @@ export type SegmentEncryptionInfo = { keyFormat: string; }; -export type ManifestInputSegmentLocation = { +export type SegmentLocation = { path: string; offset: number; length: number | null; }; -export class ManifestInputSegment { - readonly variant: ManifestInputVariant; - readonly location: ManifestInputSegmentLocation; +export class Segment { + readonly variant: SegmentedInput; + readonly location: SegmentLocation; readonly relativeTimestamp: number; readonly duration: number; readonly title: string | null; readonly encryption: SegmentEncryptionInfo | null; - readonly firstSegment: ManifestInputSegment | null; - readonly initSegment: ManifestInputSegment | null; + readonly firstSegment: Segment | null; + readonly initSegment: Segment | null; constructor( - variant: ManifestInputVariant, - location: ManifestInputSegmentLocation, + variant: SegmentedInput, + location: SegmentLocation, relativeTimestamp: number, duration: number, title: string | null, encryption: SegmentEncryptionInfo | null, - firstSegment: ManifestInputSegment | null, - initSegment: ManifestInputSegment | null, + firstSegment: Segment | null, + initSegment: Segment | null, ) { this.variant = variant; this.location = location; @@ -67,14 +66,14 @@ export class ManifestInputSegment { const needsSlice = this.location.offset > 0 || this.location.length !== null; if (!this.encryption) { - source = await this.variant.input._getSourceCached(this.location.path); + source = await this.variant.input._getSourceCached({ path: this.location.path }); if (needsSlice) { source = source.slice(this.location.offset, this.location.length ?? undefined); } } else { assert(this.encryption.iv); - let ciphertextSource = await this.variant.input._getSourceCached(this.location.path); + let ciphertextSource = await this.variant.input._getSourceCached({ path: this.location.path }); if (needsSlice) { // Slice before decrypting ciphertextSource = ciphertextSource.slice(this.location.offset, this.location.length ?? undefined); @@ -83,7 +82,11 @@ export class ManifestInputSegment { const ciphertextReader = new Reader(ciphertextSource); const stream = createAesDecryptStream(ciphertextReader, async () => { - const keyReader = await this.variant.input._getEncryptionKeyReader(this.encryption!.keyUri); + const keySource = await this.variant.input._getSourceCached( + { path: this.encryption!.keyUri }, + ENCRYPTION_KEY_CACHE_GROUP, + ); + const keyReader = new Reader(keySource); const keySlice = await keyReader.requestSlice(0, AES_128_BLOCK_SIZE); if (!keySlice) { throw new Error('Invalid AES-128 key; expected at least 16 bytes of data.'); @@ -100,7 +103,7 @@ export class ManifestInputSegment { return new Input({ source, - formats: this.variant.input._mediaFormats, + formats: this.variant.input._formats, initInput: initInput ?? undefined, }); })(); diff --git a/src/manifest-input-variant.ts b/src/segmented-input.ts similarity index 82% rename from src/manifest-input-variant.ts rename to src/segmented-input.ts index 64b2f05..c346922 100644 --- a/src/manifest-input-variant.ts +++ b/src/segmented-input.ts @@ -18,15 +18,14 @@ import { InputVideoTrack, InputVideoTrackBacking, } from './input-track'; -import { ManifestInput } from './manifest-input'; -import { ManifestInputSegment } from './manifest-input-segment'; +import { Segment } from './segment'; import { PacketRetrievalOptions } from './media-sink'; import { MetadataTags, TrackDisposition } from './metadata'; import { arrayCount, assert, Rotation } from './misc'; import { EncodedPacket } from './packet'; import { NullSource } from './source'; -export type ManifestInputVariantMetadata = { +export type SegmentedInputMetadata = { name: string | null; bitrate: number | null; // doc block: this refers to the _peak_ bitrate averageBitrate: number | null; @@ -42,36 +41,33 @@ export type AssociatedGroup = { type: 'video' | 'audio' | 'subtitles' | 'closed-captions'; }; -export abstract class ManifestInputVariant { - readonly input: ManifestInput; +export abstract class SegmentedInput { + readonly input: Input; readonly path: string; + otherInputLol: Input | null = null; /** @internal */ _nextInputCacheAge = 0; /** @internal */ _inputCache: { - segment: ManifestInputSegment; + segment: Segment; inputPromise: Promise; // We store the promise so it's immediately available in the cache age: number; }[] = []; /** @internal */ - constructor(input: ManifestInput, path: string) { + constructor(input: Input, path: string) { this.input = input; this.path = path; } - abstract get metadata(): ManifestInputVariantMetadata; - abstract get groupId(): string | null; - abstract get associatedGroups(): AssociatedGroup[]; - - abstract getFirstSegment(): Promise; - abstract getSegmentAt(timestamp: number): Promise; - abstract getNextSegment(segment: ManifestInputSegment): Promise; - abstract getPreviousSegment(segment: ManifestInputSegment): Promise; + abstract getFirstSegment(): Promise; + abstract getSegmentAt(timestamp: number): Promise; + abstract getNextSegment(segment: Segment): Promise; + abstract getPreviousSegment(segment: Segment): Promise; async* segments(startTimestamp?: number) { - let currentSegment: ManifestInputSegment | null; + let currentSegment: Segment | null; if (startTimestamp !== undefined) { currentSegment = await this.getSegmentAt(startTimestamp); @@ -86,20 +82,20 @@ export abstract class ManifestInputVariant { } toInput() { - return new Input({ + return this.otherInputLol ??= new Input({ source: new NullSource(), - formats: [new VirtualInputFormat(input => new ManifestInputVariantDemuxer(input, this))], + formats: [new VirtualInputFormat(() => new SegmentedInputDemuxer(this.input, this))], }); } } -class ManifestInputVariantDemuxer extends Demuxer { - variant: ManifestInputVariant; +class SegmentedInputDemuxer extends Demuxer { + variant: SegmentedInput; tracksPromise: Promise | null = null; - firstSegment: ManifestInputSegment | null = null; - firstSegmentFirstTimestamps = new WeakMap(); + firstSegment: Segment | null = null; + firstSegmentFirstTimestamps = new WeakMap(); - constructor(input: Input, variant: ManifestInputVariant) { + constructor(input: Input, variant: SegmentedInput) { super(input); this.variant = variant; @@ -116,11 +112,11 @@ class ManifestInputVariantDemuxer extends Demuxer { } async getMetadataTags(): Promise { - return {}; // todo? + throw new Error('Unreachable'); } async getMimeType(): Promise { - return ''; // todo? + throw new Error('Unreachable'); } async getTracks(): Promise { @@ -140,14 +136,14 @@ class ManifestInputVariantDemuxer extends Demuxer { tracks.push(new InputVideoTrack( this.input, - new ManifestInputVariantInputVideoTrackBacking(track, this, number), + new SegmentedInputInputVideoTrackBacking(track, this, number), )); } else if (track.type === 'audio') { const number = arrayCount(tracks, x => x.type === 'audio') + 1; tracks.push(new InputAudioTrack( this.input, - new ManifestInputVariantInputAudioTrackBacking(track, this, number), + new SegmentedInputInputAudioTrackBacking(track, this, number), )); } } @@ -156,7 +152,7 @@ class ManifestInputVariantDemuxer extends Demuxer { })(); } - async getMediaOffset(segment: ManifestInputSegment, input: Input) { + async getMediaOffset(segment: Segment, input: Input) { const firstSegment = segment.firstSegment ?? segment; let firstSegmentFirstTimestamp: number; @@ -192,18 +188,18 @@ class ManifestInputVariantDemuxer extends Demuxer { } type PacketInfo = { - segment: ManifestInputSegment; + segment: Segment; track: InputTrack; sourcePacket: EncodedPacket; }; -class ManifestInputVariantInputTrackBacking implements InputTrackBacking { +class SegmentedInputInputTrackBacking implements InputTrackBacking { firstInputTrack: InputTrack; - demuxer: ManifestInputVariantDemuxer; + demuxer: SegmentedInputDemuxer; packetInfos = new WeakMap(); number: number; - constructor(firstInputTrack: InputTrack, demuxer: ManifestInputVariantDemuxer, number: number) { + constructor(firstInputTrack: InputTrack, demuxer: SegmentedInputDemuxer, number: number) { this.firstInputTrack = firstInputTrack; this.demuxer = demuxer; this.number = number; @@ -213,6 +209,14 @@ class ManifestInputVariantInputTrackBacking implements InputTrackBacking { return this.firstInputTrack._backing.getId(); } + getGroupId(): number { + return this.firstInputTrack._backing.getGroupId(); + } + + getPairingMask(): bigint { + return this.firstInputTrack._backing.getPairingMask(); + } + getNumber(): number { return this.number; } @@ -241,11 +245,15 @@ class ManifestInputVariantInputTrackBacking implements InputTrackBacking { return this.firstInputTrack._backing.getTimeResolution(); } - getVariant(): ManifestInputVariant | null { - return this.demuxer.variant; + getBitrate(): number | null { + return this.firstInputTrack._backing.getBitrate(); } - async createAdjustedPacket(packet: EncodedPacket, segment: ManifestInputSegment, track: InputTrack) { + getAverageBitrate(): number | null { + return this.firstInputTrack._backing.getAverageBitrate(); + } + + async createAdjustedPacket(packet: EncodedPacket, segment: Segment, track: InputTrack) { const mediaOffset = await this.demuxer.getMediaOffset(segment, track.input); const modified = packet.clone({ @@ -302,7 +310,7 @@ class ManifestInputVariantInputTrackBacking implements InputTrackBacking { return this.createAdjustedPacket(nextPacket, info.segment, info.track); } - let currentSegment: ManifestInputSegment | null = info.segment; + let currentSegment: Segment | null = info.segment; while (true) { const nextSegment = await this.demuxer.variant.getNextSegment(currentSegment); if (!nextSegment) { @@ -378,8 +386,8 @@ class ManifestInputVariantInputTrackBacking implements InputTrackBacking { } } -class ManifestInputVariantInputVideoTrackBacking - extends ManifestInputVariantInputTrackBacking +class SegmentedInputInputVideoTrackBacking + extends SegmentedInputInputTrackBacking implements InputVideoTrackBacking { override firstInputTrack!: InputVideoTrack; @@ -395,6 +403,14 @@ class ManifestInputVariantInputVideoTrackBacking return this.firstInputTrack._backing.getCodedHeight(); } + getSquarePixelWidth(): number { + return this.firstInputTrack._backing.getSquarePixelWidth(); + } + + getSquarePixelHeight(): number { + return this.firstInputTrack._backing.getSquarePixelHeight(); + } + getRotation(): Rotation { return this.firstInputTrack._backing.getRotation(); } @@ -412,8 +428,8 @@ class ManifestInputVariantInputVideoTrackBacking } } -class ManifestInputVariantInputAudioTrackBacking - extends ManifestInputVariantInputTrackBacking +class SegmentedInputInputAudioTrackBacking + extends SegmentedInputInputTrackBacking implements InputAudioTrackBacking { override firstInputTrack!: InputAudioTrack; diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 95966ad..c846972 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -394,11 +394,23 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.audioInfo.sampleRate; } - getName() { + getGroupId() { + return this.getId(); + } + + getPairingMask() { + return 1n; + } + + getBitrate() { return null; } - getVariant() { + getAverageBitrate() { + return null; + } + + getName() { return null; } diff --git a/testfiles_temp/entry-apple.m3u8 b/testfiles_temp/entry-apple.m3u8 new file mode 100644 index 0000000..fed8ece --- /dev/null +++ b/testfiles_temp/entry-apple.m3u8 @@ -0,0 +1,152 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-INDEPENDENT-SEGMENTS + + +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a1",NAME="English",LANGUAGE="en-US",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2",URI="a1/prog_index.m3u8" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a2",NAME="English",LANGUAGE="en-US",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="6",URI="a2/prog_index.m3u8" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a3",NAME="English",LANGUAGE="en-US",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="6",URI="a3/prog_index.m3u8" + + +#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,INSTREAM-ID="CC1" + + +#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,FORCED=NO,URI="s1/en/prog_index.m3u8" + + +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=928091,BANDWIDTH=1015727,CODECS="avc1.640028",RESOLUTION=1920x1080,URI="tp5/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=731514,BANDWIDTH=760174,CODECS="avc1.64001f",RESOLUTION=1280x720,URI="tp4/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=509153,BANDWIDTH=520162,CODECS="avc1.64001f",RESOLUTION=960x540,URI="tp3/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=176942,BANDWIDTH=186651,CODECS="avc1.64001f",RESOLUTION=640x360,URI="tp2/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=90796,BANDWIDTH=95410,CODECS="avc1.64001f",RESOLUTION=480x270,URI="tp1/iframe_index.m3u8" + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2190673,BANDWIDTH=2523597,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v5/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=8052613,BANDWIDTH=9873268,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v9/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6133114,BANDWIDTH=7318337,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v8/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4681537,BANDWIDTH=5421720,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v7/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3183969,BANDWIDTH=3611257,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v6/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1277747,BANDWIDTH=1475903,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v4/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=890848,BANDWIDTH=1017705,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v3/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=533420,BANDWIDTH=582820,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v2/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=303898,BANDWIDTH=339404,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v1/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2413172,BANDWIDTH=2746096,CODECS="avc1.640020,ac-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v5/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=8275112,BANDWIDTH=10095767,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v9/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6355613,BANDWIDTH=7540836,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v8/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4904036,BANDWIDTH=5644219,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v7/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3406468,BANDWIDTH=3833756,CODECS="avc1.640020,ac-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v6/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1500246,BANDWIDTH=1698402,CODECS="avc1.64001f,ac-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v4/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1113347,BANDWIDTH=1240204,CODECS="avc1.64001f,ac-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v3/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=755919,BANDWIDTH=805319,CODECS="avc1.64001f,ac-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v2/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=526397,BANDWIDTH=561903,CODECS="avc1.64001f,ac-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v1/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2221172,BANDWIDTH=2554096,CODECS="avc1.640020,ec-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v5/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=8083112,BANDWIDTH=9903767,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v9/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6163613,BANDWIDTH=7348836,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v8/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4712036,BANDWIDTH=5452219,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v7/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3214468,BANDWIDTH=3641756,CODECS="avc1.640020,ec-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v6/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1308246,BANDWIDTH=1506402,CODECS="avc1.64001f,ec-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v4/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=921347,BANDWIDTH=1048204,CODECS="avc1.64001f,ec-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v3/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=563919,BANDWIDTH=613319,CODECS="avc1.64001f,ec-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v2/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=334397,BANDWIDTH=369903,CODECS="avc1.64001f,ec-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v1/prog_index.m3u8 + + +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=287207,BANDWIDTH=328352,CODECS="hvc1.2.4.L123.B0",RESOLUTION=1920x1080,URI="tp10/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=216605,BANDWIDTH=226274,CODECS="hvc1.2.4.L123.B0",RESOLUTION=1280x720,URI="tp9/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=154000,BANDWIDTH=159037,CODECS="hvc1.2.4.L123.B0",RESOLUTION=960x540,URI="tp8/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=90882,BANDWIDTH=92800,CODECS="hvc1.2.4.L123.B0",RESOLUTION=640x360,URI="tp7/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=50569,BANDWIDTH=51760,CODECS="hvc1.2.4.L123.B0",RESOLUTION=480x270,URI="tp6/iframe_index.m3u8" + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1966314,BANDWIDTH=2164328,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v14/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6105163,BANDWIDTH=6664228,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v18/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4801073,BANDWIDTH=5427899,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v17/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3441312,BANDWIDTH=4079770,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v16/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2635933,BANDWIDTH=2764701,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v15/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1138612,BANDWIDTH=1226255,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v13/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=829339,BANDWIDTH=901770,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v12/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=522229,BANDWIDTH=548927,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v11/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=314941,BANDWIDTH=340713,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v10/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2188813,BANDWIDTH=2386827,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v14/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6327662,BANDWIDTH=6886727,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v18/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=5023572,BANDWIDTH=5650398,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v17/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3663811,BANDWIDTH=4302269,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v16/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2858432,BANDWIDTH=2987200,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v15/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1361111,BANDWIDTH=1448754,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v13/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1051838,BANDWIDTH=1124269,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v12/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=744728,BANDWIDTH=771426,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v11/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=537440,BANDWIDTH=563212,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v10/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1996813,BANDWIDTH=2194827,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v14/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6135662,BANDWIDTH=6694727,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v18/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4831572,BANDWIDTH=5458398,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v17/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3471811,BANDWIDTH=4110269,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v16/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2666432,BANDWIDTH=2795200,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v15/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1169111,BANDWIDTH=1256754,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v13/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=859838,BANDWIDTH=932269,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v12/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=552728,BANDWIDTH=579426,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v11/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=345440,BANDWIDTH=371212,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v10/prog_index.m3u8 + + + + diff --git a/testfiles_temp/entry.m3u8 b/testfiles_temp/entry.m3u8 index 98b3ca5..4cb310a 100644 --- a/testfiles_temp/entry.m3u8 +++ b/testfiles_temp/entry.m3u8 @@ -9,3 +9,7 @@ url_4/193039199_mp4_h264_aac_7.m3u8 url_6/193039199_mp4_h264_aac_hq_7.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=6221600,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=1920x1080,NAME="1080" url_8/193039199_mp4_h264_aac_fhd_7.m3u8 + + + +https://test-streams.mux.dev/x36xhzz/url_0/193039199_mp4_h264_aac_hd_7.m3u8 \ No newline at end of file diff --git a/todo.txt b/todo.txt index 770ad43..c40d0e1 100644 --- a/todo.txt +++ b/todo.txt @@ -9,4 +9,7 @@ Also: for robustness, do a different track matching algorithm for hls playback. - EXT-X-MEDIA (DONE, but surface more metadata from it, like language and shit) - should it be passed down to the tracks?? - getSegments() API? Any point in partially reading the playlist file? Idk -- Add warnings when piping m3u8 or dash shit into Input +- Timestamp across variants; i think the date should actually be used. Make the timestamp relative to the date? How does that play with timeResolution? + +- add comment that addTracks by default returns in the order in the file. Same with track.number +- ALL_FORMATS but for HLS only \ No newline at end of file