mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-01 21:03:46 +02:00
Refactor HLS API: Get rid of specialized Manifest* classes, use Input instead. Add unhydrated InputTracks, InputTrack compatibility, InputTrack queries
This commit is contained in:
+142
-21
@@ -8,13 +8,31 @@
|
||||
|
||||
import { Demuxer } from './demuxer';
|
||||
import { InputFormat } from './input-format';
|
||||
import { assert, polyfillSymbolDispose } from './misc';
|
||||
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.
|
||||
@@ -25,7 +43,8 @@ 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;
|
||||
source: S | ((request: SourceRequest) => MaybePromise<Source>);
|
||||
entryPath?: string;
|
||||
initInput?: Input;
|
||||
};
|
||||
|
||||
@@ -36,19 +55,32 @@ export type InputOptions<S extends Source = Source> = {
|
||||
*/
|
||||
export class Input<S extends Source = Source> implements Disposable {
|
||||
/** @internal */
|
||||
_source: S;
|
||||
_source: InputOptions<S>['source'];
|
||||
/** @internal */
|
||||
_formats: InputFormat[];
|
||||
/** @internal */
|
||||
_initInput: Input | null;
|
||||
/** @internal */
|
||||
_entryPath: string | null;
|
||||
/** @internal */
|
||||
_demuxerPromise: Promise<Demuxer> | 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<Source>;
|
||||
age: number;
|
||||
cacheGroup: number;
|
||||
}[] = [];
|
||||
|
||||
/** True if the input has been disposed. */
|
||||
get disposed() {
|
||||
@@ -66,25 +98,82 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
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)) {
|
||||
throw new TypeError('options.source must be a Source.');
|
||||
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._disposed) {
|
||||
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 () => {
|
||||
this._reader = new Reader(this._source);
|
||||
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);
|
||||
@@ -103,6 +192,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
* constructor.
|
||||
*/
|
||||
get source() {
|
||||
// TODO throw if function or some shit?
|
||||
return this._source;
|
||||
}
|
||||
|
||||
@@ -159,33 +249,58 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
}
|
||||
|
||||
/** Returns the list of all tracks of this input file. */
|
||||
async getTracks() {
|
||||
async getTracks(query?: TrackQuery<InputTrack>) {
|
||||
const demuxer = await this._getDemuxer();
|
||||
return demuxer.getTracks();
|
||||
const tracks = this._tracksCache ??= await demuxer.getTracks();
|
||||
return queryTracks(tracks, query);
|
||||
}
|
||||
|
||||
async pluckTrack(query?: TrackQuery<InputTrack>) {
|
||||
return (await this.getTracks(query))[0];
|
||||
}
|
||||
|
||||
/** Returns the list of all video tracks of this input file. */
|
||||
async getVideoTracks() {
|
||||
async getVideoTracks(query?: TrackQuery<InputVideoTrack>) {
|
||||
const tracks = await this.getTracks();
|
||||
return tracks.filter(x => x.isVideoTrack());
|
||||
return queryTracks(tracks.filter(x => x.isVideoTrack()) as InputVideoTrack[], query);
|
||||
}
|
||||
|
||||
async pluckVideoTrack(query?: TrackQuery<InputVideoTrack>) {
|
||||
return (await this.getVideoTracks(query))[0] ?? null;
|
||||
}
|
||||
|
||||
/** Returns the list of all audio tracks of this input file. */
|
||||
async getAudioTracks() {
|
||||
async getAudioTracks(query?: TrackQuery<InputAudioTrack>) {
|
||||
const tracks = await this.getTracks();
|
||||
return tracks.filter(x => x.isAudioTrack());
|
||||
return queryTracks(tracks.filter(x => x.isAudioTrack()) as InputAudioTrack[], query);
|
||||
}
|
||||
|
||||
async pluckAudioTrack(query?: TrackQuery<InputAudioTrack>) {
|
||||
return (await this.getAudioTracks(query))[0] ?? null;
|
||||
}
|
||||
|
||||
/** Returns the primary video track of this input file, or null if there are no video tracks. */
|
||||
async getPrimaryVideoTrack() {
|
||||
const tracks = await this.getTracks();
|
||||
return tracks.find(x => x.isVideoTrack()) ?? null;
|
||||
getPrimaryVideoTrack(query?: TrackQuery<InputVideoTrack>) {
|
||||
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() {
|
||||
const tracks = await this.getTracks();
|
||||
return tracks.find(x => x.isAudioTrack()) ?? null;
|
||||
async getPrimaryAudioTrack(query?: TrackQuery<InputAudioTrack>) {
|
||||
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. */
|
||||
@@ -218,8 +333,14 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
|
||||
this._disposed = true;
|
||||
|
||||
this._source._disposed = true;
|
||||
this._source._dispose();
|
||||
if (this._source instanceof Source) {
|
||||
this._source._disposed = true;
|
||||
this._source._dispose();
|
||||
} else {
|
||||
// TODO
|
||||
// TODO
|
||||
throw new Error('TODO');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user