diff --git a/dev/demux.html b/dev/demux.html
index f1bdfe0..588e25f 100644
--- a/dev/demux.html
+++ b/dev/demux.html
@@ -18,28 +18,13 @@
});
const manifest = new Mediabunny.Input({
- entryPath: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8',
+ entryPath: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/redundant.m3u8',
source: ({ path }) => new Mediabunny.UrlSource(path),
formats: Mediabunny.ALL_FORMATS,
});
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(tracks)
/*
const manifest = new Mediabunny.ManifestInput({
diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts
index 93fdb5a..7edb005 100644
--- a/src/adts/adts-demuxer.ts
+++ b/src/adts/adts-demuxer.ts
@@ -206,6 +206,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
return sampleRate / SAMPLES_PER_AAC_FRAME;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getGroupId() {
return this.getId();
}
diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts
index 68ba366..4a9c4c3 100644
--- a/src/flac/flac-demuxer.ts
+++ b/src/flac/flac-demuxer.ts
@@ -558,6 +558,10 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.audioInfo.sampleRate;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getGroupId() {
return this.getId();
}
diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts
index 4caf91b..8cbf4f3 100644
--- a/src/hls/hls-demuxer.ts
+++ b/src/hls/hls-demuxer.ts
@@ -80,6 +80,8 @@ export class HlsDemuxer extends Demuxer {
lineNumber: number;
}[] = [];
+ // Let's first iterate through the entire file, collecting all variant streams and media tags
+
while (true) {
let line = this.lineReader.readNextLine();
if (line instanceof Promise) line = await line;
@@ -170,27 +172,11 @@ export class HlsDemuxer extends Demuxer {
.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);
+ // Now, let's process & resolve all variant streams in parallel, mapping each of them to tracks.
- 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 internalTracksByVariant = await Promise.all(variantStreams.map(async (variantStream, i) => {
+ const result: { track: InternalTrack; canMerge: boolean }[] = [];
const codecsList = variantStream.attributes.get('codecs');
let codecStrings: string[];
@@ -198,6 +184,7 @@ export class HlsDemuxer extends Demuxer {
if (codecsList) {
codecStrings = codecsList.split(',').map(x => x.trim());
} else {
+ // No codecs were specified, we need to read the underlying media data
const segmentedInput = this.getSegmentedInputForPath(variantStream.fullPath);
const input = segmentedInput.toInput();
const tracks = await input.getTracks();
@@ -211,8 +198,16 @@ export class HlsDemuxer extends Demuxer {
const videoGroupId = variantStream.attributes.get('video');
const audioGroupId = variantStream.attributes.get('audio');
+ const containsVideoCodecs = codecStrings.some(x =>
+ VIDEO_CODECS.includes(inferCodecFromCodecString(x) as VideoCodec),
+ );
+ const containsAudioCodecs = codecStrings.some(x =>
+ AUDIO_CODECS.includes(inferCodecFromCodecString(x) as AudioCodec),
+ );
+
+ if (videoGroupId !== null && !containsVideoCodecs) {
+ // A video group is linked but no video codec is listed, sigh. Let's resolve the video codec.
- if (videoGroupId !== null) {
if (!videoGroupIds.includes(videoGroupId)) {
throw new Error(
`Invalid M3U8 file; variant stream references video group "${videoGroupId}" which`
@@ -220,32 +215,40 @@ export class HlsDemuxer extends Demuxer {
);
}
- for (const mediaTag of mediaTags) {
+ const matchingVideoMediaTags = mediaTags.filter((mediaTag) => {
const groupId = mediaTag.attributes.get('group-id')!;
const type = mediaTag.attributes.get('type')!;
+ return groupId === videoGroupId && type.toLowerCase() === 'video';
+ });
- if (groupId !== videoGroupId || type.toLowerCase() !== 'video') {
- continue;
+ const additionalCodecStrings = await Promise.all(matchingVideoMediaTags.map(async (tag) => {
+ const uri = tag.attributes.get('uri');
+ if (uri === null) {
+ return null;
}
- 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();
+ 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 (!videoTrack || videoTrack.codec === null) {
+ return null;
}
- }
+
+ const codecParameterString = await videoTrack.getCodecParameterString();
+ assert(codecParameterString !== null);
+ return codecParameterString;
+ }));
+
+ codecStrings.push(
+ ...additionalCodecStrings.filter((x): x is string => x !== null),
+ );
}
- if (audioGroupId !== null) {
+ if (audioGroupId !== null && !containsAudioCodecs) {
+ // An audio group is linked but no audio codec is listed, sigh. Let's resolve the audio codec.
+
if (!audioGroupIds.includes(audioGroupId)) {
throw new Error(
`Invalid M3U8 file; variant stream references audio group "${audioGroupId}" which`
@@ -253,29 +256,35 @@ export class HlsDemuxer extends Demuxer {
);
}
- for (const mediaTag of mediaTags) {
- const groupId = mediaTag.attributes.get('group-id')!;
- const type = mediaTag.attributes.get('type')!;
+ const matchingAudioMediaTags = mediaTags.filter((tag) => {
+ const groupId = tag.attributes.get('group-id')!;
+ const type = tag.attributes.get('type')!;
+ return groupId === audioGroupId && type.toLowerCase() === 'audio';
+ });
- if (groupId !== audioGroupId || type.toLowerCase() !== 'audio') {
- continue;
+ const additionalCodecStrings = await Promise.all(matchingAudioMediaTags.map(async (tag) => {
+ const uri = tag.attributes.get('uri');
+ if (uri === null) {
+ return null;
}
- 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();
+ 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);
- }
+ if (!audioTrack || audioTrack.codec === null) {
+ return null;
}
- }
+
+ const codecParameterString = await audioTrack.getCodecParameterString();
+ assert(codecParameterString !== null);
+ return codecParameterString;
+ }));
+
+ codecStrings.push(
+ ...additionalCodecStrings.filter((x): x is string => x !== null),
+ );
}
// Unique that shit
@@ -290,6 +299,7 @@ export class HlsDemuxer extends Demuxer {
const averageBandwidth = variantStream.attributes.getAsNumber('average-bandwidth');
const name = variantStream.attributes.get('name');
+ // Now, finally, loop over each codec string for the variant and resolve each one to one or more tracks.
for (const codecString of codecStrings) {
const inferredCodec = inferCodecFromCodecString(codecString);
if (inferredCodec === null) {
@@ -321,8 +331,8 @@ export class HlsDemuxer extends Demuxer {
}
}
- addInternalTrack({
- id: internalTracks.length + 1,
+ result.push({ track: {
+ id: -1,
demuxer: this,
inputTrack: null,
backingTrack: null,
@@ -341,12 +351,12 @@ export class HlsDemuxer extends Demuxer {
width,
height,
},
- }, false);
+ }, canMerge: 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.`,
+ `Invalid M3U8 file; variant stream references video group "${videoGroupId}"`
+ + ` which is not defined in any #EXT-X-MEDIA tags.`,
);
}
@@ -371,8 +381,8 @@ export class HlsDemuxer extends Demuxer {
}
}
- addInternalTrack({
- id: internalTracks.length + 1,
+ result.push({ track: {
+ id: -1,
demuxer: this,
inputTrack: null,
backingTrack: null,
@@ -391,7 +401,7 @@ export class HlsDemuxer extends Demuxer {
width,
height,
},
- }, true);
+ }, canMerge: true });
}
}
} else if (AUDIO_CODECS.includes(inferredCodec as AudioCodec)) {
@@ -412,8 +422,8 @@ export class HlsDemuxer extends Demuxer {
? Number(channels)
: null;
- addInternalTrack({
- id: internalTracks.length + 1,
+ result.push({ track: {
+ id: -1,
demuxer: this,
inputTrack: null,
backingTrack: null,
@@ -430,18 +440,18 @@ export class HlsDemuxer extends Demuxer {
info: {
type: 'audio',
numberOfChannels:
- parsedChannels !== null
- && Number.isInteger(parsedChannels)
- && parsedChannels > 0
- ? parsedChannels
- : null,
+ parsedChannels !== null
+ && Number.isInteger(parsedChannels)
+ && parsedChannels > 0
+ ? parsedChannels
+ : null,
},
- }, false);
+ }, canMerge: 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.`,
+ `Invalid M3U8 file; variant stream references audio group "${audioGroupId}"`
+ + ` which is not defined in any #EXT-X-MEDIA tags.`,
);
}
@@ -459,8 +469,8 @@ export class HlsDemuxer extends Demuxer {
? Number(channels)
: null;
- addInternalTrack({
- id: internalTracks.length + 1,
+ result.push({ track: {
+ id: -1,
demuxer: this,
inputTrack: null,
backingTrack: null,
@@ -477,17 +487,45 @@ export class HlsDemuxer extends Demuxer {
info: {
type: 'audio',
numberOfChannels:
- parsedChannels !== null
- && Number.isInteger(parsedChannels)
- && parsedChannels > 0
- ? parsedChannels
- : null,
+ parsedChannels !== null
+ && Number.isInteger(parsedChannels)
+ && parsedChannels > 0
+ ? parsedChannels
+ : null,
},
- }, true);
+ }, canMerge: true });
}
}
}
}
+
+ return result;
+ }));
+
+ 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 {
+ track.id = internalTracks.length + 1;
+ internalTracks.push(track);
+ }
+ };
+
+ for (const variantInternalTracks of internalTracksByVariant) {
+ for (const trackEntry of variantInternalTracks) {
+ addInternalTrack(trackEntry.track, trackEntry.canMerge);
+ }
}
// Order tracks by how they appear in the file
@@ -628,6 +666,14 @@ abstract class HlsInputTrackBacking implements InputTrackBacking {
return this.internalTrack.backingTrack._backing.getTimeResolution();
}
+ getTimestampsAreRelativeToUnixEpoch(): boolean {
+ if (!this.internalTrack.backingTrack) {
+ throw new TrackNotHydratedError();
+ }
+
+ return this.internalTrack.backingTrack._backing.getTimestampsAreRelativeToUnixEpoch();
+ }
+
getBitrate(): number | null {
return this.internalTrack.peakBitrate;
}
diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts
index 631a3e7..40d03e3 100644
--- a/src/hls/hls-segmented-input.ts
+++ b/src/hls/hls-segmented-input.ts
@@ -1,7 +1,7 @@
import { AES_128_BLOCK_SIZE } from '../aes';
import { Segment, SegmentEncryptionInfo, SegmentLocation } from '../segment';
import { SegmentedInput } from '../segmented-input';
-import { toDataView, joinPaths } from '../misc';
+import { toDataView, joinPaths, last } from '../misc';
import { LineReader, Reader } from '../reader';
import { HlsDemuxer } from './hls-demuxer';
import { AttributeList, canIgnoreLine } from './hls-misc';
@@ -10,7 +10,6 @@ const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/;
export class HlsSegmentedInput extends SegmentedInput {
demuxer: HlsDemuxer;
- segments: Segment[] = [];
nextSegmentDuration: number | null = null;
nextSegmentTitle: string | null = null;
accumulatedTime = 0;
@@ -22,6 +21,7 @@ export class HlsSegmentedInput extends SegmentedInput {
currentInitSegment: Segment | null = null;
lastByteRangeEnd: number | null = null;
nextByteRange: { offset: number; length: number } | null = null;
+ lastProgramDateTimeSeconds: number | null = null;
constructor(
demuxer: HlsDemuxer,
@@ -43,6 +43,8 @@ export class HlsSegmentedInput extends SegmentedInput {
}
this.segmentsPromise ??= (async () => {
+ const segments: Segment[] = [];
+
while (true) {
let line = lineReader.readNextLine();
if (line instanceof Promise) line = await line;
@@ -90,13 +92,14 @@ export class HlsSegmentedInput extends SegmentedInput {
this,
location,
this.accumulatedTime,
+ this.lastProgramDateTimeSeconds !== null,
this.nextSegmentDuration,
this.nextSegmentTitle,
key,
this.currentFirstSegment,
this.currentInitSegment,
);
- this.segments.push(segment);
+ segments.push(segment);
this.accumulatedTime += this.nextSegmentDuration;
this.nextSequenceNumber++;
this.currentFirstSegment ??= segment;
@@ -151,6 +154,7 @@ export class HlsSegmentedInput extends SegmentedInput {
this,
location,
this.accumulatedTime,
+ this.lastProgramDateTimeSeconds !== null,
0,
null,
this.currentKey,
@@ -218,13 +222,51 @@ export class HlsSegmentedInput extends SegmentedInput {
this.nextSequenceNumber = number;
} else if (line.startsWith('#EXT-X-BYTERANGE:')) {
this.parseAndUpdateByteRange(line.slice(17));
+ } else if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) {
+ const dateTime = line.slice(25);
+ const dateTimeMs = Date.parse(dateTime);
+
+ if (!Number.isFinite(dateTimeMs)) {
+ continue;
+ }
+
+ const dateTimeSeconds = dateTimeMs / 1000;
+ if (this.lastProgramDateTimeSeconds === dateTimeSeconds) {
+ continue;
+ }
+
+ if (this.lastProgramDateTimeSeconds === null && segments.length > 0) {
+ // "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after
+ // one or more Media Segment URIs, the client SHOULD extrapolate
+ // backward from that tag (using EXTINF durations and/or media
+ // timestamps) to associate dates with those segments."
+ const lastSegment = last(segments)!;
+ const lastSegmentEnd = lastSegment.relativeTimestamp + lastSegment.duration;
+ const offset = dateTimeSeconds - lastSegmentEnd;
+
+ for (const segment of segments) {
+ segment.relativeTimestamp += offset;
+ segment.relativeToUnixEpoch = true;
+ }
+
+ this.accumulatedTime += offset;
+ }
+
+ this.lastProgramDateTimeSeconds = dateTimeSeconds;
+
+ if (Math.abs(this.accumulatedTime - dateTimeSeconds) >= 1) {
+ // Only snap to the datetime if the current time is sufficiently far away from it. If we always
+ // snapped, we'd lose the sub-second accuracy that's often provided by precise
+ // segment durations.
+ this.accumulatedTime = dateTimeSeconds;
+ }
} else if (line.startsWith('#EXT-X-DISCONTINUITY')) {
this.currentFirstSegment = null;
this.currentInitSegment = null;
}
}
- return this.segments;
+ return segments;
})();
}
diff --git a/src/input-track.ts b/src/input-track.ts
index d80c127..bfee119 100644
--- a/src/input-track.ts
+++ b/src/input-track.ts
@@ -38,6 +38,7 @@ export interface InputTrackBacking {
getName(): string | null;
getLanguageCode(): string;
getTimeResolution(): number;
+ getTimestampsAreRelativeToUnixEpoch(): boolean;
getDisposition(): TrackDisposition;
getGroupId(): number;
getPairingMask(): bigint;
@@ -156,6 +157,14 @@ export abstract class InputTrack {
return this._backing.getTimeResolution();
}
+ /**
+ * Whether the timestamps of this track are relative to the Unix epoch (January 1, 1970 00:00:00 UTC). When `true`,
+ * each timestamp maps to a definitive point in time.
+ */
+ get timestampsAreRelativeToUnixEpoch() {
+ return this._backing.getTimestampsAreRelativeToUnixEpoch();
+ }
+
/** The track's disposition, i.e. information about its intended usage. */
get disposition() {
return this._backing.getDisposition();
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index 52f8f0b..b2bb001 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -2536,6 +2536,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return this.internalTrack.timescale;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getDisposition() {
return this.internalTrack.disposition;
}
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index 06da2d6..2fc4ef4 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -1946,6 +1946,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return this.internalTrack.segment.timestampFactor;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getDisposition() {
return this.internalTrack.disposition;
}
diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts
index 39a455b..9afa979 100644
--- a/src/mp3/mp3-demuxer.ts
+++ b/src/mp3/mp3-demuxer.ts
@@ -217,6 +217,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getGroupId() {
return this.getId();
}
diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts
index d5ea43e..eac9bd3 100644
--- a/src/mpeg-ts/mpeg-ts-demuxer.ts
+++ b/src/mpeg-ts/mpeg-ts-demuxer.ts
@@ -1079,6 +1079,10 @@ abstract class MpegTsTrackBacking implements InputTrackBacking {
return TIMESCALE;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getGroupId() {
return this.getId();
}
diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index 15a260d..aa0516f 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -444,6 +444,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
return this.bitstream.sampleRate;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getGroupId() {
return this.getId();
}
diff --git a/src/segment.ts b/src/segment.ts
index 3fd7772..b13cdb2 100644
--- a/src/segment.ts
+++ b/src/segment.ts
@@ -22,6 +22,7 @@ export class Segment {
input: SegmentedInput;
location: SegmentLocation;
relativeTimestamp: number;
+ relativeToUnixEpoch: boolean;
duration: number;
title: string | null;
encryption: SegmentEncryptionInfo | null;
@@ -32,6 +33,7 @@ export class Segment {
input: SegmentedInput,
location: SegmentLocation,
relativeTimestamp: number,
+ relativeToUnixEpoch: boolean,
duration: number,
title: string | null,
encryption: SegmentEncryptionInfo | null,
@@ -41,6 +43,7 @@ export class Segment {
this.input = input;
this.location = location;
this.relativeTimestamp = relativeTimestamp;
+ this.relativeToUnixEpoch = relativeToUnixEpoch;
this.duration = duration;
this.title = title;
this.encryption = encryption;
diff --git a/src/segmented-input.ts b/src/segmented-input.ts
index 5a651f1..acb47f3 100644
--- a/src/segmented-input.ts
+++ b/src/segmented-input.ts
@@ -21,7 +21,7 @@ import {
import { Segment } from './segment';
import { PacketRetrievalOptions } from './media-sink';
import { MetadataTags, TrackDisposition } from './metadata';
-import { arrayCount, assert, binarySearchLessOrEqual, Rotation } from './misc';
+import { arrayCount, assert, binarySearchLessOrEqual, Rotation, roundToMultiple } from './misc';
import { EncodedPacket } from './packet';
import { NullSource } from './source';
@@ -255,6 +255,11 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
return this.firstInputTrack._backing.getTimeResolution();
}
+ getTimestampsAreRelativeToUnixEpoch(): boolean {
+ assert(this.demuxer.firstSegment);
+ return this.demuxer.firstSegment.relativeToUnixEpoch;
+ }
+
getBitrate(): number | null {
return this.firstInputTrack._backing.getBitrate();
}
@@ -267,7 +272,10 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
const mediaOffset = await this.demuxer.getMediaOffset(segment, track.input);
const modified = packet.clone({
- timestamp: packet.timestamp + mediaOffset,
+ timestamp: roundToMultiple(
+ packet.timestamp + mediaOffset,
+ 1 / track.timeResolution,
+ ),
// The 1e8 assumes a max of 100 MB per second, highly unlikely to be hit, so this should guarantee
// monotonically increasing sequence numbers across segments.
sequenceNumber: Math.floor(1e8 * segment.relativeTimestamp) + packet.sequenceNumber,
diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts
index c846972..700bd80 100644
--- a/src/wave/wave-demuxer.ts
+++ b/src/wave/wave-demuxer.ts
@@ -394,6 +394,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.audioInfo.sampleRate;
}
+ getTimestampsAreRelativeToUnixEpoch() {
+ return false;
+ }
+
getGroupId() {
return this.getId();
}
diff --git a/testfiles_temp/entry25.m3u8 b/testfiles_temp/entry25.m3u8
new file mode 100644
index 0000000..48088e4
--- /dev/null
+++ b/testfiles_temp/entry25.m3u8
@@ -0,0 +1,34 @@
+#EXTM3U
+#EXT-X-VERSION:3
+
+#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Chinese",FORCED=NO,AUTOSELECT=YES,URI="media/chinese/ed.m3u8",LANGUAGE="zh"
+#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="French",FORCED=NO,AUTOSELECT=YES,URI="media/french/ed.m3u8",LANGUAGE="fr"
+
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="media/b160000-english.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="sp",NAME="Spanish",DEFAULT=NO,AUTOSELECT=YES,URI="media/b160000-spanish.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="en",NAME="Commentary (eng)",DEFAULT=NO,AUTOSELECT=NO,URI="media/b160000-commentary.m3u8"
+
+#EXT-X-STREAM-INF:BANDWIDTH=2962000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=1280x720,AUDIO="aac",SUBTITLES="subs"
+media/b2962000-video.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=1427000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=768x432,AUDIO="aac",SUBTITLES="subs"
+media/b1427000-video.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=688000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=448x252,AUDIO="aac",SUBTITLES="subs"
+media/b688000-video.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=331000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=284x160,AUDIO="aac",SUBTITLES="subs"
+media/b331000-video.m3u8
+
+#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs_b",NAME="Chinese",FORCED=NO,AUTOSELECT=YES,URI="media_b/chinese/ed.m3u8",LANGUAGE="zh"
+#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs_b",NAME="French",FORCED=NO,AUTOSELECT=YES,URI="media_b/french/ed.m3u8",LANGUAGE="fr"
+
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac_b",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="media_b/b160000-english.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac_b",LANGUAGE="sp",NAME="Spanish",DEFAULT=NO,AUTOSELECT=YES,URI="media_b/b160000-spanish.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac_b",LANGUAGE="en",NAME="Commentary (eng)",DEFAULT=NO,AUTOSELECT=NO,URI="media_b/b160000-commentary.m3u8"
+
+#EXT-X-STREAM-INF:BANDWIDTH=2962000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=1280x720,AUDIO="aac_b",SUBTITLES="subs_b"
+media_b/b2962000-video.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=1427000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=768x432,AUDIO="aac_b",SUBTITLES="subs_b"
+media_b/b1427000-video.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=688000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=448x252,AUDIO="aac_b",SUBTITLES="subs_b"
+media_b/b688000-video.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=331000,NAME="Main",CODECS="avc1.66.30",RESOLUTION=284x160,AUDIO="aac_b",SUBTITLES="subs_b"
+media_b/b331000-video.m3u8