diff --git a/dev/convert.html b/dev/convert.html
index 10264e1..6b88382 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -30,10 +30,12 @@
p.textContent = 'Capturing...';
document.body.append(p);
+ /*
const button = document.createElement('button');
button.textContent = 'Cancel';
button.onclick = () => conversion.cancel();
document.body.append(button);
+ */
const yo = document.createElement('canvas');
yo.width = 512;
@@ -58,7 +60,7 @@
if (true) {
input = new Mediabunny.Input({
- entryPath: 'https://zdf-hls-15.akamaized.net/hls/live/2016498/de/high/master.m3u8',
+ entryPath: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8',
source: ({ path }) => new Mediabunny.UrlSource(path),
formats: Mediabunny.ALL_FORMATS,
});
@@ -84,7 +86,7 @@
}
let ctx = null;
- const conversion = await Mediabunny.Conversion.init({
+ let conversion = await Mediabunny.Conversion.init({
input,
output,
audio: (track, n) => ({
@@ -220,13 +222,13 @@
}
},
trim: {
- start,
- end: start + 5,
- //start: 0,
- end: 2
+ //start,
+ //end: start + 5,
+ ////start: 0,
+ //end: 2
},
});
- console.log(conversion);
+ //console.log(conversion);
let progress = 0;
conversion.onProgress = newProgress => progress = newProgress;
@@ -247,6 +249,7 @@
console.timeEnd()
console.log("Done", target.buffer);
+ input.dispose()
const video = document.createElement('video');
video.src = URL.createObjectURL(new Blob([target.buffer], { type: outputFormat.mimeType }));
@@ -264,5 +267,9 @@
a.click();
URL.revokeObjectURL(url);
}
+
+ conversion.onProgress = null;
+ conversion = null;
+ input = null;
}, { once: true });
\ No newline at end of file
diff --git a/dev/demux.html b/dev/demux.html
index 464b425..ebe2944 100644
--- a/dev/demux.html
+++ b/dev/demux.html
@@ -26,11 +26,14 @@
*/
const manifest = new Mediabunny.Input({
- entryPath: 'https://zdf-hls-15.akamaized.net/hls/live/2016498/de/high/master.m3u8',
+ entryPath: 'https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8',
source: ({ path }) => new Mediabunny.UrlSource(path),
formats: Mediabunny.ALL_FORMATS,
});
+ console.log(await manifest.getTracks());
+ return;
+
const videoTrack = await manifest.getPrimaryVideoTrack();
await videoTrack.hydrate();
diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts
index 1721456..32d2cc9 100644
--- a/examples/media-player/media-player.ts
+++ b/examples/media-player/media-player.ts
@@ -103,7 +103,7 @@ const initMediaPlayer = async (resource: File | string) => {
let audioTrack: InputAudioTrack | null = null;
if (true || typeof resource === 'string' && resource.includes('.m3u8')) {
const input = new Input({
- entryPath: 'playlist.m3u8',
+ entryPath: 'master.m3u8',
source: async ({ path }) => {
const fileHandle = await dirHandle.getFileHandle(path);
const file = await fileHandle.getFile();
diff --git a/examples/procedural-generation/procedural-generation.ts b/examples/procedural-generation/procedural-generation.ts
index 8dc6c17..3c37357 100644
--- a/examples/procedural-generation/procedural-generation.ts
+++ b/examples/procedural-generation/procedural-generation.ts
@@ -9,6 +9,9 @@ import {
getFirstEncodableVideoCodec,
OutputFormat,
HlsOutputFormat,
+ OutputTrackGroup,
+ MpegTsOutputFormat,
+ AdtsOutputFormat,
} from 'mediabunny';
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
@@ -92,7 +95,7 @@ const generateVideo = async () => {
// Create a new output file
output = new Output({
- rootPath: 'playlist.m3u8',
+ rootPath: 'master.m3u8',
target: ({ path }) => {
const target = new BufferTarget();
target.onfinalized = async () => {
@@ -103,8 +106,11 @@ const generateVideo = async () => {
};
return target;
- }, // Stored in memory
- format: new HlsOutputFormat(),
+ },
+ format: new HlsOutputFormat({
+ segmentFormats: [new AdtsOutputFormat(), new MpegTsOutputFormat()],
+ getPlaylistPath: info => `sussex-${info.n}.m3u8`,
+ }),
});
// Retrieve the first video codec supported by this browser that can be contained in the output format
@@ -124,8 +130,11 @@ const generateVideo = async () => {
});
output.addVideoTrack(canvasSource, { frameRate });
+ // output._defaultTrackGroup.pair(otherGroup);
+
// For audio, we use ArrayBufferSource, because we'll be creating an ArrayBuffer with OfflineAudioContext
let audioBufferSource: AudioBufferSource | null = null;
+ let audioBufferSource2: AudioBufferSource | null = null;
// Retrieve the first audio codec supported by this browser that can be contained in the output format
const audioCodec = await getFirstEncodableAudioCodec(output.format.getSupportedAudioCodecs(), {
@@ -138,6 +147,12 @@ const generateVideo = async () => {
bitrate: QUALITY_HIGH,
});
output.addAudioTrack(audioBufferSource);
+
+ audioBufferSource2 = new AudioBufferSource({
+ codec: audioCodec,
+ bitrate: QUALITY_HIGH,
+ });
+ output.addAudioTrack(audioBufferSource2, { languageCode: 'esp' });
} else {
alert('Your browser doesn\'t support audio encoding, so we won\'t include audio in the output file.');
}
@@ -180,6 +195,9 @@ const generateVideo = async () => {
const audioBuffer = await audioContext.startRendering();
await audioBufferSource.add(audioBuffer);
audioBufferSource.close();
+
+ await audioBufferSource2!.add(audioBuffer);
+ audioBufferSource2!.close();
}
clearInterval(progressInterval);
diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts
index 4f85b2b..4282161 100644
--- a/src/hls/hls-demuxer.ts
+++ b/src/hls/hls-demuxer.ts
@@ -24,6 +24,7 @@ type InternalTrack = {
inputTrack: InputTrack | null;
backingTrack: InputTrack | null;
default: boolean;
+ autoselect: boolean;
languageCode: string;
lineNumber: number;
@@ -339,6 +340,7 @@ export class HlsDemuxer extends Demuxer {
inputTrack: null,
backingTrack: null,
default: true,
+ autoselect: true,
languageCode: UNDETERMINED_LANGUAGE,
lineNumber: variantStream.lineNumber,
fullPath: variantStream.fullPath,
@@ -390,6 +392,9 @@ export class HlsDemuxer extends Demuxer {
inputTrack: null,
backingTrack: null,
default: getMediaTagDefault(mediaTag.attributes),
+ // Autoselect is inferred to be true if the default is true
+ autoselect: getMediaTagDefault(mediaTag.attributes)
+ || getMediaTagAutoselect(mediaTag.attributes),
languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')),
lineNumber: mediaTag.lineNumber,
fullPath: mediaTag.fullPath ?? variantStream.fullPath,
@@ -432,6 +437,7 @@ export class HlsDemuxer extends Demuxer {
inputTrack: null,
backingTrack: null,
default: true,
+ autoselect: true,
languageCode: UNDETERMINED_LANGUAGE,
lineNumber: variantStream.lineNumber,
fullPath: variantStream.fullPath,
@@ -480,6 +486,9 @@ export class HlsDemuxer extends Demuxer {
inputTrack: null,
backingTrack: null,
default: getMediaTagDefault(mediaTag.attributes),
+ // Autoselect is inferred to be true if the default is true
+ autoselect: getMediaTagDefault(mediaTag.attributes)
+ || getMediaTagAutoselect(mediaTag.attributes),
languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')),
lineNumber: mediaTag.lineNumber,
fullPath: mediaTag.fullPath ?? variantStream.fullPath,
@@ -634,7 +643,9 @@ abstract class HlsInputTrackBacking implements InputTrackBacking {
getDisposition(): TrackDisposition {
return {
...DEFAULT_TRACK_DISPOSITION,
- default: this.internalTrack.default,
+ // Meanings are swapped in HLS: "Default" means that a track is the primary track.
+ default: this.internalTrack.autoselect,
+ primary: this.internalTrack.default,
};
}
@@ -943,6 +954,25 @@ const getMediaTagDefault = (attributes: AttributeList) => {
);
};
+const getMediaTagAutoselect = (attributes: AttributeList) => {
+ const value = attributes.get('autoselect');
+ if (value === null) {
+ return false;
+ }
+
+ const normalized = value.toUpperCase();
+ if (normalized === 'YES') {
+ return true;
+ }
+ if (normalized === 'NO') {
+ return false;
+ }
+
+ throw new Error(
+ `Invalid M3U8 file; #EXT-X-MEDIA AUTOSELECT attribute must be YES or NO, got "${value}".`,
+ );
+};
+
const preprocessLanguageCode = (code: string | null) => {
if (code === null) {
return UNDETERMINED_LANGUAGE;
diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts
index c04e7bd..2fae58a 100644
--- a/src/hls/hls-muxer.ts
+++ b/src/hls/hls-muxer.ts
@@ -1,16 +1,16 @@
-import { validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
+import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
import { EncodedAudioPacketSource, EncodedVideoPacketSource, MediaSource } from '../media-source';
-import { assert, joinPaths, last, textEncoder } from '../misc';
+import { assert, joinPaths, textEncoder, UNDETERMINED_LANGUAGE } from '../misc';
import { Muxer } from '../muxer';
-import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
-import { MpegTsOutputFormat } from '../output-format';
+import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack, TrackType } from '../output';
+import { HlsOutputFormat, HlsOutputFormatOptions, OutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { SubtitleCue, SubtitleMetadata } from '../subtitles';
-import { Writer } from '../writer';
type HlsTrackData = {
track: OutputTrack;
packets: EncodedPacket[];
+ playlist: Playlist;
info: {
type: 'video';
decoderConfig: VideoDecoderConfig;
@@ -22,34 +22,429 @@ type HlsTrackData = {
type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } };
type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } };
-export class HlsMuxer extends Muxer {
- targetSegmentDuration = 2;
- trackDatas: HlsTrackData[] = [];
- currentSegmentStartTimestamp: number | null = null;
- nextSegmentId = 1;
+type Playlist = {
+ id: number;
+ key: string;
+ path: string;
+ tracks: OutputTrack[];
+ segmentFormat: OutputFormat;
+
+ currentSegmentStartTimestamp: number | null;
+ nextSegmentId: number;
writtenSegments: {
path: string;
duration: number;
- }[] = [];
+ byteSize: number;
+ }[];
+ peakBitrate: number | null;
+ averageBitrate: number | null;
+};
- constructor(output: Output) {
- if (typeof output.target !== 'function') {
+type PlaylistDeclaration = {
+ playlist: Playlist;
+ groupId: string | null;
+ noUri: boolean;
+ references: PlaylistDeclaration[];
+};
+
+export class HlsMuxer extends Muxer {
+ format: HlsOutputFormat;
+ getPlaylistPath: NonNullable;
+ getSegmentPath: NonNullable;
+
+ targetSegmentDuration = 2;
+ trackDatas: HlsTrackData[] = [];
+
+ playlists: Playlist[] = [];
+ playlistDeclarations: PlaylistDeclaration[] = [];
+
+ constructor(output: Output, format: HlsOutputFormat) {
+ if (typeof output._target !== 'function') {
throw new TypeError('HLS outputs require `OutputOptions.target` to be a function.');
}
super(output);
+
+ this.format = format;
+
+ this.getPlaylistPath = format._options.getPlaylistPath
+ ?? (({ n }) => `playlist-${n}.m3u8`);
+ this.getSegmentPath = format._options.getSegmentPath
+ ?? (info => `segment-${info.playlist.n}-${info.n}${info.format.fileExtension}`);
}
async start(): Promise {
- // Nada
+ // 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();
+ const groups: {
+ name: string;
+ key: string;
+ tracks: OutputTrack[];
+ needsEmit: boolean;
+ firstNoUri: boolean;
+ }[] = [];
+
+ let hasVideo = false;
+ let illegalPairingDetected = 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();
+ const trackGroups = Array.isArray(track.metadata.group)
+ ? track.metadata.group
+ : [track.metadata.group!];
+
+ for (const otherTrack of this.output._tracks) {
+ if (track === otherTrack) {
+ continue;
+ }
+
+ let pairable = false;
+ const otherTrackGroups = Array.isArray(otherTrack.metadata.group)
+ ? otherTrack.metadata.group
+ : [otherTrack.metadata.group!];
+
+ for (const group of trackGroups) {
+ const pairableInSameGroup = track.type !== otherTrack.type
+ && otherTrackGroups.some(otherGroup => group === otherGroup);
+ if (pairableInSameGroup) {
+ pairable = true;
+ break;
+ }
+
+ const pairableAcrossGroups = otherTrackGroups.some(
+ otherGroup => group._pairedGroups.has(otherGroup),
+ );
+ if (pairableAcrossGroups) {
+ pairable = true;
+ break;
+ }
+ }
+
+ if (!pairable) {
+ 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;
+ }
+
+ 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);
+ }
+ }
+ }
+
+ // 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(({ metadata }) => {
+ let key = '';
+ key += `${metadata.languageCode ?? UNDETERMINED_LANGUAGE}-`;
+ key += `${metadata.disposition?.default ?? true}-`;
+ key += `${metadata.disposition?.primary ?? false}-`;
+ key += `${metadata.disposition?.forced ?? false}-`;
+
+ return key;
+ }));
+
+ 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(({ metadata }) => {
+ let key = '';
+ key += `${metadata.languageCode ?? UNDETERMINED_LANGUAGE}-`;
+ key += `${metadata.disposition?.default ?? true}-`;
+ key += `${metadata.disposition?.primary ?? false}-`;
+ key += `${metadata.disposition?.forced ?? false}-`;
+
+ return key;
+ }));
+
+ 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.type === 'video') {
+ videoCount++;
+ requiresRotationMetadata ||= (track.metadata.rotation ?? 0) !== 0;
+ } else if (track.type === 'audio') {
+ audioCount++;
+ }
+
+ codecs.push(track.source._codec);
+ }
+
+ for (const format of this.format._options.segmentFormats) {
+ 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[]) => {
+ const id = this.playlists.length + 1;
+ const path = await this.getPlaylistPath({
+ n: id,
+ tracks,
+ });
+ 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 key = tracks.map(x => x.id).join('-');
+ const format = deduceSegmentFormat(tracks);
+
+ const playlist: Playlist = {
+ id: this.playlists.length + 1,
+ key,
+ path,
+ tracks,
+ segmentFormat: format,
+ currentSegmentStartTimestamp: null,
+ nextSegmentId: 1,
+ writtenSegments: [],
+ peakBitrate: null,
+ averageBitrate: null,
+ };
+ 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]!;
+
+ const key = track.id.toString();
+ let playlist = this.playlists.find(x => x.key === key);
+ playlist ??= await registerPlaylist([track]);
+
+ this.playlistDeclarations.push({
+ playlist,
+ groupId: group.name,
+ noUri: group.firstNoUri && i === 0,
+ references: [],
+ });
+ }
+ }
+
+ for (const variant of variantStreams) {
+ const key = variant.tracks.map(x => x.id).join('-');
+ let playlist = this.playlists.find(x => x.key === key);
+ 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)
+ : [],
+ });
+ }
}
async getMimeType(): Promise {
throw new Error('TODO');
}
- private allTracksAreKnown() {
- for (const track of this.output._tracks) {
+ 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
}
@@ -63,11 +458,12 @@ export class HlsMuxer extends Muxer {
const release = await this.mutex.acquire();
try {
- if (!this.trackDatas.some(x => x.track === track)) {
+ const trackData = this.trackDatas.find(x => x.track === track);
+ if (!trackData) {
return;
}
- await this.ting();
+ await this.advancePlaylist(trackData.playlist);
} finally {
release();
}
@@ -84,9 +480,13 @@ export class HlsMuxer extends Muxer {
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]!,
info: {
type: 'video',
decoderConfig: meta.decoderConfig,
@@ -108,9 +508,13 @@ export class HlsMuxer extends Muxer {
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]!,
info: {
type: 'audio',
decoderConfig: meta.decoderConfig,
@@ -136,16 +540,18 @@ export class HlsMuxer extends Muxer {
trackData.packets.push(adjustedPacket);
- if (this.currentSegmentStartTimestamp === null) {
- this.currentSegmentStartTimestamp = adjustedPacket.timestamp;
+ const playlist = trackData.playlist;
+
+ if (playlist.currentSegmentStartTimestamp === null) {
+ playlist.currentSegmentStartTimestamp = adjustedPacket.timestamp;
} else {
- this.currentSegmentStartTimestamp = Math.min(
- this.currentSegmentStartTimestamp,
+ playlist.currentSegmentStartTimestamp = Math.min(
+ playlist.currentSegmentStartTimestamp,
adjustedPacket.timestamp,
);
}
- await this.ting();
+ await this.advancePlaylist(playlist);
} finally {
release();
}
@@ -166,38 +572,43 @@ export class HlsMuxer extends Muxer {
trackData.packets.push(adjustedPacket);
- if (this.currentSegmentStartTimestamp === null) {
- this.currentSegmentStartTimestamp = adjustedPacket.timestamp;
+ const playlist = trackData.playlist;
+
+ if (playlist.currentSegmentStartTimestamp === null) {
+ playlist.currentSegmentStartTimestamp = adjustedPacket.timestamp;
} else {
- this.currentSegmentStartTimestamp = Math.min(
- this.currentSegmentStartTimestamp,
+ playlist.currentSegmentStartTimestamp = Math.min(
+ playlist.currentSegmentStartTimestamp,
adjustedPacket.timestamp,
);
}
- await this.ting();
+ 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 ting(isFinalCall = false) {
- assert(this.currentSegmentStartTimestamp !== null);
+ async advancePlaylist(playlist: Playlist, isFinalCall = false) {
+ assert(playlist.currentSegmentStartTimestamp !== null);
- if (!this.allTracksAreKnown()) {
+ if (!this.allTracksAreKnown(playlist)) {
return;
}
while (true) {
- const currentSegmentEndTimestamp = this.currentSegmentStartTimestamp + this.targetSegmentDuration;
+ const currentSegmentEndTimestamp = playlist.currentSegmentStartTimestamp + this.targetSegmentDuration;
let videoKeyEndTimestamp: number | null = null;
let videoEndTimestamp: number | null = null;
@@ -205,7 +616,9 @@ export class HlsMuxer extends Muxer {
let flushAllVideo = false;
let flushAllAudio = false;
- for (const trackData of this.trackDatas) {
+ const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track));
+
+ for (const trackData of trackDatas) {
for (let i = 0; i < trackData.packets.length; i++) {
const packet = trackData.packets[i]!;
const endTimestamp = packet.timestamp + packet.duration;
@@ -265,7 +678,7 @@ export class HlsMuxer extends Muxer {
return;
}
- for (const trackData of this.trackDatas) {
+ for (const trackData of trackDatas) {
const closed = trackData.track.source._closed || isFinalCall;
if (!closed && !trackData.packets.some(x => x.timestamp >= endTimestamp)) {
return;
@@ -273,20 +686,46 @@ export class HlsMuxer extends Muxer {
}
}
- assert(this.output._rootPath !== null);
- const segmentPath = joinPaths(this.output._rootPath, `segment-${this.nextSegmentId}.ts`);
- const target = await this.output._getTarget({ path: segmentPath });
- this.nextSegmentId++;
+ const relativeSegmentPath = await this.getSegmentPath({
+ n: playlist.nextSegmentId,
+ format: playlist.segmentFormat,
+ playlist: {
+ n: playlist.id,
+ tracks: playlist.tracks,
+ },
+ });
+ if (typeof relativeSegmentPath !== 'string') {
+ throw new TypeError('options.getSegmentPath must return or resolve to a string');
+ }
+ if (/[\n\r"]/.test(relativeSegmentPath)) {
+ throw new TypeError(
+ 'Segment paths cannot contain line feed or carriage return characters.',
+ );
+ }
+ assert(this.output._rootPath !== null);
+ const fullSegmentPath = joinPaths(joinPaths(this.output._rootPath, playlist.path), relativeSegmentPath);
+ playlist.nextSegmentId++;
+
+ let segmentSize = 0;
const output = new Output({
- format: new MpegTsOutputFormat(),
- target,
+ format: playlist.segmentFormat,
+ rootPath: fullSegmentPath,
+ target: async (request) => {
+ const target = await this.output._getTarget(request);
+
+ if (request.path === fullSegmentPath) {
+ target.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end);
+ }
+
+ return target;
+ },
});
try {
const packetSources = new Map();
- for (const trackData of this.trackDatas) {
+ for (const trackData of trackDatas) {
if (trackData.packets.length === 0) {
continue;
}
@@ -308,7 +747,7 @@ export class HlsMuxer extends Muxer {
await output.start();
- for (const trackData of this.trackDatas) {
+ for (const trackData of trackDatas) {
const source = packetSources.get(trackData);
if (!source) {
continue;
@@ -353,41 +792,244 @@ export class HlsMuxer extends Muxer {
throw e;
}
- this.writtenSegments.push({
- path: segmentPath,
- duration: endTimestamp - this.currentSegmentStartTimestamp,
+ playlist.writtenSegments.push({
+ path: relativeSegmentPath,
+ duration: endTimestamp - playlist.currentSegmentStartTimestamp,
+ byteSize: segmentSize,
});
- this.currentSegmentStartTimestamp = endTimestamp;
+ playlist.currentSegmentStartTimestamp = endTimestamp;
}
}
async finalize() {
+ assert(this.output._rootPath !== null);
const release = await this.mutex.acquire();
- await this.ting(true);
+ for (const playlist of this.playlists) {
+ if (playlist.currentSegmentStartTimestamp !== null) {
+ await this.advancePlaylist(playlist, true);
+ } else {
+ // Never had any data written to it
+ }
- let targetDuration = this.targetSegmentDuration;
- for (const segment of this.writtenSegments) {
- targetDuration = Math.max(targetDuration, segment.duration);
+ let peakBitrate = 0;
+ let totalBits = 0;
+
+ for (const segment of playlist.writtenSegments) {
+ peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segment.duration);
+ totalBits += 8 * segment.byteSize;
+ }
+
+ playlist.peakBitrate = peakBitrate;
+
+ if (playlist.currentSegmentStartTimestamp !== null) {
+ playlist.averageBitrate = totalBits / playlist.currentSegmentStartTimestamp;
+ }
}
- const playlist = '#EXTM3U\n'
- + '#EXT-X-VERSION:3\n'
- + '#EXT-X-PLAYLIST-TYPE:VOD\n'
- + `#EXT-X-TARGETDURATION:${+targetDuration.toPrecision(13)}\n`
- + '\n'
- + (this.writtenSegments
- .map(segment => (
- `#EXTINF:${+segment.duration.toPrecision(13)}\n`
- + `${segment.path}\n`
- ))
- .join(''))
- + '\n'
- + '#EXT-X-ENDLIST\n';
+ const playlistPromises = this.playlists.map(async (playlist) => {
+ let targetDuration = this.targetSegmentDuration;
+ for (const segment of playlist.writtenSegments) {
+ targetDuration = Math.max(targetDuration, segment.duration);
+ }
- const rootWriter = await this.output._getRootWriter();
- rootWriter.write(textEncoder.encode(playlist));
+ const playlistPath = joinPaths(this.output._rootPath!, playlist.path);
+ const playlistText = '#EXTM3U\n'
+ + '#EXT-X-VERSION:3\n'
+ + '#EXT-X-PLAYLIST-TYPE:VOD\n'
+ + `#EXT-X-TARGETDURATION:${+targetDuration.toPrecision(13)}\n`
+ + '\n'
+ + (playlist.writtenSegments
+ .map(segment => (
+ `#EXTINF:${+segment.duration.toPrecision(13)}\n`
+ + `${segment.path}\n`
+ ))
+ .join(''))
+ + '\n'
+ + '#EXT-X-ENDLIST\n';
+
+ const target = await this.output._getTarget({ path: playlistPath });
+ const writer = target._createWriter();
+ writer.write(textEncoder.encode(playlistText));
+ await writer.finalize();
+ });
+
+ const masterPlaylistPromise = (async () => {
+ let masterPlaylistText = '#EXTM3U\n';
+ let lastGroupId: string | null = null;
+ let firstVariantWritten = false;
+
+ for (const decl of this.playlistDeclarations) {
+ if (decl.groupId === null) {
+ 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 totalDeclAverageBitrate = 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);
+
+ if (ref.playlist.averageBitrate !== null) {
+ totalDeclAverageBitrate += ref.playlist.averageBitrate;
+ }
+ }
+ }
+
+ assert(decl.playlist.peakBitrate !== null);
+ const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate;
+ const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0)
+ + totalDeclAverageBitrate / (decl.references.length || 1);
+
+ if (!firstVariantWritten) {
+ masterPlaylistText += '\n';
+ firstVariantWritten = true;
+ }
+
+ 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.type === 'video');
+ if (videoTrack) {
+ 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}`;
+ }
+ }
+
+ if (videoTrack.metadata.frameRate !== undefined) {
+ masterPlaylistText += `,FRAME-RATE=${videoTrack.metadata.frameRate}`;
+ }
+ }
+
+ const name = decl.playlist.tracks.find(x => x.metadata.name !== undefined)?.metadata.name;
+ if (name !== undefined) {
+ if (/[\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.',
+ );
+ } else {
+ masterPlaylistText += `,NAME="${name}"`;
+ }
+ }
+
+ const groupIdForType = new Map();
+ 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}"`;
+ }
+
+ masterPlaylistText += '\n';
+ masterPlaylistText += `${decl.playlist.path}\n`;
+ } else {
+ assert(decl.playlist.tracks.length === 1);
+
+ const track = decl.playlist.tracks[0]!;
+ const type = track.type;
+ const name = track.metadata.name;
+ const languageCode = track.metadata.languageCode;
+ const disposition = track.metadata.disposition;
+
+ if (lastGroupId === null || decl.groupId !== lastGroupId) {
+ masterPlaylistText += '\n';
+ }
+ lastGroupId = decl.groupId;
+
+ masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`;
+
+ if (name !== undefined) {
+ if (/[\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.',
+ );
+ } else {
+ 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) {
+ // HLS's "DEFAULT" behaves like our "primary"
+ masterPlaylistText += ',DEFAULT=YES';
+ }
+
+ 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';
+ }
+ }
+
+ const rootWriter = await this.output._getRootWriter();
+ rootWriter.write(textEncoder.encode(masterPlaylistText));
+ })();
+
+ await Promise.all([...playlistPromises, masterPlaylistPromise]);
release();
}
diff --git a/src/index.ts b/src/index.ts
index 3583a73..e174d18 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -135,6 +135,7 @@ export {
prefer,
} from './misc';
export {
+ OutputTrackGroup,
TrackType,
ALL_TRACK_TYPES,
} from './output';
diff --git a/src/input.ts b/src/input.ts
index dce3299..dbe5f23 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -267,11 +267,8 @@ export class Input implements Disposable {
}
/**
- * @deprecated Prefer not using this getter since it is ill-defined for files driven by multiple sources. The
- * {@link Input.onSource} callback provides an alternative.
- *
* Returns the source from which this input file reads data for the entry path. Throws if the source-resolving
- * function returns a Promise.
+ * function returns a promise; prefer the {@link Input.onSource} callback for those cases.
*/
get source(): S {
if (this._source instanceof SourceRef) {
@@ -284,7 +281,7 @@ export class Input implements Disposable {
if (source instanceof Promise) {
throw new TypeError(
'Input.source cannot be used when the source function resolves asynchronously.'
- + ' Use getSource() instead.',
+ + ' Use the onSource event instead.',
);
}
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index cc68d88..a77b40d 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -762,6 +762,7 @@ export class IsobmffDemuxer extends Demuxer {
inputTrack: null,
disposition: {
...DEFAULT_TRACK_DISPOSITION,
+ primary: false,
},
info: null,
timescale: -1,
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index 4fcea22..84c3bbc 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -1016,6 +1016,7 @@ export class MatroskaDemuxer extends Demuxer {
disposition: {
...DEFAULT_TRACK_DISPOSITION,
+ primary: false,
},
inputTrack: null,
codecId: null,
diff --git a/src/metadata.ts b/src/metadata.ts
index 5fe983e..66adc11 100644
--- a/src/metadata.ts
+++ b/src/metadata.ts
@@ -270,10 +270,11 @@ export const metadataTagsAreEmpty = (tags: MetadataTags) => {
*/
export type TrackDisposition = {
/**
- * Indicates that this track is eligible for automatic selection by a player; that it is the main track among other,
- * non-default tracks of the same type.
+ * Indicates that this track is eligible for automatic selection by a player. Multiple tracks can be default tracks.
*/
default: boolean;
+ /** Indicates that the track is the primary track among other tracks of its type. */
+ primary: boolean;
/**
* Indicates that players should always display this track by default, even if it goes against the user's default
* preferences. For example, a subtitle track only containing translations of foreign-language audio.
@@ -291,6 +292,7 @@ export type TrackDisposition = {
export const DEFAULT_TRACK_DISPOSITION: TrackDisposition = {
default: true,
+ primary: true,
forced: false,
original: false,
commentary: false,
@@ -305,6 +307,9 @@ export const validateTrackDisposition = (disposition: Partial)
if (disposition.default !== undefined && typeof disposition.default !== 'boolean') {
throw new TypeError('disposition.default must be a boolean.');
}
+ if (disposition.primary !== undefined && typeof disposition.primary !== 'boolean') {
+ throw new TypeError('disposition.primary must be a boolean.');
+ }
if (disposition.forced !== undefined && typeof disposition.forced !== 'boolean') {
throw new TypeError('disposition.forced must be a boolean.');
}
diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts
index 671a63f..48709c2 100644
--- a/src/mpeg-ts/mpeg-ts-demuxer.ts
+++ b/src/mpeg-ts/mpeg-ts-demuxer.ts
@@ -49,7 +49,7 @@ import {
InputVideoTrackBacking,
} from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
-import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
+import { DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata';
import {
assert,
binarySearchExact,
@@ -1084,8 +1084,11 @@ abstract class MpegTsTrackBacking implements InputTrackBacking {
return UNDETERMINED_LANGUAGE;
}
- getDisposition() {
- return DEFAULT_TRACK_DISPOSITION;
+ getDisposition(): TrackDisposition {
+ return {
+ ...DEFAULT_TRACK_DISPOSITION,
+ primary: false,
+ };
}
getTimeResolution() {
diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index 8f2a7a5..52fa2e6 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -12,7 +12,7 @@ import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
-import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
+import { DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata';
import {
assert,
AsyncMutex,
@@ -503,9 +503,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
return UNDETERMINED_LANGUAGE;
}
- getDisposition() {
+ getDisposition(): TrackDisposition {
return {
...DEFAULT_TRACK_DISPOSITION,
+ primary: false,
};
}
diff --git a/src/output-format.ts b/src/output-format.ts
index 5dfcacd..15ade17 100644
--- a/src/output-format.ts
+++ b/src/output-format.ts
@@ -25,10 +25,11 @@ import { MediaSource } from './media-source';
import { Mp3Muxer } from './mp3/mp3-muxer';
import { Muxer } from './muxer';
import { OggMuxer } from './ogg/ogg-muxer';
-import { Output, TrackType } from './output';
+import { Output, OutputTrack, TrackType } from './output';
import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer';
import { WaveMuxer } from './wave/wave-muxer';
import { HlsMuxer } from './hls/hls-muxer';
+import { MaybePromise } from './misc';
/**
* Specifies an inclusive range of integers.
@@ -1093,11 +1094,57 @@ export class MpegTsOutputFormat extends OutputFormat {
}
}
+export type HlsOutputPlaylistInfo = {
+ n: number;
+ tracks: OutputTrack[];
+};
+
+type HlsOutputSegmentInfo = {
+ n: number;
+ format: OutputFormat;
+ playlist: HlsOutputPlaylistInfo;
+};
+
+export type HlsOutputFormatOptions = {
+ segmentFormats: OutputFormat[];
+ getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise;
+ getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise;
+};
+
export class HlsOutputFormat extends OutputFormat {
- _createMuxer(output: Output): Muxer {
- return new HlsMuxer(output);
+ /** @internal */
+ _options: HlsOutputFormatOptions;
+
+ /** Creates a new {@link HlsOutputFormat} configured with the specified `options`. */
+ constructor(options: HlsOutputFormatOptions) {
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (
+ !Array.isArray(options.segmentFormats)
+ || options.segmentFormats.length === 0
+ || !options.segmentFormats.every(format => format instanceof OutputFormat)
+ ) {
+ throw new TypeError('options.segmentFormats must be a non-empty array of OutputFormat instances.');
+ }
+ if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') {
+ throw new TypeError('options.getPlaylistPath, when provided, must be a function.');
+ }
+ if (options.getSegmentPath !== undefined && typeof options.getSegmentPath !== 'function') {
+ throw new TypeError('options.getSegmentPath, when provided, must be a function.');
+ }
+
+ super();
+
+ this._options = options;
}
+ /** @internal */
+ _createMuxer(output: Output): Muxer {
+ return new HlsMuxer(output, this);
+ }
+
+ /** @internal */
get _name() {
return 'HTTP Live Streaming (HLS)';
}
@@ -1111,27 +1158,37 @@ export class HlsOutputFormat extends OutputFormat {
}
getSupportedCodecs(): MediaCodec[] {
- // TODO this should vary based on the HLS "variant"
- return [
- ...VIDEO_CODECS.filter(codec => ['avc', 'hevc'].includes(codec)),
- ...AUDIO_CODECS.filter(codec => ['aac', 'mp3', 'ac3', 'eac3'].includes(codec)),
- ];
+ const uniqueCodecs = new Set(this._options.segmentFormats.flatMap(x => x.getSupportedCodecs()));
+ return [...uniqueCodecs];
}
getSupportedTrackCounts(): TrackCountLimits {
+ let supportsVideo = false;
+ let supportsAudio = false;
+ let supportsSubtitle = false;
+
+ for (const format of this._options.segmentFormats) {
+ const trackCounts = format.getSupportedTrackCounts();
+ supportsVideo ||= trackCounts.video.max > 0;
+ supportsAudio ||= trackCounts.audio.max > 0;
+ supportsSubtitle ||= trackCounts.subtitle.max > 0;
+ }
+
return {
- video: { min: 0, max: Infinity },
- audio: { min: 0, max: Infinity },
- subtitle: { min: 0, max: Infinity },
+ video: { min: 0, max: supportsVideo ? Infinity : 0 },
+ audio: { min: 0, max: supportsAudio ? Infinity : 0 },
+ subtitle: { min: 0, max: 0 }, // Currently disabled
total: { min: 1, max: Infinity },
};
}
get supportsVideoRotationMetadata(): boolean {
- return false; // TODO this is not true with fmp4
+ return this._options.segmentFormats.some(format => format.supportsVideoRotationMetadata);
}
get supportsTimestampedMediaData(): boolean {
return true; // I guess??
}
}
+
+export const HLS_OUTPUT_FORMATS_DEFAULT = [new AdtsOutputFormat(), new MpegTsOutputFormat()];
diff --git a/src/output.ts b/src/output.ts
index b386433..3feae77 100644
--- a/src/output.ts
+++ b/src/output.ts
@@ -69,6 +69,20 @@ export type OutputVideoTrack = OutputTrack & { type: 'video' };
export type OutputAudioTrack = OutputTrack & { type: 'audio' };
export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' };
+export class OutputTrackGroup {
+ /** @internal */
+ _pairedGroups = new Set();
+
+ pairWith(other: OutputTrackGroup) {
+ if (!(other instanceof OutputTrackGroup)) {
+ throw new TypeError('other must be an OutputTrackGroup.');
+ }
+
+ this._pairedGroups.add(other);
+ other._pairedGroups.add(this);
+ }
+}
+
/**
* Base track metadata, applicable to all tracks.
* @group Output files
@@ -96,6 +110,7 @@ export type BaseTrackMetadata = {
* If you're not fully sure, make sure to add a buffer of around 33% to make sure you stay below the maximum.
*/
maximumPacketCount?: number;
+ group?: OutputTrackGroup | OutputTrackGroup[];
};
/**
@@ -145,6 +160,16 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
) {
throw new TypeError('metadata.maximumPacketCount, when provided, must be a non-negative integer.');
}
+ if (
+ metadata.group !== undefined
+ && !(metadata.group instanceof OutputTrackGroup)
+ && (!Array.isArray(metadata.group) || metadata.group.some(group => !(group instanceof OutputTrackGroup)))
+ ) {
+ throw new TypeError(
+ 'metadata.group, when provided, must be an OutputTrackGroup instance or an array of'
+ + ' OutputTrackGroup instances.',
+ );
+ }
};
/**
@@ -157,9 +182,9 @@ export class Output<
T extends Target = Target,
> {
/** The format of the output file. */
- format: F;
- /** The target to which the file will be written. */
- target: T | ((request: TargetRequest) => MaybePromise);
+ readonly format: F;
+ /** @internal */
+ _target: T | ((request: TargetRequest) => MaybePromise);
/** The current state of the output. */
state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending';
@@ -181,6 +206,30 @@ export class Output<
_mutex = new AsyncMutex();
/** @internal */
_metadataTags: MetadataTags = {};
+ /** @internal */
+ _defaultTrackGroup = new OutputTrackGroup();
+
+ /** The target to which the root file will be written. Throws if the target-resolving function returns a Promise. */
+ get target(): T {
+ if (this._target instanceof Target) {
+ return this._target;
+ }
+
+ assert(this._rootPath !== null);
+ const returnValue = this._target({ path: this._rootPath });
+ if (returnValue instanceof Promise) {
+ throw new TypeError(
+ 'Output.target cannot be used when the target function resolves asynchronously.',
+ );
+ }
+
+ return returnValue;
+ }
+
+ /**
+ * Called whenever a target is resolved for internal operations.
+ */
+ onTarget?: (target: Target, request: TargetRequest | null) => unknown;
/**
* Creates a new instance of {@link Output} which can then be used to create a new media file according to the
@@ -210,28 +259,32 @@ export class Output<
}
this.format = options.format;
- this.target = options.target;
+ this._target = options.target;
this._rootPath = options.rootPath ?? null;
this._muxer = options.format._createMuxer(this);
}
- _getTarget(request: TargetRequest) {
- assert(typeof this.target === 'function');
+ async _getTarget(request: TargetRequest) {
+ assert(typeof this._target === 'function');
- return this.target(request);
+ const target = await this._target(request);
+ this.onTarget?.(target, request);
+
+ return target;
}
_getRootWriter() {
return this._rootWriterPromise ??= (async () => {
let writer: Writer;
- if (typeof this.target === 'function') {
+ if (typeof this._target === 'function') {
assert(this._rootPath !== null);
const rootTarget = await this._getTarget({ path: this._rootPath });
writer = rootTarget._createWriter();
} else {
- writer = this.target._createWriter();
+ writer = this._target._createWriter();
+ this.onTarget?.(this._target, null);
}
writer.start();
@@ -260,7 +313,10 @@ export class Output<
);
}
- this._addTrack('video', source, metadata);
+ const metadataCopy = { ...metadata };
+ metadataCopy.group ??= this._defaultTrackGroup;
+
+ this._addTrack('video', source, metadataCopy);
}
/** Adds an audio track to the output with the given source. Can only be called before the output is started. */
@@ -270,7 +326,10 @@ export class Output<
}
validateBaseTrackMetadata(metadata);
- this._addTrack('audio', source, metadata);
+ const metadataCopy = { ...metadata };
+ metadataCopy.group ??= this._defaultTrackGroup;
+
+ this._addTrack('audio', source, metadataCopy);
}
/** Adds a subtitle track to the output with the given source. Can only be called before the output is started. */
@@ -280,7 +339,10 @@ export class Output<
}
validateBaseTrackMetadata(metadata);
- this._addTrack('subtitle', source, metadata);
+ const metadataCopy = { ...metadata };
+ metadataCopy.group ??= this._defaultTrackGroup;
+
+ this._addTrack('subtitle', source, metadataCopy);
}
/**
@@ -300,7 +362,7 @@ export class Output<
}
/** @internal */
- private _addTrack(type: OutputTrack['type'], source: MediaSource, metadata: object) {
+ private _addTrack(type: OutputTrack['type'], source: MediaSource, metadata: BaseTrackMetadata) {
if (this.state !== 'pending') {
throw new Error('Cannot add track after output has been started or canceled.');
}
@@ -516,7 +578,6 @@ export class Output<
await this._muxer.finalize();
if (this._rootWriterPromise) {
- console.log('HERE HERE');
const rootWriter = await this._rootWriterPromise;
await rootWriter.flush();
await rootWriter.finalize();
diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts
index 5151f43..3db2d0b 100644
--- a/test/node/hls-input.test.ts
+++ b/test/node/hls-input.test.ts
@@ -414,6 +414,46 @@ test.concurrent('fMP4', { timeout: 15_000 }, async () => {
expect(sourceCount).toBe(1 + 2 * (1 + 1 + 1 + 1));
});
+test.concurrent('Track disposition & metadata', { timeout: 15_000 }, async () => {
+ using input = new Input({
+ entryPath: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8',
+ source: ({ path }) => new UrlSource(path),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTracks = await input.getAudioTracks();
+
+ expect(audioTracks).toHaveLength(6);
+
+ expect(audioTracks[0]!.languageCode).toBe('en');
+ expect(audioTracks[1]!.languageCode).toBe('de');
+ expect(audioTracks[2]!.languageCode).toBe('it');
+ expect(audioTracks[3]!.languageCode).toBe('fr');
+ expect(audioTracks[4]!.languageCode).toBe('es');
+ expect(audioTracks[5]!.languageCode).toBe('en');
+
+ expect(audioTracks[0]!.disposition.primary).toBe(true);
+ expect(audioTracks[1]!.disposition.primary).toBe(false);
+ expect(audioTracks[2]!.disposition.primary).toBe(false);
+ expect(audioTracks[3]!.disposition.primary).toBe(false);
+ expect(audioTracks[4]!.disposition.primary).toBe(false);
+ expect(audioTracks[5]!.disposition.primary).toBe(false);
+
+ expect(audioTracks[0]!.disposition.default).toBe(true);
+ expect(audioTracks[1]!.disposition.default).toBe(true);
+ expect(audioTracks[2]!.disposition.default).toBe(true);
+ expect(audioTracks[3]!.disposition.default).toBe(true);
+ expect(audioTracks[4]!.disposition.default).toBe(true);
+ expect(audioTracks[5]!.disposition.default).toBe(false);
+
+ expect(audioTracks[0]!.name).toBe('stream_5');
+ expect(audioTracks[1]!.name).toBe('stream_4');
+ expect(audioTracks[2]!.name).toBe('stream_8');
+ expect(audioTracks[3]!.name).toBe('stream_7');
+ expect(audioTracks[4]!.name).toBe('stream_9');
+ expect(audioTracks[5]!.name).toBe('stream_6');
+});
+
test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => {
using input = new Input({
entryPath: 'https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s-fmp4/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8',
diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts
new file mode 100644
index 0000000..8339206
--- /dev/null
+++ b/test/node/hls-output.test.ts
@@ -0,0 +1,793 @@
+import { expect, test, vi } from 'vitest';
+import { Output, OutputTrackGroup } from '../../src/output.js';
+import { HLS_OUTPUT_FORMATS_DEFAULT, HlsOutputFormat } from '../../src/output-format.js';
+import { NullTarget } from '../../src/target.js';
+import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js';
+import { HlsMuxer } from '../../src/hls/hls-muxer.js';
+import { AudioCodec, VideoCodec } from '../../src/codec.js';
+
+const videoSource = (codec: VideoCodec = 'avc') => new EncodedVideoPacketSource(codec);
+const audioSource = (codec: AudioCodec = 'aac') => new EncodedAudioPacketSource(codec);
+
+test('Playlist assignment, single video', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, single audio', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addAudioTrack(audioSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, multiple video', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource());
+ output.addVideoTrack(videoSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(2);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, multiple audio', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addAudioTrack(audioSource());
+ output.addAudioTrack(audioSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(2);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, multiple video with different metadata #1', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource(), { languageCode: 'eng' });
+ output.addVideoTrack(videoSource(), { languageCode: 'esp' });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(3);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBe('video-1');
+ expect(decl[0]!.references).toHaveLength(0);
+ expect(decl[0]!.noUri).toBe(true);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.groupId).toBe('video-1');
+ expect(decl[1]!.references).toHaveLength(0);
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toEqual(decl.slice(0, 2));
+
+ expect(decl[2]!.playlist).toBe(decl[0]!.playlist);
+});
+
+test('Playlist assignment, multiple video with different metadata #2', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource(), { disposition: { primary: true } });
+ output.addVideoTrack(videoSource(), { disposition: { primary: false } });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(3);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBe('video-1');
+ expect(decl[0]!.references).toHaveLength(0);
+ expect(decl[0]!.noUri).toBe(true);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.groupId).toBe('video-1');
+ expect(decl[1]!.references).toHaveLength(0);
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toEqual(decl.slice(0, 2));
+
+ expect(decl[2]!.playlist).toBe(decl[0]!.playlist);
+});
+
+test('Playlist assignment, multiple audio with different metadata', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addAudioTrack(audioSource(), { languageCode: 'eng' });
+ output.addAudioTrack(audioSource(), { languageCode: 'esp' });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(3);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.references).toHaveLength(0);
+ expect(decl[0]!.noUri).toBe(true);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.groupId).toBe('audio-1');
+ expect(decl[1]!.references).toHaveLength(0);
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toEqual(decl.slice(0, 2));
+
+ expect(decl[2]!.playlist).toBe(decl[0]!.playlist);
+});
+
+test('Playlist assignment, video and audio', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource());
+ output.addAudioTrack(audioSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks).toHaveLength(2);
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, one video and multiple audio', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource());
+ output.addAudioTrack(audioSource());
+ output.addAudioTrack(audioSource());
+ output.addAudioTrack(audioSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(4);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[2]!.noUri).toBe(false);
+
+ expect(decl[3]!.playlist.tracks).toHaveLength(1);
+ expect(decl[3]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[3]!.groupId).toBeNull();
+ expect(decl[3]!.references).toEqual(decl.slice(0, 3));
+});
+
+test('Playlist assignment, multiple video and one audio', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource());
+ output.addVideoTrack(videoSource());
+ output.addVideoTrack(videoSource());
+ output.addAudioTrack(audioSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(4);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toEqual([decl[0]!]);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toEqual([decl[0]!]);
+
+ expect(decl[3]!.playlist.tracks).toHaveLength(1);
+ expect(decl[3]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[3]!.groupId).toBeNull();
+ expect(decl[3]!.references).toEqual([decl[0]!]);
+});
+
+test('Playlist assignment, multiple video and audio', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource());
+ output.addVideoTrack(videoSource());
+ output.addVideoTrack(videoSource());
+ output.addAudioTrack(audioSource());
+ output.addAudioTrack(audioSource());
+ output.addAudioTrack(audioSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(6);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[1]!.groupId).toBe('audio-1');
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[2]!.groupId).toBe('audio-1');
+ expect(decl[2]!.noUri).toBe(false);
+
+ expect(decl[3]!.playlist.tracks).toHaveLength(1);
+ expect(decl[3]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[3]!.groupId).toBeNull();
+ expect(decl[3]!.references).toEqual(decl.slice(0, 3));
+
+ expect(decl[4]!.playlist.tracks).toHaveLength(1);
+ expect(decl[4]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[4]!.groupId).toBeNull();
+ expect(decl[4]!.references).toEqual(decl.slice(0, 3));
+
+ expect(decl[5]!.playlist.tracks).toHaveLength(1);
+ expect(decl[5]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[5]!.groupId).toBeNull();
+ expect(decl[5]!.references).toEqual(decl.slice(0, 3));
+});
+
+test('Playlist assignment, video and audio in different groups', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ const a = new OutputTrackGroup();
+ const b = new OutputTrackGroup();
+
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addAudioTrack(audioSource(), { group: b });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(2);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, multiple video and audio in pairs', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ const a = new OutputTrackGroup();
+ const b = new OutputTrackGroup();
+ const c = new OutputTrackGroup();
+
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addVideoTrack(videoSource(), { group: b });
+ output.addVideoTrack(videoSource(), { group: c });
+ output.addAudioTrack(audioSource(), { group: a });
+ output.addAudioTrack(audioSource(), { group: b });
+ output.addAudioTrack(audioSource(), { group: c });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(3);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(2);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[0]!.playlist.tracks[1]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(2);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[1]!.playlist.tracks[1]!.type).toBe('audio');
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toHaveLength(0);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(2);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[2]!.playlist.tracks[1]!.type).toBe('audio');
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, multiple video and audio with some unpaired', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ const a = new OutputTrackGroup();
+ const b = new OutputTrackGroup();
+ const c = new OutputTrackGroup();
+
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addVideoTrack(videoSource(), { group: b });
+ output.addAudioTrack(audioSource(), { group: a });
+ output.addAudioTrack(audioSource(), { group: a });
+ output.addAudioTrack(audioSource(), { group: c });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(6);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[1]!.groupId).toBe('audio-1');
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toEqual(decl.slice(0, 2));
+
+ expect(decl[3]!.playlist.tracks).toHaveLength(1);
+ expect(decl[3]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[3]!.groupId).toBeNull();
+ expect(decl[3]!.references).toEqual(decl.slice(0, 2));
+
+ expect(decl[4]!.playlist.tracks).toHaveLength(1);
+ expect(decl[4]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[4]!.groupId).toBeNull();
+ expect(decl[4]!.references).toHaveLength(0);
+
+ expect(decl[5]!.playlist.tracks).toHaveLength(1);
+ expect(decl[5]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[5]!.groupId).toBeNull();
+ expect(decl[5]!.references).toHaveLength(0);
+});
+
+test('Playlist assignment, multiple video and audio with multiple groups', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ const a = new OutputTrackGroup();
+ const b = new OutputTrackGroup();
+
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addVideoTrack(videoSource(), { group: b });
+ output.addVideoTrack(videoSource(), { group: b });
+ output.addAudioTrack(audioSource(), { group: a });
+ output.addAudioTrack(audioSource(), { group: a });
+ output.addAudioTrack(audioSource(), { group: b });
+ output.addAudioTrack(audioSource(), { group: b });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(8);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[1]!.groupId).toBe('audio-1');
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[2]!.groupId).toBe('audio-2');
+ expect(decl[2]!.noUri).toBe(false);
+
+ expect(decl[3]!.playlist.tracks).toHaveLength(1);
+ expect(decl[3]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[3]!.groupId).toBe('audio-2');
+ expect(decl[3]!.noUri).toBe(false);
+
+ expect(decl[4]!.playlist.tracks).toHaveLength(1);
+ expect(decl[4]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[4]!.groupId).toBeNull();
+ expect(decl[4]!.references).toEqual(decl.slice(0, 2));
+
+ expect(decl[5]!.playlist.tracks).toHaveLength(1);
+ expect(decl[5]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[5]!.groupId).toBeNull();
+ expect(decl[5]!.references).toEqual(decl.slice(0, 2));
+
+ expect(decl[6]!.playlist.tracks).toHaveLength(1);
+ expect(decl[6]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[6]!.groupId).toBeNull();
+ expect(decl[6]!.references).toEqual(decl.slice(2, 4));
+
+ expect(decl[7]!.playlist.tracks).toHaveLength(1);
+ expect(decl[7]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[7]!.groupId).toBeNull();
+ expect(decl[7]!.references).toEqual(decl.slice(2, 4));
+});
+
+test('Playlist assignment, video with multiple audio codecs', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource());
+ output.addAudioTrack(audioSource('aac'));
+ output.addAudioTrack(audioSource('ac3'));
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(4);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.playlist.tracks[0]!.source._codec).toBe('aac');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[1]!.playlist.tracks[0]!.source._codec).toBe('ac3');
+ expect(decl[1]!.groupId).toBe('audio-2');
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toEqual([decl[0]!]);
+
+ expect(decl[3]!.playlist.tracks).toHaveLength(1);
+ expect(decl[3]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[3]!.groupId).toBeNull();
+ expect(decl[3]!.references).toEqual([decl[1]!]);
+
+ expect(decl[2]!.playlist).toBe(decl[3]!.playlist);
+});
+
+test('Playlist assignment, audio with multiple video codecs', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ output.addVideoTrack(videoSource('avc'));
+ output.addVideoTrack(videoSource('hevc'));
+ output.addAudioTrack(audioSource());
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(3);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[1]!.playlist.tracks[0]!.source._codec).toBe('avc');
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toEqual([decl[0]!]);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[2]!.playlist.tracks[0]!.source._codec).toBe('hevc');
+ expect(decl[2]!.groupId).toBeNull();
+ expect(decl[2]!.references).toEqual([decl[0]!]);
+});
+
+test('Playlist assignment, multiple video with conflicting audio interests', async () => {
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ const a = new OutputTrackGroup();
+ const b = new OutputTrackGroup();
+
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addVideoTrack(videoSource(), { group: b });
+ output.addAudioTrack(audioSource(), { group: [a, b] });
+ output.addAudioTrack(audioSource(), { group: a });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(5);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[0]!.groupId).toBe('audio-1');
+ expect(decl[0]!.noUri).toBe(false);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[1]!.groupId).toBe('audio-1');
+ expect(decl[1]!.noUri).toBe(false);
+
+ expect(decl[2]!.playlist.tracks).toHaveLength(1);
+ expect(decl[2]!.playlist.tracks[0]!.type).toBe('audio');
+ expect(decl[2]!.playlist).toBe(decl[0]!.playlist);
+ expect(decl[2]!.groupId).toBe('audio-2');
+ expect(decl[2]!.noUri).toBe(false);
+
+ expect(decl[3]!.playlist.tracks).toHaveLength(1);
+ expect(decl[3]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[3]!.groupId).toBeNull();
+ expect(decl[3]!.references).toEqual([decl[0]!, decl[1]!]);
+
+ expect(decl[4]!.playlist.tracks).toHaveLength(1);
+ expect(decl[4]!.playlist.tracks[0]!.type).toBe('video');
+ expect(decl[4]!.groupId).toBeNull();
+ expect(decl[4]!.references).toEqual([decl[2]!]);
+});
+
+test('Playlist assignment, video paired with video', async () => {
+ const consoleSpy = vi.spyOn(console, 'warn');
+
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ const a = new OutputTrackGroup();
+ const b = new OutputTrackGroup();
+ a.pairWith(b);
+
+ output.addVideoTrack(videoSource(), { group: a });
+ output.addVideoTrack(videoSource(), { group: b });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(2);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toHaveLength(0);
+
+ expect(consoleSpy.mock.calls).toHaveLength(1);
+ expect(consoleSpy.mock.calls[0]![0]).toContain('Illegal pairing');
+});
+
+test('Playlist assignment, audio paired with audio', async () => {
+ const consoleSpy = vi.spyOn(console, 'warn');
+
+ const output = new Output({
+ format: new HlsOutputFormat({
+ segmentFormats: HLS_OUTPUT_FORMATS_DEFAULT,
+ }),
+ target: () => new NullTarget(),
+ rootPath: '',
+ });
+
+ const a = new OutputTrackGroup();
+ const b = new OutputTrackGroup();
+ a.pairWith(b);
+
+ output.addAudioTrack(audioSource(), { group: a });
+ output.addAudioTrack(audioSource(), { group: b });
+
+ await output.start();
+
+ const muxer = output._muxer as HlsMuxer;
+ const decl = muxer.playlistDeclarations;
+
+ expect(decl).toHaveLength(2);
+
+ expect(decl[0]!.playlist.tracks).toHaveLength(1);
+ expect(decl[0]!.groupId).toBeNull();
+ expect(decl[0]!.references).toHaveLength(0);
+
+ expect(decl[1]!.playlist.tracks).toHaveLength(1);
+ expect(decl[1]!.groupId).toBeNull();
+ expect(decl[1]!.references).toHaveLength(0);
+
+ expect(consoleSpy.mock.calls).toHaveLength(1);
+ expect(consoleSpy.mock.calls[0]![0]).toContain('Illegal pairing');
+});