From 08e3a85370f2229a8ff7c477bd4905a60764c273 Mon Sep 17 00:00:00 2001 From: Tom Yang <82199152+tomyang11@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:26:40 -0700 Subject: [PATCH 1/8] Fix orphaned queued reads when a freed worker slot is stolen concurrently (#404) * Fix orphaned queued reads when a freed worker slot is stolen concurrently ReadOrchestrator.runWorker's finally callback dequeues the oldest queued read and asserts that createWorker succeeds ("we just freed up a worker"). That assumption races: the callback runs on a later microtask than the worker's stop, and concurrent read() calls in that gap can LRU-evict the freed worker and saturate every slot. The assert then throws as an unhandled rejection after the read was removed from the queue but before it was attached to any worker - its pending slices' promises never settle and the awaiting reads hang forever. Observed in production-like load (a 4-source composition player): 25 back-to-back occurrences saturating both workers, leaving clips permanently undecodable. Fix: create the worker first; only dequeue the read once a slot was actually obtained. If every slot is busy, leave the read queued - each running worker drains the queue from this same block when it stops, so the read is picked up by whichever worker stops next. Co-Authored-By: Claude Opus 4.8 * Update logic --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- src/source.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/source.ts b/src/source.ts index 1fb266a..d181a14 100644 --- a/src/source.ts +++ b/src/source.ts @@ -2108,7 +2108,7 @@ class ReadOrchestrator { } }) .finally(() => { - if (worker.running) { + if (worker.running || this.workers.length >= this.options.maxWorkerCount) { // Rare, but can happen with multiple concurrent reads. In this case, don't do anything. return; } From 324fae515356193ee1d5f447a1031cdb14c5900d Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:42:02 +0200 Subject: [PATCH 2/8] Adjusted error management in ReadOrchestrator (closes #405) --- src/source.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/source.ts b/src/source.ts index d181a14..a8975dd 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1989,6 +1989,14 @@ class ReadOrchestrator { } satisfies ReadResult)); } else { // The requested region was satisfied by the cache, but the entire prefetch region was not + promise.catch((error) => { + if (this.disposed) { + return; // Swallow the error + } + + // Nobody's awaiting this result but an errored read is still notable + throw error; + }); } return result; @@ -2103,7 +2111,7 @@ class ReadOrchestrator { if (worker.pendingSlices.length > 0) { worker.pendingSlices.forEach(x => x.reject(error)); // Make sure to propagate any errors worker.pendingSlices.length = 0; - } else { + } else if (!worker.aborted && !this.disposed) { throw error; // So it doesn't get swallowed } }) From 4affad934ea4bafa73f8a53a50d2036e6b761a04 Mon Sep 17 00:00:00 2001 From: Tom Yang <82199152+tomyang11@users.noreply.github.com> Date: Tue, 16 Jun 2026 04:42:24 -0700 Subject: [PATCH 3/8] Add per-sink decoder preferences (hardwareAcceleration, optimizeForLatency) (#406) * Add per-sink decoder preferences to VideoSampleSink and CanvasSink Adds an optional VideoSinkDecoderOptions ({ hardwareAcceleration, optimizeForLatency }) parameter to VideoSampleSink, exposed on CanvasSink via options.decoderOptions, applied to the decoder config before the VideoDecoderWrapper is constructed. Motivation: applications that run many sinks concurrently (multi-track video editors) need to manage hardware decode sessions deliberately - the number of concurrent hardware sessions is OS-limited, undocumented, and exceeding it fails silently on some platforms (macOS VideoToolbox accepts configure() and decode() and simply never outputs). Such an application places overflow sinks on 'prefer-software' explicitly. optimizeForLatency is exposed alongside it since it is the other WebCodecs decoder-config preference an application may want per sink. The override composes with the existing interlaced-AVC Chromium workaround, which runs later and can only strengthen the preference toward software. Validation mirrors decode.ts's validateVideoDecodingConfig. Co-Authored-By: Claude Opus 4.8 * Modify docs --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- docs/guide/media-sinks.md | 11 ++++++++ src/index.ts | 1 + src/media-sink.ts | 57 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/guide/media-sinks.md b/docs/guide/media-sinks.md index aed2ce2..6d32b3b 100644 --- a/docs/guide/media-sinks.md +++ b/docs/guide/media-sinks.md @@ -225,6 +225,11 @@ Create the sink like so: import { VideoSampleSink } from 'mediabunny'; const sink = new VideoSampleSink(videoTrack); + +// Optionally, configure the decoder: +const sink = new VideoSampleSink(videoTrack, { + hardwarePreference: 'prefer-software', +}); ``` #### Single retrieval @@ -363,6 +368,8 @@ type CanvasSinkOptions = { rotation?: 0 | 90 | 180 | 270; crop?: { left: number; top: number; width: number; height: number }; poolSize?: number; + alpha?: boolean; + decoderOptions?: VideoSinkDecoderOptions; }; ``` - `width`\ @@ -380,6 +387,10 @@ type CanvasSinkOptions = { Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to the dimensions of the input video track. Cropping is performed after rotation but before resizing. The crop region is in the _display pixel space_ of the underlying video data. - `poolSize`\ See [Canvas pool](#canvas-pool). +- `alpha`\ + Whether the output canvases should have transparency instead of a black background. Defaults to `false`. Set this to `true` when using this sink to read transparent videos. +- `decoderOptions`\ + Additional preferences for the underlying video decoder. Some examples: ```ts diff --git a/src/index.ts b/src/index.ts index 241229d..3f7e95a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -270,6 +270,7 @@ export { EncodedPacketSink, type PacketRetrievalOptions, VideoSampleSink, + type VideoSinkDecoderOptions, type WrappedAudioBuffer, type WrappedCanvas, } from './media-sink'; diff --git a/src/media-sink.ts b/src/media-sink.ts index c245b16..36f6174 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -1749,6 +1749,42 @@ const colorAlphaMergerWorkerCode = () => { }; }; +/** + * Describes additional decoder preferences for video sinks. + * @group Media sinks + * @public + */ +export type VideoSinkDecoderOptions = { + /** + * A hint that configures the hardware acceleration method of the decoder. This is best left on `'no-preference'`, + * the default. + */ + hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software'; + /** + * Hint that the selected decoder should be configured to minimize the number of packets that have to be decoded + * before video frames are output. + */ + optimizeForLatency?: boolean; +}; + +const validateVideoSinkDecoderOptions = (decoderOptions: VideoSinkDecoderOptions) => { + if (!decoderOptions || typeof decoderOptions !== 'object') { + throw new TypeError('decoderOptions must be an object.'); + } + if ( + decoderOptions.hardwareAcceleration !== undefined + && !['no-preference', 'prefer-hardware', 'prefer-software'].includes(decoderOptions.hardwareAcceleration) + ) { + throw new TypeError( + 'decoderOptions.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or' + + ' \'prefer-software\'.', + ); + } + if (decoderOptions.optimizeForLatency !== undefined && typeof decoderOptions.optimizeForLatency !== 'boolean') { + throw new TypeError('decoderOptions.optimizeForLatency, when provided, must be a boolean.'); + } +}; + /** * A sink that retrieves decoded video samples (video frames) from a video track. * @group Media sinks @@ -1757,16 +1793,20 @@ const colorAlphaMergerWorkerCode = () => { export class VideoSampleSink extends BaseMediaSampleSink { /** @internal */ _track: InputVideoTrack; + /** @internal */ + _decoderOptions: VideoSinkDecoderOptions; /** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */ - constructor(videoTrack: InputVideoTrack) { + constructor(videoTrack: InputVideoTrack, decoderOptions: VideoSinkDecoderOptions = {}) { if (!(videoTrack instanceof InputVideoTrack)) { throw new TypeError('videoTrack must be an InputVideoTrack.'); } + validateVideoSinkDecoderOptions(decoderOptions); super(); this._track = videoTrack; + this._decoderOptions = decoderOptions; } /** @internal */ @@ -1783,10 +1823,16 @@ export class VideoSampleSink extends BaseMediaSampleSink { const codec = await this._track.getCodec(); const rotation = await this._track.getRotation(); - const decoderConfig = await this._track.getDecoderConfig(); + let decoderConfig = await this._track.getDecoderConfig(); const timeResolution = await this._track.getTimeResolution(); assert(codec && decoderConfig); + decoderConfig = { + ...decoderConfig, + hardwareAcceleration: this._decoderOptions.hardwareAcceleration, + optimizeForLatency: this._decoderOptions.optimizeForLatency, + }; + return new VideoDecoderWrapper(onSample, onError, codec, decoderConfig, rotation, timeResolution); } @@ -1903,6 +1949,8 @@ export type CanvasSinkOptions = { * canvas is created each time. */ poolSize?: number; + /** Additional preferences for the underlying video decoder. */ + decoderOptions?: VideoSinkDecoderOptions; }; /** @@ -1982,12 +2030,15 @@ export class CanvasSink { ) { throw new TypeError('poolSize must be a non-negative integer.'); } + if (options.decoderOptions !== undefined) { + validateVideoSinkDecoderOptions(options.decoderOptions); + } this._videoTrack = videoTrack; this._alpha = options.alpha ?? false; this._options = options; this._fit = options.fit ?? 'fill'; - this._videoSampleSink = new VideoSampleSink(videoTrack); + this._videoSampleSink = new VideoSampleSink(videoTrack, options.decoderOptions); this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null); } From b4a675773959905c71b5c2d8a97b9bb19e3bb553 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:43:03 +0200 Subject: [PATCH 4/8] Fix field typo --- docs/guide/media-sinks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/media-sinks.md b/docs/guide/media-sinks.md index 6d32b3b..dc39292 100644 --- a/docs/guide/media-sinks.md +++ b/docs/guide/media-sinks.md @@ -228,7 +228,7 @@ const sink = new VideoSampleSink(videoTrack); // Optionally, configure the decoder: const sink = new VideoSampleSink(videoTrack, { - hardwarePreference: 'prefer-software', + hardwareAcceleration: 'prefer-software', }); ``` From 2f0c040fcda848486ac4dbd0f07d4a061e01d59c Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:53:27 +0200 Subject: [PATCH 5/8] Properly handle subrequests for redirected URLs --- src/hls/hls-demuxer.ts | 5 ++++- src/hls/hls-segmented-input.ts | 17 ++++++++++++----- src/source.ts | 19 ++++++++++++++++--- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 057c7da..7e9f588 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -77,12 +77,15 @@ export class HlsDemuxer extends Demuxer { readMetadata() { return this.metadataPromise ??= (async () => { assert(this.input._rootSource instanceof PathedSource); - const { rootPath } = this.input._rootSource; const slice = await this.input._reader.requestEntireFile(); assert(slice); const lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine }); + // Important: get the root path AFTER reading data to get the final root path, possibly affected by + // redirects. Any follow requests should be related to the redirected path, not the original one. + const { rootPath } = this.input._rootSource; + const variantStreams: { fullPath: string; attributes: AttributeList; diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 4815652..537fcf4 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -20,7 +20,7 @@ import { base64ToBytes, } from '../misc'; import { readAllLines, readBytes, Reader } from '../reader'; -import { CustomPathedSource, ReadableStreamSource, SourceRef, SourceRequest } from '../source'; +import { CustomPathedSource, PathedSource, ReadableStreamSource, SourceRef, SourceRequest } from '../source'; import { HlsDemuxer } from './hls-demuxer'; import { AttributeList, @@ -68,6 +68,7 @@ export type HlsSegmentLocation = { }; export class HlsSegmentedInput extends SegmentedInput { + rootPath: string; demuxer: HlsDemuxer; segments: HlsSegment[] = []; nextLines: string[] | null = null; @@ -84,6 +85,7 @@ export class HlsSegmentedInput extends SegmentedInput { ) { super(demuxer.input, path, trackDeclarations); + this.rootPath = path; this.demuxer = demuxer; this.nextLines = lines; } @@ -126,12 +128,17 @@ export class HlsSegmentedInput extends SegmentedInput { this.nextLines = null; if (!lines) { - using ref = await this.demuxer.input._getSourceUncached({ path: this.path, isRoot: false }); + using ref = await this.demuxer.input._getSourceUncached({ path: this.rootPath, isRoot: false }); const reader = new Reader(ref.source); const slice = await reader.requestEntireFile(); assert(slice); lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine }); + + if (ref.source instanceof PathedSource) { + // Copy back the source's path to become aware of potential redirects + this.rootPath = ref.source.rootPath; + } } let headerRead = false; @@ -219,7 +226,7 @@ export class HlsSegmentedInput extends SegmentedInput { key = { ...key, iv }; } - const fullPath = joinPaths(this.path, line); + const fullPath = joinPaths(this.rootPath, line); const location: HlsSegmentLocation = { path: fullPath, offset: nextByteRange?.offset ?? 0, @@ -299,7 +306,7 @@ export class HlsSegmentedInput extends SegmentedInput { } if (!prevLastSegment) { - const fullPath = joinPaths(this.path, uri); + const fullPath = joinPaths(this.rootPath, uri); const location: HlsSegmentLocation = { path: fullPath, offset: parsedByteRange?.offset ?? 0, @@ -375,7 +382,7 @@ export class HlsSegmentedInput extends SegmentedInput { currentKey = { method: 'AES-128', - keyUri: joinPaths(this.path, uri), + keyUri: joinPaths(this.rootPath, uri), iv, keyFormat, }; diff --git a/src/source.ts b/src/source.ts index a8975dd..7e9acec 100644 --- a/src/source.ts +++ b/src/source.ts @@ -289,10 +289,15 @@ export class SourceRef implements Disposable { */ export abstract class PathedSource extends Source { constructor( - /** The path that points to the root file; the entry file of the media. */ + /** + * The path that points to the root file; the entry file of the media. + * + * This path may be modified by the source to indicate a redirect: an updated path to perform new requests + * relative to. + */ public rootPath: FilePath, /** The callback that is called for each requested file; must return a {@link Source} or {@link SourceRef}. */ - public requestHandler: (request: SourceRequest) => MaybePromise, + public readonly requestHandler: (request: SourceRequest) => MaybePromise, ) { if (typeof rootPath !== 'string') { throw new TypeError('rootPath must be a string.'); @@ -778,7 +783,10 @@ export class UrlSource extends PathedSource { ? url.href : url; - super(urlString, request => new UrlSource(request.path, this._options)); + super( + urlString, + request => new UrlSource(request.path, this._options), + ); this._url = url; this._options = options; @@ -905,6 +913,11 @@ export class UrlSource extends PathedSource { throw new Error(`Error fetching ${String(this._url)}: ${response.status} ${response.statusText}`); } + if (response.redirected) { + // Modify our own root path so that future subrequests get made relative to the redirected URL + this.rootPath = response.url; + } + outer: if (this._orchestrator.fileSize === null) { // See if we can deduce the file size from the response From 5b4a5db1a56f56bfa228c40b13055dc464707402 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:32:53 +0200 Subject: [PATCH 6/8] Add HlsInputFormatOptions.offsetTimestampsByDateTime option, add InputTrack.getUnixTimeForTimestamp() --- docs/guide/reading-hls.md | 29 ++++++++++++++++++++ src/adts/adts-demuxer.ts | 4 +++ src/flac/flac-demuxer.ts | 4 +++ src/hls/hls-demuxer.ts | 4 +++ src/hls/hls-segmented-input.ts | 27 ++++++++++++++----- src/index.ts | 1 + src/input-format.ts | 22 +++++++++++++++ src/input-track.ts | 23 ++++++++++++++++ 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/segmented-input.ts | 25 +++++++++++++++-- src/wave/wave-demuxer.ts | 4 +++ test/node/hls-input.test.ts | 46 ++++++++++++++++++++++++++++++++ 16 files changed, 200 insertions(+), 9 deletions(-) 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'), From 794c97b91a5d62c5e1b3dc8a6b6fcde9554edf99 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:35:34 +0200 Subject: [PATCH 7/8] Bump minor --- package-lock.json | 14 +++++++------- package.json | 2 +- packages/aac-encoder/package.json | 2 +- packages/ac3/package.json | 2 +- packages/flac-encoder/package.json | 2 +- packages/mp3-encoder/package.json | 2 +- packages/server/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 379e9ec..0135c8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.47.0", + "version": "1.48.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.47.0", + "version": "1.48.0", "license": "MPL-2.0", "workspaces": [ ".", @@ -12864,7 +12864,7 @@ }, "packages/aac-encoder": { "name": "@mediabunny/aac-encoder", - "version": "1.47.0", + "version": "1.48.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12879,7 +12879,7 @@ }, "packages/ac3": { "name": "@mediabunny/ac3", - "version": "1.47.0", + "version": "1.48.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12894,7 +12894,7 @@ }, "packages/flac-encoder": { "name": "@mediabunny/flac-encoder", - "version": "1.47.0", + "version": "1.48.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12909,7 +12909,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.47.0", + "version": "1.48.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12924,7 +12924,7 @@ }, "packages/server": { "name": "@mediabunny/server", - "version": "1.47.0", + "version": "1.48.0", "license": "MPL-2.0", "dependencies": { "node-av": "^6.0.0" diff --git a/package.json b/package.json index 4be323d..f11dfc9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.47.0", + "version": "1.48.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/aac-encoder/package.json b/packages/aac-encoder/package.json index 72831c1..15c659a 100644 --- a/packages/aac-encoder/package.json +++ b/packages/aac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/aac-encoder", "author": "Vanilagy", - "version": "1.47.0", + "version": "1.48.0", "description": "AAC encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-aac-encoder.mjs", "module": "./dist/bundles/mediabunny-aac-encoder.mjs", diff --git a/packages/ac3/package.json b/packages/ac3/package.json index cbd2f2d..101a84b 100644 --- a/packages/ac3/package.json +++ b/packages/ac3/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/ac3", "author": "Vanilagy", - "version": "1.47.0", + "version": "1.48.0", "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-ac3.mjs", "module": "./dist/bundles/mediabunny-ac3.mjs", diff --git a/packages/flac-encoder/package.json b/packages/flac-encoder/package.json index 60e2e45..0eeee46 100644 --- a/packages/flac-encoder/package.json +++ b/packages/flac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/flac-encoder", "author": "Vanilagy", - "version": "1.47.0", + "version": "1.48.0", "description": "FLAC encoder extension for Mediabunny, based on libFLAC.", "main": "./dist/bundles/mediabunny-flac-encoder.mjs", "module": "./dist/bundles/mediabunny-flac-encoder.mjs", diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index f9be7f2..944a564 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.47.0", + "version": "1.48.0", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", diff --git a/packages/server/package.json b/packages/server/package.json index 084d9a6..9efdd53 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/server", "author": "Vanilagy", - "version": "1.47.0", + "version": "1.48.0", "description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.", "main": "./dist/bundles/mediabunny-server.cjs", "module": "./dist/bundles/mediabunny-server.mjs", From 53833f6e9404a969bbebe02ee98f73f946ddbfb4 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:36:54 +0200 Subject: [PATCH 8/8] Add missing validation for HlsInputFormatOptions --- src/input-format.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/input-format.ts b/src/input-format.ts index 178b0ce..28eeb7e 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -789,4 +789,15 @@ export const validateInputFormatOptions = (options: InputFormatOptions, prefix: throw new TypeError(`${prefix}.isobmff.resolveKeyId, when provided, must be a function.`); } } + if (options.hls !== undefined) { + if (!options.hls || typeof options.hls !== 'object') { + throw new TypeError(`${prefix}.hls, when provided, must be an object.`); + } + if ( + options.hls.offsetTimestampsByDateTime !== undefined + && typeof options.hls.offsetTimestampsByDateTime !== 'boolean' + ) { + throw new TypeError(`${prefix}.hls.offsetTimestampsByDateTime, when provided, must be a boolean.`); + } + } };