diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index b2a43aa..9d5ecef 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -92,13 +92,13 @@ A progress of `1` doesn't indicate the conversion has finished; the conversion i Tracking conversion progress can slightly affect performance as it requires knowledge of the input file's total duration. This is usually negligible but should be avoided when using append-only input sources such as [`ReadableStreamSource`](./reading-media-files#readablestreamsource). ::: -If you want to monitor the output size of the conversion (in bytes), simply use the `onwrite` callback on your `Target`: +If you want to monitor the output size of the conversion (in bytes), simply use the `write` event on your `Target`: ```ts let currentFileSize = 0; -output.target.onwrite = (start, end) => { +const stopListening = output.target.on('write', ({ start, end }) => { currentFileSize = Math.max(currentFileSize, end); -}; +}); ``` ### Canceling a conversion diff --git a/docs/guide/writing-media-files.md b/docs/guide/writing-media-files.md index 6882eb4..b4c1006 100644 --- a/docs/guide/writing-media-files.md +++ b/docs/guide/writing-media-files.md @@ -223,14 +223,14 @@ The _output target_ determines where the data created by the `Output` will be wr --- -All targets have an optional `onwrite` callback you can set to monitor which byte regions are being written to: +All targets emit a `write` event you can listen to in order to monitor which byte regions are being written to: ```ts -target.onwrite = (start, end) => { +const stopListening = target.on('write', ({ start, end }) => { // ... -}; +}); ``` -You can use this to track the size of the output file as it grows. But be warned, this function is chatty and gets called *extremely* frequently. +You can use this to track the size of the output file as it grows. But be warned, this event is chatty and gets fired *extremely* frequently. ### `BufferTarget` diff --git a/examples/procedural-generation/procedural-generation.ts b/examples/procedural-generation/procedural-generation.ts index cd50c07..b47d0a8 100644 --- a/examples/procedural-generation/procedural-generation.ts +++ b/examples/procedural-generation/procedural-generation.ts @@ -99,29 +99,35 @@ const generateVideo = async () => { output = new Output({ rootPath: 'master.m3u8', target: async ({ path }) => { + /* const fileHandle = await dirHandle.getFileHandle(path, { create: true }); const writable = await fileHandle.createWritable(); const target = new StreamTarget(writable); - target.onfinalized = () => console.log('Finalizado', path); - - return target; - - /* - const target = new BufferTarget(); - target.onfinalized = async () => { - const fileHandle = await dirHandle.getFileHandle(path, { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(target.buffer); - await writable.close(); - }; + target.on('finalized', () => console.log('Finalizado', path)); return target; */ + + const target = new BufferTarget(); + target.on('finalized', async () => { + if (path.includes('m3u8')) { + console.log(new TextDecoder().decode(target.buffer!)); + } + }); + + if (path.includes('m4s')) { + console.log('here'); + target.on('write', ({ start, end }) => console.log(start, end)); + target.on('finalized', () => console.log('yippie')); + } + + return target; }, format: new HlsOutputFormat({ segmentFormat: new CmafOutputFormat(), - singleFilePerPlaylist: true, + // singleFilePerPlaylist: true, + live: true, getPlaylistPath: info => `sussex-${info.n}.m3u8`, }), }); @@ -159,7 +165,7 @@ const generateVideo = async () => { codec: audioCodec, bitrate: QUALITY_HIGH, }); - output.addAudioTrack(audioBufferSource); + // output.addAudioTrack(audioBufferSource); /* audioBufferSource2 = new AudioBufferSource({ @@ -199,6 +205,8 @@ const generateVideo = async () => { // Add the current state of the canvas as a frame to the video. Using `await` here is crucial to // automatically slow down the rendering loop when the encoder can't keep up. await canvasSource.add(currentTime, 1 / frameRate); + + await new Promise(resolve => setTimeout(resolve, 1000 / frameRate)); } // Signal to the output that no more video frames are coming (not necessary, but recommended) @@ -208,8 +216,8 @@ const generateVideo = async () => { // Let's render the audio. Ideally, the audio is rendered before the video (or concurrently to it), but for // simplicity, we're rendering it after we've cranked through all frames. const audioBuffer = await audioContext.startRendering(); - await audioBufferSource.add(audioBuffer); - audioBufferSource.close(); + // await audioBufferSource.add(audioBuffer); + // audioBufferSource.close(); // await audioBufferSource2!.add(audioBuffer); // audioBufferSource2!.close(); diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 750edfa..284d786 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -60,6 +60,7 @@ type Playlist = { writtenSegments: PlaylistSegment[]; peakBitrate: number | null; averageBitrate: number | null; + done: boolean; singleFile: { target: Target; @@ -85,6 +86,8 @@ export class HlsMuxer extends Muxer { targetSegmentDuration: number; trackDatas: HlsTrackData[] = []; singleFilePerPlaylist: boolean; + isLive: boolean; + globalTargetDuration: number; playlists: Playlist[] = []; playlistDeclarations: PlaylistDeclaration[] = []; @@ -99,6 +102,8 @@ export class HlsMuxer extends Muxer { this.format = format; this.targetSegmentDuration = format._options.targetDuration ?? 2; this.singleFilePerPlaylist = format._options.singleFilePerPlaylist ?? false; + this.isLive = format._options.live ?? false; + this.globalTargetDuration = this.targetSegmentDuration; this.getPlaylistPath = format._options.getPlaylistPath ?? (({ n }) => `playlist-${n}.m3u8`); @@ -423,6 +428,7 @@ export class HlsMuxer extends Muxer { writtenSegments: [], peakBitrate: null, averageBitrate: null, + done: false, singleFile: null, }; this.playlists.push(playlist); @@ -494,6 +500,7 @@ export class HlsMuxer extends Muxer { const playlist = this.playlists.find(x => x.tracks.includes(track)); assert(playlist); // If there isn't one then the assignment algo failed innit + await this.advancePlaylist(playlist); } finally { release(); @@ -634,12 +641,19 @@ export class HlsMuxer extends Muxer { } async advancePlaylist(playlist: Playlist) { - assert(playlist.currentSegmentStartTimestamp !== null); + assert(!playlist.done); if (!this.allTracksAreKnown(playlist)) { return; } + if (playlist.currentSegmentStartTimestamp === null) { + // All tracks are known but we never received any data - all tracks must be closed already + await this.onPlaylistDone(playlist); + + return; + } + const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track)); const videoTrack = trackDatas.find(x => x.info.type === 'video') as HlsVideoTrackData | undefined; const audioTrack = trackDatas.find(x => x.info.type === 'audio') as HlsAudioTrackData | undefined; @@ -754,6 +768,12 @@ export class HlsMuxer extends Muxer { } if (videoEndIndex === 0 && audioEndIndex === 0) { + // No more segments to write - if all tracks are closed, this playlist is done + const allClosed = trackDatas.every(x => x.closed); + if (allClosed) { + await this.onPlaylistDone(playlist); + } + return; } @@ -824,13 +844,13 @@ export class HlsMuxer extends Muxer { if (request.isRoot) { if (playlist.singleFile) { const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); - slice.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end); + slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); return slice; } else { const target = await this.output._getTarget(request); outputTarget = target; - target.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end); + target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); return target; } @@ -853,12 +873,12 @@ export class HlsMuxer extends Muxer { }; const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); - slice.onwrite = (_, end) => { + slice.on('write', ({ end }) => { playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); - }; - slice.onfinalized = () => { + }); + slice.on('finalized', () => { playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize; - }; + }); return slice; } else { @@ -877,12 +897,12 @@ export class HlsMuxer extends Muxer { path, isRoot: false, }); - target.onwrite = (_, end) => { + target.on('write', ({ end }) => { playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); - }; - target.onfinalized = () => { + }); + target.on('finalized', () => { this.format._options.onInit?.(target, playlistInfo); - }; + }); return target; } @@ -968,24 +988,371 @@ export class HlsMuxer extends Muxer { : maxEndTimestamp; // Happens for the last segment for example assert(Number.isFinite(nextSegmentStartTimestamp)); + const segmentDuration = nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp; + playlist.writtenSegments.push({ path: relativeSegmentPath, - duration: nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp, + duration: segmentDuration, byteSize: segmentSize, byteOffset: playlist.singleFile ? playlist.singleFile.nextOffset : null, }); + this.globalTargetDuration = Math.max(this.globalTargetDuration, segmentDuration); + playlist.currentSegmentStartTimestamp = nextSegmentStartTimestamp; playlist.currentSegmentStartTimestampIsFixed = true; // After the first segment, the timestamp is now fixed if (playlist.singleFile) { playlist.singleFile.nextOffset += segmentSize; } + + if (this.isLive) { + await this.writePlaylist(playlist); + await this.tryWriteMasterPlaylist(); + } } } + private async onPlaylistDone(playlist: Playlist) { + assert(!playlist.done); + playlist.done = true; + + if (playlist.singleFile) { + await playlist.singleFile.target._flush(); + await playlist.singleFile.target._finalize(); + + this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info); + } + + await this.writePlaylist(playlist); + + if (this.isLive && playlist.writtenSegments.length === 0) { + await this.tryWriteMasterPlaylist(); + } + } + + private updatePlaylistBitrates(playlist: Playlist) { + const segments = playlist.writtenSegments; + + let peakBitrate = 0; + let totalBits = 0; + let totalDuration = 0; + + // Per spec, peak bitrate is the largest bit rate of any contiguous set of segments whose total duration is + // between 0.5 and 1.5 times the target duration + for (let i = 0; i < segments.length; i++) { + totalDuration += segments[i]!.duration; + + let windowBytes = 0; + let windowDuration = 0; + + for (let j = i; j < segments.length; j++) { + windowBytes += segments[j]!.byteSize; + windowDuration += segments[j]!.duration; + + if ( + windowDuration >= 0.5 * this.globalTargetDuration + && windowDuration <= 1.5 * this.globalTargetDuration + ) { + peakBitrate = Math.max(peakBitrate, 8 * windowBytes / windowDuration); + } + + if (windowDuration > 1.5 * this.globalTargetDuration) { + break; + } + } + } + + // Fallback: if no contiguous set falls within the range, use per-segment max + if (peakBitrate === 0) { + for (const segment of segments) { + peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segment.duration); + } + } + + for (const segment of segments) { + totalBits += 8 * segment.byteSize; + } + + playlist.peakBitrate = peakBitrate; + playlist.averageBitrate = totalBits / (totalDuration || 1); + } + + private async writePlaylist(playlist: Playlist) { + assert(this.output._rootPath !== null); + + this.updatePlaylistBitrates(playlist); + + let hasByteOffsets = false; + for (const segment of playlist.writtenSegments) { + hasByteOffsets ||= segment.byteOffset !== null; + } + + const isKeyPacketsOnly = playlist.tracks[0]!.isVideoTrack() + && playlist.tracks[0].metadata.hasOnlyKeyPackets; + + let version = 3; + if (isKeyPacketsOnly || hasByteOffsets) { + version = 4; + } + if (playlist.initSegment) { + version = 5; + } + if (playlist.initSegment && !isKeyPacketsOnly) { + // "if it contains the EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY" + version = 6; + } + + // In live mode, target duration is not allowed to change, so we use the nominal value + const targetDuration = this.isLive ? this.targetSegmentDuration : this.globalTargetDuration; + + const playlistPath = joinPaths(this.output._rootPath, playlist.path); + const playlistText = '#EXTM3U\n' + + `#EXT-X-VERSION:${version}\n` + + (!this.isLive ? '#EXT-X-PLAYLIST-TYPE:VOD\n' : '') + + `#EXT-X-TARGETDURATION:${Math.ceil(targetDuration)}\n` // Must be a "decimal-integer" + + '#EXT-X-INDEPENDENT-SEGMENTS\n' // Todo not for live? + + (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '') + + (playlist.initSegment + ? (`#EXT-X-MAP:URI="${playlist.initSegment.path}"` + + (playlist.initSegment.byteOffset !== null + ? `,BYTERANGE="${playlist.initSegment.byteSize}@${playlist.initSegment.byteOffset}"` + : '') + + '\n') + : '') + + '\n' + + (playlist.writtenSegments + .map(segment => ( + `#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec + + (segment.byteOffset !== null + ? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n` + : '') + + `${segment.path}\n` + )) + .join('')) + + (playlist.done + ? (playlist.writtenSegments.length > 0 ? '\n' : '') + '#EXT-X-ENDLIST\n' + : ''); + + this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist)); + + const target = await this.output._getTarget({ path: playlistPath, isRoot: false }); + const writer = new Writer(target); + writer.start(); + writer.write(textEncoder.encode(playlistText)); + + await writer.flush(); + await writer.finalize(); + } + + private async writeMasterPlaylist() { + let masterPlaylistText = '#EXTM3U\n'; + let firstVariantWritten = false; + + let lastGroupId: string | null = null; + let groupIdTrackCount = 0; + let hasHadDefaultTrackInGroup = false; + + for (const decl of this.playlistDeclarations) { + if (decl.groupId === null) { + const isKeyPacketsOnly = decl.playlist.tracks[0]!.isVideoTrack() + && decl.playlist.tracks[0].metadata.hasOnlyKeyPackets; + + const codecs: string[] = []; + for (const track of decl.playlist.tracks) { + const trackData = this.trackDatas.find(x => x.track === track); + const codecString = trackData?.info.decoderConfig.codec ?? track.source._codec; + codecs.push(codecString); + } + + let peakDeclBitrate = 0; + let maxRefAverageBitrate = 0; + + if (decl.references.length > 0) { + const firstRef = decl.references[0]!; + const firstTrack = firstRef.playlist.tracks[0]!; + const trackData = this.trackDatas.find(x => x.track === firstTrack); + const codecString = trackData?.info.decoderConfig.codec ?? firstTrack.source._codec; + codecs.push(codecString); + + for (const ref of decl.references) { + assert(ref.playlist.peakBitrate !== null); + peakDeclBitrate = Math.max(peakDeclBitrate, ref.playlist.peakBitrate); + maxRefAverageBitrate = Math.max(maxRefAverageBitrate, ref.playlist.averageBitrate ?? 0); + } + } + + assert(decl.playlist.peakBitrate !== null); + const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate; + const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0) + maxRefAverageBitrate; + + if (!firstVariantWritten) { + masterPlaylistText += '\n'; + firstVariantWritten = true; + } + + if (isKeyPacketsOnly) { + masterPlaylistText += `#EXT-X-I-FRAME-STREAM-INF:`; + } else { + masterPlaylistText += `#EXT-X-STREAM-INF:`; + } + + masterPlaylistText += `BANDWIDTH=${Math.ceil(totalPeakBitrate)}`; + + if (totalAverageBitrate > 0) { + masterPlaylistText += `,AVERAGE-BANDWIDTH=${Math.ceil(totalAverageBitrate)}`; + } + + masterPlaylistText += `,CODECS="${codecs.join(',')}"`; + + const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack()); + if (videoTrack?.isVideoTrack()) { + const trackData = this.trackDatas.find(x => x.track === videoTrack) as + HlsVideoTrackData | undefined; + const decoderConfig = trackData?.info.decoderConfig; + if (decoderConfig) { + let width = decoderConfig.displayAspectWidth ?? decoderConfig.codedWidth; + let height = decoderConfig.displayAspectHeight ?? decoderConfig.codedHeight; + + if (width !== undefined && height !== undefined) { + if ( + videoTrack.metadata.rotation !== undefined + && videoTrack.metadata.rotation % 180 !== 90 + ) { + [width, height] = [height, width]; + } + + masterPlaylistText += `,RESOLUTION=${width}x${height}`; + } + } + + // FRAME-RATE is not defined for EXT-X-I-FRAME-STREAM-INF + if (!isKeyPacketsOnly && videoTrack.metadata.frameRate !== undefined) { + // Spec requires that frame rate be rounded to 3 decimal places + masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(3)}`; + } + } + + if (!isKeyPacketsOnly) { + const groupIdForType = new Map(); + for (const ref of decl.references) { + assert(ref.groupId !== null); + const type = ref.playlist.tracks[0]!.type; + groupIdForType.set(type, ref.groupId); + } + + for (const [type, id] of groupIdForType) { + masterPlaylistText += `,${type.toUpperCase()}="${id}"`; + } + } + + if (isKeyPacketsOnly) { + // EXT-X-I-FRAME-STREAM-INF is standalone with a URI attribute + masterPlaylistText += `,URI="${decl.playlist.path}"`; + masterPlaylistText += '\n'; + } else { + masterPlaylistText += '\n'; + masterPlaylistText += `${decl.playlist.path}\n`; + } + } else { + assert(decl.playlist.tracks.length === 1); + + const track = decl.playlist.tracks[0]!; + const type = track.type; + let name = track.metadata.name ?? null; + const languageCode = track.metadata.languageCode; + const disposition = track.metadata.disposition; + + if (lastGroupId === null || decl.groupId !== lastGroupId) { + groupIdTrackCount = 0; + masterPlaylistText += '\n'; + hasHadDefaultTrackInGroup = false; + } + lastGroupId = decl.groupId; + groupIdTrackCount++; + + masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`; + + if (name !== null && /[\n\r"]/.test(name)) { + console.warn( + 'Dropping track name since it includes a line feed, carriage return, or double quote' + + ' character, which are not allowed in HLS playlist attributes.', + ); + name = null; + } + + // Name is required, so we have to set it to SOMETHING + name ??= `${languageCode ?? decl.groupId}-${groupIdTrackCount}`; + + masterPlaylistText += `,NAME="${name}"`; + + if (languageCode !== undefined) { + masterPlaylistText += `,LANGUAGE="${languageCode}"`; + } + + const dispositionPrimary = disposition?.primary ?? false; + const dispositionDefault = disposition?.default ?? true; + const dispositionForced = disposition?.forced ?? false; + + if (dispositionPrimary && !hasHadDefaultTrackInGroup) { + // HLS's "DEFAULT" behaves like our "primary" + masterPlaylistText += ',DEFAULT=YES'; + hasHadDefaultTrackInGroup = true; // Only one DEFAULT label per group allowed + } + + if (dispositionPrimary || dispositionDefault) { + masterPlaylistText += ',AUTOSELECT=YES'; + } + + if (dispositionForced) { + masterPlaylistText += ',FORCED=YES'; + } + + if (type === 'audio') { + const trackData = this.trackDatas.find(x => x.track === track) as + HlsAudioTrackData | undefined; + const decoderConfig = trackData?.info.decoderConfig; + + if (decoderConfig) { + masterPlaylistText += `,CHANNELS="${decoderConfig.numberOfChannels}"`; + } + } + + if (!decl.noUri) { + masterPlaylistText += `,URI="${decl.playlist.path}"`; + } + + masterPlaylistText += '\n'; + } + } + + this.format._options.onMaster?.(masterPlaylistText); + + const target = await this.output._getTarget({ path: this.output._rootPath!, isRoot: true }); + const writer = new Writer(target); + + writer.start(); + writer.write(textEncoder.encode(masterPlaylistText)); + + await writer.flush(); + await writer.finalize(); + } + + private async tryWriteMasterPlaylist() { + assert(this.isLive); + + // The master playlist is written once all playlists have either produced at least one segment or are done + for (const playlist of this.playlists) { + if (playlist.writtenSegments.length === 0 && !playlist.done) { + return; + } + } + + await this.writeMasterPlaylist(); + } + async finalize() { assert(this.output._rootPath !== null); const release = await this.mutex.acquire(); @@ -994,320 +1361,14 @@ export class HlsMuxer extends Muxer { trackData.closed = true; } - // Compute a single target duration across all playlists, as the spec mandates they match - let globalTargetDuration = this.targetSegmentDuration; - for (const playlist of this.playlists) { - for (const segment of playlist.writtenSegments) { - globalTargetDuration = Math.max(globalTargetDuration, segment.duration); - } + await Promise.all(this.playlists.map(playlist => ( + playlist.done ? Promise.resolve() : this.advancePlaylist(playlist) + ))); + + if (!this.isLive) { + await this.writeMasterPlaylist(); } - for (const playlist of this.playlists) { - if (playlist.currentSegmentStartTimestamp !== null) { - await this.advancePlaylist(playlist); - } else { - // Never had any data written to it - } - - let peakBitrate = 0; - let totalBits = 0; - let totalDuration = 0; - - // Per spec, peak bitrate is the largest bit rate of any contiguous set of segments whose total duration is - // between 0.5 and 1.5 times the target duration - const segments = playlist.writtenSegments; - - for (let i = 0; i < segments.length; i++) { - totalDuration += segments[i]!.duration; - - let windowBytes = 0; - let windowDuration = 0; - - for (let j = i; j < segments.length; j++) { - windowBytes += segments[j]!.byteSize; - windowDuration += segments[j]!.duration; - - if (windowDuration >= 0.5 * globalTargetDuration && windowDuration <= 1.5 * globalTargetDuration) { - peakBitrate = Math.max(peakBitrate, 8 * windowBytes / windowDuration); - } - - if (windowDuration > 1.5 * globalTargetDuration) { - break; - } - } - } - - // Fallback: if no contiguous set falls within the range, use per-segment max - if (peakBitrate === 0) { - for (const segment of segments) { - peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segment.duration); - } - } - - for (const segment of segments) { - totalBits += 8 * segment.byteSize; - } - - playlist.peakBitrate = peakBitrate; - playlist.averageBitrate = totalBits / (totalDuration || 1); - } - - // Write all playlists in parallel - const playlistPromises = this.playlists.map(async (playlist) => { - if (playlist.singleFile) { - await playlist.singleFile.target._flush(); - await playlist.singleFile.target._finalize(); - - this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info); - } - - let hasByteOffsets = false; - for (const segment of playlist.writtenSegments) { - hasByteOffsets ||= segment.byteOffset !== null; - } - - const isKeyPacketsOnly = playlist.tracks[0]!.isVideoTrack() - && playlist.tracks[0].metadata.hasOnlyKeyPackets; - - let version = 3; - if (isKeyPacketsOnly || hasByteOffsets) { - version = 4; - } - if (playlist.initSegment) { - version = 5; - } - if (playlist.initSegment && !isKeyPacketsOnly) { - // "if it contains the EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY" - version = 6; - } - - const playlistPath = joinPaths(this.output._rootPath!, playlist.path); - const playlistText = '#EXTM3U\n' - + `#EXT-X-VERSION:${version}\n` - + '#EXT-X-PLAYLIST-TYPE:VOD\n' - + `#EXT-X-TARGETDURATION:${Math.ceil(globalTargetDuration)}\n` // Must be a "decimal-integer" - + '#EXT-X-INDEPENDENT-SEGMENTS\n' // TODO not when live - + (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '') - + (playlist.initSegment - ? (`#EXT-X-MAP:URI="${playlist.initSegment.path}"` - + (playlist.initSegment.byteOffset !== null - ? `,BYTERANGE="${playlist.initSegment.byteSize}@${playlist.initSegment.byteOffset}"` - : '') - + '\n') - : '') - + '\n' - + (playlist.writtenSegments - .map(segment => ( - `#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec - + (segment.byteOffset !== null - ? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n` - : '') - + `${segment.path}\n` - )) - .join('')) - + (playlist.writtenSegments.length > 0 ? '\n' : '') - + '#EXT-X-ENDLIST\n'; - - this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist)); - - const target = await this.output._getTarget({ path: playlistPath, isRoot: false }); - const writer = new Writer(target); - writer.start(); - writer.write(textEncoder.encode(playlistText)); - - await writer.flush(); - await writer.finalize(); - }); - - const masterPlaylistPromise = (async () => { - let masterPlaylistText = '#EXTM3U\n'; - let firstVariantWritten = false; - - let lastGroupId: string | null = null; - let groupIdTrackCount = 0; - let hasHadDefaultTrackInGroup = false; - - for (const decl of this.playlistDeclarations) { - if (decl.groupId === null) { - const isKeyPacketsOnly = decl.playlist.tracks[0]!.isVideoTrack() - && decl.playlist.tracks[0].metadata.hasOnlyKeyPackets; - - const codecs: string[] = []; - for (const track of decl.playlist.tracks) { - const trackData = this.trackDatas.find(x => x.track === track); - const codecString = trackData?.info.decoderConfig.codec ?? track.source._codec; - codecs.push(codecString); - } - - let peakDeclBitrate = 0; - let maxRefAverageBitrate = 0; - - if (decl.references.length > 0) { - const firstRef = decl.references[0]!; - const firstTrack = firstRef.playlist.tracks[0]!; - const trackData = this.trackDatas.find(x => x.track === firstTrack); - const codecString = trackData?.info.decoderConfig.codec ?? firstTrack.source._codec; - codecs.push(codecString); - - for (const ref of decl.references) { - assert(ref.playlist.peakBitrate !== null); - peakDeclBitrate = Math.max(peakDeclBitrate, ref.playlist.peakBitrate); - maxRefAverageBitrate = Math.max(maxRefAverageBitrate, ref.playlist.averageBitrate ?? 0); - } - } - - assert(decl.playlist.peakBitrate !== null); - const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate; - const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0) + maxRefAverageBitrate; - - if (!firstVariantWritten) { - masterPlaylistText += '\n'; - firstVariantWritten = true; - } - - if (isKeyPacketsOnly) { - masterPlaylistText += `#EXT-X-I-FRAME-STREAM-INF:`; - } else { - masterPlaylistText += `#EXT-X-STREAM-INF:`; - } - - masterPlaylistText += `BANDWIDTH=${Math.ceil(totalPeakBitrate)}`; - - if (totalAverageBitrate > 0) { - masterPlaylistText += `,AVERAGE-BANDWIDTH=${Math.ceil(totalAverageBitrate)}`; - } - - masterPlaylistText += `,CODECS="${codecs.join(',')}"`; - - const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack()); - if (videoTrack?.isVideoTrack()) { - const trackData = this.trackDatas.find(x => x.track === videoTrack) as - HlsVideoTrackData | undefined; - const decoderConfig = trackData?.info.decoderConfig; - if (decoderConfig) { - let width = decoderConfig.displayAspectWidth ?? decoderConfig.codedWidth; - let height = decoderConfig.displayAspectHeight ?? decoderConfig.codedHeight; - - if (width !== undefined && height !== undefined) { - if ( - videoTrack.metadata.rotation !== undefined - && videoTrack.metadata.rotation % 180 !== 90 - ) { - [width, height] = [height, width]; - } - - masterPlaylistText += `,RESOLUTION=${width}x${height}`; - } - } - - // FRAME-RATE is not defined for EXT-X-I-FRAME-STREAM-INF - if (!isKeyPacketsOnly && videoTrack.metadata.frameRate !== undefined) { - // Spec requires that frame rate be rounded to 3 decimal places - masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(3)}`; - } - } - - if (!isKeyPacketsOnly) { - const groupIdForType = new Map(); - for (const ref of decl.references) { - assert(ref.groupId !== null); - const type = ref.playlist.tracks[0]!.type; - groupIdForType.set(type, ref.groupId); - } - - for (const [type, id] of groupIdForType) { - masterPlaylistText += `,${type.toUpperCase()}="${id}"`; - } - } - - if (isKeyPacketsOnly) { - // EXT-X-I-FRAME-STREAM-INF is standalone with a URI attribute - masterPlaylistText += `,URI="${decl.playlist.path}"`; - masterPlaylistText += '\n'; - } else { - masterPlaylistText += '\n'; - masterPlaylistText += `${decl.playlist.path}\n`; - } - } else { - assert(decl.playlist.tracks.length === 1); - - const track = decl.playlist.tracks[0]!; - const type = track.type; - let name = track.metadata.name ?? null; - const languageCode = track.metadata.languageCode; - const disposition = track.metadata.disposition; - - if (lastGroupId === null || decl.groupId !== lastGroupId) { - groupIdTrackCount = 0; - masterPlaylistText += '\n'; - hasHadDefaultTrackInGroup = false; - } - lastGroupId = decl.groupId; - groupIdTrackCount++; - - masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`; - - if (name !== null && /[\n\r"]/.test(name)) { - console.warn( - 'Dropping track name since it includes a line feed, carriage return, or double quote' - + ' character, which are not allowed in HLS playlist attributes.', - ); - name = null; - } - - // Name is required, so we have to set it to SOMETHING - name ??= `${languageCode ?? decl.groupId}-${groupIdTrackCount}`; - - masterPlaylistText += `,NAME="${name}"`; - - if (languageCode !== undefined) { - masterPlaylistText += `,LANGUAGE="${languageCode}"`; - } - - const dispositionPrimary = disposition?.primary ?? false; - const dispositionDefault = disposition?.default ?? true; - const dispositionForced = disposition?.forced ?? false; - - if (dispositionPrimary && !hasHadDefaultTrackInGroup) { - // HLS's "DEFAULT" behaves like our "primary" - masterPlaylistText += ',DEFAULT=YES'; - hasHadDefaultTrackInGroup = true; // Only one DEFAULT label per group allowed - } - - if (dispositionPrimary || dispositionDefault) { - masterPlaylistText += ',AUTOSELECT=YES'; - } - - if (dispositionForced) { - masterPlaylistText += ',FORCED=YES'; - } - - if (type === 'audio') { - const trackData = this.trackDatas.find(x => x.track === track) as - HlsAudioTrackData | undefined; - const decoderConfig = trackData?.info.decoderConfig; - - if (decoderConfig) { - masterPlaylistText += `,CHANNELS="${decoderConfig.numberOfChannels}"`; - } - } - - if (!decl.noUri) { - masterPlaylistText += `,URI="${decl.playlist.path}"`; - } - - masterPlaylistText += '\n'; - } - } - - this.format._options.onMaster?.(masterPlaylistText); - - const rootWriter = await this.output._getRootWriter(); - rootWriter.write(textEncoder.encode(masterPlaylistText)); - })(); - - await Promise.all([...playlistPromises, masterPlaylistPromise]); - release(); } } diff --git a/src/index.ts b/src/index.ts index ecd863a..8713f55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export { VideoTrackMetadata, AudioTrackMetadata, SubtitleTrackMetadata, + OutputEvents, } from './output'; export { OutputFormat, @@ -129,6 +130,7 @@ export { StreamTarget, StreamTargetOptions, StreamTargetChunk, + TargetEvents, } from './target'; export { AnyIterable, @@ -141,6 +143,7 @@ export { asc, desc, prefer, + EventEmitter, } from './misc'; export { TrackType, @@ -191,6 +194,7 @@ export { export { Input, InputOptions, + InputEvents, InputDisposedError, } from './input'; export { diff --git a/src/input.ts b/src/input.ts index abcaef9..e885feb 100644 --- a/src/input.ts +++ b/src/input.ts @@ -17,7 +17,17 @@ import { TrackQuery, } from './input-track'; import { PacketRetrievalOptions } from './media-sink'; -import { arrayArgmin, arrayCount, assert, desc, MaybePromise, polyfillSymbolDispose, prefer, removeItem } from './misc'; +import { + arrayArgmin, + arrayCount, + assert, + desc, + EventEmitter, + MaybePromise, + polyfillSymbolDispose, + prefer, + removeItem, +} from './misc'; import { Reader } from './reader'; import { Source, SourceRef } from './source'; @@ -70,7 +80,11 @@ type SourceCacheEntry = { * @group Input files & tracks * @public */ -export class Input implements Disposable { +export type InputEvents = { + source: { source: Source; request: SourceRequest | null }; +}; + +export class Input extends EventEmitter implements Disposable { /** @internal */ _source: SourceRef | ((request: SourceRequest) => MaybePromise>); /** @internal */ @@ -102,11 +116,6 @@ export class Input implements Disposable { promise: Promise>; }[] = []; - /** - * Called whenever a source is resolved for internal operations. - */ - onSource?: (source: Source, request: SourceRequest | null) => unknown; - /** True if the input has been disposed. */ get disposed() { return this._disposed; @@ -117,6 +126,8 @@ export class Input implements Disposable { * called on this instance. */ constructor(options: InputOptions) { + super(); + if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } @@ -171,7 +182,7 @@ export class Input implements Disposable { ref = source; } - this.onSource?.(ref.source, request); + this.emit('source', { source: ref.source, request }); return ref; } @@ -246,7 +257,7 @@ export class Input implements Disposable { let ref: SourceRef; if (this._source instanceof SourceRef) { ref = this._source; - this.onSource?.(ref.source, null); + this.emit('source', { source: ref.source, request: null }); } else { assert(this._entryPath !== null); ref = await this._getSourceUncached({ path: this._entryPath, isRoot: true }); @@ -269,7 +280,7 @@ export class Input implements Disposable { /** * Returns the source from which this input file reads data for the entry path. Throws if the source-resolving - * function returns a promise; prefer the {@link Input.onSource} callback for those cases. + * function returns a promise; prefer the `'source'` event for those cases. */ get source(): S { if (this._source instanceof SourceRef) { @@ -282,7 +293,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 the onSource event instead.', + + ' Use the \'source\' event instead.', ); } diff --git a/src/misc.ts b/src/misc.ts index 0f411f8..128c469 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -1158,3 +1158,43 @@ export const toArray = (x: T | T[]) => { return [x]; } }; + +type ListenerOptions = { + once?: boolean; +}; + +export class EventEmitter> { + private _listeners = new Map unknown; once: boolean }>>(); + + on( + event: K, + listener: (data: TEvents[K]) => unknown, + options?: ListenerOptions, + ): () => void { + if (!this._listeners.has(event)) { + this._listeners.set(event, new Set()); + } + const entry = { fn: listener as (data: never) => void, once: options?.once ?? false }; + this._listeners.get(event)!.add(entry); + + return () => { + this._listeners.get(event)?.delete(entry); + }; + } + + emit( + ...args: TEvents[K] extends void ? [event: K] : [event: K, data: TEvents[K]] + ): void { + const [event, data] = args; + const listeners = this._listeners.get(event); + if (!listeners) { + return; + } + for (const entry of listeners) { + (entry.fn as (data: unknown) => void)(data); + if (entry.once) { + listeners.delete(entry); + } + } + } +} diff --git a/src/output-format.ts b/src/output-format.ts index f0f63cd..b7512bd 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -1172,6 +1172,7 @@ export type HlsOutputFormatOptions = { segmentFormat: OutputFormat | OutputFormat[]; targetDuration?: number; singleFilePerPlaylist?: boolean; + live?: boolean; getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise; getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; @@ -1213,6 +1214,9 @@ export class HlsOutputFormat extends OutputFormat { if (options.singleFilePerPlaylist !== undefined && typeof options.singleFilePerPlaylist !== 'boolean') { throw new TypeError('options.singleFilePerPlaylist, when provided, must be a boolean.'); } + if (options.live !== undefined && typeof options.live !== 'boolean') { + throw new TypeError('options.live, when provided, must be a boolean.'); + } if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') { throw new TypeError('options.getPlaylistPath, when provided, must be a function.'); } diff --git a/src/output.ts b/src/output.ts index 67cdbe8..b0a274b 100644 --- a/src/output.ts +++ b/src/output.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation, toArray } from './misc'; +import { assert, AsyncMutex, EventEmitter, isIso639Dash2LanguageCode, MaybePromise, Rotation, toArray } from './misc'; import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; @@ -284,10 +284,14 @@ export type OutputOptions< * @group Output files * @public */ +export type OutputEvents = { + target: { target: Target; request: TargetRequest | null }; +}; + export class Output< F extends OutputFormat = OutputFormat, T extends Target = Target, -> { +> extends EventEmitter { /** The format of the output file. */ readonly format: F; /** @internal */ @@ -335,16 +339,13 @@ export class Output< 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 * specified {@link OutputOptions}. */ constructor(options: OutputOptions) { + super(); + if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } @@ -389,7 +390,7 @@ export class Output< assert(typeof this._target === 'function'); const target = await this._target(request); - this.onTarget?.(target, request); + this.emit('target', { target, request }); return target; } @@ -403,7 +404,7 @@ export class Output< target = await this._getTarget({ path: this._rootPath, isRoot: true }); } else { target = this._target; - this.onTarget?.(this._target, null); + this.emit('target', { target: this._target, request: null }); } const writer = new Writer(target); diff --git a/src/target.ts b/src/target.ts index ee1e45e..b04c96b 100644 --- a/src/target.ts +++ b/src/target.ts @@ -9,18 +9,28 @@ import type { FileHandle } from 'node:fs/promises'; import { Output } from './output'; import * as nodeAlias from './node'; -import { assert } from './misc'; +import { assert, EventEmitter } from './misc'; const node = typeof nodeAlias !== 'undefined' ? nodeAlias // Aliasing it prevents some bundler warnings : undefined!; +/** + * The events emitted by a {@link Target}. + * @group Output targets + * @public + */ +export type TargetEvents = { + write: { start: number; end: number }; + finalized: void; +}; + /** * Base class for targets, specifying where output files are written. * @group Output targets * @public */ -export abstract class Target { +export abstract class Target extends EventEmitter { /** @internal */ _output: Output | null = null; @@ -43,11 +53,25 @@ export abstract class Target { * * Use this callback to track the size of the output file as it grows. But be warned, this function is chatty and * gets called *extremely* often. + * @deprecated Use `target.on('write', ({ start, end }) => ...)` instead. */ onwrite: ((start: number, end: number) => unknown) | null = null; + /** @deprecated Use `target.on('finalized', () => ...)` instead. */ onfinalized: (() => unknown) | null = null; + /** @internal */ + _dispatchWrite(start: number, end: number) { + this.onwrite?.(start, end); + this.emit('write', { start, end }); + } + + /** @internal */ + _dispatchFinalized() { + this.onfinalized?.(); + this.emit('finalized'); + } + slice(offset: number) { if (!Number.isInteger(offset) || offset < 0) { throw new TypeError('offset must be a non-negative integer.'); @@ -139,7 +163,7 @@ export class BufferTarget extends Target { this._maxPos = Math.max(this._maxPos, pos + data.byteLength); - this.onwrite?.(pos, pos + data.byteLength); + this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ @@ -148,7 +172,7 @@ export class BufferTarget extends Target { /** @internal */ async _finalize() { this.buffer = this._buffer.slice(0, this._maxPos); - this.onfinalized?.(); + this._dispatchFinalized(); } /** @internal */ @@ -293,7 +317,7 @@ export class StreamTarget extends Target { this._lastWriteEnd = Math.max(this._lastWriteEnd, pos + data.byteLength); - this.onwrite?.(pos, pos + data.byteLength); + this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ @@ -502,7 +526,7 @@ export class StreamTarget extends Target { await this._streamWriter.ready; await this._streamWriter.close(); - this.onfinalized?.(); + this._dispatchFinalized(); } /** @internal */ @@ -575,7 +599,7 @@ export class FilePathTarget extends Target { /** @internal */ _write(data: Uint8Array, pos: number) { this._streamTarget._write(data, pos); - this.onwrite?.(pos, pos + data.byteLength); + this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ @@ -586,7 +610,7 @@ export class FilePathTarget extends Target { /** @internal */ async _finalize() { await this._streamTarget._finalize(); - this.onfinalized?.(); + this._dispatchFinalized(); } /** @internal */ @@ -608,7 +632,7 @@ export class NullTarget extends Target { /** @internal */ _write(data: Uint8Array, pos: number) { - this.onwrite?.(pos, pos + data.byteLength); + this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ @@ -616,7 +640,7 @@ export class NullTarget extends Target { /** @internal */ async _finalize() { - this.onfinalized?.(); + this._dispatchFinalized(); } /** @internal */ @@ -643,7 +667,7 @@ export class RangedTarget extends Target { /** @internal */ _write(data: Uint8Array, pos: number): void { this._baseTarget._write(data, this._offset + pos); - this.onwrite?.(pos, pos + data.byteLength); + this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ @@ -653,7 +677,7 @@ export class RangedTarget extends Target { /** @internal */ async _finalize() { - this.onfinalized?.(); + this._dispatchFinalized(); } /** @internal */ diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index 745ec53..32b3e84 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -15,7 +15,7 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { }); let sourceCount = 0; - input.onSource = () => sourceCount++; + input.on('source', () => sourceCount++); expect(await input.getFormat()).toBeInstanceOf(HlsInputFormat); expect(await input.getFormat()).toBe(HLS); @@ -253,7 +253,7 @@ test.concurrent('AES and discontinuities', { timeout: 15_000 }, async () => { }); let sourceCount = 0; - input.onSource = () => sourceCount++; + input.on('source', () => sourceCount++); const videoTrack = await input.getPrimaryVideoTrack(); assert(videoTrack); @@ -287,7 +287,7 @@ test.concurrent('Range requests', { timeout: 15_000 }, async () => { }); let sourceCount = 0; - input.onSource = () => sourceCount++; + input.on('source', () => sourceCount++); const tracks = await input.getTracks(); expect(tracks).toHaveLength(2); @@ -316,7 +316,7 @@ test.concurrent('Custom IV', { timeout: 15_000 }, async () => { }); let sourceCount = 0; - input.onSource = () => sourceCount++; + input.on('source', () => sourceCount++); const tracks = await input.getTracks(); expect(tracks).toHaveLength(2); @@ -377,7 +377,7 @@ test.concurrent('fMP4', { timeout: 15_000 }, async () => { }); let sourceCount = 0; - input.onSource = () => sourceCount++; + input.on('source', () => sourceCount++); const videoTrack = await input.getPrimaryVideoTrack(); const audioTrack = await input.getPrimaryAudioTrack(); @@ -460,7 +460,7 @@ test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => { }); let sourceCount = 0; - input.onSource = () => sourceCount++; + input.on('source', () => sourceCount++); const tracks = await input.getTracks(); expect(tracks.filter(x => x.isVideoTrack())).toHaveLength(6); diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index dc5c4c2..083fc76 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -835,9 +835,9 @@ const setUpSegmentationEnvironment = async (options: { target: (request) => { const target = new BufferTarget(); if (request.path.includes('playlist')) { - target.onfinalized = () => { + target.on('finalized', () => { result = new TextDecoder().decode(target.buffer!); - }; + }); } else if (request.path.includes('segment')) { segmentCount++; @@ -846,7 +846,7 @@ const setUpSegmentationEnvironment = async (options: { const audioBundle = promiseWithResolvers(); lastSegmentAudioTimestamps = audioBundle.promise; - target.onfinalized = async () => { + target.on('finalized', async () => { try { using input = new Input({ source: new BufferSource(target.buffer!), @@ -880,7 +880,7 @@ const setUpSegmentationEnvironment = async (options: { videoBundle.resolve([]); audioBundle.resolve([]); } - }; + }); } return target; @@ -1912,9 +1912,9 @@ test('Single-file mode', async () => { const target = new BufferTarget(); if (request.path.includes('playlist')) { - target.onfinalized = () => { + target.on('finalized', () => { playlistText = new TextDecoder().decode(target.buffer!); - }; + }); } else if (request.path.includes('segment')) { segmentPaths.add(request.path); } @@ -2061,20 +2061,20 @@ test('I-frame stream, pairing warning', async () => { test('CMAF segmentation', async () => { let playlistText: string | null = null; - const writtenPaths = new Set(); + const targets = new Map(); const output = new Output({ format: new HlsOutputFormat({ segmentFormat: new CmafOutputFormat(), }), target: (request) => { - writtenPaths.add(request.path); const target = new BufferTarget(); + targets.set(request.path, target); if (request.path.includes('playlist')) { - target.onfinalized = () => { + target.on('finalized', () => { playlistText = new TextDecoder().decode(target.buffer!); - }; + }); } return target; @@ -2098,9 +2098,9 @@ test('CMAF segmentation', async () => { await output.finalize(); - expect(writtenPaths).toContain('init-1.m4s'); - expect(writtenPaths).toContain('segment-1-1.m4s'); - expect(writtenPaths).toContain('segment-1-2.m4s'); + expect(targets.has('init-1.m4s')).toBe(true); + expect(targets.has('segment-1-1.m4s')).toBe(true); + expect(targets.has('segment-1-2.m4s')).toBe(true); expect(playlistText).toBe(`#EXTM3U #EXT-X-VERSION:6 @@ -2117,6 +2117,34 @@ segment-1-2.m4s #EXT-X-ENDLIST `, ); + + // Verify that each segment contains exactly 4 video packets + const initTarget = targets.get('init-1.m4s')!; + using initInput = new Input({ + source: new BufferSource(initTarget.buffer!), + formats: ALL_FORMATS, + }); + + for (const segmentPath of ['segment-1-1.m4s', 'segment-1-2.m4s']) { + const segmentTarget = targets.get(segmentPath)!; + + using segmentInput = new Input({ + source: new BufferSource(segmentTarget.buffer!), + formats: ALL_FORMATS, + initInput, + }); + + const videoTrack = await segmentInput.getPrimaryVideoTrack() as InputVideoTrack; + expect(videoTrack).toBeTruthy(); + + const sink = new EncodedPacketSink(videoTrack); + let packetCount = 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const packet of sink.packets()) { + packetCount++; + } + expect(packetCount).toBe(4); + } }); test('CMAF segmentation, single file per playlist', async () => { @@ -2133,9 +2161,9 @@ test('CMAF segmentation, single file per playlist', async () => { const target = new BufferTarget(); if (request.path.includes('playlist')) { - target.onfinalized = () => { + target.on('finalized', () => { playlistText = new TextDecoder().decode(target.buffer!); - }; + }); } return target; @@ -2168,3 +2196,233 @@ test('CMAF segmentation, single file per playlist', async () => { expect(playlistText).toContain('#EXT-X-VERSION:6'); expect(playlistText).toContain('#EXT-X-MAP:URI='); }); + +test('Live mode', async () => { + const writtenTexts = new Map(); + const writeCounts = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + live: true, + }), + target: (request) => { + const target = new BufferTarget(); + target.on('finalized', () => { + if (request.path.endsWith('.m3u8')) { + writtenTexts.set(request.path, new TextDecoder().decode(target.buffer!)); + writeCounts.set(request.path, (writeCounts.get(request.path) ?? 0) + 1); + } + }); + return target; + }, + rootPath: 'master.m3u8', + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + + await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0.5), avcMetadata); + + expect(writtenTexts.size).toBe(0); + + await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0.5), avcMetadata); + + expect(writtenTexts.has('playlist-1.m3u8')).toBe(true); + expect(writtenTexts.has('master.m3u8')).toBe(true); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +segment-1-1.ts +`); + + await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0.5), avcMetadata); + + await source.add(new EncodedPacket(avcPacketData, 'key', 4, 0.5), avcMetadata); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +segment-1-1.ts +#EXTINF:2, +segment-1-2.ts +`); + + await source.add(new EncodedPacket(avcPacketData, 'delta', 4.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 5.5, 0.5), avcMetadata); + + await output.finalize(); + + expect(writeCounts.get('master.m3u8')).toBe(3); + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:2, +segment-1-1.ts +#EXTINF:2, +segment-1-2.ts +#EXTINF:2, +segment-1-3.ts + +#EXT-X-ENDLIST +`); +}); + +test('Live mode, CMAF', async () => { + const writtenTexts = new Map(); + const writeCounts = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new CmafOutputFormat(), + live: true, + }), + target: (request) => { + const target = new BufferTarget(); + target.on('finalized', () => { + if (request.path.endsWith('.m3u8')) { + writtenTexts.set(request.path, new TextDecoder().decode(target.buffer!)); + writeCounts.set(request.path, (writeCounts.get(request.path) ?? 0) + 1); + } + }); + return target; + }, + rootPath: 'master.m3u8', + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + + await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0.5), avcMetadata); + + expect(writtenTexts.size).toBe(0); + + await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0.5), avcMetadata); + + expect(writtenTexts.has('playlist-1.m3u8')).toBe(true); + expect(writtenTexts.has('master.m3u8')).toBe(true); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-MAP:URI="init-1.m4s" + +#EXTINF:2, +segment-1-1.m4s +`); + + await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0.5), avcMetadata); + + await source.add(new EncodedPacket(avcPacketData, 'key', 4, 0.5), avcMetadata); + + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-MAP:URI="init-1.m4s" + +#EXTINF:2, +segment-1-1.m4s +#EXTINF:2, +segment-1-2.m4s +`); + + await source.add(new EncodedPacket(avcPacketData, 'delta', 4.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 5.5, 0.5), avcMetadata); + + await output.finalize(); + + expect(writeCounts.get('master.m3u8')).toBe(3); + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-MAP:URI="init-1.m4s" + +#EXTINF:2, +segment-1-1.m4s +#EXTINF:2, +segment-1-2.m4s +#EXTINF:2, +segment-1-3.m4s + +#EXT-X-ENDLIST +`); +}); + +test('Live mode, fixed target duration', async () => { + const writtenTexts = new Map(); + const writeCounts = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + live: true, + }), + target: (request) => { + const target = new BufferTarget(); + target.on('finalized', () => { + if (request.path.endsWith('.m3u8')) { + writtenTexts.set(request.path, new TextDecoder().decode(target.buffer!)); + writeCounts.set(request.path, (writeCounts.get(request.path) ?? 0) + 1); + } + }); + return target; + }, + rootPath: 'master.m3u8', + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + + await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 2, 0.5), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0.5), avcMetadata); + + await output.finalize(); + + // The TARGETDURATION remains 2 even tho there are segments longer than that; this is because the spec disallows the + // target duration to change, and it must be the same across all playlists + expect(writeCounts.get('master.m3u8')).toBe(1); + expect(writtenTexts.get('playlist-1.m3u8')).toBe(`#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXTINF:3, +segment-1-1.ts + +#EXT-X-ENDLIST +`); +}); diff --git a/todo.txt b/todo.txt index c0f22a4..20a5295 100644 --- a/todo.txt +++ b/todo.txt @@ -4,4 +4,4 @@ Also, why not just have the packet metadata on the packet? I think that would ma - keep input/demuxer.isSupported()? - getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field? -- Add an HLS "live mode". Challenge, where does bitrate come from? +- close writers on error \ No newline at end of file