diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index 53ca81f..ed3fc4f 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -211,7 +211,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking { return sampleRate / SAMPLES_PER_AAC_FRAME; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index 848580b..d7d3366 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -598,7 +598,7 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.audioInfo.sampleRate; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 549ce4d..503b0c1 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -701,12 +701,12 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { return this.internalTrack.backingTrack._backing.getTimeResolution(); } - getTimestampsAreRelativeToUnixEpoch(): boolean { + isRelativeToUnixEpoch(): boolean { if (!this.internalTrack.backingTrack) { throw new TrackNotHydratedError(); } - return this.internalTrack.backingTrack._backing.getTimestampsAreRelativeToUnixEpoch(); + return this.internalTrack.backingTrack._backing.isRelativeToUnixEpoch(); } getBitrate(): number | null { diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 284d786..958c9ee 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -43,6 +43,7 @@ type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } }; type PlaylistSegment = { path: string; duration: number; + timestamp: number; byteSize: number; byteOffset: number | null; }; @@ -87,13 +88,14 @@ export class HlsMuxer extends Muxer { trackDatas: HlsTrackData[] = []; singleFilePerPlaylist: boolean; isLive: boolean; + isRelativeToUnixEpoch = false; globalTargetDuration: number; playlists: Playlist[] = []; playlistDeclarations: PlaylistDeclaration[] = []; constructor(output: Output, format: HlsOutputFormat) { - if (typeof output._target !== 'function') { + if (!output._targetIsFunction()) { throw new TypeError('HLS outputs require `OutputOptions.target` to be a function.'); } @@ -116,6 +118,16 @@ export class HlsMuxer extends Muxer { } async start(): Promise { + const someRelative = this.output._tracks.some(t => t.metadata.isRelativeToUnixEpoch); + const someNotRelative = this.output._tracks.some(t => !t.metadata.isRelativeToUnixEpoch); + if (someRelative && someNotRelative) { + throw new Error( + 'All tracks must agree on `relativeToUnixEpoch`: some tracks are relative to the Unix epoch and some' + + ' are not.', + ); + } + this.isRelativeToUnixEpoch = someRelative; + // Upon starting, we now need to assign the tracks to separate playlists. This assignment will make use of the // track pairability information provided by the user as well as other metadata specified on the tracks. The // resulting master playlist should preserve track pairability; meaning that all tracks that are pairable @@ -868,6 +880,7 @@ export class HlsMuxer extends Muxer { playlist.initSegment = { path: playlist.singleFile.path, duration: 0, + timestamp: 0, byteSize: 0, byteOffset: 0, }; @@ -889,6 +902,7 @@ export class HlsMuxer extends Muxer { playlist.initSegment = { path, duration: 0, + timestamp: 0, byteSize: 0, byteOffset: null, }; @@ -993,6 +1007,7 @@ export class HlsMuxer extends Muxer { playlist.writtenSegments.push({ path: relativeSegmentPath, duration: segmentDuration, + timestamp: playlist.currentSegmentStartTimestamp, byteSize: segmentSize, byteOffset: playlist.singleFile ? playlist.singleFile.nextOffset @@ -1126,6 +1141,9 @@ export class HlsMuxer extends Muxer { + (playlist.writtenSegments .map(segment => ( `#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec + + (this.isRelativeToUnixEpoch + ? `#EXT-X-PROGRAM-DATE-TIME:${new Date(1000 * segment.timestamp).toISOString()}\n` + : '') + (segment.byteOffset !== null ? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n` : '') diff --git a/src/input-track.ts b/src/input-track.ts index c4289a4..34feb95 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -39,7 +39,7 @@ export interface InputTrackBacking { getName(): string | null; getLanguageCode(): string; getTimeResolution(): number; - getTimestampsAreRelativeToUnixEpoch(): boolean; + isRelativeToUnixEpoch(): boolean; getDisposition(): TrackDisposition; getPairingMask(): bigint; getBitrate(): number | null; @@ -158,8 +158,8 @@ export abstract class InputTrack { * Whether the timestamps of this track are relative to the Unix epoch (January 1, 1970 00:00:00 UTC). When `true`, * each timestamp maps to a definitive point in time. */ - get timestampsAreRelativeToUnixEpoch() { - return this._backing.getTimestampsAreRelativeToUnixEpoch(); + get isRelativeToUnixEpoch() { + return this._backing.isRelativeToUnixEpoch(); } /** The track's disposition, i.e. information about its intended usage. */ diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 05e740e..e139e85 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -2558,7 +2558,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { return this.internalTrack.timescale; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index bdc863a..0edc52e 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -24,7 +24,7 @@ import { import { Muxer } from '../muxer'; import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output'; import { Writer } from '../writer'; -import { BufferTarget, Target } from '../target'; +import { BufferTarget } from '../target'; import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc'; import { IsobmffOutputFormatOptions, IsobmffOutputFormat, MovOutputFormat, CmafOutputFormat } from '../output-format'; import { inlineTimestampRegex, SubtitleConfig, SubtitleCue, SubtitleMetadata } from '../subtitles'; @@ -228,7 +228,7 @@ export class IsobmffMuxer extends Muxer { } if (this.isCmaf) { - if (this.output._initTarget === null) { + if (!this.output._hasInitTarget()) { throw new Error( `CMAF outputs require the initTarget field in OutputOptions to be set; the init segment` + ` will be written to it.`, @@ -236,9 +236,7 @@ export class IsobmffMuxer extends Muxer { } // Set up the init writer to which we'll write the init segment - const initTarget = this.output._initTarget instanceof Target - ? this.output._initTarget - : await this.output._initTarget(); + const initTarget = await this.output._getInitTarget(); const initWriter = new Writer(initTarget); initWriter.start(); diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 34b6efd..74c500d 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -1971,7 +1971,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { return this.internalTrack.segment.timestampFactor; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index 23e6c3c..81e8631 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -289,7 +289,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 02f01e8..9cdcd3b 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -1096,7 +1096,7 @@ abstract class MpegTsTrackBacking implements InputTrackBacking { return TIMESCALE; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index dd171b9..bb9d80a 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -448,7 +448,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return this.bitstream.sampleRate; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/output.ts b/src/output.ts index b0a274b..af3d43e 100644 --- a/src/output.ts +++ b/src/output.ts @@ -190,6 +190,11 @@ export type BaseTrackMetadata = { * If you're not fully sure, make sure to add a buffer of around 33% to make sure you stay below the maximum. */ maximumPacketCount?: number; + /** + * Whether the timestamps of this track are relative to the Unix epoch (January 1, 1970 00:00:00 UTC). When `true`, + * each timestamp maps to a definitive point in time. + */ + isRelativeToUnixEpoch?: boolean; group?: OutputTrackGroup | OutputTrackGroup[]; }; @@ -295,17 +300,19 @@ export class Output< /** The format of the output file. */ readonly format: F; /** @internal */ - _target: T | ((request: TargetRequest) => MaybePromise); + private _target: T | ((request: TargetRequest) => MaybePromise); /** The current state of the output. */ state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending'; /** @internal */ _rootPath: string | null; /** @internal */ - _initTarget: T | (() => MaybePromise) | null; + private _initTarget: T | (() => MaybePromise) | null; /** @internal */ _muxer: Muxer; /** @internal */ + _targets = new Set(); + /** @internal */ _rootWriterPromise: Promise | null = null; /** @internal */ _tracks: OutputTrack[] = []; @@ -359,7 +366,9 @@ export class Output< if (options.target._output) { throw new Error('Target is already used for another output.'); } + options.target._output = this; + this._targets.add(options.target); } if (options.rootPath !== undefined && typeof options.rootPath !== 'string') { throw new TypeError('options.rootPath, when provided, must be a string.'); @@ -381,8 +390,13 @@ export class Output< this.format = options.format; this._target = options.target; - this._rootPath = options.rootPath ?? null; this._initTarget = options.initTarget ?? null; + if (this._initTarget instanceof Target) { + this._initTarget._output = this; + this._targets.add(this._initTarget); + } + + this._rootPath = options.rootPath ?? null; this._muxer = options.format._createMuxer(this); } @@ -390,11 +404,48 @@ export class Output< assert(typeof this._target === 'function'); const target = await this._target(request); + target._output = this; this.emit('target', { target, request }); + if (this.state === 'canceled') { + await target._close(); + } else { + this._targets.add(target); + } + return target; } + async _getInitTarget(): Promise { + assert(this._initTarget !== null); + + if (this._initTarget instanceof Target) { + return this._initTarget; + } + + const target = await this._initTarget(); + target._output = this; + + if (this.state === 'canceled') { + await target._close(); + } else { + this._targets.add(target); + } + + return target; + } + + /** @internal */ + _targetIsFunction() { + return typeof this._target === 'function'; + } + + /** @internal */ + _hasInitTarget() { + return this._initTarget !== null; + } + + /** @internal */ _getRootWriter() { return this._rootWriterPromise ??= (async () => { let target: Target; @@ -672,9 +723,8 @@ export class Output< const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close await Promise.all(promises); - if (this._rootWriterPromise) { - await (await this._rootWriterPromise).close(); - } + await Promise.all([...this._targets].map(target => target._close())); + this._targets.clear(); } finally { release(); } diff --git a/src/segmented-input.ts b/src/segmented-input.ts index a553897..9dcb7d0 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -255,7 +255,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { return this.firstInputTrack._backing.getTimeResolution(); } - getTimestampsAreRelativeToUnixEpoch(): boolean { + isRelativeToUnixEpoch(): boolean { assert(this.demuxer.firstSegment); return this.demuxer.firstSegment.relativeToUnixEpoch; } diff --git a/src/target.ts b/src/target.ts index b04c96b..466bcf3 100644 --- a/src/target.ts +++ b/src/target.ts @@ -659,6 +659,7 @@ export class RangedTarget extends Target { this._baseTarget = baseTarget; this._offset = offset; + this._output = baseTarget._output; } /** @internal */ diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 4376ee3..a642f19 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -401,7 +401,7 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.audioInfo.sampleRate; } - getTimestampsAreRelativeToUnixEpoch() { + isRelativeToUnixEpoch() { return false; } diff --git a/src/writer.ts b/src/writer.ts index 88b3e30..f3cf4c6 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { assert } from './misc'; import { Target } from './target'; export class Writer { @@ -50,12 +51,10 @@ export class Writer { /** Called after muxing has finished. */ async finalize() { - await this.target._finalize(); - } + assert(this.target._output); - /** Closes the writer. */ - async close() { - return this.target._close(); + await this.target._finalize(); + this.target._output._targets.delete(this.target); } private trackedWrites: Uint8Array | null = null; diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index 32b3e84..d1627cb 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -137,7 +137,7 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { expect(sourceCount).toBe(1 + 5 + 5); expect(tracks.every(x => x.isHydrated)).toBe(true); - expect(tracks.every(x => !x.timestampsAreRelativeToUnixEpoch)).toBe(true); + expect(tracks.every(x => !x.isRelativeToUnixEpoch)).toBe(true); for (const track of tracks) { expect(await track.isLive()).toBe(false); @@ -490,7 +490,7 @@ test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { }); const tracks = await input.getTracks(); - expect(tracks.every(x => x.isHydrated && x.timestampsAreRelativeToUnixEpoch)).toBe(true); + expect(tracks.every(x => x.isHydrated && x.isRelativeToUnixEpoch)).toBe(true); const track = tracks[0]!; const firstTimestamp = await track.getFirstTimestamp(); @@ -567,7 +567,7 @@ test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { await audioTrack.hydrate(); - expect(audioTrack.timestampsAreRelativeToUnixEpoch).toBe(false); + expect(audioTrack.isRelativeToUnixEpoch).toBe(false); }); test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index 083fc76..6186c68 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -2426,3 +2426,100 @@ segment-1-1.ts #EXT-X-ENDLIST `); }); + +test('Live mode, empty', async () => { + const writtenTexts = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + live: true, + }), + target: (request) => { + const target = new BufferTarget(); + target.on('finalized', () => { + if (request.path.endsWith('.m3u8')) { + writtenTexts.set(request.path, new TextDecoder().decode(target.buffer!)); + } + }); + return target; + }, + rootPath: 'master.m3u8', + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + await output.finalize(); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXT-X-ENDLIST +`); +}); + +test('EXT-X-PROGRAM-DATE-TIME writing', async () => { + let result: string | null = null; + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + onPlaylist: (text) => { result = text; }, + }), + target: () => new BufferTarget(), + rootPath: '', + }); + + const source = videoSource(); + output.addVideoTrack(source, { isRelativeToUnixEpoch: true }); + + await output.start(); + + const base = Date.parse('2026-01-01T00:00:00.250Z') / 1000; + + await source.add(new EncodedPacket(avcPacketData, 'key', base + 0, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', base + 0.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', base + 1, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', base + 1.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'key', base + 2, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', base + 2.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', base + 3, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', base + 3.5, 0), avcMetadata); + + await output.finalize(); + + expect(result).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.250Z +segment-1-1.ts +#EXTINF:1.5, +#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:02.250Z +segment-1-2.ts + +#EXT-X-ENDLIST +`); +}); + +test('Throws if some tracks are relativeToUnixEpoch and some are not', async () => { + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + }), + target: () => new NullTarget(), + rootPath: '', + }); + + output.addVideoTrack(videoSource(), { isRelativeToUnixEpoch: true }); + output.addAudioTrack(audioSource(), { isRelativeToUnixEpoch: false }); + + await expect(output.start()).rejects.toThrow('relativeToUnixEpoch'); +}); diff --git a/todo.txt b/todo.txt index 20a5295..2ce0c32 100644 --- a/todo.txt +++ b/todo.txt @@ -3,5 +3,3 @@ Also, why not just have the packet metadata on the packet? I think that would ma - keep input/demuxer.isSupported()? - getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field? - -- close writers on error \ No newline at end of file