diff --git a/docs/guide/media-sinks.md b/docs/guide/media-sinks.md index aed2ce2..dc39292 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, { + hardwareAcceleration: '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/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/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", 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 057c7da..d0748bb 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; @@ -756,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 4815652..d530bdc 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,16 +128,24 @@ 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; + } } + 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; @@ -182,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; } } @@ -219,7 +232,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, @@ -228,7 +241,7 @@ export class HlsSegmentedInput extends SegmentedInput { const segment: HlsSegment = { timestamp: accumulatedTime, - relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, + unixEpochTimestamp: accumulatedUnixTime, firstSegment: currentFirstSegment, sequenceNumber: nextSequenceNumber, location, @@ -240,6 +253,9 @@ export class HlsSegmentedInput extends SegmentedInput { currentFirstSegment ??= segment; accumulatedTime += nextSegmentDuration; + if (accumulatedUnixTime !== null) { + accumulatedUnixTime += nextSegmentDuration; + } this.segments.push(segment); } else { @@ -299,7 +315,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, @@ -313,7 +329,7 @@ export class HlsSegmentedInput extends SegmentedInput { const segment: HlsSegment = { timestamp: accumulatedTime, - relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, + unixEpochTimestamp: accumulatedUnixTime, firstSegment: null, sequenceNumber: null, location, @@ -375,7 +391,7 @@ export class HlsSegmentedInput extends SegmentedInput { currentKey = { method: 'AES-128', - keyUri: joinPaths(this.path, uri), + keyUri: joinPaths(this.rootPath, uri), iv, keyFormat, }; @@ -471,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 241229d..f608e72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -197,6 +197,7 @@ export { IsobmffInputFormat, type IsobmffInputFormatOptions, HlsInputFormat, + type HlsInputFormatOptions, MatroskaInputFormat, Mp3InputFormat, Mp4InputFormat, @@ -270,6 +271,7 @@ export { EncodedPacketSink, type PacketRetrievalOptions, VideoSampleSink, + type VideoSinkDecoderOptions, type WrappedAudioBuffer, type WrappedCanvas, } from './media-sink'; diff --git a/src/input-format.ts b/src/input-format.ts index 8034621..28eeb7e 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.`); @@ -767,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.`); + } + } }; 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/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); } 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/source.ts b/src/source.ts index 1fb266a..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 @@ -1989,6 +2002,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,12 +2124,12 @@ 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 } }) .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; } 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'),