From 3400b3ac12967b0c63e95fe0adf778048fce4f9f Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:58:02 +0200 Subject: [PATCH] Add maxLiveSegmentCount HLS option --- src/hls/hls-muxer.ts | 10 ++++ src/output-format.ts | 8 +++ test/node/hls-output.test.ts | 100 +++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 958c9ee..a38792b 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -61,6 +61,7 @@ type Playlist = { writtenSegments: PlaylistSegment[]; peakBitrate: number | null; averageBitrate: number | null; + mediaSequence: number; done: boolean; singleFile: { @@ -88,6 +89,7 @@ export class HlsMuxer extends Muxer { trackDatas: HlsTrackData[] = []; singleFilePerPlaylist: boolean; isLive: boolean; + maxLiveSegmentCount: number; isRelativeToUnixEpoch = false; globalTargetDuration: number; @@ -105,6 +107,7 @@ export class HlsMuxer extends Muxer { this.targetSegmentDuration = format._options.targetDuration ?? 2; this.singleFilePerPlaylist = format._options.singleFilePerPlaylist ?? false; this.isLive = format._options.live ?? false; + this.maxLiveSegmentCount = format._options.maxLiveSegmentCount ?? Infinity; this.globalTargetDuration = this.targetSegmentDuration; this.getPlaylistPath = format._options.getPlaylistPath @@ -440,6 +443,7 @@ export class HlsMuxer extends Muxer { writtenSegments: [], peakBitrate: null, averageBitrate: null, + mediaSequence: 0, done: false, singleFile: null, }; @@ -1024,6 +1028,11 @@ export class HlsMuxer extends Muxer { } if (this.isLive) { + while (playlist.writtenSegments.length > this.maxLiveSegmentCount) { + playlist.writtenSegments.shift(); + playlist.mediaSequence++; + } + await this.writePlaylist(playlist); await this.tryWriteMasterPlaylist(); } @@ -1128,6 +1137,7 @@ export class HlsMuxer extends Muxer { + `#EXT-X-VERSION:${version}\n` + (!this.isLive ? '#EXT-X-PLAYLIST-TYPE:VOD\n' : '') + `#EXT-X-TARGETDURATION:${Math.ceil(targetDuration)}\n` // Must be a "decimal-integer" + + (Number.isFinite(this.maxLiveSegmentCount) ? `#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence}\n` : '') + '#EXT-X-INDEPENDENT-SEGMENTS\n' // Todo not for live? + (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '') + (playlist.initSegment diff --git a/src/output-format.ts b/src/output-format.ts index b7512bd..46f4152 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -1173,6 +1173,7 @@ export type HlsOutputFormatOptions = { targetDuration?: number; singleFilePerPlaylist?: boolean; live?: boolean; + maxLiveSegmentCount?: number; getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise; getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; @@ -1217,6 +1218,13 @@ export class HlsOutputFormat extends OutputFormat { if (options.live !== undefined && typeof options.live !== 'boolean') { throw new TypeError('options.live, when provided, must be a boolean.'); } + if ( + options.maxLiveSegmentCount !== undefined + && (typeof options.maxLiveSegmentCount !== 'number' || options.maxLiveSegmentCount < 1 + || (Number.isFinite(options.maxLiveSegmentCount) && !Number.isInteger(options.maxLiveSegmentCount))) + ) { + throw new TypeError('options.maxLiveSegmentCount, when provided, must be a positive integer or Infinity.'); + } if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') { throw new TypeError('options.getPlaylistPath, when provided, must be a function.'); } diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index 6186c68..c4f234f 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -2523,3 +2523,103 @@ test('Throws if some tracks are relativeToUnixEpoch and some are not', async () await expect(output.start()).rejects.toThrow('relativeToUnixEpoch'); }); + +test('Live mode, maxLiveSegmentCount', async () => { + const writtenTexts = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + live: true, + maxLiveSegmentCount: 2, + }), + 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 source.add(new EncodedPacket(avcPacketData, 'key', 0, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0.5), avcMetadata); + + await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0.5), avcMetadata); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +segment-1-1.ts +`); + + await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0.5), avcMetadata); + + await source.add(new EncodedPacket(avcPacketData, 'key', 4, 0.5), avcMetadata); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +segment-1-1.ts +#EXTINF:2, +segment-1-2.ts +`); + + await source.add(new EncodedPacket(avcPacketData, 'delta', 4.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 5.5, 0.5), avcMetadata); + + await source.add(new EncodedPacket(avcPacketData, 'key', 6, 0.5), avcMetadata); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +segment-1-2.ts +#EXTINF:2, +segment-1-3.ts +`); + + await source.add(new EncodedPacket(avcPacketData, 'delta', 6.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 7, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 7.5, 0.5), avcMetadata); + + await output.finalize(); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:2 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +segment-1-3.ts +#EXTINF:2, +segment-1-4.ts + +#EXT-X-ENDLIST +`); +});