From dc4e55777646b1980b8a119d94a2b2da73fbbd72 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:08:42 +0100 Subject: [PATCH] Read #EXT-X-PROGRAM-DATE-TIME, add Unix epoch-offset timestamps, parallelize HLS master playlist resolution --- dev/demux.html | 19 +-- src/adts/adts-demuxer.ts | 4 + src/flac/flac-demuxer.ts | 4 + src/hls/hls-demuxer.ts | 208 +++++++++++++++++++------------ src/hls/hls-segmented-input.ts | 50 +++++++- src/input-track.ts | 9 ++ src/isobmff/isobmff-demuxer.ts | 4 + src/matroska/matroska-demuxer.ts | 4 + src/mp3/mp3-demuxer.ts | 4 + src/mpeg-ts/mpeg-ts-demuxer.ts | 4 + src/ogg/ogg-demuxer.ts | 4 + src/segment.ts | 3 + src/segmented-input.ts | 12 +- src/wave/wave-demuxer.ts | 4 + testfiles_temp/entry25.m3u8 | 34 +++++ 15 files changed, 263 insertions(+), 104 deletions(-) create mode 100644 testfiles_temp/entry25.m3u8 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