Add HlsInputFormatOptions.offsetTimestampsByDateTime option, add InputTrack.getUnixTimeForTimestamp()

This commit is contained in:
Vanilagy
2026-06-16 16:32:53 +02:00
parent 2f0c040fcd
commit 5b4a5db1a5
16 changed files with 200 additions and 9 deletions
+29
View File
@@ -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:
+4
View File
@@ -215,6 +215,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getPairingMask() {
return 1n;
}
+4
View File
@@ -595,6 +595,10 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getPairingMask() {
return 1n;
}
+4
View File
@@ -759,6 +759,10 @@ abstract class HlsInputTrackBacking implements InputTrackBacking {
return this.delegate(() => this.internalTrack.backingTrack!.isRelativeToUnixEpoch());
}
getUnixTimeForTimestamp(timestamp: number): MaybePromise<number | null> {
return this.delegate(() => this.internalTrack.backingTrack!.getUnixTimeForTimestamp(timestamp));
}
getBitrate(): number | null {
return this.internalTrack.peakBitrate;
}
+20 -7
View File
@@ -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
+1
View File
@@ -197,6 +197,7 @@ export {
IsobmffInputFormat,
type IsobmffInputFormatOptions,
HlsInputFormat,
type HlsInputFormatOptions,
MatroskaInputFormat,
Mp3InputFormat,
Mp4InputFormat,
+22
View File
@@ -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.`);
+23
View File
@@ -42,6 +42,7 @@ export interface InputTrackBacking {
getLanguageCode(): MaybePromise<string>;
getTimeResolution(): MaybePromise<number>;
isRelativeToUnixEpoch(): MaybePromise<boolean>;
getUnixTimeForTimestamp(timestamp: number): MaybePromise<number | null>;
getDisposition(): MaybePromise<TrackDisposition>;
getPairingMask(): bigint;
getBitrate(): MaybePromise<number | null>;
@@ -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<number | null> {
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<boolean> {
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();
+4
View File
@@ -2811,6 +2811,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getDisposition() {
return this.internalTrack.disposition;
}
+4
View File
@@ -1953,6 +1953,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getDisposition() {
return this.internalTrack.disposition;
}
+4
View File
@@ -264,6 +264,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getPairingMask() {
return 1n;
}
+4
View File
@@ -1103,6 +1103,10 @@ abstract class MpegTsTrackBacking implements InputTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getPairingMask() {
return 1n;
}
+4
View File
@@ -454,6 +454,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getPairingMask() {
return 1n;
}
+23 -2
View File
@@ -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<number | null> {
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<InputTrackBacking[]> {
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() {
+4
View File
@@ -402,6 +402,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return false;
}
getUnixTimeForTimestamp() {
return null;
}
getPairingMask() {
return 1n;
}
+46
View File
@@ -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'),