From 3e87d747e64b6d9f2216d3fca4ec314e07e968dd Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:53:21 +0100 Subject: [PATCH] Add HLS master playlist generation, add multi-playlistadd output track grouping, add multi-playlist HLS files, add configurable playlist and segment names, add TrackDisposition.primary --- dev/convert.html | 21 +- dev/demux.html | 5 +- examples/media-player/media-player.ts | 2 +- .../procedural-generation.ts | 24 +- src/hls/hls-demuxer.ts | 32 +- src/hls/hls-muxer.ts | 774 +++++++++++++++-- src/index.ts | 1 + src/input.ts | 7 +- src/isobmff/isobmff-demuxer.ts | 1 + src/matroska/matroska-demuxer.ts | 1 + src/metadata.ts | 9 +- src/mpeg-ts/mpeg-ts-demuxer.ts | 9 +- src/ogg/ogg-demuxer.ts | 5 +- src/output-format.ts | 81 +- src/output.ts | 89 +- test/node/hls-input.test.ts | 40 + test/node/hls-output.test.ts | 793 ++++++++++++++++++ 17 files changed, 1777 insertions(+), 117 deletions(-) create mode 100644 test/node/hls-output.test.ts 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'); +});