mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 10:53:50 +02:00
Add HlsInputFormatOptions.offsetTimestampsByDateTime option, add InputTrack.getUnixTimeForTimestamp()
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -215,6 +215,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPairingMask() {
|
||||
return 1n;
|
||||
}
|
||||
|
||||
@@ -595,6 +595,10 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPairingMask() {
|
||||
return 1n;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,6 +197,7 @@ export {
|
||||
IsobmffInputFormat,
|
||||
type IsobmffInputFormatOptions,
|
||||
HlsInputFormat,
|
||||
type HlsInputFormatOptions,
|
||||
MatroskaInputFormat,
|
||||
Mp3InputFormat,
|
||||
Mp4InputFormat,
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2811,6 +2811,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return this.internalTrack.disposition;
|
||||
}
|
||||
|
||||
@@ -1953,6 +1953,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return this.internalTrack.disposition;
|
||||
}
|
||||
|
||||
@@ -264,6 +264,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPairingMask() {
|
||||
return 1n;
|
||||
}
|
||||
|
||||
@@ -1103,6 +1103,10 @@ abstract class MpegTsTrackBacking implements InputTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPairingMask() {
|
||||
return 1n;
|
||||
}
|
||||
|
||||
@@ -454,6 +454,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPairingMask() {
|
||||
return 1n;
|
||||
}
|
||||
|
||||
+23
-2
@@ -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() {
|
||||
|
||||
@@ -402,6 +402,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return false;
|
||||
}
|
||||
|
||||
getUnixTimeForTimestamp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPairingMask() {
|
||||
return 1n;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user