diff --git a/docs/guide/reading-hls.md b/docs/guide/reading-hls.md index b0ed735..ca45467 100644 --- a/docs/guide/reading-hls.md +++ b/docs/guide/reading-hls.md @@ -233,6 +233,35 @@ This makes cross-track synchronization trivial. To know if a track's timestamps await track.isRelativeToUnixEpoch(); // => boolean ``` +### Disabling Unix offsets + +If you don't want Mediabunny to offset packet timestamps to be in Unix time, you can set `offsetTimestampsByDateTime` to `false` in the input format options: +```ts +const input = new Input({ + // ... + formatOptions: { + hls: { + offsetTimestampsByDateTime: false, + }, + }, +}); +``` + +This way, track and packet timestamps behave as if no `#EXT-X-PROGRAM-DATE-TIME` tags existed. This also means that any date time gaps are completely collapsed. + +You will still be able to query the Unix time metadata via a mapping function on the `InputTrack`: +```ts +const firstTimestamp = await inputTrack.getFirstTimestamp(); // => 0 +await inputTrack.getUnixTimeForTimestamp(firstTimestamp); // => 1704067200 (Unix timestamp for 2024-01-01T00:00:00Z) +``` + +This function performs a piecewise-continuous mapping of timestamp space into Unix time space. + +If no wall-clock time information is available, `getUnixTimeForTimestamp()` will return `null`. You can check the presence of Unix time metadata via: +```ts +await inputTrack.hasUnixTimeMapping(); // boolean +``` + ## Live HLS HLS playlists may be live. You can check that a track is live via: diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index 5c78489..d39427f 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -215,6 +215,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index c0e3576..0edc26c 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -595,6 +595,10 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 7e9f588..d0748bb 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -759,6 +759,10 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { return this.delegate(() => this.internalTrack.backingTrack!.isRelativeToUnixEpoch()); } + getUnixTimeForTimestamp(timestamp: number): MaybePromise { + return this.delegate(() => this.internalTrack.backingTrack!.getUnixTimeForTimestamp(timestamp)); + } + getBitrate(): number | null { return this.internalTrack.peakBitrate; } diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 537fcf4..d530bdc 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -141,8 +141,11 @@ export class HlsSegmentedInput extends SegmentedInput { } } + const offsetTimestampsByDateTime = this.input._formatOptions.hls?.offsetTimestampsByDateTime !== false; + let headerRead = false; let accumulatedTime = 0; + let accumulatedUnixTime: number | null = null; let nextSegmentDuration: number | null = null; let currentKey: HlsEncryptionInfo | null = null; let nextSequenceNumber = 0; @@ -189,6 +192,9 @@ export class HlsSegmentedInput extends SegmentedInput { currentFirstSegment = prevLastSegment.firstSegment; currentInitSegment = prevLastSegment.initSegment; lastProgramDateTimeSeconds = prevLastSegment.lastProgramDateTimeSeconds; + accumulatedUnixTime = prevLastSegment.unixEpochTimestamp !== null + ? prevLastSegment.unixEpochTimestamp + prevLastSegment.duration + : null; prevLastSegment = null; } } @@ -235,7 +241,7 @@ export class HlsSegmentedInput extends SegmentedInput { const segment: HlsSegment = { timestamp: accumulatedTime, - relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, + unixEpochTimestamp: accumulatedUnixTime, firstSegment: currentFirstSegment, sequenceNumber: nextSequenceNumber, location, @@ -247,6 +253,9 @@ export class HlsSegmentedInput extends SegmentedInput { currentFirstSegment ??= segment; accumulatedTime += nextSegmentDuration; + if (accumulatedUnixTime !== null) { + accumulatedUnixTime += nextSegmentDuration; + } this.segments.push(segment); } else { @@ -320,7 +329,7 @@ export class HlsSegmentedInput extends SegmentedInput { const segment: HlsSegment = { timestamp: accumulatedTime, - relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, + unixEpochTimestamp: accumulatedUnixTime, firstSegment: null, sequenceNumber: null, location, @@ -478,15 +487,19 @@ export class HlsSegmentedInput extends SegmentedInput { const offset = dateTimeSeconds - lastSegmentEnd; for (const segment of this.segments) { - segment.timestamp += offset; - segment.relativeToUnixEpoch = true; + segment.unixEpochTimestamp = segment.timestamp + offset; + if (offsetTimestampsByDateTime) { + segment.timestamp = segment.unixEpochTimestamp; + } } - - accumulatedTime += offset; } lastProgramDateTimeSeconds = dateTimeSeconds; - accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime + accumulatedUnixTime = dateTimeSeconds; + + if (offsetTimestampsByDateTime) { + accumulatedTime = dateTimeSeconds; // Snap the accumulated time into Unix space + } } else if (line === TAG_DISCONTINUITY) { currentFirstSegment = null; // Note: the init segment is not reset; the #EXT-X-MAP statement simply lasts until the next diff --git a/src/index.ts b/src/index.ts index 3f7e95a..f608e72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -197,6 +197,7 @@ export { IsobmffInputFormat, type IsobmffInputFormatOptions, HlsInputFormat, + type HlsInputFormatOptions, MatroskaInputFormat, Mp3InputFormat, Mp4InputFormat, diff --git a/src/input-format.ts b/src/input-format.ts index 8034621..178b0ce 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -727,6 +727,8 @@ export const HLS_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MP3, ADTS, MPEG_TS]; export type InputFormatOptions = { /** ISOBMFF-specific configuration. */ isobmff?: IsobmffInputFormatOptions; + /** HLS-specific configuration. */ + hls?: HlsInputFormatOptions; }; /** @@ -755,6 +757,26 @@ export type IsobmffInputFormatOptions = { _suppressPsshParsing?: boolean; }; +/** + * Additional HLS input configuration. + * @group Input formats + * @public + */ +export type HlsInputFormatOptions = { + /** + * Whether, in the presence of `#EXT-X-PROGRAM-DATE-TIME` tags, to offset track and packet timestamps to be relative + * to the Unix epoch. + * + * Defaults to `true`, meaning packet timestamps map directly to wall-clock time. This guarantees AV sync across + * multiple tracks, even with gaps present. + * + * When you don't want this mapping, you can set this value to `false`. In addition to timestamps not being Unix + * timestamps anymore, any gaps in the playlist are also naturally removed. When `false`, you can still access the + * wall-clock Unix timestamps via {@link InputTrack.getUnixTimeForTimestamp}. + */ + offsetTimestampsByDateTime?: boolean; +}; + export const validateInputFormatOptions = (options: InputFormatOptions, prefix: string) => { if (!options || typeof options !== 'object') { throw new TypeError(`${prefix}, when provided, must be an object.`); diff --git a/src/input-track.ts b/src/input-track.ts index fcaeb9e..035fba2 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -42,6 +42,7 @@ export interface InputTrackBacking { getLanguageCode(): MaybePromise; getTimeResolution(): MaybePromise; isRelativeToUnixEpoch(): MaybePromise; + getUnixTimeForTimestamp(timestamp: number): MaybePromise; getDisposition(): MaybePromise; getPairingMask(): bigint; getBitrate(): MaybePromise; @@ -203,6 +204,28 @@ export abstract class InputTrack { return this._backing.isRelativeToUnixEpoch(); } + /** + * Returns the Unix time (in seconds since January 1, 1970 00:00:00 UTC) that the given track timestamp (in seconds) + * maps to, or `null` if there is no such mapping. This provides a piecewise-continuous mapping from this track's + * timestamp space into wall-clock time. Such mapping exists, for example, for HLS playlists with + * `#EXT-X-PROGRAM-DATE-TIME` tags present. + * + * This mapping can be available even when {@link InputTrack.isRelativeToUnixEpoch} is `false`, for example for HLS + * streams with program date time information but with {@link HlsInputFormatOptions.offsetTimestampsByDateTime} + * set to `false`. + */ + async getUnixTimeForTimestamp(timestamp: number): Promise { + return this._backing.getUnixTimeForTimestamp(timestamp); + } + + /** + * Whether the track's timestamps can be mapped to Unix wall clock time via + * {@link InputTrack.getUnixTimeForTimestamp}. + */ + async hasUnixTimeMapping(): Promise { + return (await this._backing.getUnixTimeForTimestamp(await this.getFirstTimestamp())) !== null; + } + /** Returns the track's disposition, i.e. information about its intended usage. */ async getDisposition() { return this._backing.getDisposition(); diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 72b142d..dac56e5 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -2811,6 +2811,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getDisposition() { return this.internalTrack.disposition; } diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 87ebece..8cb7403 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -1953,6 +1953,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getDisposition() { return this.internalTrack.disposition; } diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index b32d76e..ebe5426 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -264,6 +264,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 41d4d79..0ac9e33 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -1103,6 +1103,10 @@ abstract class MpegTsTrackBacking implements InputTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 6191e7f..c550beb 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -454,6 +454,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/segmented-input.ts b/src/segmented-input.ts index c38f32a..5f104df 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -41,7 +41,12 @@ export type AssociatedGroup = { export type Segment = { timestamp: number; duration: number; - relativeToUnixEpoch: boolean; + /** + * The Unix time (in seconds) corresponding to this segment's start timestamp, or null if unknown. This is computed + * whenever the source provides wall-clock information (e.g. HLS program date time), even if the segment timestamps + * themselves are not shifted into Unix time space. + */ + unixEpochTimestamp: number | null; firstSegment: Segment | null; }; @@ -97,6 +102,18 @@ export abstract class SegmentedInput { return lastSegment.timestamp + lastSegment.duration; } + async getUnixTimeForTimestamp(timestamp: number): Promise { + let segment = await this.getSegmentAt(timestamp, {}); + segment ??= await this.getFirstSegment({}); + + if (!segment || segment.unixEpochTimestamp === null) { + return null; + } + + const elapsed = timestamp - segment.timestamp; + return segment.unixEpochTimestamp + elapsed; + } + async getTrackBackings(): Promise { return this.trackBackingsPromise ??= (async () => { const backings: InputTrackBacking[] = []; @@ -310,7 +327,11 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { await this.hydrate(); assert(this.segmentedInput.firstSegment); - return this.segmentedInput.firstSegment.relativeToUnixEpoch; + return this.segmentedInput.firstSegment.unixEpochTimestamp === this.segmentedInput.firstSegment.timestamp; + } + + getUnixTimeForTimestamp(timestamp: number) { + return this.segmentedInput.getUnixTimeForTimestamp(timestamp); } getBitrate() { diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index f52231c..1324c6f 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -402,6 +402,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index cd29be4..3aadfc6 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -526,11 +526,14 @@ test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { const tracks = await input.getTracks(); expect((await Promise.all(tracks.map(x => x.isRelativeToUnixEpoch()))).every(x => x)).toBe(true); + expect((await Promise.all(tracks.map(x => x.hasUnixTimeMapping()))).every(x => x)).toBe(true); const track = tracks[0]!; const firstTimestamp = await track.getFirstTimestamp(); expect(firstTimestamp).toBe(Date.parse('2013-05-08T17:40:50Z') / 1000); + expect(await track.getUnixTimeForTimestamp(firstTimestamp)).toBe(firstTimestamp); + const endTimestamp = await track.computeDuration(); expect(endTimestamp).toBe(firstTimestamp + 50); @@ -600,6 +603,49 @@ test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { expect(await audioTrack.isRelativeToUnixEpoch()).toBe(false); }); +test.concurrent('Single-value PDT with unix offsets disabled', { timeout: 15_000 }, async () => { + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/aviion/manifest.m3u8'), + formats: ALL_FORMATS, + formatOptions: { + hls: { + offsetTimestampsByDateTime: false, + }, + }, + }); + + const tracks = await input.getTracks(); + expect((await Promise.all(tracks.map(x => x.isRelativeToUnixEpoch()))).every(x => x)).toBe(false); + expect((await Promise.all(tracks.map(x => x.hasUnixTimeMapping()))).every(x => x)).toBe(true); + + const track = tracks[0]!; + const firstTimestamp = await track.getFirstTimestamp(); + expect(firstTimestamp).toBe(0); + + const endTimestamp = await track.computeDuration(); + expect(endTimestamp).toBe(firstTimestamp + 50); + + const firstPacket = await new EncodedPacketSink(track).getFirstPacket(); + assert(firstPacket); + expect(firstPacket.timestamp).toBe(firstTimestamp); // Kinda obvious check tbh + + const unixStartTime = await track.getUnixTimeForTimestamp(firstTimestamp); + expect(unixStartTime).toBe(Date.parse('2013-05-08T17:40:50Z') / 1000); + + const unixEndTime = await track.getUnixTimeForTimestamp(endTimestamp); + expect(unixEndTime).toBe(Date.parse('2013-05-08T17:41:40Z') / 1000); + + const unixTimeBeforeStart = await track.getUnixTimeForTimestamp(firstTimestamp - 10); + expect(unixTimeBeforeStart).toBe(Date.parse('2013-05-08T17:40:40Z') / 1000); + + const unixTimeAfterEnd = await track.getUnixTimeForTimestamp(endTimestamp + 10); + expect(unixTimeAfterEnd).toBe(Date.parse('2013-05-08T17:41:50Z') / 1000); + + const timestampDt = 0.001; + const unixTimeDt = (await track.getUnixTimeForTimestamp(firstTimestamp + 0.001))! - unixStartTime!; + expect(unixTimeDt).toBeCloseTo(timestampDt); +}); + test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { using input = new Input({ source: new UrlSource('https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8'),