UNPKG

mediabunny

Version:

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

568 lines (492 loc) 17.9 kB
/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { Demuxer, DurationMetadataRequestOptions } from './demuxer'; import { InputFormat, InputFormatOptions, validateInputFormatOptions } from './input-format'; import { InputAudioTrack, InputAudioTrackBacking, InputTrack, InputTrackBacking, InputVideoTrack, InputVideoTrackBacking, InputTrackQuery, mergeInputTrackQueries, queryInputTracks, toValidatedInputTrackQuery, prefer, desc, } from './input-track'; import { PacketRetrievalOptions } from './media-sink'; import { arrayArgmin, arrayCount, assert, EventEmitter, polyfillSymbolDispose, removeItem, } from './misc'; import { Reader } from './reader'; import { PathedSource, Source, SourceRef, SourceRequest, sourceRequestsAreEqual, } from './source'; polyfillSymbolDispose(); export const DEFAULT_SOURCE_CACHE_GROUP = 1; export const ENCRYPTION_KEY_CACHE_GROUP = 2; /** * The options for creating an Input object. * @group Input files & tracks * @public */ export type InputOptions<S extends Source = Source> = { /** 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<S>; /** * 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; /** Can be used to specify additional per-format configuration. */ formatOptions?: InputFormatOptions; }; type SourceCacheEntry = { request: SourceRequest; sourceRef: SourceRef; age: number; cacheGroup: number; }; /** * 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 = { /** 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; /** Whether the source is the root file of the media. */ isRoot: boolean; }; }; /** * 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<S extends Source = Source> extends EventEmitter<InputEvents> implements Disposable { /** @internal */ _rootRef: SourceRef<S>; /** @internal */ _formats: InputFormat[]; /** @internal */ _initInput: Input | null; /** @internal */ _demuxerPromise: Promise<Demuxer> | null = null; /** @internal */ _format: InputFormat | null = null; /** @internal */ _reader!: Reader; /** @internal */ _trackBackingsCache: InputTrackBacking[] | null = null; /** @internal */ _backingToTrack = new Map<InputTrackBacking, InputTrack>(); /** @internal */ _disposed = false; /** @internal */ _nextSourceCacheAge = 0; /** @internal */ _sourceRefs: SourceRef[] = []; /** @internal */ _sourceCache: SourceCacheEntry[] = []; /** @internal */ _sourceCachePromises: { request: SourceRequest; cacheGroup: number; promise: Promise<SourceCacheEntry>; }[] = []; /** @internal */ _formatOptions: InputFormatOptions; /** @internal */ _onFormatDetermined: ((format: InputFormat) => void) | null = null; /** True if the input has been disposed. */ get disposed() { return this._disposed; } /** * Creates a new input file from the specified options. No reading operations will be performed until methods are * called on this instance. */ constructor(options: InputOptions<S>) { super(); if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } 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 || options.source instanceof SourceRef)) { throw new TypeError('options.source must be a Source or SourceRef.'); } if (options.source instanceof Source && options.source._disposed) { throw new TypeError('options.source must not be a disposed Source.'); } if (options.initInput !== undefined && !(options.initInput instanceof Input)) { throw new TypeError('options.initInput, when provided, must be an Input.'); } if (options.formatOptions !== undefined) { validateInputFormatOptions(options.formatOptions, 'formatOptions'); } this._formats = options.formats; this._initInput = options.initInput ?? null; this._formatOptions = options.formatOptions ?? {}; if (options.source instanceof Source) { this._rootRef = options.source.ref(); } else { this._rootRef = options.source; } this._sourceRefs.push(this._rootRef); } /** @internal */ get _rootSource() { return this._rootRef.source; } /** @internal */ async _getSourceUncached(request: SourceRequest) { assert(this._rootSource instanceof PathedSource); const ref = await this._rootSource._resolveRequest(request); this._emit('source', { source: ref.source, request, isRoot: request.isRoot }); return ref; } /** @internal */ _getSourceCached(request: SourceRequest, cacheGroup = DEFAULT_SOURCE_CACHE_GROUP): Promise<SourceRef> { const cachedEntry = this._sourceCache.find(x => x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request), ); if (cachedEntry) { cachedEntry.age++; return Promise.resolve(cachedEntry.sourceRef.source.ref()); } const cachedPromiseEntry = this._sourceCachePromises.find(x => x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request), ); if (cachedPromiseEntry) { return cachedPromiseEntry.promise.then(x => x.sourceRef.source.ref()); } const promise = (async () => { const sourceRef = await this._getSourceUncached(request); const MAX_SOURCE_CACHE_SIZE = 4; const count = arrayCount( this._sourceCache, x => x.cacheGroup === cacheGroup && x.sourceRef.source._refCount === 1, ); if (count >= MAX_SOURCE_CACHE_SIZE) { const minAgeIndex = arrayArgmin( this._sourceCache, x => x.cacheGroup === cacheGroup && x.sourceRef.source._refCount === 1 ? x.age : Infinity, ); assert(minAgeIndex !== -1); const entry = this._sourceCache[minAgeIndex]!; this._sourceCache.splice(minAgeIndex, 1); entry.sourceRef.free(); removeItem(this._sourceRefs, entry.sourceRef); } this._sourceRefs.push(sourceRef); const promiseIndex = this._sourceCachePromises.findIndex(x => x.request === request); assert(promiseIndex !== -1); this._sourceCachePromises.splice(promiseIndex, 1); const cacheEntry: SourceCacheEntry = { request, sourceRef, age: this._nextSourceCacheAge++, cacheGroup, }; return cacheEntry; })(); this._sourceCachePromises.push({ request, cacheGroup, promise, }); return promise.then((entry) => { const ref = entry.sourceRef.source.ref(); // We need to add it to the cache this late to avoid the ref being freed prematurely due to race conditions this._sourceCache.push(entry); return ref; }); } /** @internal */ _getDemuxer() { return this._demuxerPromise ??= (async () => { this._reader = new Reader(this._rootSource); this._emit('source', { source: this._rootSource, request: null, isRoot: true }); for (const format of this._formats) { const canRead = await format._canReadInput(this); if (canRead) { this._format = format; this._onFormatDetermined?.(format); return format._createDemuxer(this); } } throw new UnsupportedInputFormatError(); })(); } /** * Returns the source from which this input file reads data for the root path. */ get source(): S { return this._rootSource; } /** * Returns the format of the input file. You can compare this result directly to the {@link InputFormat} singletons * or use `instanceof` checks for subset-aware logic (for example, `format instanceof MatroskaInputFormat` is true * for both MKV and WebM). */ async getFormat() { await this._getDemuxer(); assert(this._format!); 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<boolean> { try { await this._getDemuxer(); return true; } catch (error) { if (error instanceof UnsupportedInputFormatError) { return false; } throw error; } } /** * Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp * among all tracks. * * Optionally, you can pass in the list of tracks for which you want to compute the starting timestamp. * * Note that this method is potentially expensive for inputs with many tracks (such as HLS manifests), since it * probes every track. */ async getFirstTimestamp(tracks?: InputTrack[]) { tracks ??= await this.getTracks(); const filtered = tracks.filter(x => x !== null); if (filtered.length === 0) { return 0; } const firstTimestamps = await Promise.all(filtered.map(x => x.getFirstTimestamp())); return Math.min(...firstTimestamps); } /** * Computes the duration of the input file, in seconds. More precisely, returns the largest end timestamp among * all tracks. * * Optionally, you can pass in the list of tracks for which you want to compute the duration. * * This method can be potentially expensive depending on the underlying file format, because it returns the most * accurate duration possible and must check all tracks. Use {@link Input.getDurationFromMetadata} for a faster but * less accurate estimate of duration. * * By default, when any track in the underlying media is live, this method will only resolve once the live stream * ends. If you want to query the current duration of the media, set {@link PacketRetrievalOptions.skipLiveWait} * to `true` in the options. */ async computeDuration(tracks?: InputTrack[], options?: PacketRetrievalOptions) { tracks ??= await this.getTracks(); const filtered = tracks.filter(x => x !== null); if (filtered.length === 0) { return 0; } const tracksDurations = await Promise.all(filtered.map(x => x.computeDuration(options))); return Math.max(...tracksDurations); } /** * Gets the duration (end timestamp) in seconds of the input file from metadata stored in the file. This value may * be approximate or diverge from the actual, precise duration returned by `.computeDuration()`, but compared to * that method, this method is cheaper. When the duration cannot be determined from the file metadata, `null` * is returned. * * Optionally, you can pass in the list of tracks for which you want to get the duration from metadata. * * By default, when the underlying media is live, this method will only resolve once the live stream * ends. If you want to query the current duration of the media, set * {@link DurationMetadataRequestOptions.skipLiveWait} to `true` in the options. */ async getDurationFromMetadata(tracks?: InputTrack[], options?: DurationMetadataRequestOptions) { tracks ??= await this.getTracks(); const filtered = tracks.filter(x => x !== null); const tracksDurations = await Promise.all(filtered.map(x => x.getDurationFromMetadata(options))); const nonNullDurations = tracksDurations.filter(x => x !== null); if (nonNullDurations.length === 0) { return null; } return Math.max(...nonNullDurations); } /** * 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?: InputTrackQuery<InputTrack>): Promise<InputTrack[]> { query &&= toValidatedInputTrackQuery(query); const backings = await this._getTrackBackings(); const tracks = backings.map(backing => this._wrapBackingAsTrack(backing)); return queryInputTracks(tracks, query); } /** Returns the list of all video tracks of this input file. An optional query can be provided. */ async getVideoTracks(query?: InputTrackQuery<InputVideoTrack>): Promise<InputVideoTrack[]> { query &&= toValidatedInputTrackQuery(query); const tracks = await this.getTracks(); const videoTracks = tracks.filter((x): x is InputVideoTrack => x.isVideoTrack()); return queryInputTracks(videoTracks, query); } /** Returns the list of all audio tracks of this input file. An optional query can be provided. */ async getAudioTracks(query?: InputTrackQuery<InputAudioTrack>): Promise<InputAudioTrack[]> { query &&= toValidatedInputTrackQuery(query); const tracks = await this.getTracks(); const audioTracks = tracks.filter((x): x is InputAudioTrack => x.isAudioTrack()); return queryInputTracks(audioTracks, query); } /** * 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?: InputTrackQuery<InputVideoTrack>, ): Promise<InputVideoTrack | null> { query &&= toValidatedInputTrackQuery(query); const merged = mergeInputTrackQueries(query, { sortBy: async t => [ prefer((await t.getDisposition()).default), prefer(await t.hasPairableAudioTrack()), prefer(!(await t.hasOnlyKeyPackets())), desc(await t.getBitrate()), ], }); const sorted = await this.getVideoTracks(merged); return sorted[0] ?? null; } /** * 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?: InputTrackQuery<InputAudioTrack>, ): Promise<InputAudioTrack | null> { query &&= toValidatedInputTrackQuery(query); const primaryVideoTrack = await this.getPrimaryVideoTrack(); const merged = mergeInputTrackQueries(query, { sortBy: async t => [ prefer(!primaryVideoTrack || t.canBePairedWith(primaryVideoTrack)), prefer((await t.getDisposition()).default), desc(await t.getBitrate()), ], }); const sorted = await this.getAudioTracks(merged); return sorted[0] ?? null; } /** @internal */ async _getTrackBackings() { const demuxer = await this._getDemuxer(); return this._trackBackingsCache ??= await demuxer.getTrackBackings(); } /** @internal */ _wrapBackingAsTrack(backing: InputTrackBacking): InputTrack { const existing = this._backingToTrack.get(backing); if (existing) { return existing; } const type = backing.getType(); const track = type === 'video' ? new InputVideoTrack(this, backing as InputVideoTrackBacking) : new InputAudioTrack(this, backing as InputAudioTrackBacking); this._backingToTrack.set(backing, track); return track; } /** Returns the full MIME type of this input file, including track codecs. */ async getMimeType() { const demuxer = await this._getDemuxer(); return demuxer.getMimeType(); } /** * Returns descriptive metadata tags about the media file, such as title, author, date, cover art, or other * attached files. */ async getMetadataTags() { const demuxer = await this._getDemuxer(); return demuxer.getMetadataTags(); } /** * Disposes this input and frees connected resources. When an input is disposed, ongoing read operations will be * canceled, all future read operations will fail, any open decoders will be closed, and all ongoing media sink * operations will be canceled. Disallowed and canceled operations will throw an {@link InputDisposedError}. * * You are expected not to use an input after disposing it. While some operations may still work, it is not * specified and may change in any future update. */ dispose() { if (this._disposed) { return; } this._disposed = true; for (const ref of this._sourceRefs) { ref.free(); } this._sourceRefs.length = 0; void this._demuxerPromise ?.then(demuxer => demuxer.dispose()); } /** * Calls `.dispose()` on the input, implementing the `Disposable` interface for use with * JavaScript Explicit Resource Management features. */ [Symbol.dispose]() { this.dispose(); } } /** * Thrown when trying to operate on an input that has an unsupported or unrecognizable format. * @group Input files & tracks * @public */ export class UnsupportedInputFormatError extends Error { /** Creates a new {@link UnsupportedInputFormatError}. */ constructor(message = 'Input has an unsupported or unrecognizable format.') { super(message); this.name = 'UnsupportedInputFormatError'; } } /** * Thrown when an operation was prevented because the corresponding {@link Input} has been disposed. * @group Input files & tracks * @public */ export class InputDisposedError extends Error { /** Creates a new {@link InputDisposedError}. */ constructor(message = 'Input has been disposed.') { super(message); this.name = 'InputDisposedError'; } }