diff --git a/docs/api-config.json b/docs/api-config.json index a17dc62..e17a21a 100644 --- a/docs/api-config.json +++ b/docs/api-config.json @@ -16,6 +16,7 @@ "Metadata tags": "Descriptive metadata tags attached to media files.", "Codecs": "Codecs understood by Mediabunny.", "Encoding": "Encoder configuration and encodability checks.", + "Decoding": "Decoder configuration and decodability checks.", "Custom coders": "API for adding custom encoders and decoders.", "Miscellaneous": "Whatever's left.", diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 29e66d1..4ba874a 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -23,6 +23,7 @@ import { EncodedPacket } from '../packet'; import { readAllLines } from '../reader'; import { AttributeList, canIgnoreLine } from './hls-misc'; import { HlsSegmentedInput } from './hls-segmented-input'; +import { PathedSource } from '../source'; type InternalTrack = { id: number; @@ -66,7 +67,8 @@ export class HlsDemuxer extends Demuxer { readMetadata() { return this.metadataPromise ??= (async () => { - assert(this.input._entryPath !== null); + assert(this.input._source instanceof PathedSource); + const source = this.input._source; const slice = await this.input._reader.requestEntireFile(); assert(slice); @@ -96,7 +98,7 @@ export class HlsDemuxer extends Demuxer { throw new Error('Incorrect M3U8 file; a line must follow the #EXT-X-STREAM-INF tag.'); } - const fullPath = joinPaths(this.input._entryPath, playlistPath); + const fullPath = joinPaths(source.rootPath, playlistPath); const attributes = new AttributeList(line.slice(18)); const bandwidth = attributes.getAsNumber('bandwidth'); @@ -123,7 +125,7 @@ export class HlsDemuxer extends Demuxer { ); } - const fullPath = joinPaths(this.input._entryPath, playlistPath); + const fullPath = joinPaths(source.rootPath, playlistPath); variantStreams.push({ fullPath, @@ -151,7 +153,7 @@ export class HlsDemuxer extends Demuxer { let fullPath: string | null = null; const uri = attributes.get('uri'); if (uri !== null) { - fullPath = joinPaths(this.input._entryPath, uri); + fullPath = joinPaths(source.rootPath, uri); } mediaTags.push({ fullPath, attributes, lineNumber: i }); @@ -159,7 +161,7 @@ export class HlsDemuxer extends Demuxer { // iFramesOnlyTagFound = true; } else if (line.startsWith('#EXTINF:')) { // This is a media playlist, not a master playlist - const segmentedInput = new HlsSegmentedInput(this, this.input._entryPath, lines); + const segmentedInput = new HlsSegmentedInput(this, source.rootPath, lines); this.segmentedInputs = [segmentedInput]; this.hasMasterPlaylist = false; @@ -236,7 +238,7 @@ export class HlsDemuxer extends Demuxer { return null; } - const fullPath = joinPaths(this.input._entryPath!, uri); + const fullPath = joinPaths(source.rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); const input = segmentedInput.toInput(); const videoTrack = await input.getPrimaryVideoTrack(); @@ -277,7 +279,7 @@ export class HlsDemuxer extends Demuxer { return null; } - const fullPath = joinPaths(this.input._entryPath!, uri); + const fullPath = joinPaths(source.rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); const input = segmentedInput.toInput(); const audioTrack = await input.getPrimaryAudioTrack(); diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 7c74b43..a52a69e 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -15,7 +15,6 @@ import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, - outputTracksArePairable, OutputVideoTrack, TrackType, } from '../output'; @@ -29,7 +28,7 @@ import { import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { SubtitleCue, SubtitleMetadata } from '../subtitles'; -import { NullTarget, Target } from '../target'; +import { NullTarget, PathedTarget, Target, TargetRequest } from '../target'; type HlsTrackData = { track: OutputTrack; @@ -105,8 +104,8 @@ export class HlsMuxer extends Muxer { playlistDeclarations: PlaylistDeclaration[] = []; constructor(output: Output, format: HlsOutputFormat) { - if (!output._targetIsFunction()) { - throw new TypeError('HLS outputs require `OutputOptions.target` to be a function.'); + if (!(output._target instanceof PathedTarget)) { + throw new TypeError('HLS outputs require `OutputOptions.target` to be a PathedTarget.'); } super(output); @@ -173,7 +172,7 @@ export class HlsMuxer extends Muxer { continue; } - if (!outputTracksArePairable(track, otherTrack)) { + if (!track.canBePairedWith(otherTrack)) { continue; } @@ -807,7 +806,8 @@ export class HlsMuxer extends Muxer { let relativeSegmentPath: string; let fullSegmentPath: string; - assert(this.output._rootPath !== null); + assert(this.output._target instanceof PathedTarget); + const pathedTarget = this.output._target; if (this.singleFilePerPlaylist) { if (playlist.singleFile === null) { @@ -822,7 +822,7 @@ export class HlsMuxer extends Muxer { validateSegmentPath(relativeSegmentPath); fullSegmentPath = joinPaths( - joinPaths(this.output._rootPath, playlist.path), + joinPaths(pathedTarget.rootPath, playlist.path), relativeSegmentPath, ); @@ -838,7 +838,7 @@ export class HlsMuxer extends Muxer { } else { relativeSegmentPath = playlist.singleFile.path; fullSegmentPath = joinPaths( - joinPaths(this.output._rootPath, playlist.path), + joinPaths(pathedTarget.rootPath, playlist.path), relativeSegmentPath, ); } @@ -853,8 +853,7 @@ export class HlsMuxer extends Muxer { relativeSegmentPath = await this.getSegmentPath(segmentInfo); validateSegmentPath(relativeSegmentPath); - assert(this.output._rootPath !== null); - fullSegmentPath = joinPaths(joinPaths(this.output._rootPath, playlist.path), relativeSegmentPath); + fullSegmentPath = joinPaths(joinPaths(pathedTarget.rootPath, playlist.path), relativeSegmentPath); playlist.nextSegmentId++; } @@ -863,25 +862,27 @@ export class HlsMuxer extends Muxer { const output = new Output({ format: playlist.segmentFormat, - rootPath: fullSegmentPath, - target: async (request) => { - if (request.isRoot) { - if (playlist.singleFile) { - const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); - slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); + target: new PathedTarget( + fullSegmentPath, + async (request: TargetRequest) => { + 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(request); - outputTarget = target; - target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); + return slice; + } else { + const target = await this.output._getTarget(request); + outputTarget = target; + target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end)); - return target; + return target; + } } - } - return this.output._getTarget(request); - }, + return this.output._getTarget(request); + }, + ), initTarget: async () => { if (playlist.initSegment) { // We already have an init segment from a previous segment @@ -1113,7 +1114,8 @@ export class HlsMuxer extends Muxer { } private async writePlaylist(playlist: Playlist) { - assert(this.output._rootPath !== null); + assert(this.output._target instanceof PathedTarget); + const pathedTarget = this.output._target; this.updatePlaylistBitrates(playlist); @@ -1140,7 +1142,7 @@ export class HlsMuxer extends Muxer { // 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 playlistPath = joinPaths(pathedTarget.rootPath, playlist.path); const playlistText = '#EXTM3U\n' + `#EXT-X-VERSION:${version}\n` + (!this.isLive ? '#EXT-X-PLAYLIST-TYPE:VOD\n' : '') @@ -1184,6 +1186,9 @@ export class HlsMuxer extends Muxer { } private async writeMasterPlaylist() { + assert(this.output._target instanceof PathedTarget); + const pathedTarget = this.output._target; + let masterPlaylistText = '#EXTM3U\n'; let firstVariantWritten = false; @@ -1366,7 +1371,7 @@ export class HlsMuxer extends Muxer { this.format._options.onMaster?.(masterPlaylistText); - const target = await this.output._getTarget({ path: this.output._rootPath!, isRoot: true }); + const target = await this.output._getTarget({ path: pathedTarget.rootPath, isRoot: true }); const writer = new Writer(target); writer.start(); @@ -1390,7 +1395,7 @@ export class HlsMuxer extends Muxer { } async finalize() { - assert(this.output._rootPath !== null); + assert(this.output._target instanceof PathedTarget); const release = await this.mutex.acquire(); for (const trackData of this.trackDatas) { diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 80ef1d8..579cd34 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -11,7 +11,7 @@ import { ENCRYPTION_KEY_CACHE_GROUP, Input } from '../input'; import { Segment, SegmentedInput, SegmentRetrievalOptions } from '../segmented-input'; import { toDataView, joinPaths, last, assert, binarySearchLessOrEqual, arrayArgmin, wait } from '../misc'; import { readAllLines, readBytes, Reader } from '../reader'; -import { ReadableStreamSource, SourceRef } from '../source'; +import { PathedSource, ReadableStreamSource, SourceRef } from '../source'; import { HlsDemuxer } from './hls-demuxer'; import { AttributeList, canIgnoreLine } from './hls-misc'; @@ -525,68 +525,70 @@ export class HlsSegmentedInput extends SegmentedInput { } const input = new Input({ - entryPath: hlsSegment.location.path, - source: async (request) => { - if (request.path !== hlsSegment.location.path) { - // This code technically allows for recursive .m3u8 files for example. Uncached because the added - // input adds its own layer of caching, so here we just do a passthrough. - return this.input._getSourceUncached(request); - } - - let ref: SourceRef; - const needsSlice = hlsSegment.location.offset > 0 || hlsSegment.location.length !== null; - - if (!hlsSegment.encryption) { - ref = await this.input._getSourceCached(request); - - if (needsSlice) { - const slice = ref.source.slice( - hlsSegment.location.offset, - hlsSegment.location.length ?? undefined, - ); - const sliceRef = slice.ref(); - ref.free(); - ref = sliceRef; - } - } else { - assert(hlsSegment.encryption.iv); - - let ciphertextRef = await this.input._getSourceCached(request); - if (needsSlice) { - // Slice before decrypting - const slice = ciphertextRef.source.slice( - hlsSegment.location.offset, - hlsSegment.location.length ?? undefined, - ); - const sliceRef = slice.ref(); - ciphertextRef.free(); - ciphertextRef = sliceRef; + source: new PathedSource( + hlsSegment.location.path, + async (request) => { + if (request.path !== hlsSegment.location.path) { + // This code technically allows for recursive .m3u8 files for example. Uncached because the + // added input adds its own layer of caching, so here we just do a passthrough. + return this.input._getSourceUncached(request); } - const ciphertextReader = new Reader(ciphertextRef.source); + let ref: SourceRef; + const needsSlice = hlsSegment.location.offset > 0 || hlsSegment.location.length !== null; - const stream = createAes128CbcDecryptStream(ciphertextReader, async () => { - using keyRef = await this.input._getSourceCached( - { path: hlsSegment.encryption!.keyUri, isRoot: false }, - ENCRYPTION_KEY_CACHE_GROUP, - ); - const keyReader = new Reader(keyRef.source); - const keySlice = await keyReader.requestSlice(0, AES_128_BLOCK_SIZE); - if (!keySlice) { - throw new Error('Invalid AES-128 key; expected at least 16 bytes of data.'); + if (!hlsSegment.encryption) { + ref = await this.input._getSourceCached(request); + + if (needsSlice) { + const slice = ref.source.slice( + hlsSegment.location.offset, + hlsSegment.location.length ?? undefined, + ); + const sliceRef = slice.ref(); + ref.free(); + ref = sliceRef; } - const key = readBytes(keySlice, AES_128_BLOCK_SIZE); + } else { + assert(hlsSegment.encryption.iv); - return { key, iv: hlsSegment.encryption!.iv! }; - }, () => { - ciphertextRef.free(); - }); + let ciphertextRef = await this.input._getSourceCached(request); + if (needsSlice) { + // Slice before decrypting + const slice = ciphertextRef.source.slice( + hlsSegment.location.offset, + hlsSegment.location.length ?? undefined, + ); + const sliceRef = slice.ref(); + ciphertextRef.free(); + ciphertextRef = sliceRef; + } - ref = new ReadableStreamSource(stream).ref(); - } + const ciphertextReader = new Reader(ciphertextRef.source); - return ref!; - }, + const stream = createAes128CbcDecryptStream(ciphertextReader, async () => { + using keyRef = await this.input._getSourceCached( + { path: hlsSegment.encryption!.keyUri, isRoot: false }, + ENCRYPTION_KEY_CACHE_GROUP, + ); + const keyReader = new Reader(keyRef.source); + const keySlice = await keyReader.requestSlice(0, AES_128_BLOCK_SIZE); + if (!keySlice) { + throw new Error('Invalid AES-128 key; expected at least 16 bytes of data.'); + } + const key = readBytes(keySlice, AES_128_BLOCK_SIZE); + + return { key, iv: hlsSegment.encryption!.iv! }; + }, () => { + ciphertextRef.free(); + }); + + ref = new ReadableStreamSource(stream).ref(); + } + + return ref!; + }, + ), formats: this.input._formats, initInput: initInput ?? undefined, }); diff --git a/src/index.ts b/src/index.ts index 116aa74..2dd87a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,8 @@ export { FlacOutputFormatOptions, HlsOutputFormat, HlsOutputFormatOptions, + HlsOutputPlaylistInfo, + HlsOutputSegmentInfo, IsobmffOutputFormat, IsobmffOutputFormatOptions, MkvOutputFormat, @@ -123,27 +125,29 @@ export { } from './encode'; export { Target, + TargetEvents, + TargetRequest, BufferTarget, FilePathTarget, FilePathTargetOptions, NullTarget, + RangedTarget, StreamTarget, StreamTargetOptions, StreamTargetChunk, - TargetEvents, + PathedTarget, } from './target'; export { AnyIterable, + EventEmitter, + EventListenerOptions, + FilePath, MaybePromise, Rational, Rectangle, Rotation, SetOptional, SetRequired, - asc, - desc, - prefer, - EventEmitter, } from './misc'; export { TrackType, @@ -151,13 +155,18 @@ export { } from './output'; export { Source, + SourceEvents, + SourceRef, + SourceRequest, BlobSource, BlobSourceOptions, BufferSource, FilePathSource, FilePathSourceOptions, + PathedSource, StreamSource, StreamSourceOptions, + RangedSource, ReadableStreamSource, ReadableStreamSourceOptions, UrlSource, @@ -192,12 +201,17 @@ export { WEBM, } from './input-format'; export { - createInputFrom, Input, InputOptions, InputEvents, InputDisposedError, + createInputFrom, + CreateInputFromOptions, + UnsupportedInputFormatError, } from './input'; +export { + DurationMetadataRequestOptions, +} from './demuxer'; export { InputTrack, InputVideoTrack, @@ -208,6 +222,10 @@ export { InputTrackDescriptor, InputVideoTrackDescriptor, InputAudioTrackDescriptor, + InputTrackDescriptorQuery, + asc, + desc, + prefer, } from './input-track-descriptor'; export { EncodedPacket, diff --git a/src/input-format.ts b/src/input-format.ts index ecda005..a563009 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -33,6 +33,7 @@ import { FlacDemuxer } from './flac/flac-demuxer'; import { MpegTsDemuxer } from './mpeg-ts/mpeg-ts-demuxer'; import { TS_PACKET_SIZE } from './mpeg-ts/mpeg-ts-misc'; import { HlsDemuxer } from './hls/hls-demuxer'; +import { PathedSource } from './source'; /** * Base class representing an input media file format. @@ -54,6 +55,10 @@ export abstract class InputFormat { /** * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files. + * + * This format can make use of {@link InputOptions.initInput}. When the file contents are fragmented but no track + * initialization info is provided (no `moov` atom), then it must be provided via `initInput`. + * * @group Input formats * @public */ @@ -521,6 +526,10 @@ export class AdtsInputFormat extends InputFormat { /** * MPEG Transport Stream (MPEG-TS) file format. * + * This format can make use of {@link InputOptions.initInput} to initialize track information even when no + * initialization information is provided for the track, for example because it has no key frames. In this case, tracks + * are matched to each other based on their PID. + * * Do not instantiate this class; use the {@link MPEG_TS} singleton instead. * * @group Input formats @@ -564,7 +573,16 @@ export class MpegTsInputFormat extends InputFormat { } } +/** + * Media described using the HTTP Live Streaming (HLS) protocol, with playlists in the M3U8 format. + * + * Do not instantiate this class; use the {@link HLS} singleton instead. + * + * @group Input formats + * @public + */ export class HlsInputFormat extends InputFormat { + /** @internal */ async _canReadInput(input: Input) { let slice = input._reader.requestSlice(0, 7); if (slice instanceof Promise) slice = await slice; @@ -575,13 +593,14 @@ export class HlsInputFormat extends InputFormat { return false; } - if (typeof input._source !== 'function') { - throw new TypeError('HLS inputs require `InputOptions.source` to be a function.'); + if (!(input._source instanceof PathedSource)) { + throw new TypeError('HLS inputs require `InputOptions.source` to be a PathedSource.'); } return true; } + /** @internal */ _createDemuxer(input: Input) { return new HlsDemuxer(input); } @@ -704,4 +723,10 @@ export const HLS = /* #__PURE__ */ new HlsInputFormat(); */ export const ALL_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS, MPEG_TS]; +/** + * List of input formats required for playback of typical HLS manifests. Includes HLS itself as well as the typical + * segment formats: MPEG Transport Stream (.ts), MP4 (CMAF), ADTS (.aac) and MP3. + * @group Input formats + * @public + */ export const HLS_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MP3, ADTS, MPEG_TS]; diff --git a/src/input-track-descriptor.ts b/src/input-track-descriptor.ts index a54772b..b78d058 100644 --- a/src/input-track-descriptor.ts +++ b/src/input-track-descriptor.ts @@ -20,6 +20,17 @@ import { TrackDisposition } from './metadata'; import { MaybePromise } from './misc'; import { TrackType } from './output'; +/** + * A lightweight descriptor for an {@link InputTrack}. Contains a subset of the track's properties, and can be + * converted/upgraded to the full track via the {@link InputTrackDescriptor.getTrack} method. + * + * For some formats, such as HLS with master playlists, obtaining track descriptors is much cheaper than obtaining the + * input track. These descriptors therefore can be used for efficient track selection and filtering without having to + * expensively hydrate all input tracks. + * + * @group Input files & tracks + * @public + */ export abstract class InputTrackDescriptor { /** The input file this descriptor belongs to. */ readonly input: Input; @@ -81,10 +92,18 @@ export abstract class InputTrackDescriptor { return this._backing.getDisposition(); } + /** + * The peak bitrate of the track as specified in the track's metadata. This might not match the actual + * media data's bitrate. + */ get bitrate(): number | null | undefined { return this._backing.getBitrate(); } + /** + * The average bitrate of the track as specified in the track's metadata. This might not match the actual + * media data's bitrate. + */ get averageBitrate(): number | null | undefined { return this._backing.getAverageBitrate(); } @@ -92,13 +111,15 @@ export abstract class InputTrackDescriptor { /** Whether the track metadata says that this track only contains key packets, `undefined` if not yet known. */ abstract get hasOnlyKeyPackets(): boolean | undefined; - canBePairedWith(other: InputTrackDescriptor | InputTrack | null) { - if (!(other instanceof InputTrack || other instanceof InputTrackDescriptor || other === null)) { - throw new TypeError('other must be an InputTrack, InputTrackDescriptor, or null.'); - } - - if (!other) { - return true; + /** + * Returns `true` if this descriptor can be paired with the given track or descriptor. Two tracks being pairable + * means they can be presented (displayed) together. + * + * Returns `false` if `other` equals `this`. + */ + canBePairedWith(other: InputTrackDescriptor | InputTrack) { + if (!(other instanceof InputTrack || other instanceof InputTrackDescriptor)) { + throw new TypeError('other must be an InputTrack or InputTrackDescriptor.'); } if (this.input !== other.input || this === other) { @@ -108,7 +129,11 @@ export abstract class InputTrackDescriptor { return (this._backing.getPairingMask() & other._backing.getPairingMask()) !== 0n; } - async getPairableDescriptors(query?: TrackDescriptorQuery) { + /** + * Gets the list of other descriptors that can be paired with this descriptor. An optional query can be provided + * to narrow down the results. + */ + async getPairableDescriptors(query?: InputTrackDescriptorQuery) { query &&= toValidatedTrackDescriptorQuery(query); const descriptors = await this.input.getTrackDescriptors(); @@ -118,7 +143,11 @@ export abstract class InputTrackDescriptor { ); } - async getPairableVideoTrackDescriptors(query?: TrackDescriptorQuery) { + /** + * Gets the list of other video track descriptors that can be paired with this descriptor. An optional query can + * be provided to narrow down the results. + */ + async getPairableVideoTrackDescriptors(query?: InputTrackDescriptorQuery) { query &&= toValidatedTrackDescriptorQuery(query); const descriptors = await this.getPairableDescriptors(); @@ -128,7 +157,11 @@ export abstract class InputTrackDescriptor { ); } - async getPairableAudioTrackDescriptors(query?: TrackDescriptorQuery) { + /** + * Gets the list of other audio track descriptors that can be paired with this descriptor. An optional query can + * be provided to narrow down the results. + */ + async getPairableAudioTrackDescriptors(query?: InputTrackDescriptorQuery) { query &&= toValidatedTrackDescriptorQuery(query); const descriptors = await this.getPairableDescriptors(); @@ -138,17 +171,20 @@ export abstract class InputTrackDescriptor { ); } + /** Returns `true` if there is another descriptor that can be paired with this descriptor. */ hasPairableDescriptor(predicate?: (descriptor: InputTrackDescriptor) => boolean) { const descriptors = [...this.input._backingToDescriptor.values()]; return descriptors.some(x => this.canBePairedWith(x) && (!predicate || predicate(x))); } + /** Returns `true` if there is a video track that can be paired with this descriptor. */ hasPairableVideoTrack(predicate?: (descriptor: InputVideoTrackDescriptor) => boolean) { return this.hasPairableDescriptor(x => x.isVideoTrackDescriptor() && (!predicate || predicate(x)), ); } + /** Returns `true` if there is an audio track that can be paired with this descriptor. */ hasPairableAudioTrack(predicate?: (descriptor: InputAudioTrackDescriptor) => boolean) { return this.hasPairableDescriptor(x => x.isAudioTrackDescriptor() && (!predicate || predicate(x)), @@ -165,7 +201,7 @@ export abstract class InputTrackDescriptor { } /** - * A lightweight descriptor for a video track. + * A lightweight descriptor for an {@link InputVideoTrack}. See {@link InputTrackDescriptor} for details. * * @group Input files & tracks * @public @@ -234,7 +270,7 @@ export class InputVideoTrackDescriptor extends InputTrackDescriptor { } /** - * A lightweight descriptor for an audio track. + * A lightweight descriptor for an {@link InputAudioTrack}. See {@link InputTrackDescriptor} for details. * * @group Input files & tracks * @public @@ -276,14 +312,69 @@ export class InputAudioTrackDescriptor extends InputTrackDescriptor { } } -export type TrackDescriptorQuery = { +/** + * Defines a query for track descriptors and, by extension, for tracks. Can be used to query tracks tersely and + * expressively, which is especially useful for media inputs with many tracks, such as HLS manifests. + * + * @group Input files & tracks + * @public + */ +export type InputTrackDescriptorQuery = { + /** + * A filter predicate function called for every track descriptor. Returning or resolving to `false` excludes the + * track from the result. + */ filter?: (descriptor: T) => MaybePromise; + /** + * A function called for every track descriptor, used to define a track ordering. Tracks are ordered in ascending + * order using the value returned by this function. When the function returns an array of numbers `arr`, tracks will + * be sorted by `arr[0]` unless they have the same value, in which case they will be sorted by `arr[1]`, and so on. + * This allows you to construct a list of ordering criteria, sorted by importance. + * + * To help construct complex ordering criteria, the {@link asc}, {@link desc}, and {@link prefer} helper functions + * can be used. + */ sortBy?: (descriptor: T) => MaybePromise; }; +/** + * Helper function for use in {@link InputTrackDescriptorQuery.sortBy}, used to describe sorting tracks by a numeric + * property in ascending order. `null` and `undefined` are accepted too and are last in the order (sorted to the end). + * + * @group Input files & tracks + * @public + */ +export const asc = (value: number | null | undefined) => { + return value ?? Infinity; // nulls and undefined last +}; + +/** + * Helper function for use in {@link InputTrackDescriptorQuery.sortBy}, used to describe sorting tracks by a numeric + * property in descending order. `null` and `undefined` are accepted too and are last in the order (sorted to the end). + * + * @group Input files & tracks + * @public + */ +export const desc = (value: number | null | undefined) => { + return -(value ?? -Infinity); // nulls and undefined last +}; + +/** + * Helper function for use in {@link InputTrackDescriptorQuery.sortBy}, used to sort tracks by boolean properties. + * `true` is sorted to the start, `false` to the end. Useful for expressing soft preferences (e.g., "I'd prefer 1080p, + * but other resolutions are fine too") as opposed to {@link InputTrackDescriptorQuery.filter} which expresses hard + * requirements for tracks. + * + * @group Input files & tracks + * @public + */ +export const prefer = (value: boolean) => { + return -value; +}; + export const toValidatedTrackDescriptorQuery = ( - query: TrackDescriptorQuery, -): TrackDescriptorQuery => { + query: InputTrackDescriptorQuery, +): InputTrackDescriptorQuery => { if (typeof query !== 'object' || !query) { throw new TypeError('query must be an object.'); } @@ -342,9 +433,9 @@ export const toValidatedTrackDescriptorQuery = ( }; export const mergeTrackDescriptorQueries = ( - queryA: TrackDescriptorQuery | undefined, - queryB: TrackDescriptorQuery | undefined, -): TrackDescriptorQuery => { + queryA: InputTrackDescriptorQuery | undefined, + queryB: InputTrackDescriptorQuery | undefined, +): InputTrackDescriptorQuery => { return { filter: queryA?.filter || queryB?.filter ? (descriptor) => { @@ -391,7 +482,7 @@ export const mergeTrackDescriptorQueries = ( export const queryTrackDescriptors = async ( descriptors: T[], - query?: TrackDescriptorQuery, + query?: InputTrackDescriptorQuery, ): Promise => { let matched = descriptors; if (query?.filter) { diff --git a/src/input-track.ts b/src/input-track.ts index acc152a..11c9481 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -21,7 +21,7 @@ import { mergeTrackDescriptorQueries, type InputVideoTrackDescriptor, type InputAudioTrackDescriptor, - type TrackDescriptorQuery, + type InputTrackDescriptorQuery, } from './input-track-descriptor'; /** @@ -178,10 +178,18 @@ export abstract class InputTrack { return this._backing.getDisposition(); } + /** + * The peak bitrate of the track as specified in the track's metadata. This might not match the actual + * media data's bitrate. + */ get bitrate() { return this._backing.getBitrate(); } + /** + * The average bitrate of the track as specified in the track's metadata. This might not match the actual + * media data's bitrate. + */ get averageBitrate() { return this._backing.getAverageBitrate(); } @@ -271,21 +279,34 @@ export abstract class InputTrack { }; } + /** + * Whether or not this track is currently live, meaning the media's end is still unknown. + * + * The value returned by this method may change over time as the track stops being live. To keep track of the + * track's live status, poll this method at the track's refresh interval + * via {@link InputTrack.getLiveRefreshInterval}. + */ async isLive() { return (await this._backing.getLiveRefreshInterval()) !== null; } + /** + * Returns the track's live refresh interval in seconds, or `null` if the track is not live. This interval describes + * the time it takes, on average, for new live media data to become available. + */ async getLiveRefreshInterval() { return this._backing.getLiveRefreshInterval(); } - canBePairedWith(other: InputTrack | InputTrackDescriptor | null) { - if (!(other instanceof InputTrack || other instanceof InputTrackDescriptor || other === null)) { - throw new TypeError('other must be an InputTrack, InputTrackDescriptor, or null.'); - } - - if (!other) { - return true; + /** + * Returns `true` if this track can be paired with the given track. Two tracks being pairable means they can be + * presented (displayed) together. + * + * Returns `false` if `other` equals `this`. + */ + canBePairedWith(other: InputTrack | InputTrackDescriptor) { + if (!(other instanceof InputTrack || other instanceof InputTrackDescriptor)) { + throw new TypeError('other must be an InputTrack or InputTrackDescriptor.'); } if (this.input !== other.input || this === other) { @@ -295,7 +316,11 @@ export abstract class InputTrack { return (this._backing.getPairingMask() & other._backing.getPairingMask()) !== 0n; } - async getPairableTracks(query?: TrackDescriptorQuery) { + /** + * Gets the list of other tracks that can be paired with this track. An optional query can be provided to narrow + * down the results. + */ + async getPairableTracks(query?: InputTrackDescriptorQuery) { const descriptors = await this.input.getTrackDescriptors(mergeTrackDescriptorQueries({ filter: d => d.canBePairedWith(this), }, query)); @@ -303,11 +328,16 @@ export abstract class InputTrack { return Promise.all(descriptors.map(d => d.getTrack())); } - async pluckPairableTrack(query?: TrackDescriptorQuery) { + /** Returns the first track that can be paired with this track, optionally steered by the provided query. */ + async pluckPairableTrack(query?: InputTrackDescriptorQuery) { return (await this.getPairableTracks(query))[0] ?? null; } - async getPairableVideoTracks(query?: TrackDescriptorQuery) { + /** + * Gets the list of other video tracks that can be paired with this track. An optional query can be provided to + * narrow down the results. + */ + async getPairableVideoTracks(query?: InputTrackDescriptorQuery) { const descriptors = await this.input.getVideoTrackDescriptors(mergeTrackDescriptorQueries({ filter: d => d.canBePairedWith(this), }, query)); @@ -315,11 +345,16 @@ export abstract class InputTrack { return Promise.all(descriptors.map(d => d.getTrack())); } - async pluckPairableVideoTrack(query?: TrackDescriptorQuery) { + /** Returns the first video track that can be paired with this track, optionally steered by the provided query. */ + async pluckPairableVideoTrack(query?: InputTrackDescriptorQuery) { return (await this.getPairableVideoTracks(query))[0] ?? null; } - async getPairableAudioTracks(query?: TrackDescriptorQuery) { + /** + * Gets the list of other audio tracks that can be paired with this track. An optional query can be provided to + * narrow down the results. + */ + async getPairableAudioTracks(query?: InputTrackDescriptorQuery) { const descriptors = await this.input.getAudioTrackDescriptors(mergeTrackDescriptorQueries({ filter: d => d.canBePairedWith(this), }, query)); @@ -327,22 +362,26 @@ export abstract class InputTrack { return Promise.all(descriptors.map(d => d.getTrack())); } - async pluckPairableAudioTrack(query?: TrackDescriptorQuery) { + /** Returns the first audio track that can be paired with this track, optionally steered by the provided query. */ + async pluckPairableAudioTrack(query?: InputTrackDescriptorQuery) { return (await this.getPairableAudioTracks(query))[0] ?? null; } - async getPrimaryPairableVideoTrack(query?: TrackDescriptorQuery) { + /** Returns the primary track that can be paired with this track, optionally steered by the provided query. */ + async getPrimaryPairableVideoTrack(query?: InputTrackDescriptorQuery) { return this.input.getPrimaryVideoTrack(mergeTrackDescriptorQueries({ filter: d => d.canBePairedWith(this), }, query)); } - async getPrimaryPairableAudioTrack(query?: TrackDescriptorQuery) { + /** Returns the primary track that can be paired with this track, optionally steered by the provided query. */ + async getPrimaryPairableAudioTrack(query?: InputTrackDescriptorQuery) { return this.input.getPrimaryAudioTrack(mergeTrackDescriptorQueries({ filter: d => d.canBePairedWith(this), }, query)); } + /** Returns `true` if there is another track that can be paired with this track. */ hasPairableTrack(predicate?: (descriptor: InputTrackDescriptor) => boolean) { predicate &&= toValidatedPredicate(predicate); @@ -352,6 +391,7 @@ export abstract class InputTrack { ); } + /** Returns `true` if there is a video track that can be paired with this track. */ hasPairableVideoTrack(predicate?: (descriptor: InputVideoTrackDescriptor) => boolean) { predicate &&= toValidatedPredicate(predicate); @@ -360,6 +400,7 @@ export abstract class InputTrack { ); } + /** Returns `true` if there is an audio track that can be paired with this track. */ hasPairableAudioTrack(predicate?: (descriptor: InputAudioTrackDescriptor) => boolean) { predicate &&= toValidatedPredicate(predicate); diff --git a/src/input.ts b/src/input.ts index 3fb0b24..1287105 100644 --- a/src/input.ts +++ b/src/input.ts @@ -23,18 +23,17 @@ import { mergeTrackDescriptorQueries, queryTrackDescriptors, toValidatedTrackDescriptorQuery, - TrackDescriptorQuery, + InputTrackDescriptorQuery, + prefer, + desc, } from './input-track-descriptor'; import { PacketRetrievalOptions } from './media-sink'; import { arrayArgmin, arrayCount, assert, - desc, EventEmitter, - MaybePromise, polyfillSymbolDispose, - prefer, removeItem, } from './misc'; import { Reader } from './reader'; @@ -44,10 +43,13 @@ import { BufferSource, FilePathSource, FilePathSourceOptions, + PathedSource, ReadableStreamSource, ReadableStreamSourceOptions, Source, SourceRef, + SourceRequest, + sourceRequestsAreEqual, UrlSource, UrlSourceOptions, } from './source'; @@ -57,15 +59,6 @@ polyfillSymbolDispose(); export const DEFAULT_SOURCE_CACHE_GROUP = 1; export const ENCRYPTION_KEY_CACHE_GROUP = 2; -export type SourceRequest = { - path: string; - isRoot: boolean; -}; - -const sourceRequestsAreEqual = (a: SourceRequest, b: SourceRequest) => { - return a.path === b.path; -}; - let inputFinalizationRegistry: FinalizationRegistry | null = null; if (typeof FinalizationRegistry !== 'undefined') { inputFinalizationRegistry = new FinalizationRegistry((refs) => { @@ -84,8 +77,14 @@ export type InputOptions = { /** A list of supported formats. If the source file is not of one of these formats, then it cannot be read. */ formats: InputFormat[]; /** The source from which data will be read. */ - source: S | SourceRef | ((request: SourceRequest) => MaybePromise>); - entryPath?: string; + source: S | SourceRef | PathedSource; + /** + * An optional, second {@link Input} instance that contains the necessary metadata to initialize the tracks of + * this input. This is necessary in cases where track initialization info and media data are carried in separate + * files, like is the case with segmented MP4 (CMAF) files. + * + * The use of this field depends on the input format. + */ initInput?: Input; }; @@ -97,24 +96,37 @@ type SourceCacheEntry = { }; /** - * Represents an input media file. This is the root object from which all media read operations start. + * Describes the events that an {@link Input} emits, with each key being an event name and its value being the + * event data. + * * @group Input files & tracks * @public */ export type InputEvents = { - source: { source: Source; request: SourceRequest | null }; + /** Emitted whenever a {@link Source} is loaded by the input. Useful to track reads. */ + source: { + /** The loaded source. */ + source: Source; + /** The request that led to loading this source, or `null` if the input is not pathed. */ + request: SourceRequest | null; + }; }; +/** + * Represents input media, backed by a single file or multiple files depending on the format. + * + * This is the root object from which all media read operations start. + * @group Input files & tracks + * @public + */ export class Input extends EventEmitter implements Disposable { /** @internal */ - _source: SourceRef | ((request: SourceRequest) => MaybePromise>); + _source: SourceRef | PathedSource; /** @internal */ _formats: InputFormat[]; /** @internal */ _initInput: Input | null; /** @internal */ - _entryPath: string | null; - /** @internal */ _demuxerPromise: Promise | null = null; /** @internal */ _format: InputFormat | null = null; @@ -161,22 +173,19 @@ export class Input extends EventEmitter if (!Array.isArray(options.formats) || options.formats.some(x => !(x instanceof InputFormat))) { throw new TypeError('options.formats must be an array of InputFormat.'); } - if (!(options.source instanceof Source) && typeof options.source !== 'function') { - throw new TypeError('options.source must be a Source or a function that returns a Source.'); - } - if (typeof options.source === 'function' && options.entryPath === undefined) { - throw new TypeError('options.entryPath must be provided when options.source is a function.'); + if (!( + options.source instanceof Source + || options.source instanceof SourceRef + || options.source instanceof PathedSource + )) { + throw new TypeError('options.source must be a Source, SourceRef, or PathedSource.'); } if (options.initInput !== undefined && !(options.initInput instanceof Input)) { throw new TypeError('options.initInput, when provided, must be an Input.'); } - if (options.entryPath !== undefined && typeof options.entryPath !== 'string') { - throw new TypeError('options.entryPath, when provided, must be a string.'); - } this._formats = options.formats; this._initInput = options.initInput ?? null; - this._entryPath = options.entryPath ?? null; if (options.source instanceof Source) { this._source = options.source.ref(); @@ -191,10 +200,11 @@ export class Input extends EventEmitter inputFinalizationRegistry?.register(this, this._sourceRefs, this); } + /** @internal */ async _getSourceUncached(request: SourceRequest) { - assert(typeof this._source === 'function'); + assert(this._source instanceof PathedSource); - const source = await this._source(request); + const source = await this._source.getSource(request); if (!(source instanceof Source || source instanceof SourceRef)) { throw new TypeError('The source function must return a Source or a SourceRef.'); } @@ -209,10 +219,11 @@ export class Input extends EventEmitter ref = source; } - this.emit('source', { source: ref.source, request }); + this._emit('source', { source: ref.source, request }); return ref; } + /** @internal */ _getSourceCached(request: SourceRequest, cacheGroup = DEFAULT_SOURCE_CACHE_GROUP): Promise> { const cachedEntry = this._sourceCache.find(x => x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request), @@ -284,10 +295,9 @@ export class Input extends EventEmitter let ref: SourceRef; if (this._source instanceof SourceRef) { ref = this._source; - this.emit('source', { source: ref.source, request: null }); + this._emit('source', { source: ref.source, request: null }); } else { - assert(this._entryPath !== null); - ref = await this._getSourceUncached({ path: this._entryPath, isRoot: true }); + ref = await this._getSourceUncached({ path: this._source.rootPath, isRoot: true }); this._sourceRefs.push(ref); } @@ -314,9 +324,7 @@ export class Input extends EventEmitter return this._source.source; } - assert(this._entryPath !== null); - - const source = this._source({ path: this._entryPath, isRoot: true }); + const source = this._source.getSource({ path: this._source.rootPath, isRoot: true }); if (source instanceof Promise) { throw new TypeError( 'Input.source cannot be used when the source function resolves asynchronously.' @@ -342,6 +350,7 @@ export class Input extends EventEmitter return this._format; } + /** Returns `true` if the format of the input file is known and the file can be read, `false` otherwise. */ async canRead(): Promise { try { await this._getDemuxer(); @@ -406,45 +415,64 @@ export class Input extends EventEmitter } /** - * Returns the list of all tracks of this input file in the order in which they appear in the file. - * A query can be provided to filter/sort tracks; the query operates on lightweight descriptors and only - * the matching tracks are fully loaded. + * Returns the list of all tracks of this input file in the order in which they appear in the file. An optional + * query can be provided. */ - async getTracks(query?: TrackDescriptorQuery): Promise { + async getTracks( + query?: InputTrackDescriptorQuery, + ): Promise { const descriptors = await this.getTrackDescriptors(query); return Promise.all(descriptors.map(x => x.getTrack())); } - async pluckTrack(query?: TrackDescriptorQuery): Promise { + /** Returns the first track in this input file, optionally steered by the provided query. */ + async pluckTrack( + query?: InputTrackDescriptorQuery, + ): Promise { const descriptors = await this.getTrackDescriptors(query); return descriptors[0]?.getTrack() ?? null; } - /** Returns the list of all video tracks of this input file. */ - async getVideoTracks(query?: TrackDescriptorQuery): Promise { + /** Returns the list of all video tracks of this input file. An optional query can be provided. */ + async getVideoTracks( + query?: InputTrackDescriptorQuery, + ): Promise { const descriptors = await this.getVideoTrackDescriptors(query); return Promise.all(descriptors.map(x => x.getTrack())); } - async pluckVideoTrack(query?: TrackDescriptorQuery): Promise { + /** Returns the first video track in this input file, optionally steered by the provided query. */ + async pluckVideoTrack( + query?: InputTrackDescriptorQuery, + ): Promise { const descriptors = await this.getVideoTrackDescriptors(query); return descriptors[0]?.getTrack() ?? null; } - /** Returns the list of all audio tracks of this input file. */ - async getAudioTracks(query?: TrackDescriptorQuery): Promise { + /** Returns the list of all audio tracks of this input file. An optional query can be provided. */ + async getAudioTracks( + query?: InputTrackDescriptorQuery, + ): Promise { const descriptors = await this.getAudioTrackDescriptors(query); return Promise.all(descriptors.map(x => x.getTrack())); } - async pluckAudioTrack(query?: TrackDescriptorQuery): Promise { + /** Returns the first audio track in this input file, optionally steered by the provided query. */ + async pluckAudioTrack( + query?: InputTrackDescriptorQuery, + ): Promise { const descriptors = await this.getAudioTrackDescriptors(query); return descriptors[0]?.getTrack() ?? null; } - /** Returns the primary video track of this input file, or null if there are no video tracks. */ + /** + * Returns the primary video track of this input file, or null if there are no video tracks. + * + * Multiple factors determine which track is considered primary, including its position in the file, disposition, + * bitrate (higher bitrate is preferred), and if it can be paired with an audio track. + */ async getPrimaryVideoTrack( - query?: TrackDescriptorQuery, + query?: InputTrackDescriptorQuery, ): Promise { query &&= toValidatedTrackDescriptorQuery(query); @@ -452,9 +480,14 @@ export class Input extends EventEmitter return descriptor?.getTrack() ?? null; } - /** Returns the primary audio track of this input file, or null if there are no audio tracks. */ + /** + * Returns the primary audio track of this input file, or null if there are no audio tracks. + * + * Multiple factors determine which track is considered primary, including its position in the file, disposition, + * bitrate (higher bitrate is preferred), and if it can be paired with the primary video track. + */ async getPrimaryAudioTrack( - query?: TrackDescriptorQuery, + query?: InputTrackDescriptorQuery, ): Promise { query &&= toValidatedTrackDescriptorQuery(query); @@ -466,7 +499,7 @@ export class Input extends EventEmitter * Returns lightweight track descriptors without loading full media data. Useful for querying and filtering * tracks (e.g. from an HLS master playlist) before committing to loading any specific track. */ - async getTrackDescriptors(query?: TrackDescriptorQuery) { + async getTrackDescriptors(query?: InputTrackDescriptorQuery) { query &&= toValidatedTrackDescriptorQuery(query); const backings = await this._getTrackBackings(); @@ -474,8 +507,12 @@ export class Input extends EventEmitter return queryTrackDescriptors(descriptors, query); } + /** + * Returns lightweight video track descriptors without loading full media data. Useful for querying and filtering + * video tracks (e.g. from an HLS master playlist) before committing to loading any specific track. + */ async getVideoTrackDescriptors( - query?: TrackDescriptorQuery, + query?: InputTrackDescriptorQuery, ): Promise { query &&= toValidatedTrackDescriptorQuery(query); @@ -486,8 +523,12 @@ export class Input extends EventEmitter return queryTrackDescriptors(videoDescriptors, query); } + /** + * Returns lightweight audio track descriptors without loading full media data. Useful for querying and filtering + * audio tracks (e.g. from an HLS master playlist) before committing to loading any specific track. + */ async getAudioTrackDescriptors( - query?: TrackDescriptorQuery, + query?: InputTrackDescriptorQuery, ): Promise { query &&= toValidatedTrackDescriptorQuery(query); @@ -498,8 +539,9 @@ export class Input extends EventEmitter return queryTrackDescriptors(audioDescriptors, query); } + /** Returns the primary video track descriptor of this input file, or null if there are no video tracks. */ async getPrimaryVideoDescriptor( - query?: TrackDescriptorQuery, + query?: InputTrackDescriptorQuery, ): Promise { query &&= toValidatedTrackDescriptorQuery(query); @@ -516,8 +558,9 @@ export class Input extends EventEmitter return sorted[0] ?? null; } + /** Returns the primary audio track descriptor of this input file, or null if there are no audio tracks. */ async getPrimaryAudioDescriptor( - query?: TrackDescriptorQuery, + query?: InputTrackDescriptorQuery, ): Promise { query &&= toValidatedTrackDescriptorQuery(query); @@ -525,7 +568,7 @@ export class Input extends EventEmitter const merged = mergeTrackDescriptorQueries(query, { sortBy: d => [ - prefer(d.canBePairedWith(primaryVideoDescriptor)), + prefer(!primaryVideoDescriptor || d.canBePairedWith(primaryVideoDescriptor)), prefer(d.disposition?.default ?? false), desc(d.bitrate ?? null), ], @@ -671,33 +714,36 @@ export class InputDisposedError extends Error { } /** - * Options for {@link Input.from}. Combines the options of all source types, plus `initInput`. + * Options for {@link createInputFrom}. Combines the options of all source types, plus `initInput`. + * * @group Input files & tracks * @public */ -export type InputFromOptions = - & Partial - & Partial - & Partial - & Partial - & { - initInput?: Input; - }; +export type CreateInputFromOptions = + & UrlSourceOptions + & BlobSourceOptions + & FilePathSourceOptions + & ReadableStreamSourceOptions + & Pick; /** * Creates an {@link Input} backed by the passed-in data. An alternative to {@link Input}'s constructor, this helper * function automatically chooses the correct underlying {@link Source} based on the type of the data passed in. * * Legal data types are `ArrayBuffer`, `SharedArrayBuffer`, `ArrayBufferView`, `Blob` (and, by extension, `File`), - * `ReadableStream`, `string` (representing either a URL or a local file path), `URL`, and `Request`. Local + * file paths require a Node-like server-side environment with access to the file system. * * The available options are the union of the options for each {@link Source}. Check the sources to see which field * applies to which source. + * + * @group Input files & tracks + * @public */ export const createInputFrom = ( data: AllowSharedBufferSource | Blob | ReadableStream | string | URL | Request, formats: InputFormat[], - options: InputFromOptions = {}, + options: CreateInputFromOptions = {}, ): Input => { if (!Array.isArray(formats) || !formats.every(x => x instanceof InputFormat)) { throw new TypeError('formats must be an array of InputFormat.'); @@ -741,8 +787,10 @@ export const createInputFrom = ( return new Input({ formats, - source: (request: SourceRequest) => new UrlSource(request.path, sourceOptions), - entryPath: url, + source: new PathedSource( + url, + request => new UrlSource(request.path, sourceOptions), + ), initInput, }); } @@ -752,14 +800,13 @@ export const createInputFrom = ( return new Input({ formats, - source: (request: SourceRequest) => { - const reqInit = (sourceOptions as UrlSourceOptions).requestInit; - return new UrlSource( - new Request(request.path, { ...reqInit, method: data.method, headers: data.headers }), + source: new PathedSource( + url, + request => new UrlSource( + new Request(request.path, data), sourceOptions, - ); - }, - entryPath: url, + ), + ), initInput, }); } @@ -770,17 +817,21 @@ export const createInputFrom = ( if (isUrl) { return new Input({ formats, - source: (request: SourceRequest) => new UrlSource(request.path, sourceOptions), - entryPath: data, + source: new PathedSource( + data, + request => new UrlSource(request.path, sourceOptions), + ), initInput, }); } - // File path, throws automatically if this isn't server-side + // It's a file path; this throws automatically if this isn't server-side return new Input({ formats, - source: (request: SourceRequest) => new FilePathSource(request.path, sourceOptions), - entryPath: data, + source: new PathedSource( + data, + request => new FilePathSource(request.path, sourceOptions), + ), initInput, }); } diff --git a/src/misc.ts b/src/misc.ts index 6b5712f..57b8896 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -789,7 +789,26 @@ export const isNumber = (x: unknown) => { return typeof x === 'number' && !Number.isNaN(x); }; -export const joinPaths = (basePath: string, relativePath: string) => { +/** + * A path to a file. File paths can be relative or absolute, and be local paths or full URLs. Paths must be POSIX-like, + * using `/` as the separator. + * + * Examples of valid paths: + * - `'video.mp4'` + * - `'path/to/video.mp4'` + * - `'./video.mp4'` + * - `'../video.mp4'` + * - `'/path/to/video.mp4'` + * - `'https://example.com/video.mp4'` + * - `'file:///home/user/video.mp4'` + * - `'video.mp4?key=foo'` + * + * @group Miscellaneous + * @public + */ +export type FilePath = string; + +export const joinPaths = (basePath: FilePath, relativePath: FilePath) => { // If relativePath is a full URL with protocol, return it as-is if (relativePath.includes('://')) { return relativePath; @@ -959,18 +978,6 @@ export const validateRectangle = (rect: Rectangle, propertyPath: string) => { } }; -export const asc = (value: number | null | undefined) => { - return value ?? Infinity; // nulls and undefined last -}; - -export const desc = (value: number | null | undefined) => { - return -(value ?? -Infinity); // nulls and undefined last -}; - -export const prefer = (value: boolean) => { - return -value; -}; - export type NonFunctionKeys = { [K in keyof T]-?: T[K] extends ((...args: never[]) => unknown) ? never : K }[keyof T]; @@ -1163,17 +1170,32 @@ export const toArray = (x: T | T[]) => { } }; -type ListenerOptions = { +/** + * Options for {@link EventEmitter.on}. + * + * @group Miscellaneous + * @public + */ +export type EventListenerOptions = { + /** If `true`, the listener will be automatically removed after being called once. Defaults to `false`. */ once?: boolean; }; +/** + * A class that manages event listeners and dispatches events to them. + * + * @group Miscellaneous + * @public + */ export class EventEmitter> { - private _listeners = new Map unknown; once: boolean }>>(); + /** @internal */ + _listeners = new Map unknown; once: boolean }>>(); + /** Registers a listener for the given event. */ on( event: K, listener: (data: TEvents[K]) => unknown, - options?: ListenerOptions, + options?: EventListenerOptions, ): () => void { if (!this._listeners.has(event)) { this._listeners.set(event, new Set()); @@ -1186,7 +1208,8 @@ export class EventEmitter> { }; } - emit( + /** @internal */ + _emit( ...args: TEvents[K] extends void ? [event: K] : [event: K, data: TEvents[K]] ): void { const [event, data] = args; diff --git a/src/output-format.ts b/src/output-format.ts index 46f4152..7622007 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -29,7 +29,7 @@ import { Output, OutputTrack, TrackType } from './output'; import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer'; import { WaveMuxer } from './wave/wave-muxer'; import { HlsMuxer } from './hls/hls-muxer'; -import { MaybePromise, toArray } from './misc'; +import { MaybePromise, FilePath, toArray } from './misc'; import { Target } from './target'; /** @@ -1155,36 +1155,136 @@ export class MpegTsOutputFormat extends OutputFormat { } } +/** + * Info about an HLS media playlist. + * @group Output formats + * @public + */ export type HlsOutputPlaylistInfo = { + /** The 1-based index of the media playlist in the master playlist. */ n: number; + /** The output tracks contained in this playlist. */ tracks: OutputTrack[]; + /** The format of the media segments in this playlist. */ segmentFormat: OutputFormat; }; +/** + * Info about an HLS media segment. + * @group Output formats + * @public + */ export type HlsOutputSegmentInfo = { + /** The 1-based index of the segment in the containing media playlist. */ n: number; + /** If the segment is a single file, meaning it is a single segment file that covers the entire playlist. */ isSingleFile: boolean; + /** The format of the media segment. */ format: OutputFormat; + /** The media playlist to which this segment belongs. */ playlist: HlsOutputPlaylistInfo; }; +/** + * HLS-specific output options. + * @group Output formats + * @public + */ export type HlsOutputFormatOptions = { + /** + * Specifies the file format of each media segment. Not all formats are supported by all players; prefer sticking + * to the most commonly used ones: {@link MpegTsOutputFormat}, {@link CmafOutputFormat}, {@link AdtsOutputFormat}, + * and {@link Mp3OutputFormat}. + * + * When an array of formats is specified, for each playlist, the first format that can contain all of the playlist's + * tracks is chosen. This allows you to, for example, package audio into .aac files and video into .ts files. + */ segmentFormat: OutputFormat | OutputFormat[]; + /** + * Specifies the target (max) duration in seconds for each media segment, defaulting to 2 seconds. + * + * Mediabunny will try not to emit media segments longer than the target duration, but it is forced to if key frames + * are provided with a longer period than the target duration. Therefore, make sure to encode a key frame at least + * every `targetDuration` seconds to guarantee segment length. + */ targetDuration?: number; + /** + * Whether to bundle all media segments for a playlist into a single file. Individual segments are then extracted + * via range requests. + */ singleFilePerPlaylist?: boolean; + /** + * If `true`, the muxer will be in "live mode", continuously emitting updated playlists as new segments are created. + * The master playlist will be emitted as soon as all playlists have been emitted at least once, and will continue + * to be emitted each time a segment is finalized to further refine the accuracy of the `BANDWIDTH` attribute. + * + * When `false` (the default), all playlists will only be emitted once, upon output finalization. + */ live?: boolean; + /** + * When in live mode, this controls the maximum number of segments contained in each playlist. Defaults to + * `Infinity`, meaning playlists continually grow in size. + */ maxLiveSegmentCount?: number; - getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; - getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise; - getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; + /** + * Returns the file path for a given media playlist. If the returned path is relative, it is relative to the root + * path. + * + * Defaults to `'playlist-{n}.m3u8'`, where `n` is the 1-based index of the media playlist in the master playlist. + */ + getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; + /** + * Returns the file path for a given media segment. If the returned path is relative, it is relative to the path + * of the containing playlist. + * + * Defaults to `'segment-{n}-{k}{ext}'`, where `n` is the 1-based index of the containing media playlist in the + * master playlist, `k` is the 1-based index of the segment in its playlist, and `ext` is the file extension of the + * segment format (including the leading dot). + * + * If {@link HlsOutputFormatOptions.singleFilePerPlaylist} is true, it defaults to `'segments-{n}{ext}'` instead. + */ + getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise; + /** + * Returns the file path for a given media init segment. If the returned path is relative, it is relative to the + * path of the containing playlist. + * + * Only necessary for segment formats that require an init file, such as {@link CmafOutputFormat}. + * + * Defaults to `'init-{n}{ext}'`, where `n` is the 1-based index of the containing media playlist in the master + * playlist and `ext` is the file extension of the segment format (including the leading dot). + */ + getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; + /** Called whenever the master playlist is written. */ onMaster?: (content: string) => unknown; + /** Called whenever a media playlist is written. */ onPlaylist?: (content: string, info: HlsOutputPlaylistInfo) => unknown; - // Document how this is called for the single-file mode + /** + * Called whenever a media segment has been fully written. In single-file mode, this function will only be called + * once when the playlist is finalized. + */ onSegment?: (target: Target, info: HlsOutputSegmentInfo) => unknown; + /** + * Called when a media playlist is initialized, before any segments have been written. In single-file mode, this + * function is never called. + */ onInit?: (target: Target, info: HlsOutputPlaylistInfo) => unknown; }; +/** + * HTTP Live Streaming (HLS) output format. HLS media is represented by a set of .m3u8 playlist files and media segment + * files, meaning this format writes out multiple files, requiring the use of a _pathed Output_ + * ({@link OutputOptions.target} must be a {@link PathedTarget}). + * + * This output format creates the following files: + * - A master playlist .m3u8 file, containing the list of available playlists. A master playlist is always emitted, + * written to the root path. + * - One .m3u8 file for each playlist, each containing a list of media segments. + * - Many media segments, containing the actual media data. + * + * @group Output formats + * @public + */ export class HlsOutputFormat extends OutputFormat { /** @internal */ _options: HlsOutputFormatOptions; @@ -1300,6 +1400,7 @@ export class HlsOutputFormat extends OutputFormat { return true; // I guess?? } + /** @internal */ // eslint-disable-next-line @typescript-eslint/no-unused-vars override _codecUnsupportedHint(codec: MediaCodec): string { return ` Using different segment formats may grant support for this codec.`; diff --git a/src/output.ts b/src/output.ts index af3d43e..fb9f8d9 100644 --- a/src/output.ts +++ b/src/output.ts @@ -11,7 +11,7 @@ import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisp import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; -import { Target } from './target'; +import { PathedTarget, Target, TargetRequest } from './target'; import { Writer } from './writer'; /** @@ -73,10 +73,43 @@ export abstract class OutputTrack { isSubtitleTrack(): this is OutputSubtitleTrack { return this.type === 'subtitle'; } + + /** + * Returns true if and only if this track can be paired with the given other track. Pairability can be set using + * the {@link BaseTrackMetadata.group} option. + */ + canBePairedWith(other: OutputTrack) { + if (!(other instanceof OutputTrack)) { + throw new TypeError('other must be an OutputTrack.'); + } + + if (this === other) { + return false; + } + + const thisGroups = toArray(this.metadata.group!); + const otherGroups = toArray(other.metadata.group!); + + for (const aGroup of thisGroups) { + const pairableInSameGroup = this.type !== other.type && otherGroups.some(bGroup => aGroup === bGroup); + if (pairableInSameGroup) { + return true; + } + + const pairableAcrossGroups = otherGroups.some( + bGroup => aGroup._pairedGroups.has(bGroup), + ); + if (pairableAcrossGroups) { + return true; + } + } + + return false; + } } /** - * An {@link OutputTrack} containing video data. + * An {@link OutputTrack} providing video data, created using {@link Output.addVideoTrack}. * @group Output files * @public */ @@ -92,7 +125,7 @@ export class OutputVideoTrack extends OutputTrack { } /** - * An {@link OutputTrack} containing audio data. + * An {@link OutputTrack} providing audio data, created using {@link Output.addAudioTrack}. * @group Output files * @public */ @@ -108,7 +141,7 @@ export class OutputAudioTrack extends OutputTrack { } /** - * An {@link OutputTrack} containing subtitle data. + * An {@link OutputTrack} providing subtitle data, created using {@link Output.addSubtitleTrack}. * @group Output files * @public */ @@ -123,10 +156,30 @@ export class OutputSubtitleTrack extends OutputTrack { } } +/** + * Used to define pairability between {@link OutputTrack} instances. First create the group, then assign tracks to it + * via {@link BaseTrackMetadata.group}. + * + * Two tracks are considered _pairable_ if they are in the same group but have a different {@link TrackType}, or if they + * are in different groups that are paired with each other. Groups can be paired with each other using the + * {@link OutputTrackGroup.pairWith} method. + * + * @group Output files + * @public + */ export class OutputTrackGroup { /** @internal */ _pairedGroups = new Set(); + /** Creates a new {@link OutputTrackGroup}. */ + constructor() { + // The object's identity is the state + } + + /** + * Marks this group as being pairable with another group, symmetrically. Output tracks where each track is assigned + * to one half of a group pairing are then considered pairable. + */ pairWith(other: OutputTrackGroup) { if (!(other instanceof OutputTrackGroup)) { throw new TypeError('other must be an OutputTrackGroup.'); @@ -137,32 +190,6 @@ export class OutputTrackGroup { } } -export const outputTracksArePairable = (a: OutputTrack, b: OutputTrack) => { - if (a === b) { - return false; - } - - const aGroups = toArray(a.metadata.group!); - const bGroups = toArray(b.metadata.group!); - - for (const aGroup of aGroups) { - const pairableInSameGroup = a.type !== b.type - && bGroups.some(bGroup => aGroup === bGroup); - if (pairableInSameGroup) { - return true; - } - - const pairableAcrossGroups = bGroups.some( - bGroup => aGroup._pairedGroups.has(bGroup), - ); - if (pairableAcrossGroups) { - return true; - } - } - - return false; -}; - /** * Base track metadata, applicable to all tracks. * @group Output files @@ -191,10 +218,20 @@ export type BaseTrackMetadata = { */ maximumPacketCount?: number; /** - * Whether the timestamps of this track are relative to the Unix epoch (January 1, 1970 00:00:00 UTC). When `true`, + * Whether the timestamps of this track are relative to the Unix epoch (January 1, 1970, 00:00:00 UTC). When `true`, * each timestamp maps to a definitive point in time. */ isRelativeToUnixEpoch?: boolean; + /** + * Defines the group(s) this track is a part of. Group assignment determines track pairability, determining which + * tracks can be presented together with other tracks. This is needed for configuring things like HLS master + * playlists. + * + * Two groups are considered pairable if they are in the same group but are of different {@link TrackType}, or if + * they are in two separate groups that have been paired with each other. + * + * If left blank, a track is automatically assigned to {@link Output.defaultTrackGroup}. + */ group?: OutputTrackGroup | OutputTrackGroup[]; }; @@ -262,11 +299,6 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => { } }; -export type TargetRequest = { - path: string; - isRoot: boolean; -}; - /** * The options for creating an Output object. * @group Output files @@ -279,20 +311,38 @@ export type OutputOptions< /** The format of the output file. */ format: F; /** The target to which the file will be written. */ - target: T | ((request: TargetRequest) => MaybePromise); - rootPath?: string; + target: T | PathedTarget; + /** + * Optional; the target to which the track initialization data will be written. Most formats do not make use of + * this, but some do, such as {@link CmafOutputFormat}. + * + * When this is a function, it will only be called if an init target is needed. + */ initTarget?: T | (() => MaybePromise); }; /** - * Main class orchestrating the creation of a new media file. + * Describes the events that an {@link Output} emits, with each key being an event name and its value being the + * event data. + * * @group Output files * @public */ export type OutputEvents = { - target: { target: Target; request: TargetRequest | null }; + /** Emitted whenever a {@link Target} is obtained by the output. Useful to track writes. */ + target: { + /** The target that was obtained. */ + target: Target; + /** The request that led to the target being obtained, or `null` if the output is not pathed. */ + request: TargetRequest | null; + }; }; +/** + * Main class orchestrating the creation of new media files. + * @group Output files + * @public + */ export class Output< F extends OutputFormat = OutputFormat, T extends Target = Target, @@ -300,12 +350,15 @@ export class Output< /** The format of the output file. */ readonly format: F; /** @internal */ - private _target: T | ((request: TargetRequest) => MaybePromise); + _target: T | PathedTarget; /** The current state of the output. */ state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending'; + /** + * The {@link OutputTrackGroup} that all tracks are assigned to by default unless otherwise specified by + * {@link BaseTrackMetadata.group}. + */ + readonly defaultTrackGroup = new OutputTrackGroup(); - /** @internal */ - _rootPath: string | null; /** @internal */ private _initTarget: T | (() => MaybePromise) | null; /** @internal */ @@ -326,8 +379,6 @@ export class Output< _mutex = new AsyncMutex(); /** @internal */ _metadataTags: MetadataTags = {}; - /** @internal */ - _defaultTrackGroup = new OutputTrackGroup(); /** The target to which the root file will be written. Throws if the target-resolving function returns a Promise. */ get target(): T { @@ -335,15 +386,14 @@ export class Output< return this._target; } - assert(this._rootPath !== null); - const returnValue = this._target({ path: this._rootPath, isRoot: true }); - if (returnValue instanceof Promise) { + const target = this._target.getTarget({ path: this._target.rootPath, isRoot: true }); + if (target instanceof Promise) { throw new TypeError( 'Output.target cannot be used when the target function resolves asynchronously.', ); } - return returnValue; + return target; } /** @@ -359,8 +409,8 @@ export class Output< if (!(options.format instanceof OutputFormat)) { throw new TypeError('options.format must be an OutputFormat.'); } - if (!(options.target instanceof Target) && typeof options.target !== 'function') { - throw new TypeError('options.target must be a Target or a function that returns or resolves to a Target.'); + if (!(options.target instanceof Target || options.target instanceof PathedTarget)) { + throw new TypeError('options.target must be a Target or a PathedTarget.'); } if (options.target instanceof Target) { if (options.target._output) { @@ -370,12 +420,6 @@ export class Output< options.target._output = this; this._targets.add(options.target); } - if (options.rootPath !== undefined && typeof options.rootPath !== 'string') { - throw new TypeError('options.rootPath, when provided, must be a string.'); - } - if (typeof options.target === 'function' && options.rootPath === undefined) { - throw new Error('options.rootPath must be provided when options.target is a function.'); - } if ( options.initTarget !== undefined && !(options.initTarget instanceof Target) @@ -396,16 +440,16 @@ export class Output< this._targets.add(this._initTarget); } - this._rootPath = options.rootPath ?? null; this._muxer = options.format._createMuxer(this); } + /** @internal */ async _getTarget(request: TargetRequest) { - assert(typeof this._target === 'function'); + assert(this._target instanceof PathedTarget); - const target = await this._target(request); + const target = await this._target.getTarget(request); target._output = this; - this.emit('target', { target, request }); + this._emit('target', { target, request }); if (this.state === 'canceled') { await target._close(); @@ -416,6 +460,7 @@ export class Output< return target; } + /** @internal */ async _getInitTarget(): Promise { assert(this._initTarget !== null); @@ -435,11 +480,6 @@ export class Output< return target; } - /** @internal */ - _targetIsFunction() { - return typeof this._target === 'function'; - } - /** @internal */ _hasInitTarget() { return this._initTarget !== null; @@ -450,12 +490,11 @@ export class Output< return this._rootWriterPromise ??= (async () => { let target: Target; - if (typeof this._target === 'function') { - assert(this._rootPath !== null); - target = await this._getTarget({ path: this._rootPath, isRoot: true }); + if (this._target instanceof PathedTarget) { + target = await this._getTarget({ path: this._target.rootPath, isRoot: true }); } else { target = this._target; - this.emit('target', { target: this._target, request: null }); + this._emit('target', { target: this._target, request: null }); } const writer = new Writer(target); @@ -486,7 +525,7 @@ export class Output< } const metadataCopy = { ...metadata }; - metadataCopy.group ??= this._defaultTrackGroup; + metadataCopy.group ??= this.defaultTrackGroup; return this._addTrack(new OutputVideoTrack( this._tracks.length + 1, this, source, metadataCopy, @@ -501,7 +540,7 @@ export class Output< validateBaseTrackMetadata(metadata); const metadataCopy = { ...metadata }; - metadataCopy.group ??= this._defaultTrackGroup; + metadataCopy.group ??= this.defaultTrackGroup; return this._addTrack(new OutputAudioTrack( this._tracks.length + 1, this, source, metadataCopy, @@ -516,7 +555,7 @@ export class Output< validateBaseTrackMetadata(metadata); const metadataCopy = { ...metadata }; - metadataCopy.group ??= this._defaultTrackGroup; + metadataCopy.group ??= this.defaultTrackGroup; return this._addTrack(new OutputSubtitleTrack( this._tracks.length + 1, this, source, metadataCopy, diff --git a/src/source.ts b/src/source.ts index 1565966..92915b1 100644 --- a/src/source.ts +++ b/src/source.ts @@ -12,6 +12,7 @@ import { binarySearchLessOrEqual, clamp, closedIntervalsOverlap, + FilePath, isNumber, isWebKit, MaybePromise, @@ -22,6 +23,7 @@ import { toDataView, toUint8Array, wait, + EventEmitter, } from './misc'; import * as nodeAlias from './node'; import { InputDisposedError } from './input'; @@ -42,12 +44,27 @@ export type ReadResult = { export const DEFAULT_MIN_READ_POSITION = 0; export const DEFAULT_MAX_READ_POSITION = Infinity; +/** + * The events emitted by a {@link Source}, with each key being an event name and its value being the event data. + * @group Input sources + * @public + */ +export type SourceEvents = { + /** Emitted each time data is retrieved from the source. */ + read: { + /** The start of the retrieved range, inclusive. */ + start: number; + /** The end of the retrieved range, exclusive. */ + end: number; + }; +}; + /** * The source base class, representing a resource from which bytes can be read. * @group Input sources * @public */ -export abstract class Source { +export abstract class Source extends EventEmitter { /** @internal */ abstract _getFileSize(): number | null | undefined; /** @internal */ @@ -111,6 +128,12 @@ export abstract class Source { return result; } + /** + * Returns a new {@link RangedSource} that maps data onto this source using the given offset and length. If a length + * is not provided, the ranged source spans until the end of this source's data. + * + * Useful for reading files that are embedded within larger files. + */ slice(offset: number, length?: number) { if (!Number.isInteger(offset) || offset < 0) { throw new TypeError('offset must be a non-negative integer.'); @@ -122,9 +145,19 @@ export abstract class Source { return new RangedSource(this, offset, length); } - /** Called each time data is retrieved from the source. Will be called with the retrieved range (end exclusive). */ + /** + * Called each time data is retrieved from the source. Will be called with the retrieved range (end exclusive). + * + * @deprecated Use `source.on('read', ({ start, end }) => ...)` instead. + */ onread: ((start: number, end: number) => unknown) | null = null; + /** @internal */ + _dispatchRead(start: number, end: number) { + this.onread?.(start, end); + this._emit('read', { start, end }); + } + /** * Creates a new `SourceRef` pointing to this source. You are expected to call `.free()` on said `SourceRef` when * you're done with it. @@ -134,10 +167,21 @@ export abstract class Source { } } +/** + * A reference to a {@link Source}, used to manage a source's lifecycle. Creating a `SourceRef` via {@link Source.ref} + * increases that source's internal reference count. As long as a source has a non-zero reference count, it is assumed + * to still be in use. Once all references are freed via {@link SourceRef.free}, the source gets disposed. + * + * @group Input sources + * @public + */ export class SourceRef implements Disposable { + /** @internal */ private _source: S | null; - freed = false; + /** @internal */ + private _freed = false; + /** @internal */ constructor(source: S) { if (source._disposed) { throw new Error('Cannot ref a disposed source.'); @@ -147,6 +191,7 @@ export class SourceRef implements Disposable { this._source = source; } + /** The {@link Source} this ref references. Accessing this field throws an error after having freed the ref. */ get source() { if (!this._source) { throw new Error('Can\'t get source; ref has already been freed.'); @@ -155,8 +200,17 @@ export class SourceRef implements Disposable { return this._source; } + /** Whether or not this reference has been freed via {@link SourceRef.free}. */ + get freed() { + return this._freed; + } + + /** + * Frees the ref, decrementing the source's internal reference count. If the source's internal reference count + * reaches zero, it gets disposed. This method is idempotent. + */ free() { - if (this.freed) { + if (this._freed) { return; } @@ -170,10 +224,13 @@ export class SourceRef implements Disposable { source._disposed = true; } - this.freed = true; + this._freed = true; this._source = null; } + /** + * Calls {@link SourceRef.free}. + */ [Symbol.dispose]() { this.free(); } @@ -219,8 +276,8 @@ export class BufferSource extends Source { /** @internal */ _read(): ReadResult { if (!this._onreadCalled) { - // We just say the first read retrives all bytes from the source (which, I mean, it does) - this.onread?.(0, this._bytes.byteLength); + // We just say the first read retrieves all bytes from the source (which, I mean, it does) + this._dispatchRead(0, this._bytes.byteLength); this._onreadCalled = true; } @@ -344,7 +401,7 @@ export class BlobSource extends Source { break; } - this.onread?.(worker.currentPos, worker.currentPos + value.length); + this._dispatchRead(worker.currentPos, worker.currentPos + value.length); this._orchestrator.supplyWorkerData(worker, value); } else { const data = await this._blob.slice(worker.currentPos, worker.targetPos).arrayBuffer(); @@ -353,7 +410,7 @@ export class BlobSource extends Source { break; } - this.onread?.(worker.currentPos, worker.currentPos + data.byteLength); + this._dispatchRead(worker.currentPos, worker.currentPos + data.byteLength); this._orchestrator.supplyWorkerData(worker, new Uint8Array(data)); } } @@ -690,7 +747,7 @@ export class UrlSource extends Source { } } - this.onread?.(worker.currentPos, worker.currentPos + value.length); + this._dispatchRead(worker.currentPos, worker.currentPos + value.length); this._orchestrator.supplyWorkerData(worker, value); } } @@ -948,7 +1005,7 @@ export class StreamSource extends Source { ); } - this.onread?.(worker.currentPos, worker.currentPos + data.length); + this._dispatchRead(worker.currentPos, worker.currentPos + data.length); this._orchestrator.supplyWorkerData(worker, data); } else if (data instanceof ReadableStream) { const reader = data.getReader(); @@ -979,7 +1036,7 @@ export class StreamSource extends Source { const data = toUint8Array(value); // Normalize things like Node.js Buffer to Uint8Array - this.onread?.(worker.currentPos, worker.currentPos + data.length); + this._dispatchRead(worker.currentPos, worker.currentPos + data.length); this._orchestrator.supplyWorkerData(worker, data); } } else { @@ -2026,6 +2083,13 @@ export class NullSource extends Source { } } +/** + * A source that covers a range (offset + length) of another source. Useful for reading files that are embedded within + * larger files. + * + * @group Input sources + * @public + */ export class RangedSource extends Source { /** @internal */ _baseSource: Source; @@ -2036,6 +2100,7 @@ export class RangedSource extends Source { /** @internal */ _length: number | null; + /** @internal */ constructor(baseSource: Source, offset: number, length?: number) { super(); @@ -2048,6 +2113,7 @@ export class RangedSource extends Source { this._length = length ?? null; } + /** @internal */ override _getFileSize(): number | null | undefined { const baseSize = this._baseSource._getFileSize(); if (baseSize === undefined) { @@ -2067,6 +2133,7 @@ export class RangedSource extends Source { return clamp(baseSize - this._offset, 0, this._length ?? Infinity); } + /** @internal */ override _read( start: number, end: number, @@ -2103,6 +2170,7 @@ export class RangedSource extends Source { } } + /** @internal */ override _dispose(): void { this._ref?.free(); } @@ -2112,3 +2180,41 @@ export class RangedSource extends Source { return super.ref(); } } + +/** + * A special source for reading multi-file media where each file is uniquely identified by a path. + * @group Input sources + * @public + */ +export class PathedSource { + /** Creates a new {@link PathedSource} from a root path and a callback. */ + constructor( + /** The path that points to the root file; the entry file of the media. */ + public readonly rootPath: FilePath, + /** The callback that is called for each requested file; must return a {@link Source} or {@link SourceRef}. */ + public readonly getSource: (request: SourceRequest) => MaybePromise>, + ) { + if (typeof rootPath !== 'string') { + throw new TypeError('rootPath must be a string.'); + } + if (typeof getSource !== 'function') { + throw new TypeError('getSource must be a function.'); + } + } +} + +/** + * A request for a {@link Source} at the given path. + * @group Input sources + * @public + */ +export type SourceRequest = { + /** The requested file path. */ + path: FilePath; + /** Whether the requested file is the root file. */ + isRoot: boolean; +}; + +export const sourceRequestsAreEqual = (a: SourceRequest, b: SourceRequest) => { + return a.path === b.path; +}; diff --git a/src/target.ts b/src/target.ts index 466bcf3..ed2f25a 100644 --- a/src/target.ts +++ b/src/target.ts @@ -9,7 +9,7 @@ import type { FileHandle } from 'node:fs/promises'; import { Output } from './output'; import * as nodeAlias from './node'; -import { assert, EventEmitter } from './misc'; +import { assert, EventEmitter, FilePath, MaybePromise } from './misc'; const node = typeof nodeAlias !== 'undefined' ? nodeAlias // Aliasing it prevents some bundler warnings @@ -21,7 +21,14 @@ const node = typeof nodeAlias !== 'undefined' * @public */ export type TargetEvents = { - write: { start: number; end: number }; + /** Emitted each time data is written to the target. */ + write: { + /** The start of the written range, inclusive. */ + start: number; + /** The end of the written range, exclusive. */ + end: number; + }; + /** Emitted when the target is finalized. */ finalized: void; }; @@ -53,25 +60,35 @@ export abstract class Target extends EventEmitter { * * 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. */ + /** + * Called when the target is finalized. + * + * @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 }); + this._emit('write', { start, end }); } /** @internal */ _dispatchFinalized() { this.onfinalized?.(); - this.emit('finalized'); + this._emit('finalized'); } + /** + * Returns a new {@link RangedTarget} that writes data to this target using the given offset. + * + * Useful for writing a file into a section of a larger file. + */ slice(offset: number) { if (!Number.isInteger(offset) || offset < 0) { throw new TypeError('offset must be a non-negative integer.'); @@ -103,6 +120,7 @@ export class BufferTarget extends Target { /** @internal */ _supportsResize: boolean; + /** Creates a new {@link BufferTarget}. The buffer holding the data will be created and managed internally. */ constructor() { super(); @@ -647,6 +665,12 @@ export class NullTarget extends Target { async _close() {} } +/** + * A target that writes to a subrange (defined by an offset) of another, underlying target. Useful for writing a file + * into a section of a larger file. + * @group Output targets + * @public + */ export class RangedTarget extends Target { /** @internal */ _baseTarget: Target; @@ -684,3 +708,37 @@ export class RangedTarget extends Target { /** @internal */ async _close() {} } + +/** + * A special target for writing multi-file media where each file is uniquely identified by a path. + * @group Output targets + * @public + */ +export class PathedTarget { + /** Creates a new {@link PathedTarget} from a root path and a callback. */ + constructor( + /** The path that points to the root file; the entry file of the media. */ + public readonly rootPath: FilePath, + /** The callback that is called for each file that needs to be written; must return a {@link Target}. */ + public readonly getTarget: (request: TargetRequest) => MaybePromise, + ) { + if (typeof rootPath !== 'string') { + throw new TypeError('rootPath must be a string.'); + } + if (typeof getTarget !== 'function') { + throw new TypeError('getTarget must be a function.'); + } + } +} + +/** + * A request for a {@link Target} at the given path. + * @group Output targets + * @public + */ +export type TargetRequest = { + /** The requested file path. */ + path: FilePath; + /** Whether the requested file is the root file. */ + isRoot: boolean; +}; diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index c4f234f..ef3c877 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { Output, OutputTrackGroup } from '../../src/output.js'; import { CmafOutputFormat, HlsOutputFormat, MpegTsOutputFormat } from '../../src/output-format.js'; -import { BufferTarget, NullTarget, StreamTarget, StreamTargetChunk } from '../../src/target.js'; +import { BufferTarget, NullTarget, PathedTarget, StreamTarget, StreamTargetChunk } from '../../src/target.js'; import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js'; import { HlsMuxer } from '../../src/hls/hls-muxer.js'; import { AudioCodec, VideoCodec } from '../../src/codec.js'; @@ -21,8 +21,7 @@ test('Playlist assignment, single video', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource()); @@ -43,8 +42,7 @@ test('Playlist assignment, single audio', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addAudioTrack(audioSource()); @@ -65,8 +63,7 @@ test('Playlist assignment, multiple video', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource()); @@ -93,8 +90,7 @@ test('Playlist assignment, multiple audio', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addAudioTrack(audioSource()); @@ -121,8 +117,7 @@ test('Playlist assignment, multiple video with different metadata #1', async () format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource(), { languageCode: 'eng' }); @@ -157,8 +152,7 @@ test('Playlist assignment, multiple video with different metadata #2', async () format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource(), { disposition: { primary: true } }); @@ -193,8 +187,7 @@ test('Playlist assignment, multiple audio with different metadata', async () => format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addAudioTrack(audioSource(), { languageCode: 'eng' }); @@ -229,8 +222,7 @@ test('Playlist assignment, video and audio', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource()); @@ -252,8 +244,7 @@ test('Playlist assignment, one video and multiple audio', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource()); @@ -294,8 +285,7 @@ test('Playlist assignment, multiple video and one audio', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource()); @@ -336,8 +326,7 @@ test('Playlist assignment, multiple video and audio', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource()); @@ -390,8 +379,7 @@ test('Playlist assignment, video and audio in different groups', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const a = new OutputTrackGroup(); @@ -423,8 +411,7 @@ test('Playlist assignment, multiple video and audio in pairs', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const a = new OutputTrackGroup(); @@ -469,8 +456,7 @@ test('Playlist assignment, multiple video and audio with some unpaired', async ( format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const a = new OutputTrackGroup(); @@ -527,8 +513,7 @@ test('Playlist assignment, multiple video and audio with multiple groups', async format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const a = new OutputTrackGroup(); @@ -596,8 +581,7 @@ test('Playlist assignment, video with multiple audio codecs', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource()); @@ -641,8 +625,7 @@ test('Playlist assignment, audio with multiple video codecs', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource('avc')); @@ -679,8 +662,7 @@ test('Playlist assignment, multiple video with conflicting audio interests', asy format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const a = new OutputTrackGroup(); @@ -732,8 +714,7 @@ test('Playlist assignment, video paired with video', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const a = new OutputTrackGroup(); @@ -769,8 +750,7 @@ test('Playlist assignment, audio paired with audio', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const a = new OutputTrackGroup(); @@ -832,7 +812,7 @@ const setUpSegmentationEnvironment = async (options: { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), // No ADTS for simplicity }), - target: (request) => { + target: new PathedTarget('', (request) => { const target = new BufferTarget(); if (request.path.includes('playlist')) { target.on('finalized', () => { @@ -884,8 +864,7 @@ const setUpSegmentationEnvironment = async (options: { } return target; - }, - rootPath: '', + }), }); const _videoSource = options.video ? new EncodedVideoPacketSource('avc') : null; @@ -1850,8 +1829,7 @@ test('onSegment, onPlaylist, onMaster events', async () => { onPlaylist, onMaster, }), - target: () => new BufferTarget(), - rootPath: '', + target: new PathedTarget('', () => new BufferTarget()), }); const source = videoSource(); @@ -1908,7 +1886,7 @@ test('Single-file mode', async () => { segmentFormat: new MpegTsOutputFormat(), singleFilePerPlaylist: true, }), - target: (request) => { + target: new PathedTarget('', (request) => { const target = new BufferTarget(); if (request.path.includes('playlist')) { @@ -1920,8 +1898,7 @@ test('Single-file mode', async () => { } return target; - }, - rootPath: '', + }), }); const source = videoSource(); @@ -1955,7 +1932,7 @@ test('StreamTarget, write is called for each target', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: (request) => { + target: new PathedTarget('', (request) => { writeCounts.set(request.path, 0); const writable = new WritableStream({ @@ -1965,8 +1942,7 @@ test('StreamTarget, write is called for each target', async () => { }); return new StreamTarget(writable); - }, - rootPath: '', + }), }); const source = videoSource(); @@ -2001,8 +1977,7 @@ test('I-frame stream', async () => { onMaster: (text) => { masterText = text; }, onPlaylist: (text) => { playlistText = text; }, }), - target: () => new BufferTarget(), - rootPath: '', + target: new PathedTarget('', () => new BufferTarget()), }); const source = videoSource(); @@ -2036,8 +2011,7 @@ test('I-frame stream, pairing warning', async () => { format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); const group = new OutputTrackGroup(); @@ -2067,7 +2041,7 @@ test('CMAF segmentation', async () => { format: new HlsOutputFormat({ segmentFormat: new CmafOutputFormat(), }), - target: (request) => { + target: new PathedTarget('', (request) => { const target = new BufferTarget(); targets.set(request.path, target); @@ -2078,8 +2052,7 @@ test('CMAF segmentation', async () => { } return target; - }, - rootPath: '', + }), }); const source = videoSource(); @@ -2156,7 +2129,7 @@ test('CMAF segmentation, single file per playlist', async () => { segmentFormat: new CmafOutputFormat(), singleFilePerPlaylist: true, }), - target: (request) => { + target: new PathedTarget('', (request) => { writtenPaths.add(request.path); const target = new BufferTarget(); @@ -2167,8 +2140,7 @@ test('CMAF segmentation, single file per playlist', async () => { } return target; - }, - rootPath: '', + }), }); const source = videoSource(); @@ -2206,7 +2178,7 @@ test('Live mode', async () => { segmentFormat: new MpegTsOutputFormat(), live: true, }), - target: (request) => { + target: new PathedTarget('master.m3u8', (request) => { const target = new BufferTarget(); target.on('finalized', () => { if (request.path.endsWith('.m3u8')) { @@ -2215,8 +2187,7 @@ test('Live mode', async () => { } }); return target; - }, - rootPath: 'master.m3u8', + }), }); const source = videoSource(); @@ -2294,7 +2265,7 @@ test('Live mode, CMAF', async () => { segmentFormat: new CmafOutputFormat(), live: true, }), - target: (request) => { + target: new PathedTarget('master.m3u8', (request) => { const target = new BufferTarget(); target.on('finalized', () => { if (request.path.endsWith('.m3u8')) { @@ -2303,8 +2274,7 @@ test('Live mode, CMAF', async () => { } }); return target; - }, - rootPath: 'master.m3u8', + }), }); const source = videoSource(); @@ -2385,7 +2355,7 @@ test('Live mode, fixed target duration', async () => { segmentFormat: new MpegTsOutputFormat(), live: true, }), - target: (request) => { + target: new PathedTarget('master.m3u8', (request) => { const target = new BufferTarget(); target.on('finalized', () => { if (request.path.endsWith('.m3u8')) { @@ -2394,8 +2364,7 @@ test('Live mode, fixed target duration', async () => { } }); return target; - }, - rootPath: 'master.m3u8', + }), }); const source = videoSource(); @@ -2435,7 +2404,7 @@ test('Live mode, empty', async () => { segmentFormat: new MpegTsOutputFormat(), live: true, }), - target: (request) => { + target: new PathedTarget('master.m3u8', (request) => { const target = new BufferTarget(); target.on('finalized', () => { if (request.path.endsWith('.m3u8')) { @@ -2443,8 +2412,7 @@ test('Live mode, empty', async () => { } }); return target; - }, - rootPath: 'master.m3u8', + }), }); const source = videoSource(); @@ -2470,8 +2438,7 @@ test('EXT-X-PROGRAM-DATE-TIME writing', async () => { segmentFormat: new MpegTsOutputFormat(), onPlaylist: (text) => { result = text; }, }), - target: () => new BufferTarget(), - rootPath: '', + target: new PathedTarget('', () => new BufferTarget()), }); const source = videoSource(); @@ -2514,8 +2481,7 @@ test('Throws if some tracks are relativeToUnixEpoch and some are not', async () format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat(), }), - target: () => new NullTarget(), - rootPath: '', + target: new PathedTarget('', () => new NullTarget()), }); output.addVideoTrack(videoSource(), { isRelativeToUnixEpoch: true }); @@ -2533,7 +2499,7 @@ test('Live mode, maxLiveSegmentCount', async () => { live: true, maxLiveSegmentCount: 2, }), - target: (request) => { + target: new PathedTarget('master.m3u8', (request) => { const target = new BufferTarget(); target.on('finalized', () => { if (request.path.endsWith('.m3u8')) { @@ -2541,8 +2507,7 @@ test('Live mode, maxLiveSegmentCount', async () => { } }); return target; - }, - rootPath: 'master.m3u8', + }), }); const source = videoSource(); diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..398bc83 --- /dev/null +++ b/todo.txt @@ -0,0 +1,2 @@ +clash between target duration and default keyframe interval which is 5. This is easy to miss. Also messes up in +conversion api. \ No newline at end of file