/*! * 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 } from './demuxer'; import { InputFormat } from './input-format'; import { InputAudioTrack, InputTrack, InputVideoTrack, mergeTrackQueries, queryTracks, TrackQuery, } from './input-track'; import { arrayArgmin, arrayCount, assert, desc, MaybePromise, polyfillSymbolDispose, prefer } from './misc'; import { Reader } from './reader'; import { Source } from './source'; polyfillSymbolDispose(); const UNSUPPORTED_INPUT_FORMAT_MESSAGE = 'Input has an unsupported or unrecognizable format.'; export const DEFAULT_SOURCE_CACHE_GROUP = 1; export const ENCRYPTION_KEY_CACHE_GROUP = 2; export type SourceRequest = { path: string; }; const sourceRequestsAreEqual = (a: SourceRequest, b: SourceRequest) => { return a.path === b.path; }; /** * The options for creating an Input object. * @group Input files & tracks * @public */ 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 | ((request: SourceRequest) => MaybePromise); entryPath?: string; initInput?: Input; }; /** * Represents an input media file. This is the root object from which all media read operations start. * @group Input files & tracks * @public */ export class Input implements Disposable { /** @internal */ _source: InputOptions['source']; /** @internal */ _formats: InputFormat[]; /** @internal */ _initInput: Input | null; /** @internal */ _entryPath: string | null; /** @internal */ _demuxerPromise: Promise | null = null; /** @internal */ _format: InputFormat | null = null; /** @internal */ _reader!: Reader; /** @internal */ _tracksCache: InputTrack[] | null = null; /** @internal */ _disposed = false; /** @internal */ _nextSourceCacheAge = 0; /** @internal */ _sourceCache: { request: SourceRequest; sourcePromise: Promise; age: number; cacheGroup: number; }[] = []; /** 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) { 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) && typeof options.source !== 'function') { throw new TypeError('options.source must be a Source or a function that returns a Source.'); } if (options.source instanceof Source && options.source._disposed) { throw new TypeError('options.source must not be disposed.'); } if (typeof options.source === 'function' && options.entryPath === undefined) { throw new TypeError('options.entryPath must be provided when options.source is a function.'); } 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._source = options.source; this._initInput = options.initInput ?? null; this._entryPath = options.entryPath ?? null; } async _getSourceUncached(request: SourceRequest) { assert(typeof this._source === 'function'); const source = await this._source(request); if (!(source instanceof Source)) { throw new TypeError('The source function must return a Source.'); } if (source._disposed) { throw new TypeError('The returned Source must not be disposed.'); } return source; } _getSourceCached(request: SourceRequest, cacheGroup = DEFAULT_SOURCE_CACHE_GROUP) { const cachedEntry = this._sourceCache.find(x => x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request), ); if (cachedEntry) { cachedEntry.age++; return cachedEntry.sourcePromise; } const sourcePromise = Promise.resolve(this._getSourceUncached(request)); this._sourceCache.push({ request, sourcePromise, age: this._nextSourceCacheAge++, cacheGroup, }); const MAX_SOURCE_CACHE_SIZE = 4; const count = arrayCount(this._sourceCache, x => x.cacheGroup === cacheGroup); if (count > MAX_SOURCE_CACHE_SIZE) { const minAgeIndex = arrayArgmin(this._sourceCache, x => x.cacheGroup === cacheGroup ? x.age : Infinity); this._sourceCache.splice(minAgeIndex, 1); } return sourcePromise; } /** @internal */ _getDemuxer() { return this._demuxerPromise ??= (async () => { let source: Source; if (this._source instanceof Source) { source = this._source; } else { assert(this._entryPath !== null); source = await this._getSourceUncached({ path: this._entryPath }); } this._reader = new Reader(source); for (const format of this._formats) { const canRead = await format._canReadInput(this); if (canRead) { this._format = format; return format._createDemuxer(this); } } throw new Error(UNSUPPORTED_INPUT_FORMAT_MESSAGE); })(); } /** * Returns the source from which this input file reads its data. This is the same source that was passed to the * constructor. */ get source() { // TODO throw if function or some shit? return this._source; } /** * 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; } async isSupported(): Promise { try { const demuxer = await this._getDemuxer(); return demuxer.isSupported(); } catch (error) { if (error instanceof Error && error.message === UNSUPPORTED_INPUT_FORMAT_MESSAGE) { return false; } throw error; } } /** * Computes the duration of the input file, in seconds. More precisely, returns the largest end timestamp among * all tracks. */ async computeDuration() { const tracks = await this.getTracks(); if (tracks.length === 0) { return 0; } const tracksDurations = await Promise.all(tracks.map(x => x.computeDuration())); return Math.max(...tracksDurations); } /** * Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp * among all tracks. */ async getFirstTimestamp() { const tracks = await this.getTracks(); if (tracks.length === 0) { return 0; } const firstTimestamps = await Promise.all(tracks.map(x => x.getFirstTimestamp())); return Math.min(...firstTimestamps); } /** Returns the list of all tracks of this input file. */ async getTracks(query?: TrackQuery) { const demuxer = await this._getDemuxer(); const tracks = this._tracksCache ??= await demuxer.getTracks(); return queryTracks(tracks, query); } async pluckTrack(query?: TrackQuery) { return (await this.getTracks(query))[0]; } /** Returns the list of all video tracks of this input file. */ async getVideoTracks(query?: TrackQuery) { const tracks = await this.getTracks(); return queryTracks(tracks.filter(x => x.isVideoTrack()) as InputVideoTrack[], query); } async pluckVideoTrack(query?: TrackQuery) { return (await this.getVideoTracks(query))[0] ?? null; } /** Returns the list of all audio tracks of this input file. */ async getAudioTracks(query?: TrackQuery) { const tracks = await this.getTracks(); return queryTracks(tracks.filter(x => x.isAudioTrack()) as InputAudioTrack[], query); } async pluckAudioTrack(query?: TrackQuery) { return (await this.getAudioTracks(query))[0] ?? null; } /** Returns the primary video track of this input file, or null if there are no video tracks. */ getPrimaryVideoTrack(query?: TrackQuery) { return this.pluckVideoTrack(mergeTrackQueries(query, { sortBy: track => [ prefer(track.disposition.default), prefer(track.hasPairableAudioTrack()), desc(track.bitrate), ], })); } /** Returns the primary audio track of this input file, or null if there are no audio tracks. */ async getPrimaryAudioTrack(query?: TrackQuery) { const videoTrack = await this.getPrimaryVideoTrack(); return this.pluckAudioTrack(mergeTrackQueries(query, { sortBy: track => [ prefer(track.canBePairedWith(videoTrack)), prefer(track.disposition.default), desc(track.bitrate), ], })); } /** 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; if (this._source instanceof Source) { this._source._disposed = true; this._source._dispose(); } else { // TODO // TODO throw new Error('TODO'); } } /** * Calls `.dispose()` on the input, implementing the `Disposable` interface for use with * JavaScript Explicit Resource Management features. */ [Symbol.dispose]() { this.dispose(); } } /** * 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'; } }