From 2b3e5f45c630384fdd57a1fda93150dea32df8d8 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:22:10 +0200 Subject: [PATCH] Remove createInputFrom, changed meaning of PathedSource, add CustomPathedSource --- README.md | 14 +- dev/demux.html | 8 +- docs/guide/quick-start.md | 7 +- docs/guide/reading-hls.md | 21 +- docs/guide/reading-media-files.md | 54 ++-- examples/media-player/media-player.ts | 11 +- .../metadata-extraction.ts | 9 +- src/hls/hls-demuxer.ts | 18 +- src/hls/hls-segmented-input.ts | 10 +- src/index.ts | 3 +- src/input-format.ts | 4 +- src/input.ts | 292 ++---------------- src/source.ts | 183 ++++++++--- test/browser/conversion.test.ts | 8 +- test/node/hls-input.test.ts | 133 +++++--- todo.txt | 2 - 16 files changed, 349 insertions(+), 428 deletions(-) diff --git a/README.md b/README.md index 09a7a52..8d8de5f 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,13 @@ Requires any JavaScript environment that can run ECMAScript 2021 or later. Media ### Read file metadata ```js -import { createInputFrom, ALL_FORMATS, BlobSource } from 'mediabunny'; +import { Input, ALL_FORMATS, BlobSource } from 'mediabunny'; // Reading from disk -const input = createInputFrom(file, ALL_FORMATS); +const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, +}); const duration = await input.computeDuration(); // in seconds const videoTrack = await input.getPrimaryVideoTrack(); @@ -155,9 +158,12 @@ const buffer = output.target.buffer; // Final MP4 file ### Convert files ```js -import { createInputFrom, Output, Conversion, ALL_FORMATS, BlobSource, WebMOutputFormat } from 'mediabunny'; +import { Input, Output, Conversion, ALL_FORMATS, BlobSource, WebMOutputFormat } from 'mediabunny'; -const input = createInputFrom(file, ALL_FORMATS); +const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, +}); const output = new Output({ format: new WebMOutputFormat(), // Convert to WebM diff --git a/dev/demux.html b/dev/demux.html index 126237b..dda2d89 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -28,10 +28,10 @@ return; */ - const manifest = Mediabunny.createInputFrom( - 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', - Mediabunny.ALL_FORMATS, - ); + const manifest = new Mediabunny.Input({ + source: new Mediabunny.UrlSource('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'), + formats: Mediabunny.ALL_FORMATS, + }); const tracks = await manifest.getTracks(); console.log(tracks); diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index b507fe9..fc6f364 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -630,9 +630,12 @@ await conversion.execute(); ## Reading HLS playlists ```ts -importĀ { createInputFrom, HLS_FORMATS, desc } from 'mediabunny'; +importĀ { Input, UrlSource, HLS_FORMATS, desc } from 'mediabunny'; -const input = createInputFrom('https://example.com/master.m3u8', HLS_FORMATS); +const input = new Input({ + source: new UrlSource('https://example.com/master.m3u8'), + formats: HLS_FORMATS, +}); // Get all tracks const tracks = await input.getTracks(); diff --git a/docs/guide/reading-hls.md b/docs/guide/reading-hls.md index 86ffa2c..e2f6779 100644 --- a/docs/guide/reading-hls.md +++ b/docs/guide/reading-hls.md @@ -8,30 +8,17 @@ Mediabunny exposes HLS playlists as if they were a single giant input file. Like ## HLS inputs -HLS playlists (master & media) are read through the same `Input` interface as all other media files in Mediabunny. The difference is that HLS uses multiple files, meaning a `PathedSource` is required: +HLS playlists (master & media) are read through the same `Input` interface as all other media files in Mediabunny. HLS must read multiple files, meaning any [`PathedSource`](../api/PathedSource) is required: ```ts -import { Input, PathedSource, HLS_FORMATS } from 'mediabunny'; +import { Input, UrlSource, HLS_FORMATS } from 'mediabunny'; const input = new Input({ - source: new PathedSource( - 'https://example.com/master.m3u8', // The path to the entry file - ({ path }) => new UrlSource(path), - ), + source: new UrlSource('https://example.com/master.m3u8'), formats: HLS_FORMATS, // HLS_FORMATS includes HLS as well as the commonly-used segment formats }); ``` -The `PathedSource` requires that you return a [`Source`](../api/Source) for every file (identified by a [path](../api/FilePath)) that Mediabunny wants to read. - -Since this pattern is common and kind of cumbersome to write, there exists a shortcut: -```ts -// From a URL: -const input = createInputFrom('https://example.com/master.m3u8', HLS_FORMATS); -// From a file (server-side environment): -const input = createInputFrom('/path/to/master.m3u8', HLS_FORMATS); -``` - -However, the `PathedSource` variant is still useful for custom sources; maybe your HLS files don't reside behind a URL but you have them in memory, or in IndexedDB. In this case, there's no way around `PathedSource`, since you'll need to supply your own "path to data" function. +You can supply any custom "path to data" resolution logic by using [`CustomPathedSource`](../api/CustomPathedSource). ## Reading tracks diff --git a/docs/guide/reading-media-files.md b/docs/guide/reading-media-files.md index c52ea25..590f235 100644 --- a/docs/guide/reading-media-files.md +++ b/docs/guide/reading-media-files.md @@ -40,17 +40,6 @@ Reading operations will throw an error if the file format could not be recognize Simply creating an instance of `Input` will perform zero reads and is practically free. The file will only be read once data is requested. ::: -For convenience, `createInputFrom` automatically constructs an `Input` along with the matching source for a given value: - -```ts -import { createInputFrom, ALL_FORMATS } from 'mediabunny'; - -const input = createInputFrom(file, ALL_FORMATS); -const input = createInputFrom(arrayBuffer, ALL_FORMATS); -const input = createInputFrom('https://example.com/video.mp4', ALL_FORMATS); -const input = createInputFrom('./video.mp4', ALL_FORMATS); // Uses the file system server-side, fetch client-side -``` - ## Reading file metadata With our instance of `Input` created, you can now start reading file-level metadata. @@ -832,20 +821,34 @@ recorder.start(1000); setTimeout(() => recorder.stop(), 10_000); // Stop recording after 10s ``` -## Pathed (multi-file) sources +### `PathedSource` -Some media formats reference more than one file. For example, an [HLS](./input-formats) stream consists of a master playlist that points to one or more media playlists, each of which in turn references many media segment files. To read this kind of multi-file media, Mediabunny needs a way to resolve those file paths into [input sources](#input-sources). You can do this using `PathedSource`. +Some media formats reference more than one file. For example, an HLS stream consists of a master playlist that points to one or more media playlists, each of which in turn references many media segment files. To read this kind of multi-file media, Mediabunny needs a way to resolve a source for each file path. + +This can be done with `PathedSource`. A `PathedSource` wraps a *root path* (the entry file of the media) together with a callback that produces a `Source` for each requested file path. It is an abstract class, so you can't use it directly, but it provides the necessary interface to read multi-file media. It is implemented by: +- [`UrlSource`](#urlsource) +- [`FilePathSource`](#filepathsource) +- [`CustomPathedSource`](#custompathedsource) + +### `CustomPathedSource` + +Allows you to implement a user-defined [`PathedSource`](#pathedsource) to provide an arbitrary "file path to data" mapping function. Useful when your data is stored in a custom structure, for example OPFS: -A `PathedSource` wraps a *root path* (the entry file of the media) together with a callback that produces a `Source` for each requested file path: ```ts -import { Input, HLS, PathedSource, UrlSource } from 'mediabunny'; +import { Input, CustomPathedSource, BlobSource } from 'mediabunny'; + +const root = await navigator.storage.getDirectory(); const input = new Input({ - formats: [HLS], - source: new PathedSource( - 'https://example.com/stream/master.m3u8', - ({ path, isRoot }) => new UrlSource(path), + source: new CustomPathedSource( + 'master.m3u8', + async ({ path }) => { + const handle = await root.getFileHandle(path); + const file = await handle.getFile(); + return new BlobSource(file); + }, ), + // ... }); ``` @@ -857,12 +860,17 @@ type SourceRequest = { }; ``` -You can return either a `Source` or a [`SourceRef`](../api/SourceRef). The kind of `Source` you create inside the callback is up to you - use `UrlSource` for streams served over HTTP, `FilePathSource` for files on disk, `BufferSource` for files in memory, or any other source type (or mix of them) that fits. - ## Init inputs Some file formats contain track initialization info in a *separate* file; CMAF is one example. To supply these to Mediabunny, load the initialization file as a separate `Input` and then pass it as an `initInput`: ```ts -const initInput = createInputFrom('init.mp4', ALL_FORMATS); -const input = createInputFrom('data.mp4', ALL_FORMATS, { initInput }); +const initInput = new Input({ + source: new FilePathSource('init.mp4'), + formats: ALL_FORMATS, +}); +const input = new Input({ + source: new FilePathSource('data.mp4'), + formats: ALL_FORMATS, + initInput, +}); ``` \ No newline at end of file diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index 10c8175..8f0fb57 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -1,8 +1,10 @@ import { ALL_FORMATS, AudioBufferSink, + BlobSource, CanvasSink, - createInputFrom, + Input, + UrlSource, WrappedAudioBuffer, WrappedCanvas, } from 'mediabunny'; @@ -96,7 +98,12 @@ const initMediaPlayer = async (resource: File | string) => { clearTimeout(liveRefreshIntervalId); // Create an Input from the resource - const input = createInputFrom(resource, ALL_FORMATS); + const input = new Input({ + source: typeof resource === 'string' + ? new UrlSource(resource) + : new BlobSource(resource), + formats: ALL_FORMATS, // Accept all formats + }); let videoTrack = await input.getPrimaryVideoTrack(); let audioTrack = await input.getPrimaryAudioTrack(); diff --git a/examples/metadata-extraction/metadata-extraction.ts b/examples/metadata-extraction/metadata-extraction.ts index c59e138..d61aa10 100644 --- a/examples/metadata-extraction/metadata-extraction.ts +++ b/examples/metadata-extraction/metadata-extraction.ts @@ -1,4 +1,4 @@ -import { ALL_FORMATS, createInputFrom } from 'mediabunny'; +import { ALL_FORMATS, BlobSource, Input, UrlSource } from 'mediabunny'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; @@ -12,7 +12,12 @@ const metadataContainer = document.querySelector('#metadata-container') as HTMLD const extractMetadata = (resource: File | string) => { // Create a new input from the resource - const input = createInputFrom(resource, ALL_FORMATS); // Accept all formats + const input = new Input({ + source: typeof resource === 'string' + ? new UrlSource(resource) + : new BlobSource(resource), + formats: ALL_FORMATS, // Accept all formats + }); let bytesRead = 0; let fileSize: number | null = null; diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 75ea635..057c7da 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -31,8 +31,8 @@ import { TAG_STREAM_INF, } from './hls-misc'; import { HlsSegmentedInput } from './hls-segmented-input'; -import { PathedSource } from '../source'; import { SegmentedInputTrackDeclaration } from '../segmented-input'; +import { PathedSource } from '../source'; type InternalTrack = { id: number; @@ -76,8 +76,8 @@ export class HlsDemuxer extends Demuxer { readMetadata() { return this.metadataPromise ??= (async () => { - assert(this.input._source instanceof PathedSource); - const source = this.input._source; + assert(this.input._rootSource instanceof PathedSource); + const { rootPath } = this.input._rootSource; const slice = await this.input._reader.requestEntireFile(); assert(slice); @@ -107,7 +107,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(source.rootPath, playlistPath); + const fullPath = joinPaths(rootPath, playlistPath); const attributes = new AttributeList(line.slice(TAG_STREAM_INF.length)); const bandwidth = attributes.getAsNumber('bandwidth'); @@ -142,7 +142,7 @@ export class HlsDemuxer extends Demuxer { ); } - const fullPath = joinPaths(source.rootPath, playlistPath); + const fullPath = joinPaths(rootPath, playlistPath); variantStreams.push({ fullPath, @@ -170,7 +170,7 @@ export class HlsDemuxer extends Demuxer { let fullPath: string | null = null; const uri = attributes.get('uri'); if (uri !== null) { - fullPath = joinPaths(source.rootPath, uri); + fullPath = joinPaths(rootPath, uri); } mediaTags.push({ fullPath, attributes, lineNumber: i }); @@ -178,7 +178,7 @@ export class HlsDemuxer extends Demuxer { // iFramesOnlyTagFound = true; } else if (line.startsWith(TAG_EXTINF)) { // This is a media playlist, not a master playlist - const segmentedInput = new HlsSegmentedInput(this, source.rootPath, null, lines); + const segmentedInput = new HlsSegmentedInput(this, rootPath, null, lines); this.segmentedInputs = [segmentedInput]; this.hasMasterPlaylist = false; @@ -258,7 +258,7 @@ export class HlsDemuxer extends Demuxer { break outer; } - const fullPath = joinPaths(source.rootPath, uri); + const fullPath = joinPaths(rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); const trackBackings = await segmentedInput.getTrackBackings(); const videoTrack = trackBackings.find(x => x.getType() === 'video'); @@ -299,7 +299,7 @@ export class HlsDemuxer extends Demuxer { break outer; } - const fullPath = joinPaths(source.rootPath, uri); + const fullPath = joinPaths(rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); const trackBackings = await segmentedInput.getTrackBackings(); const audioTrack = trackBackings.find(x => x.getType() === 'audio'); diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 8a4f05f..39e8233 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, SegmentedInputTrackDeclaration, SegmentRetrievalOptions } from '../segmented-input'; import { toDataView, joinPaths, last, assert, binarySearchLessOrEqual, arrayArgmin, wait } from '../misc'; import { readAllLines, readBytes, Reader } from '../reader'; -import { PathedSource, ReadableStreamSource, SourceRef } from '../source'; +import { CustomPathedSource, ReadableStreamSource, SourceRef } from '../source'; import { HlsDemuxer } from './hls-demuxer'; import { AttributeList, @@ -540,14 +540,10 @@ export class HlsSegmentedInput extends SegmentedInput { } const input = new Input({ - source: new PathedSource( + source: new CustomPathedSource( 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); - } + assert(request.isRoot); // Shouldn't fail since we don't allow recursive HLS let ref: SourceRef; const needsSlice = hlsSegment.location.offset > 0 || hlsSegment.location.length !== null; diff --git a/src/index.ts b/src/index.ts index 1fa55b0..7e7e5ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,6 +165,7 @@ export { BlobSource, BlobSourceOptions, BufferSource, + CustomPathedSource, FilePathSource, FilePathSourceOptions, PathedSource, @@ -209,8 +210,6 @@ export { InputOptions, InputEvents, InputDisposedError, - createInputFrom, - CreateInputFromOptions, UnsupportedInputFormatError, } from './input'; export { diff --git a/src/input-format.ts b/src/input-format.ts index 6b8cc41..34ee4c4 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -594,8 +594,8 @@ export class HlsInputFormat extends InputFormat { return false; } - if (!(input._source instanceof PathedSource)) { - throw new TypeError('HLS inputs require `InputOptions.source` to be a PathedSource.'); + if (!(input._rootSource instanceof PathedSource)) { + throw new TypeError('HLS inputs require `InputOptions.source` to be a PathedSource or a ref to one.'); } return true; diff --git a/src/input.ts b/src/input.ts index 0a09efb..5afbeec 100644 --- a/src/input.ts +++ b/src/input.ts @@ -28,35 +28,20 @@ import { arrayCount, assert, EventEmitter, - MaybePromise, polyfillSymbolDispose, removeItem, } from './misc'; import { Reader } from './reader'; import { - BlobSource, - BlobSourceOptions, - BufferSource, - FilePathSource, - FilePathSourceOptions, PathedSource, - ReadableStreamSource, - ReadableStreamSourceOptions, Source, SourceRef, SourceRequest, sourceRequestsAreEqual, - UrlSource, - UrlSourceOptions, } from './source'; -import * as nodeAlias from './node'; polyfillSymbolDispose(); -const node = typeof nodeAlias !== 'undefined' - ? nodeAlias // Aliasing it prevents some bundler warnings - : undefined!; - export const DEFAULT_SOURCE_CACHE_GROUP = 1; export const ENCRYPTION_KEY_CACHE_GROUP = 2; @@ -80,7 +65,7 @@ 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 | PathedSource; + source: S | SourceRef; /** * 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 @@ -91,9 +76,9 @@ export type InputOptions = { initInput?: Input; }; -type SourceCacheEntry = { +type SourceCacheEntry = { request: SourceRequest; - sourceRef: SourceRef; + sourceRef: SourceRef; age: number; cacheGroup: number; }; @@ -126,7 +111,7 @@ export type InputEvents = { */ export class Input extends EventEmitter implements Disposable { /** @internal */ - _source: SourceRef | PathedSource; + _rootRef: SourceRef; /** @internal */ _formats: InputFormat[]; /** @internal */ @@ -148,16 +133,12 @@ export class Input extends EventEmitter /** @internal */ _sourceRefs: SourceRef[] = []; /** @internal */ - _sourceCache: SourceCacheEntry[] = []; - /** @internal */ - _rootRef: SourceRef | null = null; - /** @internal */ - _rootRefPromise: Promise> | null = null; + _sourceCache: SourceCacheEntry[] = []; /** @internal */ _sourceCachePromises: { request: SourceRequest; cacheGroup: number; - promise: Promise>; + promise: Promise; }[] = []; /** True if the input has been disposed. */ @@ -178,12 +159,8 @@ 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 - || options.source instanceof SourceRef - || options.source instanceof PathedSource - )) { - throw new TypeError('options.source must be a Source, SourceRef, or PathedSource.'); + 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.'); @@ -196,60 +173,32 @@ export class Input extends EventEmitter this._initInput = options.initInput ?? null; if (options.source instanceof Source) { - this._source = options.source.ref(); + this._rootRef = options.source.ref(); } else { - this._source = options.source; - } - - if (this._source instanceof SourceRef) { - this._sourceRefs.push(this._source); + this._rootRef = options.source; } + this._sourceRefs.push(this._rootRef); inputFinalizationRegistry?.register(this, this._sourceRefs, this); } /** @internal */ - _getSourceValidated(request: SourceRequest): MaybePromise> { - assert(this._source instanceof PathedSource); - - const result = this._source.getSource(request); - const handleResult = (result: S | SourceRef) => { - if (!(result instanceof Source || result instanceof SourceRef)) { - throw new TypeError('getSource must return a Source or a SourceRef.'); - } - if (result instanceof Source && result._disposed) { - throw new TypeError('The returned Source must not be disposed.'); - } - - return result; - }; - - if (result instanceof Promise) { - return result.then(handleResult); - } else { - return handleResult(result); - } + get _rootSource() { + return this._rootRef.source; } /** @internal */ async _getSourceUncached(request: SourceRequest) { - assert(this._source instanceof PathedSource); + assert(this._rootSource instanceof PathedSource); - const source = await this._getSourceValidated(request); - - let ref: SourceRef; - if (source instanceof Source) { - ref = source.ref(); - } else { - ref = source; - } + 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> { + _getSourceCached(request: SourceRequest, cacheGroup = DEFAULT_SOURCE_CACHE_GROUP): Promise { const cachedEntry = this._sourceCache.find(x => x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request), ); @@ -293,7 +242,7 @@ export class Input extends EventEmitter assert(promiseIndex !== -1); this._sourceCachePromises.splice(promiseIndex, 1); - const cacheEntry: SourceCacheEntry = { + const cacheEntry: SourceCacheEntry = { request, sourceRef, age: this._nextSourceCacheAge++, @@ -318,54 +267,11 @@ export class Input extends EventEmitter }); } - /** @internal */ - _getRootSourceRef(): MaybePromise> { - if (this._rootRef) { - return this._rootRef; - } - if (this._rootRefPromise) { - return this._rootRefPromise; - } - - if (this._source instanceof SourceRef) { - this._emit('source', { source: this._source.source, request: null, isRoot: true }); - this._rootRef = this._source; - - assert(this._sourceRefs.includes(this._source)); // Assert that it's already been added - - return this._source; - } - - const request: SourceRequest = { path: this._source.rootPath, isRoot: true }; - const result = this._getSourceValidated(request); - - const handleResult = (result: S | SourceRef) => { - let ref: SourceRef; - if (result instanceof Source) { - ref = result.ref(); - } else { - ref = result; - } - - this._sourceRefs.push(ref); - this._emit('source', { source: ref.source, request, isRoot: true }); - this._rootRef = ref; - - return ref; - }; - - if (result instanceof Promise) { - return this._rootRefPromise = result.then(handleResult); - } else { - return handleResult(result); - } - } - /** @internal */ _getDemuxer() { return this._demuxerPromise ??= (async () => { - const rootRef = await this._getRootSourceRef(); - this._reader = new Reader(rootRef.source); + 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); @@ -380,27 +286,10 @@ export class Input extends EventEmitter } /** - * Returns the source from which this input file reads data for the root path. Throws when using - * {@link PathedSource} with an async callback; prefer the `'source'` event for those cases. + * Returns the source from which this input file reads data for the root path. */ get source(): S { - const errorMessage = 'Input.source cannot be used when using PathedSource with an async callback.' - + ' Use the \'source\' event instead.'; - - // We use this field to make sure we can reliably throw in the `source` getter whenever retrieving the source - // requiring awaiting a promise. We do this so there is no different behavior based on order: if the source has - // already been retrieved via the normal internal operations, and then somebody calls the `source` getter, even - // if the source is now available, the getter should still throw to be consistent in behavior and in definition. - if (this._rootRefPromise) { - throw new TypeError(errorMessage); - } - - const rootRefResult = this._getRootSourceRef(); - if (rootRefResult instanceof Promise) { - throw new TypeError(errorMessage); - } - - return rootRefResult.source; + return this._rootSource; } /** @@ -676,142 +565,3 @@ export class InputDisposedError extends Error { this.name = 'InputDisposedError'; } } - -/** - * Options for {@link createInputFrom}. Combines the options of all source types, plus `initInput`. - * - * @group Input files & tracks - * @public - */ -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`, `Request`, `Source` - * and `PathedSource`. 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. - * - * **Note:** In server-side environments, it is critical that you validate the input to this function if it is a string. - * If you're expecting a user-defined URL, you must validate that it's actually a URL and not a local file path. - * - * @group Input files & tracks - * @public - */ -export const createInputFrom = ( - data: AllowSharedBufferSource | Blob | ReadableStream | string | URL | Request | Source | PathedSource, - formats: InputFormat[], - options: CreateInputFromOptions = {}, -): Input => { - if (!Array.isArray(formats) || !formats.every(x => x instanceof InputFormat)) { - throw new TypeError('formats must be an array of InputFormat.'); - } - if (typeof options !== 'object' || !options) { - throw new TypeError('options must be an object.'); - } - - const { initInput, ...sourceOptions } = options; - - if (data instanceof Source || data instanceof PathedSource) { - return new Input({ - formats, - source: data, - initInput, - }); - } - - if ( - data instanceof ArrayBuffer - || (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer) - || ArrayBuffer.isView(data) - ) { - return new Input({ - formats, - source: new BufferSource(data), - initInput, - }); - } - - if (typeof Blob !== 'undefined' && data instanceof Blob) { - return new Input({ - formats, - source: new BlobSource(data, sourceOptions), - initInput, - }); - } - - if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) { - return new Input({ - formats, - source: new ReadableStreamSource(data, sourceOptions), - initInput, - }); - } - - if (typeof URL !== 'undefined' && data instanceof URL) { - const url = data.href; - - return new Input({ - formats, - source: new PathedSource( - url, - request => new UrlSource(request.path, sourceOptions), - ), - initInput, - }); - } - - if (typeof Request !== 'undefined' && data instanceof Request) { - const url = data.url; - - return new Input({ - formats, - source: new PathedSource( - url, - request => new UrlSource( - new Request(request.path, data), - sourceOptions, - ), - ), - initInput, - }); - } - - if (typeof data === 'string') { - const isTreatedAsUrl = !node.fs || data.includes('://'); - if (isTreatedAsUrl) { - return new Input({ - formats, - source: new PathedSource( - data, - request => new UrlSource(request.path, sourceOptions), - ), - initInput, - }); - } - - // Treat it as a local file path - return new Input({ - formats, - source: new PathedSource( - data, - request => new FilePathSource(request.path, sourceOptions), - ), - initInput, - }); - } - - throw new TypeError( - 'Input.from: first argument must be an ArrayBuffer, SharedArrayBuffer, ArrayBufferView, Blob,' - + ' ReadableStream, string, URL, or Request.', - ); -}; diff --git a/src/source.ts b/src/source.ts index 694a347..c4eb8a2 100644 --- a/src/source.ts +++ b/src/source.ts @@ -239,6 +239,137 @@ export class SourceRef implements Disposable { } } +/** + * A source which can create new sources from file paths. Required for multi-file inputs such as HLS playlists. + * @public + * @group Input sources + */ +export abstract class PathedSource extends Source { + constructor( + /** The path that points to the root file; the entry file of the media. */ + public rootPath: FilePath, + /** The callback that is called for each requested file; must return a {@link Source} or {@link SourceRef}. */ + public requestHandler: (request: SourceRequest) => MaybePromise, + ) { + if (typeof rootPath !== 'string') { + throw new TypeError('rootPath must be a string.'); + } + if (typeof requestHandler !== 'function') { + throw new TypeError('requestHandler must be a function.'); + } + + super(); + } + + /** @internal */ + _resolveRequest(request: SourceRequest): MaybePromise { + const result = this.requestHandler(request); + + const handle = (result: Source | SourceRef) => { + if (!(result instanceof Source || result instanceof SourceRef)) { + throw new TypeError('requestHandler must return or resolve to a Source or SourceRef.'); + } + + return result instanceof Source + ? result.ref() + : result; + }; + + if (result instanceof Promise) { + return result.then(handle); + } else { + return handle(result); + } + } +} + +/** + * 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; +}; + +/** + * A custom multi-file source where each file is uniquely identified by a {@link FilePath} and can be resolved to + * an arbitrary {@link Source}. + * + * @public + * @group Input sources + */ +export class CustomPathedSource extends PathedSource { + /** @internal */ + _root: SourceRef | null = null; + /** @internal */ + _rootRequest: Promise | null = null; + + /** @internal */ + override _read( + start: number, + end: number, + minReadPosition: number, + maxReadPosition: number, + ): MaybePromise { + if (!this._root) { + if (!this._rootRequest) { + const result = this._resolveRequest({ path: this.rootPath, isRoot: true }); + + const handle = (result: Source | SourceRef) => { + const ref = result instanceof Source + ? result.ref() + : result; + + this._root = ref; + this._rootRequest = null; + + return ref; + }; + + if (result instanceof Promise) { + this._rootRequest = result.then(handle); + } else { + handle(result); + assert(this._root); + } + } + + if (this._rootRequest) { + return this._rootRequest.then(ref => ref.source._read(start, end, minReadPosition, maxReadPosition)); + } + } + + return this._root!.source._read(start, end, minReadPosition, maxReadPosition); + } + + /** @internal */ + override _getFileSize(): number | null | undefined { + if (this._root) { + return this._root.source._getFileSize(); + } + + return undefined; + } + + /** @internal */ + override _dispose(): void { + if (this._root) { + this._root.free(); + } else if (this._rootRequest) { + void this._rootRequest + .then(ref => ref.free()); + } + } +} + /** * A source backed by an ArrayBuffer or ArrayBufferView, with the entire file held in memory. * @group Input sources @@ -517,7 +648,7 @@ export type UrlSourceOptions = { * @group Input sources * @public */ -export class UrlSource extends Source { +export class UrlSource extends PathedSource { /** @internal */ _url: string | URL | Request; /** @internal */ @@ -573,7 +704,13 @@ export class UrlSource extends Source { // Won't bother validating this function beyond this } - super(); + const urlString = url instanceof Request + ? url.url + : url instanceof URL + ? url.href + : url; + + super(urlString, request => new UrlSource(request.path, this._options)); this._url = url; this._options = options; @@ -784,7 +921,7 @@ export type FilePathSourceOptions = { * @group Input sources * @public */ -export class FilePathSource extends Source { +export class FilePathSource extends PathedSource { /** @internal */ _streamSource: StreamSource; /** @internal */ @@ -811,7 +948,7 @@ export class FilePathSource extends Source { ); } - super(); + super(filePath, request => new FilePathSource(request.path, options)); // Let's back this source with a StreamSource, makes the implementation very simple this._streamSource = new StreamSource({ @@ -2183,41 +2320,3 @@ 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/test/browser/conversion.test.ts b/test/browser/conversion.test.ts index 2617bff..561fe4f 100644 --- a/test/browser/conversion.test.ts +++ b/test/browser/conversion.test.ts @@ -2,7 +2,7 @@ import { ALL_FORMATS } from '../../src/input-format.js'; import { Input } from '../../src/input.js'; import { AdtsOutputFormat, HlsOutputFormat, Mp4OutputFormat, MpegTsOutputFormat } from '../../src/output-format.js'; import { Output, OutputTrackGroup } from '../../src/output.js'; -import { BufferSource, PathedSource, UrlSource } from '../../src/source.js'; +import { BufferSource, CustomPathedSource, UrlSource } from '../../src/source.js'; import { expect, test } from 'vitest'; import { BufferTarget, PathedTarget } from '../../src/target.js'; import { Conversion } from '../../src/conversion.js'; @@ -176,7 +176,7 @@ test('HLS track assignability is kept #1', async () => { using input = new Input({ formats: ALL_FORMATS, - source: new PathedSource( + source: new CustomPathedSource( 'master.m3u8', ({ path }) => new BufferSource(files.get(path)!), ), @@ -255,7 +255,7 @@ test('HLS track assignability is kept #2', async () => { using input = new Input({ formats: ALL_FORMATS, - source: new PathedSource( + source: new CustomPathedSource( 'master.m3u8', ({ path }) => new BufferSource(files.get(path)!), ), @@ -334,7 +334,7 @@ test('HLS track assignability can be overridden', async () => { using input = new Input({ formats: ALL_FORMATS, - source: new PathedSource( + source: new CustomPathedSource( 'master.m3u8', ({ path }) => new BufferSource(files.get(path)!), ), diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index febb3e8..76578e8 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -1,14 +1,18 @@ /* eslint-disable @stylistic/max-len */ -import { ALL_FORMATS, BufferSource, createInputFrom, EncodedPacketSink, Input, InputAudioTrack, InputVideoTrack, PathedSource } from 'mediabunny'; +import { ALL_FORMATS, BufferSource, EncodedPacketSink, Input, InputAudioTrack, InputVideoTrack, UrlSource } from 'mediabunny'; import { expect, test } from 'vitest'; import { HLS, HLS_FORMATS, HlsInputFormat } from '../../src/input-format.js'; import { assert, rejectAfter } from '../../src/misc.js'; +import { CustomPathedSource } from '../../src/source.js'; // A lot of test cases taken from: // https://github.com/video-dev/hls.js/blob/master/tests/test-streams.js test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'), + formats: ALL_FORMATS, + }); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -205,7 +209,10 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { }); test.concurrent('Big Buck Bunny, codec parameter strings from master playlist', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'), + formats: ALL_FORMATS, + }); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -229,7 +236,10 @@ test.concurrent('Big Buck Bunny, codec parameter strings from master playlist', }); test.concurrent('Big Buck Bunny, determining duration from metadata', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'), + formats: ALL_FORMATS, + }); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -246,7 +256,10 @@ test.concurrent('Big Buck Bunny, determining duration from metadata', { timeout: }); test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8'), + formats: ALL_FORMATS, + }); const tracks = await input.getTracks(); expect(tracks).toHaveLength(2); @@ -261,7 +274,10 @@ test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () = }); test.concurrent('Codec-less (underspecified) master playlist', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://test-streams.mux.dev/test_001/stream.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://test-streams.mux.dev/test_001/stream.m3u8'), + formats: ALL_FORMATS, + }); const tracks = await input.getTracks(); expect(tracks).toHaveLength(12); @@ -273,7 +289,10 @@ test.concurrent('Codec-less (underspecified) master playlist', { timeout: 15_000 }); test.concurrent('AES and discontinuities', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://test-streams.mux.dev/dai-discontinuity-deltatre/manifest.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://test-streams.mux.dev/dai-discontinuity-deltatre/manifest.m3u8'), + formats: ALL_FORMATS, + }); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -302,7 +321,10 @@ test.concurrent('AES and discontinuities', { timeout: 15_000 }, async () => { }); test.concurrent('Range requests', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/issue666/playlists/cisq0gim60007xzvi505emlxx.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/issue666/playlists/cisq0gim60007xzvi505emlxx.m3u8'), + formats: ALL_FORMATS, + }); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -326,7 +348,10 @@ test.concurrent('Range requests', { timeout: 15_000 }, async () => { }); test.concurrent('Custom IV', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/customIV/prog_index.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/customIV/prog_index.m3u8'), + formats: ALL_FORMATS, + }); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -338,7 +363,10 @@ test.concurrent('Custom IV', { timeout: 15_000 }, async () => { }); test.concurrent('Out-of-band audio track via ADTS', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/aes-with-tracks/master.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/aes-with-tracks/master.m3u8'), + formats: ALL_FORMATS, + }); const tracks = await input.getTracks(); const videoOnlyKeyPacketsFlags = await Promise.all( @@ -368,7 +396,10 @@ test.concurrent('Out-of-band audio track via ADTS', { timeout: 15_000 }, async ( }); test.concurrent('MP3 audio only', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://pl.streamingvideoprovider.com/mp3-playlist/playlist.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://pl.streamingvideoprovider.com/mp3-playlist/playlist.m3u8'), + formats: ALL_FORMATS, + }); const audioTrack = (await input.getAudioTracks())[0]; assert(audioTrack); @@ -376,7 +407,10 @@ test.concurrent('MP3 audio only', { timeout: 15_000 }, async () => { }); test.concurrent('fMP4', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8'), + formats: ALL_FORMATS, + }); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -402,7 +436,10 @@ test.concurrent('fMP4', { timeout: 15_000 }, async () => { }); test.concurrent('Track disposition & metadata', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8'), + formats: ALL_FORMATS, + }); const audioTracks = await input.getAudioTracks(); @@ -438,16 +475,19 @@ test.concurrent('Track disposition & metadata', { timeout: 15_000 }, async () => }); test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s-fmp4/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8', ALL_FORMATS, { - requestInit: { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.9', - 'Origin': 'https://bitmovin.com', - 'Referer': 'https://bitmovin.com/', + using input = new Input({ + source: new UrlSource('https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s-fmp4/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8', { + requestInit: { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Origin': 'https://bitmovin.com', + 'Referer': 'https://bitmovin.com/', + }, }, - }, + }), + formats: ALL_FORMATS, }); let sourceCount = 0; @@ -479,7 +519,10 @@ test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => { }); test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/aviion/manifest.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/aviion/manifest.m3u8'), + formats: ALL_FORMATS, + }); const tracks = await input.getTracks(); expect((await Promise.all(tracks.map(x => x.isRelativeToUnixEpoch()))).every(x => x)).toBe(true); @@ -507,7 +550,10 @@ test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { }); test.concurrent('Duplicate PDT', { timeout: 30_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/artbeats/manifest.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/artbeats/manifest.m3u8'), + formats: ALL_FORMATS, + }); const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); @@ -524,7 +570,10 @@ test.concurrent('Duplicate PDT', { timeout: 30_000 }, async () => { }); test.concurrent('PDT with large gaps', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/boxee/playlist.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/boxee/playlist.m3u8'), + formats: ALL_FORMATS, + }); const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); @@ -540,7 +589,10 @@ test.concurrent('PDT with large gaps', { timeout: 15_000 }, async () => { }); test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/progdatime/playlist2.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/progdatime/playlist2.m3u8'), + formats: ALL_FORMATS, + }); const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); @@ -549,7 +601,10 @@ test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { }); test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8'), + formats: ALL_FORMATS, + }); const tracks = await input.getTracks(); expect(tracks).toHaveLength(2); @@ -564,7 +619,10 @@ test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { }); test.concurrent('Advanced Apple HLS', { timeout: 30_000 }, async () => { - using input = createInputFrom('https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_adv_example_hevc/master.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_adv_example_hevc/master.m3u8'), + formats: ALL_FORMATS, + }); const tracks = await input.getTracks(); const videoTracks = tracks.filter((x): x is InputVideoTrack => x.isVideoTrack()); @@ -661,7 +719,10 @@ test.concurrent('Advanced Apple HLS', { timeout: 30_000 }, async () => { }); test.concurrent('Live HLS', { timeout: 30_000 }, async () => { - using input = createInputFrom('https://stream.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://stream.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM.m3u8'), + formats: ALL_FORMATS, + }); const videoTrack = await input.getPrimaryVideoTrack(); assert(videoTrack); @@ -729,7 +790,7 @@ test.concurrent('#EXT-X-I-FRAME-STREAM-INF tags are parsed properly', async () = const input = new Input({ formats: ALL_FORMATS, - source: new PathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), + source: new CustomPathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), }); const tracks = await input.getTracks(); @@ -744,7 +805,7 @@ test.concurrent('#EXT-X-I-FRAME-STREAM-INF tags without BANDWIDTH attribute are const input = new Input({ formats: ALL_FORMATS, - source: new PathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), + source: new CustomPathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), }); await expect(input.getTracks()).rejects.toThrow('BANDWIDTH'); @@ -758,14 +819,17 @@ test.concurrent('#EXT-X-STREAM-INF tags without BANDWIDTH attribute are rejected const input = new Input({ formats: ALL_FORMATS, - source: new PathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), + source: new CustomPathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), }); await expect(input.getTracks()).rejects.toThrow('BANDWIDTH'); }); test.concurrent('Missing media tag codec', async () => { - using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8', ALL_FORMATS); + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8'), + formats: ALL_FORMATS, + }); const tracks = await input.getTracks(); expect(tracks).toHaveLength(7); @@ -788,10 +852,9 @@ root.m3u8 `; const input = new Input({ - source: new PathedSource( + source: new CustomPathedSource( 'root.m3u8', ({ path }) => { - console.log(1, path); assert(path === 'root.m3u8'); return new BufferSource(new TextEncoder().encode(text)); }, diff --git a/todo.txt b/todo.txt index d8f40b8..2c6b2f8 100644 --- a/todo.txt +++ b/todo.txt @@ -6,5 +6,3 @@ writablestream target? Thoughts: So, a certain "lookahead" logic is definitely needed. The question is if this is a per-demuxer thing or a general thing instead. The demuxer could get in a "packet query" that specifies things like "I am interested in the next 20 seconds guaranteed", allowing the demuxer to pre-fetch more intelligently. The alternative would be some sort of demuxer-agnostic approach where there is a magical "packet requester" that has to be segment-aware. I'm actually not sure if that's good. - -- Remove createInputFrom. Much more elegant solution now that PathedSource is a thing!! \ No newline at end of file