Add onSegmentPopped, add more tests, improve validation, improve M3U8 grammar

This commit is contained in:
Vanilagy
2026-04-16 14:48:57 +02:00
parent f99adf2208
commit 56b25fbdf8
5 changed files with 102 additions and 2 deletions
+7
View File
@@ -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"
+8 -1
View File
@@ -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);
+9
View File
@@ -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();
+5
View File
@@ -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);
+73 -1
View File
@@ -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<string, string>();
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);
});