diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index 3d33577..bb8b94d 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -11,6 +11,7 @@ import { WrappedCanvas, asc, canDecodeAudio, + createInputFrom, desc, prefer, } from 'mediabunny'; @@ -102,6 +103,9 @@ const initMediaPlayer = async (resource: File | string) => { let videoTrack: InputVideoTrack | null = null; let audioTrack: InputAudioTrack | null = null; if (true || typeof resource === 'string' && resource.includes('.m3u8')) { + const input = createInputFrom(resource, ALL_FORMATS); + + /* const input = new Input({ entryPath: resource, // 'master.m3u8', source: async ({ path }) => { @@ -112,15 +116,18 @@ const initMediaPlayer = async (resource: File | string) => { }, formats: ALL_FORMATS, }); + */ // const variant = (await manifestInput.getVariants())[0]!; // console.log(await input.getFormat(), await input.getTracks()); // return; + /* await input.getVideoTracks({ filter: desc => desc.hasPairableAudioTrack(), sortBy: desc => asc(desc.bitrate), }); + */ videoTrack = await input.getPrimaryVideoTrack({ filter: async desc => (desc.displayHeight ?? (await desc.getTrack()).displayHeight) < 720, diff --git a/src/index.ts b/src/index.ts index 02a2729..116aa74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -192,6 +192,7 @@ export { WEBM, } from './input-format'; export { + createInputFrom, Input, InputOptions, InputEvents, diff --git a/src/input.ts b/src/input.ts index 6a58255..7c095ca 100644 --- a/src/input.ts +++ b/src/input.ts @@ -38,7 +38,19 @@ import { removeItem, } from './misc'; import { Reader } from './reader'; -import { Source, SourceRef } from './source'; +import { + BlobSource, + BlobSourceOptions, + BufferSource, + FilePathSource, + FilePathSourceOptions, + ReadableStreamSource, + ReadableStreamSourceOptions, + Source, + SourceRef, + UrlSource, + UrlSourceOptions, +} from './source'; polyfillSymbolDispose(); @@ -654,3 +666,124 @@ export class InputDisposedError extends Error { this.name = 'InputDisposedError'; } } + +/** + * Options for {@link Input.from}. Combines the options of all source types, plus `initInput`. + * @group Input files & tracks + * @public + */ +export type InputFromOptions = + & Partial + & Partial + & Partial + & Partial + & { + initInput?: Input; + }; + +/** + * 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 | URL | Request, + formats: InputFormat[], + options: InputFromOptions = {}, +): 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 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: (request: SourceRequest) => new UrlSource(request.path, sourceOptions), + entryPath: url, + initInput, + }); + } + + if (typeof Request !== 'undefined' && data instanceof Request) { + const url = data.url; + + 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 }), + sourceOptions, + ); + }, + entryPath: url, + initInput, + }); + } + + if (typeof data === 'string') { + const isUrl = data.includes('://'); + + if (isUrl) { + return new Input({ + formats, + source: (request: SourceRequest) => new UrlSource(request.path, sourceOptions), + entryPath: data, + initInput, + }); + } + + // File path, throws automatically if this isn't server-side + return new Input({ + formats, + source: (request: SourceRequest) => new FilePathSource(request.path, sourceOptions), + entryPath: data, + 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 28f63a8..1565966 100644 --- a/src/source.ts +++ b/src/source.ts @@ -745,6 +745,12 @@ export class FilePathSource extends Source { throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.'); } + if (!node.fs) { + throw new Error( + 'FilePathSource is only available in server-side environments (Node.js, Bun, Deno).', + ); + } + super(); // Let's back this source with a StreamSource, makes the implementation very simple diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index 1126167..14a84e5 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @stylistic/max-len */ -import { ALL_FORMATS, EncodedPacketSink, Input, InputAudioTrack, InputVideoTrack, UrlSource } from 'mediabunny'; +import { ALL_FORMATS, createInputFrom, EncodedPacketSink, InputAudioTrack, InputVideoTrack } from 'mediabunny'; import { expect, test } from 'vitest'; import { HLS, HlsInputFormat } from '../../src/input-format.js'; import { assert, rejectAfter } from '../../src/misc.js'; @@ -8,11 +8,7 @@ import { assert, rejectAfter } from '../../src/misc.js'; // https://github.com/video-dev/hls.js/blob/master/tests/test-streams.js test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', ALL_FORMATS); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -218,11 +214,7 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { }); test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8', ALL_FORMATS); const tracks = await input.getTracks(); expect(tracks).toHaveLength(2); @@ -237,11 +229,7 @@ test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () = }); test.concurrent('Codec-less (underspecified) master playlist', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://test-streams.mux.dev/test_001/stream.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://test-streams.mux.dev/test_001/stream.m3u8', ALL_FORMATS); const descriptors = await input.getTrackDescriptors(); expect(descriptors).toHaveLength(12); @@ -253,11 +241,7 @@ test.concurrent('Codec-less (underspecified) master playlist', { timeout: 15_000 }); test.concurrent('AES and discontinuities', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://test-streams.mux.dev/dai-discontinuity-deltatre/manifest.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://test-streams.mux.dev/dai-discontinuity-deltatre/manifest.m3u8', ALL_FORMATS); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -286,11 +270,7 @@ test.concurrent('AES and discontinuities', { timeout: 15_000 }, async () => { }); test.concurrent('Range requests', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/issue666/playlists/cisq0gim60007xzvi505emlxx.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/issue666/playlists/cisq0gim60007xzvi505emlxx.m3u8', ALL_FORMATS); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -314,11 +294,7 @@ test.concurrent('Range requests', { timeout: 15_000 }, async () => { }); test.concurrent('Custom IV', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/customIV/prog_index.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/customIV/prog_index.m3u8', ALL_FORMATS); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -330,11 +306,7 @@ test.concurrent('Custom IV', { timeout: 15_000 }, async () => { }); test.concurrent('Out-of-band audio track via ADTS', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/aes-with-tracks/master.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/aes-with-tracks/master.m3u8', ALL_FORMATS); const descriptors = await input.getTrackDescriptors(); expect(descriptors.some(x => x.isVideoTrackDescriptor() && x.hasOnlyKeyPackets)).toBe(true); @@ -362,11 +334,7 @@ test.concurrent('Out-of-band audio track via ADTS', { timeout: 15_000 }, async ( }); test.concurrent('MP3 audio only', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://pl.streamingvideoprovider.com/mp3-playlist/playlist.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://pl.streamingvideoprovider.com/mp3-playlist/playlist.m3u8', ALL_FORMATS); const audioDescriptor = (await input.getAudioTrackDescriptors())[0]; assert(audioDescriptor); @@ -374,11 +342,7 @@ test.concurrent('MP3 audio only', { timeout: 15_000 }, async () => { }); test.concurrent('fMP4', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8', ALL_FORMATS); let sourceCount = 0; input.on('source', () => sourceCount++); @@ -404,11 +368,7 @@ test.concurrent('fMP4', { timeout: 15_000 }, async () => { }); test.concurrent('Track disposition & metadata', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8', ALL_FORMATS); const audioDescriptors = await input.getAudioTrackDescriptors(); @@ -444,20 +404,16 @@ test.concurrent('Track disposition & metadata', { timeout: 15_000 }, async () => }); test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s-fmp4/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8', - source: ({ path }) => new UrlSource(path, { - 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 = 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/', }, - }), - formats: ALL_FORMATS, + }, }); let sourceCount = 0; @@ -484,11 +440,7 @@ test.concurrent('fMP4 Bitmovin', { timeout: 15_000 }, async () => { }); test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/aviion/manifest.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/aviion/manifest.m3u8', ALL_FORMATS); const tracks = await input.getTracks(); expect(tracks.every(x => x.isRelativeToUnixEpoch)).toBe(true); @@ -516,11 +468,7 @@ test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { }); test.concurrent('Duplicate PDT', { timeout: 30_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/artbeats/manifest.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/artbeats/manifest.m3u8', ALL_FORMATS); const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); @@ -537,11 +485,7 @@ test.concurrent('Duplicate PDT', { timeout: 30_000 }, async () => { }); test.concurrent('PDT with large gaps', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/boxee/playlist.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/boxee/playlist.m3u8', ALL_FORMATS); const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); @@ -557,11 +501,7 @@ test.concurrent('PDT with large gaps', { timeout: 15_000 }, async () => { }); test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/progdatime/playlist2.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/progdatime/playlist2.m3u8', ALL_FORMATS); const audioTrack = await input.getPrimaryAudioTrack(); assert(audioTrack); @@ -570,11 +510,7 @@ test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { }); test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { - using input = new Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8', ALL_FORMATS); const descriptors = await input.getTrackDescriptors(); expect(descriptors).toHaveLength(2); @@ -584,11 +520,7 @@ test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { }); test.concurrent('Advanced Apple HLS', { timeout: 30_000 }, async () => { - using input = new Input({ - entryPath: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_adv_example_hevc/master.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_adv_example_hevc/master.m3u8', ALL_FORMATS); const descriptors = await input.getTrackDescriptors(); const videoDescriptors = descriptors.filter(x => x.isVideoTrackDescriptor()); @@ -703,11 +635,7 @@ test.concurrent('Advanced Apple HLS', { timeout: 30_000 }, async () => { }); test.concurrent('Live HLS', { timeout: 30_000 }, async () => { - using input = new Input({ - entryPath: 'https://stream.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM.m3u8', - source: ({ path }) => new UrlSource(path), - formats: ALL_FORMATS, - }); + using input = createInputFrom('https://stream.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM.m3u8', ALL_FORMATS); const videoTrack = await input.getPrimaryVideoTrack(); assert(videoTrack); diff --git a/todo.txt b/todo.txt index 1be1c02..6538a2f 100644 --- a/todo.txt +++ b/todo.txt @@ -4,5 +4,3 @@ Also, why not just have the packet metadata on the packet? I think that would ma - keep input/demuxer.isSupported()? - conversion behavior for HLS; only take primary tracks by default? - -easier way to create input \ No newline at end of file