From 56b25fbdf8504548b4514b7807117ab79a6afad4 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:48:57 +0200 Subject: [PATCH] Add onSegmentPopped, add more tests, improve validation, improve M3U8 grammar --- docs/.vitepress/m3u8-grammar.json | 7 +++ src/hls/hls-muxer.ts | 9 +++- src/output-format.ts | 9 ++++ src/output.ts | 5 +++ test/node/hls-output.test.ts | 74 ++++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/docs/.vitepress/m3u8-grammar.json b/docs/.vitepress/m3u8-grammar.json index 4c39451..6a672f0 100644 --- a/docs/.vitepress/m3u8-grammar.json +++ b/docs/.vitepress/m3u8-grammar.json @@ -56,6 +56,13 @@ } ] }, + { + "match": "^(#)(EXT-X-[A-Z0-9-]+|EXT-[A-Z0-9-]+)$", + "captures": { + "1": { "name": "punctuation.definition.comment.m3u8" }, + "2": { "name": "keyword.control.tag.m3u8" } + } + }, { "match": "^[^#\\s][^\\n]*$", "name": "source.m3u8.uri" diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 5c0751b..0582cab 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -54,6 +54,7 @@ type PlaylistSegment = { timestamp: number; byteSize: number; byteOffset: number | null; + info?: HlsOutputSegmentInfo; }; type Playlist = { @@ -1034,6 +1035,7 @@ export class HlsMuxer extends Muxer { byteOffset: playlist.singleFile ? playlist.singleFile.nextOffset : null, + info: segmentInfo ?? undefined, }); this.globalTargetDuration = Math.max(this.globalTargetDuration, segmentDuration); @@ -1047,8 +1049,13 @@ export class HlsMuxer extends Muxer { if (this.isLive) { while (playlist.writtenSegments.length > this.maxLiveSegmentCount) { - playlist.writtenSegments.shift(); + const popped = playlist.writtenSegments.shift()!; playlist.mediaSequence++; + + if (!this.singleFilePerPlaylist) { + assert(popped.info); + this.format._options.onSegmentPopped?.(popped.path, popped.info); + } } await this.writePlaylist(playlist); diff --git a/src/output-format.ts b/src/output-format.ts index 2133592..b3b040a 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -1271,6 +1271,12 @@ export type HlsOutputFormatOptions = { * function is never called. */ onInit?: (target: Target, info: HlsOutputPlaylistInfo) => unknown; + /** + * Called when a media segment is removed from the start of a media playlist due to + * {@link HlsOutputFormatOptions.maxLiveSegmentCount}. Will not be called when + * {@link HlsOutputFormatOptions.singleFilePerPlaylist} is `true`. + */ + onSegmentPopped?: (path: string, info: HlsOutputSegmentInfo) => unknown; }; /** @@ -1348,6 +1354,9 @@ export class HlsOutputFormat extends OutputFormat { if (options.onInit !== undefined && typeof options.onInit !== 'function') { throw new TypeError('options.onInit, when provided, must be a function.'); } + if (options.onSegmentPopped !== undefined && typeof options.onSegmentPopped !== 'function') { + throw new TypeError('options.onSegmentPopped, when provided, must be a function.'); + } super(); diff --git a/src/output.ts b/src/output.ts index 9219a8b..93487ae 100644 --- a/src/output.ts +++ b/src/output.ts @@ -179,11 +179,16 @@ export class OutputTrackGroup { /** * Marks this group as being pairable with another group, symmetrically. Output tracks where each track is assigned * to one half of a group pairing are then considered pairable. + * + * You cannot pair a group with itself. */ pairWith(other: OutputTrackGroup) { if (!(other instanceof OutputTrackGroup)) { throw new TypeError('other must be an OutputTrackGroup.'); } + if (this === other) { + throw new TypeError('Cannot pair a group with itself.'); + } this._pairedGroups.add(other); other._pairedGroups.add(this); diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index 7aef616..cd4e056 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -1,6 +1,11 @@ import { expect, test, vi } from 'vitest'; import { Output, OutputTrackGroup } from '../../src/output.js'; -import { CmafOutputFormat, HlsOutputFormat, MpegTsOutputFormat } from '../../src/output-format.js'; +import { + CmafOutputFormat, + HlsOutputFormat, + HlsOutputSegmentInfo, + MpegTsOutputFormat, +} from '../../src/output-format.js'; import { BufferTarget, NullTarget, PathedTarget, StreamTarget, StreamTargetChunk } from '../../src/target.js'; import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js'; import { HlsMuxer } from '../../src/hls/hls-muxer.js'; @@ -2551,12 +2556,16 @@ test('Throws if some tracks are relativeToUnixEpoch and some are not', async () test('Live mode, maxLiveSegmentCount', async () => { const writtenTexts = new Map(); + const poppedSegments: { path: string; info: HlsOutputSegmentInfo }[] = []; const output = new Output({ format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), live: true, maxLiveSegmentCount: 2, + onSegmentPopped: (path, info) => { + poppedSegments.push({ path, info }); + }, }), target: new PathedTarget('master.m3u8', (request) => { const target = new BufferTarget(); @@ -2627,6 +2636,11 @@ segment-1-2.ts segment-1-3.ts `); + expect(poppedSegments).toHaveLength(1); + expect(poppedSegments[0]!.path).toBe('segment-1-1.ts'); + expect(poppedSegments[0]!.info.n).toBe(1); + expect(poppedSegments[0]!.info.isSingleFile).toBe(false); + 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); @@ -2646,4 +2660,62 @@ segment-1-4.ts #EXT-X-ENDLIST `); + + expect(poppedSegments).toHaveLength(2); + expect(poppedSegments[1]!.path).toBe('segment-1-2.ts'); + expect(poppedSegments[1]!.info.n).toBe(2); +}); + +test('Live mode, maxLiveSegmentCount with singleFilePerPlaylist', async () => { + const onSegmentPopped = vi.fn(); + let lastPlaylistText = ''; + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + live: true, + maxLiveSegmentCount: 2, + singleFilePerPlaylist: true, + onSegmentPopped, + onPlaylist: (content) => { + lastPlaylistText = content; + }, + }), + target: new PathedTarget('master.m3u8', () => { + return new BufferTarget(); + }), + }); + + 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); + 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); + 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); + 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(onSegmentPopped).not.toHaveBeenCalled(); + + // Popping still happened + const extinfCount = (lastPlaylistText.match(/#EXTINF:/g) ?? []).length; + expect(extinfCount).toBe(2); });