Add support for writing Unix epoch-relative tracks to HLS, add proper cleanup of all Targets obtained by an Output

This commit is contained in:
Vanilagy
2026-03-31 15:12:38 +02:00
parent b06e407d65
commit 340b5eede7
19 changed files with 197 additions and 36 deletions
+1 -1
View File
@@ -211,7 +211,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
return sampleRate / SAMPLES_PER_AAC_FRAME;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+1 -1
View File
@@ -598,7 +598,7 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.audioInfo.sampleRate;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+2 -2
View File
@@ -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 {
+19 -1
View File
@@ -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<void> {
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`
: '')
+3 -3
View File
@@ -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. */
+1 -1
View File
@@ -2558,7 +2558,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return this.internalTrack.timescale;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+3 -5
View File
@@ -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();
+1 -1
View File
@@ -1971,7 +1971,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return this.internalTrack.segment.timestampFactor;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+1 -1
View File
@@ -289,7 +289,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+1 -1
View File
@@ -1096,7 +1096,7 @@ abstract class MpegTsTrackBacking implements InputTrackBacking {
return TIMESCALE;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+1 -1
View File
@@ -448,7 +448,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
return this.bitstream.sampleRate;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+56 -6
View File
@@ -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<T>);
private _target: T | ((request: TargetRequest) => MaybePromise<T>);
/** The current state of the output. */
state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending';
/** @internal */
_rootPath: string | null;
/** @internal */
_initTarget: T | (() => MaybePromise<T>) | null;
private _initTarget: T | (() => MaybePromise<T>) | null;
/** @internal */
_muxer: Muxer;
/** @internal */
_targets = new Set<Target>();
/** @internal */
_rootWriterPromise: Promise<Writer> | 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<T> {
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();
}
+1 -1
View File
@@ -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;
}
+1
View File
@@ -659,6 +659,7 @@ export class RangedTarget extends Target {
this._baseTarget = baseTarget;
this._offset = offset;
this._output = baseTarget._output;
}
/** @internal */
+1 -1
View File
@@ -401,7 +401,7 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.audioInfo.sampleRate;
}
getTimestampsAreRelativeToUnixEpoch() {
isRelativeToUnixEpoch() {
return false;
}
+4 -5
View File
@@ -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;
+3 -3
View File
@@ -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 () => {
+97
View File
@@ -2426,3 +2426,100 @@ segment-1-1.ts
#EXT-X-ENDLIST
`);
});
test('Live mode, empty', async () => {
const writtenTexts = new Map<string, string>();
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');
});
-2
View File
@@ -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