mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
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:
@@ -211,7 +211,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return sampleRate / SAMPLES_PER_AAC_FRAME;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -598,7 +598,7 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.demuxer.audioInfo.sampleRate;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -2558,7 +2558,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
return this.internalTrack.timescale;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1971,7 +1971,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
return this.internalTrack.segment.timestampFactor;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -289,7 +289,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1096,7 +1096,7 @@ abstract class MpegTsTrackBacking implements InputTrackBacking {
|
||||
return TIMESCALE;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -448,7 +448,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.bitstream.sampleRate;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+56
-6
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -659,6 +659,7 @@ export class RangedTarget extends Target {
|
||||
|
||||
this._baseTarget = baseTarget;
|
||||
this._offset = offset;
|
||||
this._output = baseTarget._output;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
||||
@@ -401,7 +401,7 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.demuxer.audioInfo.sampleRate;
|
||||
}
|
||||
|
||||
getTimestampsAreRelativeToUnixEpoch() {
|
||||
isRelativeToUnixEpoch() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user