Add HLS live mode

This commit is contained in:
Vanilagy
2026-03-31 14:13:00 +02:00
parent 6b677291cd
commit b06e407d65
13 changed files with 812 additions and 401 deletions
+385 -324
View File
@@ -60,6 +60,7 @@ type Playlist = {
writtenSegments: PlaylistSegment[];
peakBitrate: number | null;
averageBitrate: number | null;
done: boolean;
singleFile: {
target: Target;
@@ -85,6 +86,8 @@ export class HlsMuxer extends Muxer {
targetSegmentDuration: number;
trackDatas: HlsTrackData[] = [];
singleFilePerPlaylist: boolean;
isLive: boolean;
globalTargetDuration: number;
playlists: Playlist[] = [];
playlistDeclarations: PlaylistDeclaration[] = [];
@@ -99,6 +102,8 @@ export class HlsMuxer extends Muxer {
this.format = format;
this.targetSegmentDuration = format._options.targetDuration ?? 2;
this.singleFilePerPlaylist = format._options.singleFilePerPlaylist ?? false;
this.isLive = format._options.live ?? false;
this.globalTargetDuration = this.targetSegmentDuration;
this.getPlaylistPath = format._options.getPlaylistPath
?? (({ n }) => `playlist-${n}.m3u8`);
@@ -423,6 +428,7 @@ export class HlsMuxer extends Muxer {
writtenSegments: [],
peakBitrate: null,
averageBitrate: null,
done: false,
singleFile: null,
};
this.playlists.push(playlist);
@@ -494,6 +500,7 @@ export class HlsMuxer extends Muxer {
const playlist = this.playlists.find(x => x.tracks.includes(track));
assert(playlist); // If there isn't one then the assignment algo failed innit
await this.advancePlaylist(playlist);
} finally {
release();
@@ -634,12 +641,19 @@ export class HlsMuxer extends Muxer {
}
async advancePlaylist(playlist: Playlist) {
assert(playlist.currentSegmentStartTimestamp !== null);
assert(!playlist.done);
if (!this.allTracksAreKnown(playlist)) {
return;
}
if (playlist.currentSegmentStartTimestamp === null) {
// All tracks are known but we never received any data - all tracks must be closed already
await this.onPlaylistDone(playlist);
return;
}
const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track));
const videoTrack = trackDatas.find(x => x.info.type === 'video') as HlsVideoTrackData | undefined;
const audioTrack = trackDatas.find(x => x.info.type === 'audio') as HlsAudioTrackData | undefined;
@@ -754,6 +768,12 @@ export class HlsMuxer extends Muxer {
}
if (videoEndIndex === 0 && audioEndIndex === 0) {
// No more segments to write - if all tracks are closed, this playlist is done
const allClosed = trackDatas.every(x => x.closed);
if (allClosed) {
await this.onPlaylistDone(playlist);
}
return;
}
@@ -824,13 +844,13 @@ export class HlsMuxer extends Muxer {
if (request.isRoot) {
if (playlist.singleFile) {
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
slice.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end);
slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
return slice;
} else {
const target = await this.output._getTarget(request);
outputTarget = target;
target.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end);
target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
return target;
}
@@ -853,12 +873,12 @@ export class HlsMuxer extends Muxer {
};
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
slice.onwrite = (_, end) => {
slice.on('write', ({ end }) => {
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
};
slice.onfinalized = () => {
});
slice.on('finalized', () => {
playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize;
};
});
return slice;
} else {
@@ -877,12 +897,12 @@ export class HlsMuxer extends Muxer {
path,
isRoot: false,
});
target.onwrite = (_, end) => {
target.on('write', ({ end }) => {
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
};
target.onfinalized = () => {
});
target.on('finalized', () => {
this.format._options.onInit?.(target, playlistInfo);
};
});
return target;
}
@@ -968,24 +988,371 @@ export class HlsMuxer extends Muxer {
: maxEndTimestamp; // Happens for the last segment for example
assert(Number.isFinite(nextSegmentStartTimestamp));
const segmentDuration = nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp;
playlist.writtenSegments.push({
path: relativeSegmentPath,
duration: nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp,
duration: segmentDuration,
byteSize: segmentSize,
byteOffset: playlist.singleFile
? playlist.singleFile.nextOffset
: null,
});
this.globalTargetDuration = Math.max(this.globalTargetDuration, segmentDuration);
playlist.currentSegmentStartTimestamp = nextSegmentStartTimestamp;
playlist.currentSegmentStartTimestampIsFixed = true; // After the first segment, the timestamp is now fixed
if (playlist.singleFile) {
playlist.singleFile.nextOffset += segmentSize;
}
if (this.isLive) {
await this.writePlaylist(playlist);
await this.tryWriteMasterPlaylist();
}
}
}
private async onPlaylistDone(playlist: Playlist) {
assert(!playlist.done);
playlist.done = true;
if (playlist.singleFile) {
await playlist.singleFile.target._flush();
await playlist.singleFile.target._finalize();
this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info);
}
await this.writePlaylist(playlist);
if (this.isLive && playlist.writtenSegments.length === 0) {
await this.tryWriteMasterPlaylist();
}
}
private updatePlaylistBitrates(playlist: Playlist) {
const segments = playlist.writtenSegments;
let peakBitrate = 0;
let totalBits = 0;
let totalDuration = 0;
// Per spec, peak bitrate is the largest bit rate of any contiguous set of segments whose total duration is
// between 0.5 and 1.5 times the target duration
for (let i = 0; i < segments.length; i++) {
totalDuration += segments[i]!.duration;
let windowBytes = 0;
let windowDuration = 0;
for (let j = i; j < segments.length; j++) {
windowBytes += segments[j]!.byteSize;
windowDuration += segments[j]!.duration;
if (
windowDuration >= 0.5 * this.globalTargetDuration
&& windowDuration <= 1.5 * this.globalTargetDuration
) {
peakBitrate = Math.max(peakBitrate, 8 * windowBytes / windowDuration);
}
if (windowDuration > 1.5 * this.globalTargetDuration) {
break;
}
}
}
// Fallback: if no contiguous set falls within the range, use per-segment max
if (peakBitrate === 0) {
for (const segment of segments) {
peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segment.duration);
}
}
for (const segment of segments) {
totalBits += 8 * segment.byteSize;
}
playlist.peakBitrate = peakBitrate;
playlist.averageBitrate = totalBits / (totalDuration || 1);
}
private async writePlaylist(playlist: Playlist) {
assert(this.output._rootPath !== null);
this.updatePlaylistBitrates(playlist);
let hasByteOffsets = false;
for (const segment of playlist.writtenSegments) {
hasByteOffsets ||= segment.byteOffset !== null;
}
const isKeyPacketsOnly = playlist.tracks[0]!.isVideoTrack()
&& playlist.tracks[0].metadata.hasOnlyKeyPackets;
let version = 3;
if (isKeyPacketsOnly || hasByteOffsets) {
version = 4;
}
if (playlist.initSegment) {
version = 5;
}
if (playlist.initSegment && !isKeyPacketsOnly) {
// "if it contains the EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY"
version = 6;
}
// In live mode, target duration is not allowed to change, so we use the nominal value
const targetDuration = this.isLive ? this.targetSegmentDuration : this.globalTargetDuration;
const playlistPath = joinPaths(this.output._rootPath, playlist.path);
const playlistText = '#EXTM3U\n'
+ `#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"
+ '#EXT-X-INDEPENDENT-SEGMENTS\n' // Todo not for live?
+ (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '')
+ (playlist.initSegment
? (`#EXT-X-MAP:URI="${playlist.initSegment.path}"`
+ (playlist.initSegment.byteOffset !== null
? `,BYTERANGE="${playlist.initSegment.byteSize}@${playlist.initSegment.byteOffset}"`
: '')
+ '\n')
: '')
+ '\n'
+ (playlist.writtenSegments
.map(segment => (
`#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec
+ (segment.byteOffset !== null
? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n`
: '')
+ `${segment.path}\n`
))
.join(''))
+ (playlist.done
? (playlist.writtenSegments.length > 0 ? '\n' : '') + '#EXT-X-ENDLIST\n'
: '');
this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist));
const target = await this.output._getTarget({ path: playlistPath, isRoot: false });
const writer = new Writer(target);
writer.start();
writer.write(textEncoder.encode(playlistText));
await writer.flush();
await writer.finalize();
}
private async writeMasterPlaylist() {
let masterPlaylistText = '#EXTM3U\n';
let firstVariantWritten = false;
let lastGroupId: string | null = null;
let groupIdTrackCount = 0;
let hasHadDefaultTrackInGroup = false;
for (const decl of this.playlistDeclarations) {
if (decl.groupId === null) {
const isKeyPacketsOnly = decl.playlist.tracks[0]!.isVideoTrack()
&& decl.playlist.tracks[0].metadata.hasOnlyKeyPackets;
const codecs: string[] = [];
for (const track of decl.playlist.tracks) {
const trackData = this.trackDatas.find(x => x.track === track);
const codecString = trackData?.info.decoderConfig.codec ?? track.source._codec;
codecs.push(codecString);
}
let peakDeclBitrate = 0;
let maxRefAverageBitrate = 0;
if (decl.references.length > 0) {
const firstRef = decl.references[0]!;
const firstTrack = firstRef.playlist.tracks[0]!;
const trackData = this.trackDatas.find(x => x.track === firstTrack);
const codecString = trackData?.info.decoderConfig.codec ?? firstTrack.source._codec;
codecs.push(codecString);
for (const ref of decl.references) {
assert(ref.playlist.peakBitrate !== null);
peakDeclBitrate = Math.max(peakDeclBitrate, ref.playlist.peakBitrate);
maxRefAverageBitrate = Math.max(maxRefAverageBitrate, ref.playlist.averageBitrate ?? 0);
}
}
assert(decl.playlist.peakBitrate !== null);
const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate;
const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0) + maxRefAverageBitrate;
if (!firstVariantWritten) {
masterPlaylistText += '\n';
firstVariantWritten = true;
}
if (isKeyPacketsOnly) {
masterPlaylistText += `#EXT-X-I-FRAME-STREAM-INF:`;
} else {
masterPlaylistText += `#EXT-X-STREAM-INF:`;
}
masterPlaylistText += `BANDWIDTH=${Math.ceil(totalPeakBitrate)}`;
if (totalAverageBitrate > 0) {
masterPlaylistText += `,AVERAGE-BANDWIDTH=${Math.ceil(totalAverageBitrate)}`;
}
masterPlaylistText += `,CODECS="${codecs.join(',')}"`;
const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack());
if (videoTrack?.isVideoTrack()) {
const trackData = this.trackDatas.find(x => x.track === videoTrack) as
HlsVideoTrackData | undefined;
const decoderConfig = trackData?.info.decoderConfig;
if (decoderConfig) {
let width = decoderConfig.displayAspectWidth ?? decoderConfig.codedWidth;
let height = decoderConfig.displayAspectHeight ?? decoderConfig.codedHeight;
if (width !== undefined && height !== undefined) {
if (
videoTrack.metadata.rotation !== undefined
&& videoTrack.metadata.rotation % 180 !== 90
) {
[width, height] = [height, width];
}
masterPlaylistText += `,RESOLUTION=${width}x${height}`;
}
}
// FRAME-RATE is not defined for EXT-X-I-FRAME-STREAM-INF
if (!isKeyPacketsOnly && videoTrack.metadata.frameRate !== undefined) {
// Spec requires that frame rate be rounded to 3 decimal places
masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(3)}`;
}
}
if (!isKeyPacketsOnly) {
const groupIdForType = new Map<string, string>();
for (const ref of decl.references) {
assert(ref.groupId !== null);
const type = ref.playlist.tracks[0]!.type;
groupIdForType.set(type, ref.groupId);
}
for (const [type, id] of groupIdForType) {
masterPlaylistText += `,${type.toUpperCase()}="${id}"`;
}
}
if (isKeyPacketsOnly) {
// EXT-X-I-FRAME-STREAM-INF is standalone with a URI attribute
masterPlaylistText += `,URI="${decl.playlist.path}"`;
masterPlaylistText += '\n';
} else {
masterPlaylistText += '\n';
masterPlaylistText += `${decl.playlist.path}\n`;
}
} else {
assert(decl.playlist.tracks.length === 1);
const track = decl.playlist.tracks[0]!;
const type = track.type;
let name = track.metadata.name ?? null;
const languageCode = track.metadata.languageCode;
const disposition = track.metadata.disposition;
if (lastGroupId === null || decl.groupId !== lastGroupId) {
groupIdTrackCount = 0;
masterPlaylistText += '\n';
hasHadDefaultTrackInGroup = false;
}
lastGroupId = decl.groupId;
groupIdTrackCount++;
masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`;
if (name !== null && /[\n\r"]/.test(name)) {
console.warn(
'Dropping track name since it includes a line feed, carriage return, or double quote'
+ ' character, which are not allowed in HLS playlist attributes.',
);
name = null;
}
// Name is required, so we have to set it to SOMETHING
name ??= `${languageCode ?? decl.groupId}-${groupIdTrackCount}`;
masterPlaylistText += `,NAME="${name}"`;
if (languageCode !== undefined) {
masterPlaylistText += `,LANGUAGE="${languageCode}"`;
}
const dispositionPrimary = disposition?.primary ?? false;
const dispositionDefault = disposition?.default ?? true;
const dispositionForced = disposition?.forced ?? false;
if (dispositionPrimary && !hasHadDefaultTrackInGroup) {
// HLS's "DEFAULT" behaves like our "primary"
masterPlaylistText += ',DEFAULT=YES';
hasHadDefaultTrackInGroup = true; // Only one DEFAULT label per group allowed
}
if (dispositionPrimary || dispositionDefault) {
masterPlaylistText += ',AUTOSELECT=YES';
}
if (dispositionForced) {
masterPlaylistText += ',FORCED=YES';
}
if (type === 'audio') {
const trackData = this.trackDatas.find(x => x.track === track) as
HlsAudioTrackData | undefined;
const decoderConfig = trackData?.info.decoderConfig;
if (decoderConfig) {
masterPlaylistText += `,CHANNELS="${decoderConfig.numberOfChannels}"`;
}
}
if (!decl.noUri) {
masterPlaylistText += `,URI="${decl.playlist.path}"`;
}
masterPlaylistText += '\n';
}
}
this.format._options.onMaster?.(masterPlaylistText);
const target = await this.output._getTarget({ path: this.output._rootPath!, isRoot: true });
const writer = new Writer(target);
writer.start();
writer.write(textEncoder.encode(masterPlaylistText));
await writer.flush();
await writer.finalize();
}
private async tryWriteMasterPlaylist() {
assert(this.isLive);
// The master playlist is written once all playlists have either produced at least one segment or are done
for (const playlist of this.playlists) {
if (playlist.writtenSegments.length === 0 && !playlist.done) {
return;
}
}
await this.writeMasterPlaylist();
}
async finalize() {
assert(this.output._rootPath !== null);
const release = await this.mutex.acquire();
@@ -994,320 +1361,14 @@ export class HlsMuxer extends Muxer {
trackData.closed = true;
}
// Compute a single target duration across all playlists, as the spec mandates they match
let globalTargetDuration = this.targetSegmentDuration;
for (const playlist of this.playlists) {
for (const segment of playlist.writtenSegments) {
globalTargetDuration = Math.max(globalTargetDuration, segment.duration);
}
await Promise.all(this.playlists.map(playlist => (
playlist.done ? Promise.resolve() : this.advancePlaylist(playlist)
)));
if (!this.isLive) {
await this.writeMasterPlaylist();
}
for (const playlist of this.playlists) {
if (playlist.currentSegmentStartTimestamp !== null) {
await this.advancePlaylist(playlist);
} else {
// Never had any data written to it
}
let peakBitrate = 0;
let totalBits = 0;
let totalDuration = 0;
// Per spec, peak bitrate is the largest bit rate of any contiguous set of segments whose total duration is
// between 0.5 and 1.5 times the target duration
const segments = playlist.writtenSegments;
for (let i = 0; i < segments.length; i++) {
totalDuration += segments[i]!.duration;
let windowBytes = 0;
let windowDuration = 0;
for (let j = i; j < segments.length; j++) {
windowBytes += segments[j]!.byteSize;
windowDuration += segments[j]!.duration;
if (windowDuration >= 0.5 * globalTargetDuration && windowDuration <= 1.5 * globalTargetDuration) {
peakBitrate = Math.max(peakBitrate, 8 * windowBytes / windowDuration);
}
if (windowDuration > 1.5 * globalTargetDuration) {
break;
}
}
}
// Fallback: if no contiguous set falls within the range, use per-segment max
if (peakBitrate === 0) {
for (const segment of segments) {
peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segment.duration);
}
}
for (const segment of segments) {
totalBits += 8 * segment.byteSize;
}
playlist.peakBitrate = peakBitrate;
playlist.averageBitrate = totalBits / (totalDuration || 1);
}
// Write all playlists in parallel
const playlistPromises = this.playlists.map(async (playlist) => {
if (playlist.singleFile) {
await playlist.singleFile.target._flush();
await playlist.singleFile.target._finalize();
this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info);
}
let hasByteOffsets = false;
for (const segment of playlist.writtenSegments) {
hasByteOffsets ||= segment.byteOffset !== null;
}
const isKeyPacketsOnly = playlist.tracks[0]!.isVideoTrack()
&& playlist.tracks[0].metadata.hasOnlyKeyPackets;
let version = 3;
if (isKeyPacketsOnly || hasByteOffsets) {
version = 4;
}
if (playlist.initSegment) {
version = 5;
}
if (playlist.initSegment && !isKeyPacketsOnly) {
// "if it contains the EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY"
version = 6;
}
const playlistPath = joinPaths(this.output._rootPath!, playlist.path);
const playlistText = '#EXTM3U\n'
+ `#EXT-X-VERSION:${version}\n`
+ '#EXT-X-PLAYLIST-TYPE:VOD\n'
+ `#EXT-X-TARGETDURATION:${Math.ceil(globalTargetDuration)}\n` // Must be a "decimal-integer"
+ '#EXT-X-INDEPENDENT-SEGMENTS\n' // TODO not when live
+ (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '')
+ (playlist.initSegment
? (`#EXT-X-MAP:URI="${playlist.initSegment.path}"`
+ (playlist.initSegment.byteOffset !== null
? `,BYTERANGE="${playlist.initSegment.byteSize}@${playlist.initSegment.byteOffset}"`
: '')
+ '\n')
: '')
+ '\n'
+ (playlist.writtenSegments
.map(segment => (
`#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec
+ (segment.byteOffset !== null
? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n`
: '')
+ `${segment.path}\n`
))
.join(''))
+ (playlist.writtenSegments.length > 0 ? '\n' : '')
+ '#EXT-X-ENDLIST\n';
this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist));
const target = await this.output._getTarget({ path: playlistPath, isRoot: false });
const writer = new Writer(target);
writer.start();
writer.write(textEncoder.encode(playlistText));
await writer.flush();
await writer.finalize();
});
const masterPlaylistPromise = (async () => {
let masterPlaylistText = '#EXTM3U\n';
let firstVariantWritten = false;
let lastGroupId: string | null = null;
let groupIdTrackCount = 0;
let hasHadDefaultTrackInGroup = false;
for (const decl of this.playlistDeclarations) {
if (decl.groupId === null) {
const isKeyPacketsOnly = decl.playlist.tracks[0]!.isVideoTrack()
&& decl.playlist.tracks[0].metadata.hasOnlyKeyPackets;
const codecs: string[] = [];
for (const track of decl.playlist.tracks) {
const trackData = this.trackDatas.find(x => x.track === track);
const codecString = trackData?.info.decoderConfig.codec ?? track.source._codec;
codecs.push(codecString);
}
let peakDeclBitrate = 0;
let maxRefAverageBitrate = 0;
if (decl.references.length > 0) {
const firstRef = decl.references[0]!;
const firstTrack = firstRef.playlist.tracks[0]!;
const trackData = this.trackDatas.find(x => x.track === firstTrack);
const codecString = trackData?.info.decoderConfig.codec ?? firstTrack.source._codec;
codecs.push(codecString);
for (const ref of decl.references) {
assert(ref.playlist.peakBitrate !== null);
peakDeclBitrate = Math.max(peakDeclBitrate, ref.playlist.peakBitrate);
maxRefAverageBitrate = Math.max(maxRefAverageBitrate, ref.playlist.averageBitrate ?? 0);
}
}
assert(decl.playlist.peakBitrate !== null);
const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate;
const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0) + maxRefAverageBitrate;
if (!firstVariantWritten) {
masterPlaylistText += '\n';
firstVariantWritten = true;
}
if (isKeyPacketsOnly) {
masterPlaylistText += `#EXT-X-I-FRAME-STREAM-INF:`;
} else {
masterPlaylistText += `#EXT-X-STREAM-INF:`;
}
masterPlaylistText += `BANDWIDTH=${Math.ceil(totalPeakBitrate)}`;
if (totalAverageBitrate > 0) {
masterPlaylistText += `,AVERAGE-BANDWIDTH=${Math.ceil(totalAverageBitrate)}`;
}
masterPlaylistText += `,CODECS="${codecs.join(',')}"`;
const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack());
if (videoTrack?.isVideoTrack()) {
const trackData = this.trackDatas.find(x => x.track === videoTrack) as
HlsVideoTrackData | undefined;
const decoderConfig = trackData?.info.decoderConfig;
if (decoderConfig) {
let width = decoderConfig.displayAspectWidth ?? decoderConfig.codedWidth;
let height = decoderConfig.displayAspectHeight ?? decoderConfig.codedHeight;
if (width !== undefined && height !== undefined) {
if (
videoTrack.metadata.rotation !== undefined
&& videoTrack.metadata.rotation % 180 !== 90
) {
[width, height] = [height, width];
}
masterPlaylistText += `,RESOLUTION=${width}x${height}`;
}
}
// FRAME-RATE is not defined for EXT-X-I-FRAME-STREAM-INF
if (!isKeyPacketsOnly && videoTrack.metadata.frameRate !== undefined) {
// Spec requires that frame rate be rounded to 3 decimal places
masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(3)}`;
}
}
if (!isKeyPacketsOnly) {
const groupIdForType = new Map<string, string>();
for (const ref of decl.references) {
assert(ref.groupId !== null);
const type = ref.playlist.tracks[0]!.type;
groupIdForType.set(type, ref.groupId);
}
for (const [type, id] of groupIdForType) {
masterPlaylistText += `,${type.toUpperCase()}="${id}"`;
}
}
if (isKeyPacketsOnly) {
// EXT-X-I-FRAME-STREAM-INF is standalone with a URI attribute
masterPlaylistText += `,URI="${decl.playlist.path}"`;
masterPlaylistText += '\n';
} else {
masterPlaylistText += '\n';
masterPlaylistText += `${decl.playlist.path}\n`;
}
} else {
assert(decl.playlist.tracks.length === 1);
const track = decl.playlist.tracks[0]!;
const type = track.type;
let name = track.metadata.name ?? null;
const languageCode = track.metadata.languageCode;
const disposition = track.metadata.disposition;
if (lastGroupId === null || decl.groupId !== lastGroupId) {
groupIdTrackCount = 0;
masterPlaylistText += '\n';
hasHadDefaultTrackInGroup = false;
}
lastGroupId = decl.groupId;
groupIdTrackCount++;
masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`;
if (name !== null && /[\n\r"]/.test(name)) {
console.warn(
'Dropping track name since it includes a line feed, carriage return, or double quote'
+ ' character, which are not allowed in HLS playlist attributes.',
);
name = null;
}
// Name is required, so we have to set it to SOMETHING
name ??= `${languageCode ?? decl.groupId}-${groupIdTrackCount}`;
masterPlaylistText += `,NAME="${name}"`;
if (languageCode !== undefined) {
masterPlaylistText += `,LANGUAGE="${languageCode}"`;
}
const dispositionPrimary = disposition?.primary ?? false;
const dispositionDefault = disposition?.default ?? true;
const dispositionForced = disposition?.forced ?? false;
if (dispositionPrimary && !hasHadDefaultTrackInGroup) {
// HLS's "DEFAULT" behaves like our "primary"
masterPlaylistText += ',DEFAULT=YES';
hasHadDefaultTrackInGroup = true; // Only one DEFAULT label per group allowed
}
if (dispositionPrimary || dispositionDefault) {
masterPlaylistText += ',AUTOSELECT=YES';
}
if (dispositionForced) {
masterPlaylistText += ',FORCED=YES';
}
if (type === 'audio') {
const trackData = this.trackDatas.find(x => x.track === track) as
HlsAudioTrackData | undefined;
const decoderConfig = trackData?.info.decoderConfig;
if (decoderConfig) {
masterPlaylistText += `,CHANNELS="${decoderConfig.numberOfChannels}"`;
}
}
if (!decl.noUri) {
masterPlaylistText += `,URI="${decl.playlist.path}"`;
}
masterPlaylistText += '\n';
}
}
this.format._options.onMaster?.(masterPlaylistText);
const rootWriter = await this.output._getRootWriter();
rootWriter.write(textEncoder.encode(masterPlaylistText));
})();
await Promise.all([...playlistPromises, masterPlaylistPromise]);
release();
}
}