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