UNPKG

mediabunny

Version:

Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.

1,529 lines (1,277 loc) 46.2 kB
/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec'; import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../media-source'; import { arrayArgmax, assert, AsyncMutex, findLastIndex, joinPaths, textEncoder, toArray, UNDETERMINED_LANGUAGE, } from '../misc'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack, TrackType, } from '../output'; import { HlsOutputFormat, HlsOutputFormatOptions, HlsOutputPlaylistInfo, HlsOutputSegmentInfo, OutputFormat, } from '../output-format'; import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { SubtitleCue, SubtitleMetadata } from '../subtitles'; import { NullTarget, PathedTarget, Target, TargetRequest } from '../target'; import { HLS_MIME_TYPE } from './hls-misc'; type HlsTrackData = { track: OutputTrack; packets: EncodedPacket[]; playlist: Playlist; // We must store it on the TrackData, reading it directly from the track leads to async race conditions! closed: boolean; info: { type: 'video'; decoderConfig: VideoDecoderConfig; } | { type: 'audio'; decoderConfig: AudioDecoderConfig; }; }; type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } }; type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } }; type PlaylistSegment = { path: string; duration: number; timestamp: number; byteSize: number; byteOffset: number | null; info: HlsOutputSegmentInfo | null; }; type Playlist = { id: number; path: string; tracks: OutputTrack[]; segmentFormat: OutputFormat; currentSegmentStartTimestamp: number | null; currentSegmentStartTimestampIsFixed: boolean; nextSegmentId: number; initSegment: PlaylistSegment | null; writtenSegments: PlaylistSegment[]; peakBitrate: number | null; averageBitrate: number | null; mediaSequence: number; done: boolean; singleFile: { target: Target; path: string; nextOffset: number; info: HlsOutputSegmentInfo; } | null; // For HLS, having a single mutex is too coarse. Every playlist is basically independent and therefore we can have // a per-playlist mutex instead of a per-muxer one. This means two packets from different playlists coming in don't // block each other. mutex: AsyncMutex; }; type PlaylistDeclaration = { playlist: Playlist; groupId: string | null; noUri: boolean; references: PlaylistDeclaration[]; }; export class HlsMuxer extends Muxer { format: HlsOutputFormat; getPlaylistPath: NonNullable<HlsOutputFormatOptions['getPlaylistPath']>; getSegmentPath: NonNullable<HlsOutputFormatOptions['getSegmentPath']>; getInitPath: NonNullable<HlsOutputFormatOptions['getInitPath']>; targetSegmentDuration: number; trackDatas: HlsTrackData[] = []; singleFilePerPlaylist: boolean; isLive: boolean; maxLiveSegmentCount: number; isRelativeToUnixEpoch = false; globalTargetDuration: number; numWrittenMasterPlaylists = 0; playlists: Playlist[] = []; playlistDeclarations: PlaylistDeclaration[] = []; constructor(output: Output, format: HlsOutputFormat) { if (!(output._target instanceof PathedTarget)) { throw new TypeError('HLS outputs require `OutputOptions.target` to be a PathedTarget.'); } super(output); this.format = format; this.targetSegmentDuration = format._options.targetDuration ?? 2; this.singleFilePerPlaylist = format._options.singleFilePerPlaylist ?? false; this.isLive = format._options.live ?? false; this.maxLiveSegmentCount = format._options.maxLiveSegmentCount ?? Infinity; this.globalTargetDuration = this.targetSegmentDuration; this.getPlaylistPath = format._options.getPlaylistPath ?? (({ n }) => `playlist-${n}.m3u8`); this.getSegmentPath = format._options.getSegmentPath ?? (info => info.isSingleFile ? `segments-${info.playlist.n}${info.format.fileExtension}` : `segment-${info.playlist.n}-${info.n}${info.format.fileExtension}`); this.getInitPath = format._options.getInitPath ?? (playlist => `init-${playlist.n}${playlist.segmentFormat.fileExtension}`); } async start(): Promise<void> { const release = await this.mutex.acquire(); const someRelative = this.output._tracks.some(t => t.metadata.isRelativeToUnixEpoch); const someNotRelative = this.output._tracks.some(t => !t.metadata.isRelativeToUnixEpoch); if (someRelative && someNotRelative) { throw new Error( 'All tracks must agree on `relativeToUnixEpoch`: some tracks are relative to the Unix epoch and some' + ' are not.', ); } this.isRelativeToUnixEpoch = someRelative; // Upon starting, we now need to assign the tracks to separate playlists. This assignment will make use of the // track pairability information provided by the user as well as other metadata specified on the tracks. The // resulting master playlist should preserve track pairability; meaning that all tracks that are pairable // remain pairable, and no two tracks become pairable that are meant to be mutually exclusive. // The algorithm determines "groups" by enumerating all pairable tracks for each track, and then materializes // each group either as #EXT-X-MEDIA tags or top-level #EXT-X-STREAM-INF tags. The algorithm is biased towards // video being the top-level grouping, since that's the standard practice. const groupAssignment = new Map<OutputTrack, string[]>(); const groups: { name: string; key: string; tracks: OutputTrack[]; needsEmit: boolean; firstNoUri: boolean; }[] = []; let hasVideo = false; let illegalPairingDetected = false; let keyPacketsOnlyPairingWarned = false; // First, let's build the "sibling" groups induced by track pairability for (const track of this.output._tracks) { if (track.type === 'video') { hasVideo = true; } const pairableGroups = new Map<MediaCodec, OutputTrack[]>(); for (const otherTrack of this.output._tracks) { if (track === otherTrack) { continue; } if (!track.canBePairedWith(otherTrack)) { continue; } if (track.type === otherTrack.type) { if (!illegalPairingDetected) { console.warn( `Illegal pairing of two ${track.type} tracks detected, which is not possible in HLS;` + ` treating them as unpaired.`, ); illegalPairingDetected = true; } continue; } // Key-packets-only tracks can neither pair with nor be paired with other tracks if ( (track.isVideoTrack() && track.metadata.hasOnlyKeyPackets) || (otherTrack.isVideoTrack() && otherTrack.metadata.hasOnlyKeyPackets) ) { if (!keyPacketsOnlyPairingWarned) { console.warn( `A key-packets-only video track is pairable with another track, which is not` + ` possible in HLS; treating them as unpaired.`, ); keyPacketsOnlyPairingWarned = true; } continue; } let groupTracks = pairableGroups.get(otherTrack.source._codec); if (!groupTracks) { pairableGroups.set(otherTrack.source._codec, groupTracks = []); } groupTracks.push(otherTrack); } for (const [, pairableTracks] of pairableGroups) { const key = pairableTracks.map(x => x.id).join('-'); const group = groups.find(x => x.key === key); if (!group) { groups.push({ name: pairableTracks[0]!.type + '-' + (groups.length + 1), key, tracks: pairableTracks, needsEmit: false, firstNoUri: false, }); } let assignedGroups = groupAssignment.get(track); if (!assignedGroups) { groupAssignment.set(track, assignedGroups = []); } assignedGroups.push(key); } } const mainType: TrackType = hasVideo ? 'video' : 'audio'; const variantStreams: { tracks: OutputTrack[]; linkedGroup: typeof groups[number] | null; }[] = []; const unpairedVideoTracks: OutputTrack[] = []; const unpairedAudioTracks: OutputTrack[] = []; // Now, create the top-level variant streams for (const track of this.output._tracks) { const assignedGroupKeys = groupAssignment.get(track); if (assignedGroupKeys) { assert(assignedGroupKeys.length > 0); if (track.type !== mainType) { continue; } for (const key of assignedGroupKeys) { const group = groups.find(x => x.key === key); assert(group); if (assignedGroupKeys.length === 1 && group.tracks.length === 1) { const otherGroupKeys = groupAssignment.get(group.tracks[0]!); assert(otherGroupKeys !== undefined); if (otherGroupKeys.length === 1) { const otherGroup = groups.find(x => x.key === otherGroupKeys[0]!)!; if (otherGroup.tracks.length === 1) { assert(otherGroup.tracks[0] === track); variantStreams.push({ tracks: [track, group.tracks[0]!], linkedGroup: null, }); continue; } } } variantStreams.push({ tracks: [track], linkedGroup: group, }); group.needsEmit = true; } } else { if (track.type === 'video') { unpairedVideoTracks.push(track); } else if (track.type === 'audio') { unpairedAudioTracks.push(track); } } } const getMetadataKeyForTrack = ({ metadata }: OutputTrack) => { let key = ''; key += `${metadata.languageCode ?? UNDETERMINED_LANGUAGE}-`; key += `${metadata.name ?? ''}-`; key += `${metadata.disposition?.default ?? true}-`; key += `${metadata.disposition?.primary ?? false}-`; key += `${metadata.disposition?.forced ?? false}-`; return key; }; // Video tracks that can't be paired with any other track always live on the top-level, the question is just if // they need to be separated into #EXT-X-MEDIA tags or not if (unpairedVideoTracks.length > 0) { const uniqueMetadata = new Set(unpairedVideoTracks.map(getMetadataKeyForTrack)); if (uniqueMetadata.size > 1) { // They differ in metadata, emit as group const group: typeof groups[number] = { key: unpairedVideoTracks.map(x => x.id).join('-'), name: 'video-' + (groups.length + 1), tracks: unpairedVideoTracks, needsEmit: true, firstNoUri: true, }; groups.push(group); variantStreams.push({ tracks: [unpairedVideoTracks[0]!], linkedGroup: group, }); } else { for (const track of unpairedVideoTracks) { variantStreams.push({ tracks: [track], linkedGroup: null, }); } } } // Audio tracks that can't be paired with any other track always live on the top-level, the question is just if // they need to be separated into #EXT-X-MEDIA tags or not if (unpairedAudioTracks.length > 0) { const uniqueMetadata = new Set(unpairedAudioTracks.map(getMetadataKeyForTrack)); if (uniqueMetadata.size > 1) { // They differ in metadata, emit as group const group: typeof groups[number] = { key: unpairedAudioTracks.map(x => x.id).join('-'), name: 'audio-' + (groups.length + 1), tracks: unpairedAudioTracks, needsEmit: true, firstNoUri: true, }; groups.push(group); variantStreams.push({ tracks: [unpairedAudioTracks[0]!], linkedGroup: group, }); } else { for (const track of unpairedAudioTracks) { variantStreams.push({ tracks: [track], linkedGroup: null, }); } } } const deduceSegmentFormat = (tracks: OutputTrack[]) => { const codecs: MediaCodec[] = []; let videoCount = 0; let audioCount = 0; let requiresRotationMetadata = false; let candidate: OutputFormat | null = null; let candidateScore = -Infinity; for (const track of tracks) { if (track.isVideoTrack()) { videoCount++; requiresRotationMetadata ||= (track.metadata.rotation ?? 0) !== 0; } else if (track.isAudioTrack()) { audioCount++; } codecs.push(track.source._codec); } for (const format of toArray(this.format._options.segmentFormat)) { const supportedCodecs = format.getSupportedCodecs(); const trackCounts = format.getSupportedTrackCounts(); if (codecs.some(codec => !supportedCodecs.includes(codec))) { continue; } if (videoCount < trackCounts.video.min || videoCount > trackCounts.video.max) { continue; } if (audioCount < trackCounts.audio.min || audioCount > trackCounts.audio.max) { continue; } let score = 0; if (requiresRotationMetadata && format.supportsVideoRotationMetadata) { score++; } if (score > candidateScore) { candidate = format; candidateScore = score; } } // We must find a format. If no format is found, that means we incorrectly gated track creation and // assignment at an earlier step. assert(candidate); return candidate; }; const registerPlaylist = async (tracks: OutputTrack[]) => { if (tracks.some(track => this.playlists.some(playlist => playlist.tracks.includes(track)))) { throw new Error('Internal error: track is already registered in a playlist.'); // Should be unreachable } const format = deduceSegmentFormat(tracks); const id = this.playlists.length + 1; const path = await this.getPlaylistPath({ n: id, tracks, segmentFormat: format, }); validatePlaylistPath(path); const playlist: Playlist = { id: this.playlists.length + 1, path, tracks, segmentFormat: format, currentSegmentStartTimestamp: null, currentSegmentStartTimestampIsFixed: false, nextSegmentId: 1, initSegment: null, writtenSegments: [], peakBitrate: null, averageBitrate: null, mediaSequence: 0, done: false, singleFile: null, mutex: new AsyncMutex(), }; this.playlists.push(playlist); return playlist; }; // Now, finally let's create all declarations. Each declaration maps to one #EXT-X-MEDIA or #EXT-X-STREAM-INF // tag in the final master playlist. for (const group of groups) { if (!group.needsEmit) { continue; } for (let i = 0; i < group.tracks.length; i++) { const track = group.tracks[i]!; let playlist = this.playlists.find(x => x.tracks[0]!.id === track.id); playlist ??= await registerPlaylist([track]); this.playlistDeclarations.push({ playlist, groupId: group.name, noUri: group.firstNoUri && i === 0, references: [], }); } } for (const variant of variantStreams) { // Since tracks can only be assigned to one playlist, the first track's ID acts as a "playlist key" let playlist = this.playlists.find(x => x.tracks[0]!.id === variant.tracks[0]!.id); playlist ??= await registerPlaylist(variant.tracks); this.playlistDeclarations.push({ playlist, groupId: null, noUri: false, references: variant.linkedGroup ? this.playlistDeclarations.filter(x => x.groupId === variant.linkedGroup!.name) : [], }); } release(); } async getMimeType(): Promise<string> { return HLS_MIME_TYPE; } private allTracksAreKnown(playlist: Playlist) { for (const track of playlist.tracks) { if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) { return false; // We haven't seen a sample from this open track yet } } return true; } // eslint-disable-next-line @typescript-eslint/no-misused-promises override async onTrackClose(track: OutputTrack) { const trackData = this.trackDatas.find(x => x.track === track); if (trackData) { trackData.closed = true; } const playlist = this.playlists.find(x => x.tracks.includes(track)); assert(playlist); // If there isn't one then the assignment algo failed innit const release = await playlist.mutex.acquire(); try { await this.advancePlaylist(playlist); } finally { release(); } } getVideoTrackData(track: OutputVideoTrack, meta?: EncodedVideoChunkMetadata) { let trackData = this.trackDatas.find(x => x.track === track) as HlsVideoTrackData; if (trackData) { return trackData; } validateVideoChunkMetadata(meta); assert(meta); assert(meta?.decoderConfig); const playlists = this.playlists.filter(x => x.tracks.includes(track)); assert(playlists.length === 1); trackData = { track, packets: [], playlist: playlists[0]!, closed: false, info: { type: 'video', decoderConfig: meta.decoderConfig, }, }; this.trackDatas.push(trackData); return trackData; } getAudioTrackData(track: OutputAudioTrack, meta?: EncodedAudioChunkMetadata) { let trackData = this.trackDatas.find(x => x.track === track) as HlsAudioTrackData; if (trackData) { return trackData; } validateAudioChunkMetadata(meta); assert(meta); assert(meta?.decoderConfig); const playlists = this.playlists.filter(x => x.tracks.includes(track)); assert(playlists.length === 1); trackData = { track, packets: [], playlist: playlists[0]!, closed: false, info: { type: 'audio', decoderConfig: meta.decoderConfig, }, }; this.trackDatas.push(trackData); return trackData; } async addEncodedVideoPacket( track: OutputVideoTrack, packet: EncodedPacket, meta?: EncodedVideoChunkMetadata, ) { const trackData = this.getVideoTrackData(track, meta); const playlist = trackData.playlist; const release = await playlist.mutex.acquire(); try { this.validateTimestamp(track, packet.timestamp, packet.type === 'key'); trackData.packets.push(packet); if (playlist.currentSegmentStartTimestamp === null) { playlist.currentSegmentStartTimestamp = packet.timestamp; } else if (!playlist.currentSegmentStartTimestampIsFixed) { playlist.currentSegmentStartTimestamp = Math.min( playlist.currentSegmentStartTimestamp, packet.timestamp, ); } await this.advancePlaylist(playlist); } finally { release(); } } async addEncodedAudioPacket( track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata, ) { const trackData = this.getAudioTrackData(track, meta); const playlist = trackData.playlist; const release = await playlist.mutex.acquire(); try { this.validateTimestamp(track, packet.timestamp, packet.type === 'key'); trackData.packets.push(packet); if (playlist.currentSegmentStartTimestamp === null) { playlist.currentSegmentStartTimestamp = packet.timestamp; } else if (!playlist.currentSegmentStartTimestampIsFixed) { playlist.currentSegmentStartTimestamp = Math.min( playlist.currentSegmentStartTimestamp, packet.timestamp, ); } await this.advancePlaylist(playlist); } finally { release(); } } async addSubtitleCue( // eslint-disable-next-line @typescript-eslint/no-unused-vars track: OutputSubtitleTrack, // eslint-disable-next-line @typescript-eslint/no-unused-vars cue: SubtitleCue, // eslint-disable-next-line @typescript-eslint/no-unused-vars meta?: SubtitleMetadata, ) { throw new Error('Unreachable.'); } async advancePlaylist(playlist: Playlist) { assert(!playlist.done); if (!this.allTracksAreKnown(playlist)) { return; } if (playlist.currentSegmentStartTimestamp === null) { // All tracks are known but we never received any data - all tracks must be closed already await this.onPlaylistDone(playlist); return; } const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track)); const videoTrack = trackDatas.find(x => x.info.type === 'video') as HlsVideoTrackData | undefined; const audioTrack = trackDatas.find(x => x.info.type === 'audio') as HlsAudioTrackData | undefined; // Loop in case we can finalize multiple segments while (true) { // This here is the core segmentation logic. The segmentation logic figures out which packets are to be // written into the next segment, and if we can write a segment at all. If tracks are still open and have // not provided sufficient media data, no segment will be written. The packets will be added to the segment // to maximize its duration AND keep it from exceeding the target duration. This condition is extended with // a key frame rule for video, meaning the algorithm must guarantee that every segment with video data // begins with a video key frame. // // The logic is quite complex but is solved in a straight-forward way: all possible permutations of the // problem are checked in a nested if-else structure, making sure all cases behave correctly. This was the // easiest, least error-prone way I found to express this behavior. const currentSegmentEndTimestamp = playlist.currentSegmentStartTimestamp + this.targetSegmentDuration; // These store the index (exclusive) until when packets can be added to the next segment let videoEndIndex = 0; let audioEndIndex = 0; if (videoTrack && (!videoTrack.closed || videoTrack.packets.length > 0)) { // A video track is active (and maybe an audio track too) const allBelow = videoTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp); let bestKeyPacket: EncodedPacket | null = null; let bestKeyPacketIndex: number | null = null; if (allBelow) { if (!videoTrack.closed) { // Not enough data yet return; } } else { // Find the best key packet timestamp for (let i = 0; i < videoTrack.packets.length; i++) { const packet = videoTrack.packets[i]!; if (bestKeyPacket !== null && packet.timestamp > currentSegmentEndTimestamp) { break; } if (i > 0 && packet.type === 'key') { bestKeyPacket = packet; bestKeyPacketIndex = i; } } } if (bestKeyPacketIndex !== null) { videoEndIndex = bestKeyPacketIndex; if (audioTrack) { // The audio track must go at least until the video key frame const index = audioTrack.packets.findIndex(x => x.timestamp >= bestKeyPacket!.timestamp); if (index !== -1) { audioEndIndex = index; } else { if (audioTrack.closed) { audioEndIndex = audioTrack.packets.length; } else { return; } } } } else { if (!videoTrack.closed) { return; } // Include the entire rest of the video (since there's no key frame to split it on) videoEndIndex = videoTrack.packets.length; const maxIndex = arrayArgmax(videoTrack.packets, x => x.timestamp); const maxPacket = videoTrack.packets[maxIndex]; assert(maxPacket); if (audioTrack) { if (maxPacket.timestamp < currentSegmentEndTimestamp) { // The audio must go until at least the start of the next segment const index = audioTrack.packets.findIndex(x => x.timestamp >= currentSegmentEndTimestamp); if (index !== -1) { audioEndIndex = index; } else { if (audioTrack.closed) { audioEndIndex = audioTrack.packets.length; } else { return; } } } else { // The audio must go beyond the last video packet const index = audioTrack.packets.findIndex(x => x.timestamp > maxPacket.timestamp); if (index !== -1) { audioEndIndex = index; } else { if (audioTrack.closed) { audioEndIndex = audioTrack.packets.length; } else { return; } } } } } } else if (audioTrack && (!audioTrack.closed || audioTrack.packets.length > 0)) { // There's only an audio track active const allBelow = audioTrack.packets.every(x => x.timestamp < currentSegmentEndTimestamp); if (allBelow) { if (audioTrack.closed) { // We can write all packets since they're all below audioEndIndex = audioTrack.packets.length; } else { // We don't know enough packets yet return; } } else { // Aim to make the segment at most as long as desired const index = findLastIndex(audioTrack.packets, x => x.timestamp <= currentSegmentEndTimestamp); audioEndIndex = Math.max(index, 1); // Always include at least the first packet } } if (videoEndIndex === 0 && audioEndIndex === 0) { // No more segments to write - if all tracks are closed, this playlist is done const allClosed = trackDatas.every(x => x.closed); if (allClosed) { await this.onPlaylistDone(playlist); } return; } // We can finalize a new segment! let segmentInfo: HlsOutputSegmentInfo | null = null; let relativeSegmentPath: string; let fullSegmentPath: string; assert(this.output._target instanceof PathedTarget); const pathedTarget = this.output._target; if (this.singleFilePerPlaylist) { if (playlist.singleFile === null) { // INTENTIONALLY shadow the outside `segmentInfo` because we don't want to set it. // In single-file mode, onSegment is called once in onPlaylistDone instead of per-segment, // so the outer `segmentInfo` intentionally stays null in this case. const segmentInfo: HlsOutputSegmentInfo = { n: playlist.nextSegmentId, format: playlist.segmentFormat, isSingleFile: true, playlist: toPlaylistInfo(playlist), }; relativeSegmentPath = await this.getSegmentPath(segmentInfo); validateSegmentPath(relativeSegmentPath); fullSegmentPath = joinPaths( joinPaths(pathedTarget.rootPath, playlist.path), relativeSegmentPath, ); const target = await this.output._getTarget({ path: fullSegmentPath, isRoot: false, mimeType: playlist.segmentFormat.mimeType, }); target._start(); playlist.singleFile = { target, path: relativeSegmentPath, nextOffset: 0, info: segmentInfo, }; } else { relativeSegmentPath = playlist.singleFile.path; fullSegmentPath = joinPaths( joinPaths(pathedTarget.rootPath, playlist.path), relativeSegmentPath, ); } } else { segmentInfo = { n: playlist.nextSegmentId, format: playlist.segmentFormat, isSingleFile: false, playlist: toPlaylistInfo(playlist), }; relativeSegmentPath = await this.getSegmentPath(segmentInfo); validateSegmentPath(relativeSegmentPath); fullSegmentPath = joinPaths(joinPaths(pathedTarget.rootPath, playlist.path), relativeSegmentPath); playlist.nextSegmentId++; } let segmentSize = 0; let outputTarget: Target | null = null; const output = new Output({ format: playlist.segmentFormat, target: new PathedTarget( fullSegmentPath, async (request: TargetRequest) => { const proxiedRequest: TargetRequest = { ...request, isRoot: false, }; if (request.isRoot) { if (playlist.singleFile) { const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); return slice; } else { const target = await this.output._getTarget(proxiedRequest); outputTarget = target; target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); return target; } } return this.output._getTarget(proxiedRequest); }, ), initTarget: async () => { if (playlist.initSegment) { // We already have an init segment from a previous segment return new NullTarget(); } if (playlist.singleFile) { playlist.initSegment = { path: playlist.singleFile.path, duration: 0, timestamp: 0, byteSize: 0, byteOffset: 0, info: null, }; const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); slice.on('write', ({ end }) => { playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); }); slice.on('finalized', () => { playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize; }); return slice; } else { const playlistInfo = toPlaylistInfo(playlist); const initPath = await this.getInitPath(playlistInfo); validateInitPath(initPath); playlist.initSegment = { path: initPath, duration: 0, timestamp: 0, byteSize: 0, byteOffset: null, info: null, }; const fullInitPath = joinPaths( joinPaths(pathedTarget.rootPath, playlist.path), initPath, ); const target = await this.output._getTarget({ path: fullInitPath, isRoot: false, mimeType: playlist.segmentFormat.mimeType, }); target.on('write', ({ end }) => { playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); }); target.on('finalized', () => { this.format._options.onInit?.(target, playlistInfo); }); return target; } }, }); let maxEndTimestamp = -Infinity; try { let videoSource: EncodedVideoPacketSource | null = null; let audioSource: EncodedAudioPacketSource | null = null; if (videoTrack) { // Always add the track, no matter if it has packets or not (maintains underlying IDs) videoSource = new EncodedVideoPacketSource((videoTrack.track as OutputVideoTrack).source._codec); output.addVideoTrack(videoSource, videoTrack.track.metadata); } if (audioTrack) { // Always add the track, no matter if it has packets or not (maintains underlying IDs) audioSource = new EncodedAudioPacketSource((audioTrack.track as OutputAudioTrack).source._codec); output.addAudioTrack(audioSource, audioTrack.track.metadata); } await output.start(); // Add all of the packets if (videoTrack) { assert(videoSource); const meta = { decoderConfig: videoTrack.info.decoderConfig }; for (let i = 0; i < videoEndIndex; i++) { const packet = videoTrack.packets[i]!; await videoSource.add(packet, meta); maxEndTimestamp = Math.max(maxEndTimestamp, packet.timestamp + packet.duration); } } if (audioTrack) { assert(audioSource); const meta = { decoderConfig: audioTrack.info.decoderConfig }; for (let i = 0; i < audioEndIndex; i++) { const packet = audioTrack.packets[i]!; await audioSource.add(packet, meta); maxEndTimestamp = Math.max(maxEndTimestamp, packet.timestamp + packet.duration); } } await output.finalize(); } catch (e) { await output.cancel(); throw e; } if (segmentInfo) { assert(outputTarget); this.format._options.onSegment?.(outputTarget, segmentInfo); } if (videoEndIndex > 0) { assert(videoTrack); videoTrack.packets.splice(0, videoEndIndex); } if (audioEndIndex > 0) { assert(audioTrack); audioTrack.packets.splice(0, audioEndIndex); } let minNextTimestamp = Infinity; if (videoTrack && videoTrack.packets.length > 0) { minNextTimestamp = videoTrack.packets[0]!.timestamp; } if (audioTrack && audioTrack.packets.length > 0) { minNextTimestamp = Math.min(minNextTimestamp, audioTrack.packets[0]!.timestamp); } const nextSegmentStartTimestamp = minNextTimestamp < Infinity ? minNextTimestamp : maxEndTimestamp; // Happens for the last segment for example assert(Number.isFinite(nextSegmentStartTimestamp)); const segmentDuration = nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp; assert(segmentDuration >= 0); playlist.writtenSegments.push({ path: relativeSegmentPath, duration: segmentDuration, timestamp: playlist.currentSegmentStartTimestamp, byteSize: segmentSize, byteOffset: playlist.singleFile ? playlist.singleFile.nextOffset : null, info: segmentInfo ?? null, }); this.globalTargetDuration = Math.max(this.globalTargetDuration, segmentDuration); playlist.currentSegmentStartTimestamp = nextSegmentStartTimestamp; playlist.currentSegmentStartTimestampIsFixed = true; // After the first segment, the timestamp is now fixed if (playlist.singleFile) { playlist.singleFile.nextOffset += segmentSize; } if (this.isLive) { while (playlist.writtenSegments.length > this.maxLiveSegmentCount) { const popped = playlist.writtenSegments.shift()!; playlist.mediaSequence++; if (!this.singleFilePerPlaylist) { assert(popped.info); this.format._options.onSegmentPopped?.(popped.path, popped.info); } } await this.writePlaylist(playlist); await this.tryWriteMasterPlaylist(); } } } private async onPlaylistDone(playlist: Playlist) { assert(!playlist.done); playlist.done = true; if (playlist.singleFile) { await playlist.singleFile.target._flush(); await playlist.singleFile.target._finalize(); this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info); } await this.writePlaylist(playlist); if (this.isLive && playlist.writtenSegments.length === 0) { await this.tryWriteMasterPlaylist(); } } private updatePlaylistBitrates(playlist: Playlist) { const segments = playlist.writtenSegments; let peakBitrate = 0; let totalBits = 0; let totalDuration = 0; // Per spec, peak bitrate is the largest bit rate of any contiguous set of segments whose total duration is // between 0.5 and 1.5 times the target duration for (let i = 0; i < segments.length; i++) { totalDuration += segments[i]!.duration; let windowBytes = 0; let windowDuration = 0; for (let j = i; j < segments.length; j++) { windowBytes += segments[j]!.byteSize; windowDuration += segments[j]!.duration; if ( windowDuration >= 0.5 * this.globalTargetDuration && windowDuration <= 1.5 * this.globalTargetDuration ) { peakBitrate = Math.max(peakBitrate, 8 * windowBytes / windowDuration); } if (windowDuration > 1.5 * this.globalTargetDuration) { break; } } } // Fallback: if no contiguous set falls within the range, use per-segment max if (peakBitrate === 0) { for (const segment of segments) { const segmentDuration = segment.duration || 1; // To catch 0-duration segments which can happen peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segmentDuration); } } for (const segment of segments) { totalBits += 8 * segment.byteSize; } playlist.peakBitrate = peakBitrate; playlist.averageBitrate = totalBits / (totalDuration || 1); } private async writePlaylist(playlist: Playlist) { assert(this.output._target instanceof PathedTarget); const pathedTarget = this.output._target; this.updatePlaylistBitrates(playlist); let hasByteOffsets = false; for (const segment of playlist.writtenSegments) { hasByteOffsets ||= segment.byteOffset !== null; } const isKeyPacketsOnly = playlist.tracks[0]!.isVideoTrack() && playlist.tracks[0].metadata.hasOnlyKeyPackets; let version = 3; if (isKeyPacketsOnly || hasByteOffsets) { version = 4; } if (playlist.initSegment) { version = 5; } if (playlist.initSegment && !isKeyPacketsOnly) { // "if it contains the EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY" version = 6; } // In live mode, target duration is not allowed to change, so we use the nominal value const targetDuration = this.isLive ? this.targetSegmentDuration : this.globalTargetDuration; const playlistPath = joinPaths(pathedTarget.rootPath, playlist.path); const playlistText = '#EXTM3U\n' + `#EXT-X-VERSION:${version}\n` + (!this.isLive ? '#EXT-X-PLAYLIST-TYPE:VOD\n' : '') + `#EXT-X-TARGETDURATION:${Math.ceil(targetDuration)}\n` // Must be a "decimal-integer" + (Number.isFinite(this.maxLiveSegmentCount) ? `#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence}\n` : '') + '#EXT-X-INDEPENDENT-SEGMENTS\n' + (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '') + (playlist.initSegment ? (`#EXT-X-MAP:URI="${playlist.initSegment.path}"` + (playlist.initSegment.byteOffset !== null ? `,BYTERANGE="${playlist.initSegment.byteSize}@${playlist.initSegment.byteOffset}"` : '') + '\n') : '') + '\n' + (playlist.writtenSegments .map(segment => ( `#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec + (this.isRelativeToUnixEpoch ? `#EXT-X-PROGRAM-DATE-TIME:${new Date(1000 * segment.timestamp).toISOString()}\n` : '') + (segment.byteOffset !== null ? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n` : '') + `${segment.path}\n` )) .join('')) + (playlist.done ? (playlist.writtenSegments.length > 0 ? '\n' : '') + '#EXT-X-ENDLIST\n' : ''); this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist)); const target = await this.output._getTarget({ path: playlistPath, isRoot: false, mimeType: HLS_MIME_TYPE, }); const writer = new Writer(target, true); writer.start(); writer.write(textEncoder.encode(playlistText)); await writer.flush(); await writer.finalize(); } private async writeMasterPlaylist() { assert(this.output._target instanceof PathedTarget); const pathedTarget = this.output._target; let masterPlaylistText = '#EXTM3U\n'; let firstVariantWritten = false; let lastGroupId: string | null = null; let groupIdTrackCount = 0; let hasHadDefaultTrackInGroup = false; for (const decl of this.playlistDeclarations) { if (decl.groupId === null) { const isKeyPacketsOnly = decl.playlist.tracks[0]!.isVideoTrack() && decl.playlist.tracks[0].metadata.hasOnlyKeyPackets; const codecs: string[] = []; for (const track of decl.playlist.tracks) { const trackData = this.trackDatas.find(x => x.track === track); const codecString = trackData?.info.decoderConfig.codec ?? track.source._codec; codecs.push(codecString); } let peakDeclBitrate = 0; let maxRefAverageBitrate = 0; if (decl.references.length > 0) { const firstRef = decl.references[0]!; const firstTrack = firstRef.playlist.tracks[0]!; const trackData = this.trackDatas.find(x => x.track === firstTrack); const codecString = trackData?.info.decoderConfig.codec ?? firstTrack.source._codec; codecs.push(codecString); for (const ref of decl.references) { assert(ref.playlist.peakBitrate !== null); peakDeclBitrate = Math.max(peakDeclBitrate, ref.playlist.peakBitrate); maxRefAverageBitrate = Math.max(maxRefAverageBitrate, ref.playlist.averageBitrate ?? 0); } } assert(decl.playlist.peakBitrate !== null); const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate; const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0) + maxRefAverageBitrate; if (!firstVariantWritten) { masterPlaylistText += '\n'; firstVariantWritten = true; } if (isKeyPacketsOnly) { masterPlaylistText += `#EXT-X-I-FRAME-STREAM-INF:`; } else { masterPlaylistText += `#EXT-X-STREAM-INF:`; } masterPlaylistText += `BANDWIDTH=${Math.ceil(totalPeakBitrate)}`; if (totalAverageBitrate > 0) { masterPlaylistText += `,AVERAGE-BANDWIDTH=${Math.ceil(totalAverageBitrate)}`; } masterPlaylistText += `,CODECS="${codecs.join(',')}"`; const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack()); if (videoTrack?.isVideoTrack()) { const trackData = this.trackDatas.find(x => x.track === videoTrack) as HlsVideoTrackData | undefined; const decoderConfig = trackData?.info.decoderConfig; if (decoderConfig) { let width = decoderConfig.displayAspectWidth ?? decoderConfig.codedWidth; let height = decoderConfig.displayAspectHeight ?? decoderConfig.codedHeight; if (width !== undefined && height !== undefined) { if ( videoTrack.metadata.rotation !== undefined && videoTrack.metadata.rotation % 180 === 90 ) { [width, height] = [height, width]; } masterPlaylistText += `,RESOLUTION=${width}x${height}`; } } // FRAME-RATE is not defined for EXT-X-I-FRAME-STREAM-INF if (!isKeyPacketsOnly && videoTrack.metadata.frameRate !== undefined) { // Spec requires that frame rate be rounded to 3 decimal places masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(3)}`; } } if (!isKeyPacketsOnly) { const groupIdForType = new Map<string, string>(); for (const ref of decl.references) { assert(ref.groupId !== null); const type = ref.playlist.tracks[0]!.type; groupIdForType.set(type, ref.groupId); } for (const [type, id] of groupIdForType) { masterPlaylistText += `,${type.toUpperCase()}="${id}"`; } } if (isKeyPacketsOnly) { // EXT-X-I-FRAME-STREAM-INF is standalone with a URI attribute masterPlaylistText += `,URI="${decl.playlist.path}"`; masterPlaylistText += '\n'; } else { masterPlaylistText += '\n'; masterPlaylistText += `${decl.playlist.path}\n`; } } else { assert(decl.playlist.tracks.length === 1); const track = decl.playlist.tracks[0]!; const type = track.type; let name = track.metadata.name ?? null; const languageCode = track.metadata.languageCode; const disposition = track.metadata.disposition; if (lastGroupId === null || decl.groupId !== lastGroupId) { groupIdTrackCount = 0; masterPlaylistText += '\n'; hasHadDefaultTrackInGroup = false; } lastGroupId = decl.groupId; groupIdTrackCount++; masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`; if (name !== null && /[\n\r"]/.test(name)) { console.warn( 'Dropping track name since it includes a line feed, carriage return, or double quote' + ' character, which are not allowed in HLS playlist attributes.', ); name = null; } // Name is required, so we have to set it to SOMETHING name ??= `${languageCode ?? decl.groupId}-${groupIdTrackCount}`; masterPlaylistText += `,NAME="${name}"`; if (languageCode !== undefined) { masterPlaylistText += `,LANGUAGE="${languageCode}"`; } const dispositionPrimary = disposition?.primary ?? false; const dispositionDefault = disposition?.default ?? true; const dispositionForced = disposition?.forced ?? false; if (dispositionPrimary && !hasHadDefaultTrackInGroup) { // HLS's "DEFAULT" behaves like our "primary" masterPlaylistText += ',DEFAULT=YES'; hasHadDefaultTrackInGroup = true; // Only one DEFAULT label per group allowed } if (dispositionPrimary || dispositionDefault) { masterPlaylistText += ',AUTOSELECT=YES'; } if (dispositionForced) { masterPlaylistText += ',FORCED=YES'; } if (type === 'audio') { const trackData = this.trackDatas.find(x => x.track === track) as HlsAudioTrackData | undefined; const decoderConfig = trackData?.info.decoderConfig; if (decoderConfig) { masterPlaylistText += `,CHANNELS="${decoderConfig.numberOfChannels}"`; } } if (!decl.noUri) { masterPlaylistText += `,URI="${decl.playlist.path}"`; } masterPlaylistText += '\n'; } } this.format._options.onMaster?.(masterPlaylistText); const release = await this.mutex.acquire(); try { let writer: Writer; if (this.numWrittenMasterPlaylists === 0) { // For the first master playlist write, we use the normal root writer getter, so that the target // returned by Output.target emits valid write events. writer = await this.output._getRootWriter(true); } else { // For subsequent master playlist writes, we *must* obtain a different target in order to overwrite // the file. const target = await this.output._getTarget({ path: pathedTarget.rootPath, isRoot: true, mimeType: HLS_MIME_TYPE, }); writer = new Writer(target, true); writer.start(); } writer.write(textEncoder.encode(masterPlaylistText)); await writer.flush(); await writer.finalize(); this.numWrittenMasterPlaylists++; } finally { release(); } } private async tryWriteMasterPlaylist() { assert(this.isLive); // The master playlist is written once all playlists have either produced at least one segment or are done for (const playlist of this.playlists) { if (playlist.writtenSegments.length === 0 && !playlist.done) { return; } } await this.writeMasterPlaylist(); } async finalize() { const releases = await Promise.all(this.playlists.map(p => p.mutex.acquire())); releases.forEach(release => release()); for (const trackData of this.trackDatas) { trackData.closed = true; } await Promise.all(this.playlists.map(playlist => ( playlist.done ? Promise.resolve() : this.advancePlaylist(playlist) ))); if (!this.isLive) { await this.writeMasterPlaylist(); } } } const validatePlaylistPath = (path: string) => { if (typeof path !== 'string') { throw new TypeError('options.getPlaylistPath must return or resolve to a string'); } if (/[\n\r"]/.test(path)) { throw new TypeError( 'Playlist paths cannot contain line feed, carriage return, or double quote characters.', ); } }; const validateSegmentPath = (path: string) => { if (typeof path !== 'string') { throw new TypeError('options.getSegmentPath must return or resolve to a string'); } if (/[\n\r"]/.test(path)) { throw new TypeError( 'Segment paths cannot contain line feed or carriage return characters.', ); } }; const validateInitPath = (path: string) => { if (typeof path !== 'string') { throw new TypeError('options.getInitPath must return or resolve to a string'); } if (/[\n\r"]/.test(path)) { throw new TypeError( 'Init paths cannot contain line feed, carriage return, or double quote characters.', ); } }; const toPlaylistInfo = (playlist: Playlist): HlsOutputPlaylistInfo => { return { n: playlist.id, tracks: playlist.tracks, segmentFormat: playlist.segmentFormat, }; };