mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
1,529 lines (1,277 loc) • 46.2 kB
text/typescript
/*!
* Copyright (c) 2026-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../media-source';
import {
arrayArgmax,
assert,
AsyncMutex,
findLastIndex,
joinPaths,
textEncoder,
toArray,
UNDETERMINED_LANGUAGE,
} from '../misc';
import { Muxer } from '../muxer';
import {
Output,
OutputAudioTrack,
OutputSubtitleTrack,
OutputTrack,
OutputVideoTrack,
TrackType,
} from '../output';
import {
HlsOutputFormat,
HlsOutputFormatOptions,
HlsOutputPlaylistInfo,
HlsOutputSegmentInfo,
OutputFormat,
} from '../output-format';
import { Writer } from '../writer';
import { EncodedPacket } from '../packet';
import { SubtitleCue, SubtitleMetadata } from '../subtitles';
import { NullTarget, PathedTarget, Target, TargetRequest } from '../target';
import { HLS_MIME_TYPE } from './hls-misc';
type HlsTrackData = {
track: OutputTrack;
packets: EncodedPacket[];
playlist: Playlist;
// We must store it on the TrackData, reading it directly from the track leads to async race conditions!
closed: boolean;
info: {
type: 'video';
decoderConfig: VideoDecoderConfig;
} | {
type: 'audio';
decoderConfig: AudioDecoderConfig;
};
};
type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } };
type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } };
type PlaylistSegment = {
path: string;
duration: number;
timestamp: number;
byteSize: number;
byteOffset: number | null;
info: HlsOutputSegmentInfo | null;
};
type Playlist = {
id: number;
path: string;
tracks: OutputTrack[];
segmentFormat: OutputFormat;
currentSegmentStartTimestamp: number | null;
currentSegmentStartTimestampIsFixed: boolean;
nextSegmentId: number;
initSegment: PlaylistSegment | null;
writtenSegments: PlaylistSegment[];
peakBitrate: number | null;
averageBitrate: number | null;
mediaSequence: number;
done: boolean;
singleFile: {
target: Target;
path: string;
nextOffset: number;
info: HlsOutputSegmentInfo;
} | null;
// For HLS, having a single mutex is too coarse. Every playlist is basically independent and therefore we can have
// a per-playlist mutex instead of a per-muxer one. This means two packets from different playlists coming in don't
// block each other.
mutex: AsyncMutex;
};
type PlaylistDeclaration = {
playlist: Playlist;
groupId: string | null;
noUri: boolean;
references: PlaylistDeclaration[];
};
export class HlsMuxer extends Muxer {
format: HlsOutputFormat;
getPlaylistPath: NonNullable<HlsOutputFormatOptions['getPlaylistPath']>;
getSegmentPath: NonNullable<HlsOutputFormatOptions['getSegmentPath']>;
getInitPath: NonNullable<HlsOutputFormatOptions['getInitPath']>;
targetSegmentDuration: number;
trackDatas: HlsTrackData[] = [];
singleFilePerPlaylist: boolean;
isLive: boolean;
maxLiveSegmentCount: number;
isRelativeToUnixEpoch = false;
globalTargetDuration: number;
numWrittenMasterPlaylists = 0;
playlists: Playlist[] = [];
playlistDeclarations: PlaylistDeclaration[] = [];
constructor(output: Output, format: HlsOutputFormat) {
if (!(output._target instanceof PathedTarget)) {
throw new TypeError('HLS outputs require `OutputOptions.target` to be a PathedTarget.');
}
super(output);
this.format = format;
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
?? (({ n }) => `playlist-${n}.m3u8`);
this.getSegmentPath = format._options.getSegmentPath
?? (info => info.isSingleFile
? `segments-${info.playlist.n}${info.format.fileExtension}`
: `segment-${info.playlist.n}-${info.n}${info.format.fileExtension}`);
this.getInitPath = format._options.getInitPath
?? (playlist => `init-${playlist.n}${playlist.segmentFormat.fileExtension}`);
}
async start(): Promise<void> {
const release = await this.mutex.acquire();
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
// remain pairable, and no two tracks become pairable that are meant to be mutually exclusive.
// The algorithm determines "groups" by enumerating all pairable tracks for each track, and then materializes
// each group either as #EXT-X-MEDIA tags or top-level #EXT-X-STREAM-INF tags. The algorithm is biased towards
// video being the top-level grouping, since that's the standard practice.
const groupAssignment = new Map<OutputTrack, string[]>();
const groups: {
name: string;
key: string;
tracks: OutputTrack[];
needsEmit: boolean;
firstNoUri: boolean;
}[] = [];
let hasVideo = false;
let illegalPairingDetected = false;
let keyPacketsOnlyPairingWarned = false;
// First, let's build the "sibling" groups induced by track pairability
for (const track of this.output._tracks) {
if (track.type === 'video') {
hasVideo = true;
}
const pairableGroups = new Map<MediaCodec, OutputTrack[]>();
for (const otherTrack of this.output._tracks) {
if (track === otherTrack) {
continue;
}
if (!track.canBePairedWith(otherTrack)) {
continue;
}
if (track.type === otherTrack.type) {
if (!illegalPairingDetected) {
console.warn(
`Illegal pairing of two ${track.type} tracks detected, which is not possible in HLS;`
+ ` treating them as unpaired.`,
);
illegalPairingDetected = true;
}
continue;
}
// Key-packets-only tracks can neither pair with nor be paired with other tracks
if (
(track.isVideoTrack() && track.metadata.hasOnlyKeyPackets)
|| (otherTrack.isVideoTrack() && otherTrack.metadata.hasOnlyKeyPackets)
) {
if (!keyPacketsOnlyPairingWarned) {
console.warn(
`A key-packets-only video track is pairable with another track, which is not`
+ ` possible in HLS; treating them as unpaired.`,
);
keyPacketsOnlyPairingWarned = true;
}
continue;
}
let groupTracks = pairableGroups.get(otherTrack.source._codec);
if (!groupTracks) {
pairableGroups.set(otherTrack.source._codec, groupTracks = []);
}
groupTracks.push(otherTrack);
}
for (const [, pairableTracks] of pairableGroups) {
const key = pairableTracks.map(x => x.id).join('-');
const group = groups.find(x => x.key === key);
if (!group) {
groups.push({
name: pairableTracks[0]!.type + '-' + (groups.length + 1),
key,
tracks: pairableTracks,
needsEmit: false,
firstNoUri: false,
});
}
let assignedGroups = groupAssignment.get(track);
if (!assignedGroups) {
groupAssignment.set(track, assignedGroups = []);
}
assignedGroups.push(key);
}
}
const mainType: TrackType = hasVideo ? 'video' : 'audio';
const variantStreams: {
tracks: OutputTrack[];
linkedGroup: typeof groups[number] | null;
}[] = [];
const unpairedVideoTracks: OutputTrack[] = [];
const unpairedAudioTracks: OutputTrack[] = [];
// Now, create the top-level variant streams
for (const track of this.output._tracks) {
const assignedGroupKeys = groupAssignment.get(track);
if (assignedGroupKeys) {
assert(assignedGroupKeys.length > 0);
if (track.type !== mainType) {
continue;
}
for (const key of assignedGroupKeys) {
const group = groups.find(x => x.key === key);
assert(group);
if (assignedGroupKeys.length === 1 && group.tracks.length === 1) {
const otherGroupKeys = groupAssignment.get(group.tracks[0]!);
assert(otherGroupKeys !== undefined);
if (otherGroupKeys.length === 1) {
const otherGroup = groups.find(x => x.key === otherGroupKeys[0]!)!;
if (otherGroup.tracks.length === 1) {
assert(otherGroup.tracks[0] === track);
variantStreams.push({
tracks: [track, group.tracks[0]!],
linkedGroup: null,
});
continue;
}
}
}
variantStreams.push({
tracks: [track],
linkedGroup: group,
});
group.needsEmit = true;
}
} else {
if (track.type === 'video') {
unpairedVideoTracks.push(track);
} else if (track.type === 'audio') {
unpairedAudioTracks.push(track);
}
}
}
const getMetadataKeyForTrack = ({ metadata }: OutputTrack) => {
let key = '';
key += `${metadata.languageCode ?? UNDETERMINED_LANGUAGE}-`;
key += `${metadata.name ?? ''}-`;
key += `${metadata.disposition?.default ?? true}-`;
key += `${metadata.disposition?.primary ?? false}-`;
key += `${metadata.disposition?.forced ?? false}-`;
return key;
};
// Video tracks that can't be paired with any other track always live on the top-level, the question is just if
// they need to be separated into #EXT-X-MEDIA tags or not
if (unpairedVideoTracks.length > 0) {
const uniqueMetadata = new Set(unpairedVideoTracks.map(getMetadataKeyForTrack));
if (uniqueMetadata.size > 1) {
// They differ in metadata, emit as group
const group: typeof groups[number] = {
key: unpairedVideoTracks.map(x => x.id).join('-'),
name: 'video-' + (groups.length + 1),
tracks: unpairedVideoTracks,
needsEmit: true,
firstNoUri: true,
};
groups.push(group);
variantStreams.push({
tracks: [unpairedVideoTracks[0]!],
linkedGroup: group,
});
} else {
for (const track of unpairedVideoTracks) {
variantStreams.push({
tracks: [track],
linkedGroup: null,
});
}
}
}
// Audio tracks that can't be paired with any other track always live on the top-level, the question is just if
// they need to be separated into #EXT-X-MEDIA tags or not
if (unpairedAudioTracks.length > 0) {
const uniqueMetadata = new Set(unpairedAudioTracks.map(getMetadataKeyForTrack));
if (uniqueMetadata.size > 1) {
// They differ in metadata, emit as group
const group: typeof groups[number] = {
key: unpairedAudioTracks.map(x => x.id).join('-'),
name: 'audio-' + (groups.length + 1),
tracks: unpairedAudioTracks,
needsEmit: true,
firstNoUri: true,
};
groups.push(group);
variantStreams.push({
tracks: [unpairedAudioTracks[0]!],
linkedGroup: group,
});
} else {
for (const track of unpairedAudioTracks) {
variantStreams.push({
tracks: [track],
linkedGroup: null,
});
}
}
}
const deduceSegmentFormat = (tracks: OutputTrack[]) => {
const codecs: MediaCodec[] = [];
let videoCount = 0;
let audioCount = 0;
let requiresRotationMetadata = false;
let candidate: OutputFormat | null = null;
let candidateScore = -Infinity;
for (const track of tracks) {
if (track.isVideoTrack()) {
videoCount++;
requiresRotationMetadata ||= (track.metadata.rotation ?? 0) !== 0;
} else if (track.isAudioTrack()) {
audioCount++;
}
codecs.push(track.source._codec);
}
for (const format of toArray(this.format._options.segmentFormat)) {
const supportedCodecs = format.getSupportedCodecs();
const trackCounts = format.getSupportedTrackCounts();
if (codecs.some(codec => !supportedCodecs.includes(codec))) {
continue;
}
if (videoCount < trackCounts.video.min || videoCount > trackCounts.video.max) {
continue;
}
if (audioCount < trackCounts.audio.min || audioCount > trackCounts.audio.max) {
continue;
}
let score = 0;
if (requiresRotationMetadata && format.supportsVideoRotationMetadata) {
score++;
}
if (score > candidateScore) {
candidate = format;
candidateScore = score;
}
}
// We must find a format. If no format is found, that means we incorrectly gated track creation and
// assignment at an earlier step.
assert(candidate);
return candidate;
};
const registerPlaylist = async (tracks: OutputTrack[]) => {
if (tracks.some(track => this.playlists.some(playlist => playlist.tracks.includes(track)))) {
throw new Error('Internal error: track is already registered in a playlist.'); // Should be unreachable
}
const format = deduceSegmentFormat(tracks);
const id = this.playlists.length + 1;
const path = await this.getPlaylistPath({
n: id,
tracks,
segmentFormat: format,
});
validatePlaylistPath(path);
const playlist: Playlist = {
id: this.playlists.length + 1,
path,
tracks,
segmentFormat: format,
currentSegmentStartTimestamp: null,
currentSegmentStartTimestampIsFixed: false,
nextSegmentId: 1,
initSegment: null,
writtenSegments: [],
peakBitrate: null,
averageBitrate: null,
mediaSequence: 0,
done: false,
singleFile: null,
mutex: new AsyncMutex(),
};
this.playlists.push(playlist);
return playlist;
};
// Now, finally let's create all declarations. Each declaration maps to one #EXT-X-MEDIA or #EXT-X-STREAM-INF
// tag in the final master playlist.
for (const group of groups) {
if (!group.needsEmit) {
continue;
}
for (let i = 0; i < group.tracks.length; i++) {
const track = group.tracks[i]!;
let playlist = this.playlists.find(x => x.tracks[0]!.id === track.id);
playlist ??= await registerPlaylist([track]);
this.playlistDeclarations.push({
playlist,
groupId: group.name,
noUri: group.firstNoUri && i === 0,
references: [],
});
}
}
for (const variant of variantStreams) {
// Since tracks can only be assigned to one playlist, the first track's ID acts as a "playlist key"
let playlist = this.playlists.find(x => x.tracks[0]!.id === variant.tracks[0]!.id);
playlist ??= await registerPlaylist(variant.tracks);
this.playlistDeclarations.push({
playlist,
groupId: null,
noUri: false,
references: variant.linkedGroup
? this.playlistDeclarations.filter(x => x.groupId === variant.linkedGroup!.name)
: [],
});
}
release();
}
async getMimeType(): Promise<string> {
return HLS_MIME_TYPE;
}
private allTracksAreKnown(playlist: Playlist) {
for (const track of playlist.tracks) {
if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) {
return false; // We haven't seen a sample from this open track yet
}
}
return true;
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async onTrackClose(track: OutputTrack) {
const trackData = this.trackDatas.find(x => x.track === track);
if (trackData) {
trackData.closed = true;
}
const playlist = this.playlists.find(x => x.tracks.includes(track));
assert(playlist); // If there isn't one then the assignment algo failed innit
const release = await playlist.mutex.acquire();
try {
await this.advancePlaylist(playlist);
} finally {
release();
}
}
getVideoTrackData(track: OutputVideoTrack, meta?: EncodedVideoChunkMetadata) {
let trackData = this.trackDatas.find(x => x.track === track) as HlsVideoTrackData;
if (trackData) {
return trackData;
}
validateVideoChunkMetadata(meta);
assert(meta);
assert(meta?.decoderConfig);
const playlists = this.playlists.filter(x => x.tracks.includes(track));
assert(playlists.length === 1);
trackData = {
track,
packets: [],
playlist: playlists[0]!,
closed: false,
info: {
type: 'video',
decoderConfig: meta.decoderConfig,
},
};
this.trackDatas.push(trackData);
return trackData;
}
getAudioTrackData(track: OutputAudioTrack, meta?: EncodedAudioChunkMetadata) {
let trackData = this.trackDatas.find(x => x.track === track) as HlsAudioTrackData;
if (trackData) {
return trackData;
}
validateAudioChunkMetadata(meta);
assert(meta);
assert(meta?.decoderConfig);
const playlists = this.playlists.filter(x => x.tracks.includes(track));
assert(playlists.length === 1);
trackData = {
track,
packets: [],
playlist: playlists[0]!,
closed: false,
info: {
type: 'audio',
decoderConfig: meta.decoderConfig,
},
};
this.trackDatas.push(trackData);
return trackData;
}
async addEncodedVideoPacket(
track: OutputVideoTrack,
packet: EncodedPacket,
meta?: EncodedVideoChunkMetadata,
) {
const trackData = this.getVideoTrackData(track, meta);
const playlist = trackData.playlist;
const release = await playlist.mutex.acquire();
try {
this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
trackData.packets.push(packet);
if (playlist.currentSegmentStartTimestamp === null) {
playlist.currentSegmentStartTimestamp = packet.timestamp;
} else if (!playlist.currentSegmentStartTimestampIsFixed) {
playlist.currentSegmentStartTimestamp = Math.min(
playlist.currentSegmentStartTimestamp,
packet.timestamp,
);
}
await this.advancePlaylist(playlist);
} finally {
release();
}
}
async addEncodedAudioPacket(
track: OutputAudioTrack,
packet: EncodedPacket,
meta?: EncodedAudioChunkMetadata,
) {
const trackData = this.getAudioTrackData(track, meta);
const playlist = trackData.playlist;
const release = await playlist.mutex.acquire();
try {
this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
trackData.packets.push(packet);
if (playlist.currentSegmentStartTimestamp === null) {
playlist.currentSegmentStartTimestamp = packet.timestamp;
} else if (!playlist.currentSegmentStartTimestampIsFixed) {
playlist.currentSegmentStartTimestamp = Math.min(
playlist.currentSegmentStartTimestamp,
packet.timestamp,
);
}
await this.advancePlaylist(playlist);
} finally {
release();
}
}
async addSubtitleCue(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
track: OutputSubtitleTrack,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
cue: SubtitleCue,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
meta?: SubtitleMetadata,
) {
throw new Error('Unreachable.');
}
async advancePlaylist(playlist: Playlist) {
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;
// Loop in case we can finalize multiple segments
while (true) {
// This here is the core segmentation logic. The segmentation logic figures out which packets are to be
// written into the next segment, and if we can write a segment at all. If tracks are still open and have
// not provided sufficient media data, no segment will be written. The packets will be added to the segment
// to maximize its duration AND keep it from exceeding the target duration. This condition is extended with
// a key frame rule for video, meaning the algorithm must guarantee that every segment with video data
// begins with a video key frame.
//
// The logic is quite complex but is solved in a straight-forward way: all possible permutations of the
// problem are checked in a nested if-else structure, making sure all cases behave correctly. This was the
// easiest, least error-prone way I found to express this behavior.
const currentSegmentEndTimestamp = playlist.currentSegmentStartTimestamp + this.targetSegmentDuration;
// These store the index (exclusive) until when packets can be added to the next segment
let videoEndIndex = 0;
let audioEndIndex = 0;
if (videoTrack && (!videoTrack.closed || videoTrack.packets.length > 0)) {
// A video track is active (and maybe an audio track too)
const allBelow = videoTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp);
let bestKeyPacket: EncodedPacket | null = null;
let bestKeyPacketIndex: number | null = null;
if (allBelow) {
if (!videoTrack.closed) {
// Not enough data yet
return;
}
} else {
// Find the best key packet timestamp
for (let i = 0; i < videoTrack.packets.length; i++) {
const packet = videoTrack.packets[i]!;
if (bestKeyPacket !== null && packet.timestamp > currentSegmentEndTimestamp) {
break;
}
if (i > 0 && packet.type === 'key') {
bestKeyPacket = packet;
bestKeyPacketIndex = i;
}
}
}
if (bestKeyPacketIndex !== null) {
videoEndIndex = bestKeyPacketIndex;
if (audioTrack) {
// The audio track must go at least until the video key frame
const index = audioTrack.packets.findIndex(x => x.timestamp >= bestKeyPacket!.timestamp);
if (index !== -1) {
audioEndIndex = index;
} else {
if (audioTrack.closed) {
audioEndIndex = audioTrack.packets.length;
} else {
return;
}
}
}
} else {
if (!videoTrack.closed) {
return;
}
// Include the entire rest of the video (since there's no key frame to split it on)
videoEndIndex = videoTrack.packets.length;
const maxIndex = arrayArgmax(videoTrack.packets, x => x.timestamp);
const maxPacket = videoTrack.packets[maxIndex];
assert(maxPacket);
if (audioTrack) {
if (maxPacket.timestamp < currentSegmentEndTimestamp) {
// The audio must go until at least the start of the next segment
const index = audioTrack.packets.findIndex(x => x.timestamp >= currentSegmentEndTimestamp);
if (index !== -1) {
audioEndIndex = index;
} else {
if (audioTrack.closed) {
audioEndIndex = audioTrack.packets.length;
} else {
return;
}
}
} else {
// The audio must go beyond the last video packet
const index = audioTrack.packets.findIndex(x => x.timestamp > maxPacket.timestamp);
if (index !== -1) {
audioEndIndex = index;
} else {
if (audioTrack.closed) {
audioEndIndex = audioTrack.packets.length;
} else {
return;
}
}
}
}
}
} else if (audioTrack && (!audioTrack.closed || audioTrack.packets.length > 0)) {
// There's only an audio track active
const allBelow = audioTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp);
if (allBelow) {
if (audioTrack.closed) {
// We can write all packets since they're all below
audioEndIndex = audioTrack.packets.length;
} else {
// We don't know enough packets yet
return;
}
} else {
// Aim to make the segment at most as long as desired
const index = findLastIndex(audioTrack.packets, x => x.timestamp <= currentSegmentEndTimestamp);
audioEndIndex = Math.max(index, 1); // Always include at least the first packet
}
}
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;
}
// We can finalize a new segment!
let segmentInfo: HlsOutputSegmentInfo | null = null;
let relativeSegmentPath: string;
let fullSegmentPath: string;
assert(this.output._target instanceof PathedTarget);
const pathedTarget = this.output._target;
if (this.singleFilePerPlaylist) {
if (playlist.singleFile === null) {
// INTENTIONALLY shadow the outside `segmentInfo` because we don't want to set it.
// In single-file mode, onSegment is called once in onPlaylistDone instead of per-segment,
// so the outer `segmentInfo` intentionally stays null in this case.
const segmentInfo: HlsOutputSegmentInfo = {
n: playlist.nextSegmentId,
format: playlist.segmentFormat,
isSingleFile: true,
playlist: toPlaylistInfo(playlist),
};
relativeSegmentPath = await this.getSegmentPath(segmentInfo);
validateSegmentPath(relativeSegmentPath);
fullSegmentPath = joinPaths(
joinPaths(pathedTarget.rootPath, playlist.path),
relativeSegmentPath,
);
const target = await this.output._getTarget({
path: fullSegmentPath,
isRoot: false,
mimeType: playlist.segmentFormat.mimeType,
});
target._start();
playlist.singleFile = {
target,
path: relativeSegmentPath,
nextOffset: 0,
info: segmentInfo,
};
} else {
relativeSegmentPath = playlist.singleFile.path;
fullSegmentPath = joinPaths(
joinPaths(pathedTarget.rootPath, playlist.path),
relativeSegmentPath,
);
}
} else {
segmentInfo = {
n: playlist.nextSegmentId,
format: playlist.segmentFormat,
isSingleFile: false,
playlist: toPlaylistInfo(playlist),
};
relativeSegmentPath = await this.getSegmentPath(segmentInfo);
validateSegmentPath(relativeSegmentPath);
fullSegmentPath = joinPaths(joinPaths(pathedTarget.rootPath, playlist.path), relativeSegmentPath);
playlist.nextSegmentId++;
}
let segmentSize = 0;
let outputTarget: Target | null = null;
const output = new Output({
format: playlist.segmentFormat,
target: new PathedTarget(
fullSegmentPath,
async (request: TargetRequest) => {
const proxiedRequest: TargetRequest = {
...request,
isRoot: false,
};
if (request.isRoot) {
if (playlist.singleFile) {
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
return slice;
} else {
const target = await this.output._getTarget(proxiedRequest);
outputTarget = target;
target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
return target;
}
}
return this.output._getTarget(proxiedRequest);
},
),
initTarget: async () => {
if (playlist.initSegment) {
// We already have an init segment from a previous segment
return new NullTarget();
}
if (playlist.singleFile) {
playlist.initSegment = {
path: playlist.singleFile.path,
duration: 0,
timestamp: 0,
byteSize: 0,
byteOffset: 0,
info: null,
};
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
slice.on('write', ({ end }) => {
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
});
slice.on('finalized', () => {
playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize;
});
return slice;
} else {
const playlistInfo = toPlaylistInfo(playlist);
const initPath = await this.getInitPath(playlistInfo);
validateInitPath(initPath);
playlist.initSegment = {
path: initPath,
duration: 0,
timestamp: 0,
byteSize: 0,
byteOffset: null,
info: null,
};
const fullInitPath = joinPaths(
joinPaths(pathedTarget.rootPath, playlist.path),
initPath,
);
const target = await this.output._getTarget({
path: fullInitPath,
isRoot: false,
mimeType: playlist.segmentFormat.mimeType,
});
target.on('write', ({ end }) => {
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
});
target.on('finalized', () => {
this.format._options.onInit?.(target, playlistInfo);
});
return target;
}
},
});
let maxEndTimestamp = -Infinity;
try {
let videoSource: EncodedVideoPacketSource | null = null;
let audioSource: EncodedAudioPacketSource | null = null;
if (videoTrack) {
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
videoSource = new EncodedVideoPacketSource((videoTrack.track as OutputVideoTrack).source._codec);
output.addVideoTrack(videoSource, videoTrack.track.metadata);
}
if (audioTrack) {
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
audioSource = new EncodedAudioPacketSource((audioTrack.track as OutputAudioTrack).source._codec);
output.addAudioTrack(audioSource, audioTrack.track.metadata);
}
await output.start();
// Add all of the packets
if (videoTrack) {
assert(videoSource);
const meta = { decoderConfig: videoTrack.info.decoderConfig };
for (let i = 0; i < videoEndIndex; i++) {
const packet = videoTrack.packets[i]!;
await videoSource.add(packet, meta);
maxEndTimestamp = Math.max(maxEndTimestamp, packet.timestamp + packet.duration);
}
}
if (audioTrack) {
assert(audioSource);
const meta = { decoderConfig: audioTrack.info.decoderConfig };
for (let i = 0; i < audioEndIndex; i++) {
const packet = audioTrack.packets[i]!;
await audioSource.add(packet, meta);
maxEndTimestamp = Math.max(maxEndTimestamp, packet.timestamp + packet.duration);
}
}
await output.finalize();
} catch (e) {
await output.cancel();
throw e;
}
if (segmentInfo) {
assert(outputTarget);
this.format._options.onSegment?.(outputTarget, segmentInfo);
}
if (videoEndIndex > 0) {
assert(videoTrack);
videoTrack.packets.splice(0, videoEndIndex);
}
if (audioEndIndex > 0) {
assert(audioTrack);
audioTrack.packets.splice(0, audioEndIndex);
}
let minNextTimestamp = Infinity;
if (videoTrack && videoTrack.packets.length > 0) {
minNextTimestamp = videoTrack.packets[0]!.timestamp;
}
if (audioTrack && audioTrack.packets.length > 0) {
minNextTimestamp = Math.min(minNextTimestamp, audioTrack.packets[0]!.timestamp);
}
const nextSegmentStartTimestamp = minNextTimestamp < Infinity
? minNextTimestamp
: maxEndTimestamp; // Happens for the last segment for example
assert(Number.isFinite(nextSegmentStartTimestamp));
const segmentDuration = nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp;
assert(segmentDuration >= 0);
playlist.writtenSegments.push({
path: relativeSegmentPath,
duration: segmentDuration,
timestamp: playlist.currentSegmentStartTimestamp,
byteSize: segmentSize,
byteOffset: playlist.singleFile
? playlist.singleFile.nextOffset
: null,
info: segmentInfo ?? 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) {
while (playlist.writtenSegments.length > this.maxLiveSegmentCount) {
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);
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) {
const segmentDuration = segment.duration || 1; // To catch 0-duration segments which can happen
peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segmentDuration);
}
}
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._target instanceof PathedTarget);
const pathedTarget = this.output._target;
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(pathedTarget.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"
+ (Number.isFinite(this.maxLiveSegmentCount) ? `#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence}\n` : '')
+ '#EXT-X-INDEPENDENT-SEGMENTS\n'
+ (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
+ (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`
: '')
+ `${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,
mimeType: HLS_MIME_TYPE,
});
const writer = new Writer(target, true);
writer.start();
writer.write(textEncoder.encode(playlistText));
await writer.flush();
await writer.finalize();
}
private async writeMasterPlaylist() {
assert(this.output._target instanceof PathedTarget);
const pathedTarget = this.output._target;
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 release = await this.mutex.acquire();
try {
let writer: Writer;
if (this.numWrittenMasterPlaylists === 0) {
// For the first master playlist write, we use the normal root writer getter, so that the target
// returned by Output.target emits valid write events.
writer = await this.output._getRootWriter(true);
} else {
// For subsequent master playlist writes, we *must* obtain a different target in order to overwrite
// the file.
const target = await this.output._getTarget({
path: pathedTarget.rootPath,
isRoot: true,
mimeType: HLS_MIME_TYPE,
});
writer = new Writer(target, true);
writer.start();
}
writer.write(textEncoder.encode(masterPlaylistText));
await writer.flush();
await writer.finalize();
this.numWrittenMasterPlaylists++;
} finally {
release();
}
}
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() {
const releases = await Promise.all(this.playlists.map(p => p.mutex.acquire()));
releases.forEach(release => release());
for (const trackData of this.trackDatas) {
trackData.closed = true;
}
await Promise.all(this.playlists.map(playlist => (
playlist.done ? Promise.resolve() : this.advancePlaylist(playlist)
)));
if (!this.isLive) {
await this.writeMasterPlaylist();
}
}
}
const validatePlaylistPath = (path: string) => {
if (typeof path !== 'string') {
throw new TypeError('options.getPlaylistPath must return or resolve to a string');
}
if (/[\n\r"]/.test(path)) {
throw new TypeError(
'Playlist paths cannot contain line feed, carriage return, or double quote characters.',
);
}
};
const validateSegmentPath = (path: string) => {
if (typeof path !== 'string') {
throw new TypeError('options.getSegmentPath must return or resolve to a string');
}
if (/[\n\r"]/.test(path)) {
throw new TypeError(
'Segment paths cannot contain line feed or carriage return characters.',
);
}
};
const validateInitPath = (path: string) => {
if (typeof path !== 'string') {
throw new TypeError('options.getInitPath must return or resolve to a string');
}
if (/[\n\r"]/.test(path)) {
throw new TypeError(
'Init paths cannot contain line feed, carriage return, or double quote characters.',
);
}
};
const toPlaylistInfo = (playlist: Playlist): HlsOutputPlaylistInfo => {
return {
n: playlist.id,
tracks: playlist.tracks,
segmentFormat: playlist.segmentFormat,
};
};