diff --git a/dev/demux.html b/dev/demux.html index 588e25f..9d4d124 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -17,14 +17,33 @@ source: new Mediabunny.BlobSource(file), }); + const audTrack = await input.getPrimaryAudioTrack(); + console.log((await audTrack.getDecoderConfig()).description.join(',')); + return; + const manifest = new Mediabunny.Input({ - entryPath: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/redundant.m3u8', + entryPath: 'https://s3.amazonaws.com/qa.jwplayer.com/hlsjs/muxed-fmp4/hls.m3u8', source: ({ path }) => new Mediabunny.UrlSource(path), formats: Mediabunny.ALL_FORMATS, }); + /* const tracks = await manifest.getTracks(); - console.log(tracks) + //await input.hydrateAllTracks(); + console.log(tracks, tracks.map(x => [x.getFirstTimestamp(), x.computeDuration()])); + + return; + */ + + const videoTrack = await manifest.getPrimaryVideoTrack(); + const audioTrack = await manifest.getPrimaryAudioTrack(); + + console.log(await videoTrack.getFirstTimestamp(), await audioTrack.getFirstTimestamp()); + /* + for await (using sample of sink.samples()) { + console.log(sample.timestamp); + } + */ /* const manifest = new Mediabunny.ManifestInput({ diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md index 553facd..eedb424 100644 --- a/docs/guide/supported-formats-and-codecs.md +++ b/docs/guide/supported-formats-and-codecs.md @@ -196,7 +196,60 @@ Codec encodability checks take [custom encoders](#custom-encoders) into account. ## Querying codec decodability -Whether a codec can be decoded depends on the specific codec configuration of an `InputTrack`; you can use its [`canDecode`](./reading-media-files#codec-information) method to check. +If you already have an `InputTrack`, you can check its decodability using its [`canDecode`](./reading-media-files#codec-information) method, which uses the track's actual codec configuration: +```ts +const canDecodeTrack = await inputTrack.canDecode(); // => boolean +``` + +However, you can also gauge decodability even in the absence of any concrete track. `canDecode` tests whether a codec can be decoded using typical settings: +```ts +import { canDecode } from 'mediabunny'; + +canDecode('avc'); // => Promise +canDecode('opus'); // => Promise +``` +Video codecs are checked using 1280x720, while audio codecs are checked using 2 channels, 48 kHz. + +You can also check decodability using specific configurations: +```ts +import { canDecodeVideo, canDecodeAudio } from 'mediabunny'; + +canDecodeVideo('hevc', { + codedWidth: 1920, codedHeight: 1080 +}); // => Promise + +canDecodeAudio('aac', { + numberOfChannels: 1, sampleRate: 44100 +}); // => Promise +``` + +All additional properties of [`VideoDecoderConfig`](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder/configure#config) and [`AudioDecoderConfig`](https://developer.mozilla.org/en-US/docs/Web/API/AudioDecoder/configure#config) can be used here as well. + +--- + +In addition, you can use the following functions to check decodability for multiple codecs at once, getting back a list of supported codecs: +```ts +import { + getDecodableCodecs, + getDecodableVideoCodecs, + getDecodableAudioCodecs, +} from 'mediabunny'; + +getDecodableCodecs(); // => Promise +getDecodableVideoCodecs(); // => Promise +getDecodableAudioCodecs(); // => Promise + +// These functions also accept optional configuration options. +// Here, we check which of AVC, HEVC and VP8 can be decoded at 1920x1080: +getDecodableVideoCodecs( + ['avc', 'hevc', 'vp8'], + { codedWidth: 1920, codedHeight: 1080 }, +); // => Promise +``` + +::: info +Codec decodability checks take [custom decoders](#custom-decoders) into account. +::: ## Custom coders diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index 9d9c1a4..3b4e87d 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -10,6 +10,7 @@ import { WrappedAudioBuffer, WrappedCanvas, asc, + canDecodeAudio, desc, prefer, } from 'mediabunny'; @@ -113,10 +114,10 @@ const initMediaPlayer = async (resource: File | string) => { }); videoTrack = await input.getPrimaryVideoTrack({ - filter: async track => (await track.resolve('displayHeight')) <= 720, + filter: async track => (await track.resolve('displayHeight')) < 1080, }); audioTrack = await input.getPrimaryAudioTrack({ - filter: track => !videoTrack || videoTrack.canBePairedWith(track), + sortBy: track => prefer(track.canBePairedWith(videoTrack)), }); await videoTrack?.hydrate(); @@ -127,6 +128,8 @@ const initMediaPlayer = async (resource: File | string) => { await audioTrack?.computeDuration() ?? 0, ); + console.log(videoTrack, audioTrack, totalDuration); + // https://test-streams.mux.dev/test_001/stream.m3u8 // https://test-streams.mux.dev/test_001/stream_1000k_48k_640x360_050.ts } else { diff --git a/src/codec.ts b/src/codec.ts index 4c7d1ff..0c868b1 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -18,6 +18,7 @@ import { MATRIX_COEFFICIENTS_MAP, TRANSFER_CHARACTERISTICS_MAP, assert, + base64ToBytes, bytesToHexString, isAllowSharedBufferSource, last, @@ -590,6 +591,42 @@ export const extractAudioCodecString = (trackInfo: { throw new TypeError(`Unhandled codec '${codec}'.`); }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const guessDescriptionForVideo = (decoderConfig: VideoDecoderConfig): Uint8Array | undefined => { + return undefined; // All codecs allow an undefined description +}; + +export const guessDescriptionForAudio = (decoderConfig: AudioDecoderConfig): Uint8Array | undefined | false => { + switch (decoderConfig.codec) { + case 'flac': { + const referenceDescription = base64ToBytes('ZkxhQ4AAACIQABAAAAYtACWtCsRC8AANRBhVFucAcYu5ASE2m1Dxv8tw'); + if (decoderConfig.sampleRate >= (1 << 20) || decoderConfig.numberOfChannels > 8) { + return false; + } + + referenceDescription[18] = decoderConfig.sampleRate >>> 12; + referenceDescription[19] = decoderConfig.sampleRate >>> 4; + referenceDescription[20] + = ((decoderConfig.sampleRate & 0x0f) << 4) | ((decoderConfig.numberOfChannels - 1) << 1); + + return referenceDescription; + }; + + case 'vorbis': { + // eslint-disable-next-line @stylistic/max-len + const referenceDescription = base64ToBytes('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AAAAAAAAAAAIAAAYcAAACDChDBQashIAiAIAcCiKZQHHsSzgOJYFJMmyAJYF0DyApgFEEQAIAAAocAAACLBBU2JxgEJDVgIAUQAABsWxLE0TRZKkaZoniiRJ0zxPFGma53meacLzPM80IYqiaJoQRVE0TZimaaoqME1VFQAAUOAAABBgg6bE4gCFhqwEAEICAByKYlma5nmeJ4qmqZokSdM8TxRF0TRNU1VJkqZ5niiKommapqqyLE3zPFEURdNUVVWFpnmeKIqiaaqq6sLzPE8URdE0VdV14XmeJ4qiaJqq6roQRVE0TdNUTVV1XSCKpmmaqqqqrgtETxRNU1Vd13WB54miaaqqq7ouEE3TVFVVdV1ZBpimaaqq68oyQFVV1XVdV5YBqqqqruu6sgxQVdd1XVmWZQCu67qyLMsCAAAOHAAAAoygk4wqi7DRhAsPQKEhKwKAKAAAwBimFFPKMCYhpBAaxiSEFEImJaXSUqogpFJSKRWEVEoqJaOUUmopVRBSKamUCkIqJZVSAADYgQMA2IGFUGjISgAgDwCAMEYpxhhzTiKkFGPOOScRUoox55yTSjHmnHPOSSkZc8w556SUzjnnnHNSSuacc845KaVzzjnnnJRSSuecc05KKSWEzkEnpZTSOeecEwAAVOAAABBgo8jmBCNBhYasBABSAQAMjmNZmuZ5omialiRpmud5niiapiZJmuZ5nieKqsnzPE8URdE0VZXneZ4oiqJpqirXFUXTNE1VVV2yLIqmaZqq6rowTdNUVdd1XZimaaqq67oubFtVVdV1ZRm2raqq6rqyDFzXdWXZloEsu67s2rIAAPAEBwCgAhtWRzgpGgssNGQlAJABAEAYg5BCCCFlEEIKIYSUUggJAAAYcAAACDChDBQashIASAUAAIyx1lprrbXWQGettdZaa62AzFprrbXWWmuttdZaa6211lJrrbXWWmuttdZaa6211lprrbXWWmuttdZaa6211lprrbXWWmuttdZaa6211lprrbXWWmstpZRSSimllFJKKaWUUkoppZRSSgUA+lU4APg/2LA6wknRWGChISsBgHAAAMAYpRhzDEIppVQIMeacdFRai7FCiDHnJKTUWmzFc85BKCGV1mIsnnMOQikpxVZjUSmEUlJKLbZYi0qho5JSSq3VWIwxqaTWWoutxmKMSSm01FqLMRYjbE2ptdhqq7EYY2sqLbQYY4zFCF9kbC2m2moNxggjWywt1VprMMYY3VuLpbaaizE++NpSLDHWXAAAd4MDAESCjTOsJJ0VjgYXGrISAAgJACAQUooxxhhzzjnnpFKMOeaccw5CCKFUijHGnHMOQgghlIwx5pxzEEIIIYRSSsaccxBCCCGEkFLqnHMQQgghhBBKKZ1zDkIIIYQQQimlgxBCCCGEEEoopaQUQgghhBBCCKmklEIIIYRSQighlZRSCCGEEEIpJaSUUgohhFJCCKGElFJKKYUQQgillJJSSimlEkoJJYQSUikppRRKCCGUUkpKKaVUSgmhhBJKKSWllFJKIYQQSikFAAAcOAAABBhBJxlVFmGjCRcegEJDVgIAZAAAkKKUUiktRYIipRikGEtGFXNQWoqocgxSzalSziDmJJaIMYSUk1Qy5hRCDELqHHVMKQYtlRhCxhik2HJLoXMOAAAAQQCAgJAAAAMEBTMAwOAA4XMQdAIERxsAgCBEZohEw0JweFAJEBFTAUBigkIuAFRYXKRdXECXAS7o4q4DIQQhCEEsDqCABByccMMTb3jCDU7QKSp1IAAAAAAADADwAACQXAAREdHMYWRobHB0eHyAhIiMkAgAAAAAABcAfAAAJCVAREQ0cxgZGhscHR4fICEiIyQBAIAAAgAAAAAggAAEBAQAAAAAAAIAAAAEBA=='); + + const view = toDataView(referenceDescription); + view.setUint8(15, decoderConfig.numberOfChannels); + view.setUint32(16, decoderConfig.sampleRate, true); + + return referenceDescription; + }; + + default: return undefined; // All other codecs allow an undefined description + } +}; + export const OPUS_SAMPLE_RATE = 48_000; const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/; @@ -637,15 +674,16 @@ export const inferCodecFromCodecString = (codecString: string): MediaCodec | nul } // Audio codecs - if (codecString.startsWith('mp4a.40') || codecString === 'mp4a.67') { - return 'aac'; - } else if ( + if ( codecString === 'mp3' || codecString === 'mp4a.69' || codecString === 'mp4a.6B' || codecString === 'mp4a.6b' + || codecString === 'mp4a.40.34' ) { return 'mp3'; + } else if (codecString.startsWith('mp4a.40.') || codecString === 'mp4a.67') { + return 'aac'; } else if (codecString === 'opus') { return 'opus'; } else if (codecString === 'vorbis') { diff --git a/src/decode.ts b/src/decode.ts new file mode 100644 index 0000000..e301af1 --- /dev/null +++ b/src/decode.ts @@ -0,0 +1,263 @@ +/*! + * 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 { + AUDIO_CODECS, + AudioCodec, + buildAudioCodecString, + buildVideoCodecString, + guessDescriptionForAudio, + guessDescriptionForVideo, + inferCodecFromCodecString, + MediaCodec, + PCM_AUDIO_CODECS, + VIDEO_CODECS, + VideoCodec, +} from './codec'; +import { customAudioDecoders, customVideoDecoders } from './custom-coder'; +import { isAllowSharedBufferSource, SetOptional } from './misc'; + +const canDecodeVideoMemo = new Map>(); +const canDecodeAudioMemo = new Map>(); + +const validateVideoDecodingConfig = (codec: VideoCodec, options: SetOptional) => { + if (!options || typeof options !== 'object') { + throw new TypeError('options must be an object.'); + } + if (options.codec !== undefined && typeof options.codec !== 'string') { + throw new TypeError('options.codec, when provided, must be a string.'); + } + if (options.codec !== undefined && inferCodecFromCodecString(options.codec) !== codec) { + throw new TypeError(`options.codec, when provided, must match the specified codec (${codec}).`); + } + if ( + options.codedWidth !== undefined + && (!Number.isInteger(options.codedWidth) || options.codedWidth <= 0) + ) { + throw new TypeError('options.codedWidth, when provided, must be a positive integer.'); + } + if ( + options.codedHeight !== undefined + && (!Number.isInteger(options.codedHeight) || options.codedHeight <= 0) + ) { + throw new TypeError('options.codedHeight, when provided, must be a positive integer.'); + } + if ( + options.displayAspectWidth !== undefined + && (!Number.isInteger(options.displayAspectWidth) || options.displayAspectWidth <= 0) + ) { + throw new TypeError('options.displayAspectWidth, when provided, must be a positive integer.'); + } + if ( + options.displayAspectHeight !== undefined + && (!Number.isInteger(options.displayAspectHeight) || options.displayAspectHeight <= 0) + ) { + throw new TypeError('options.displayAspectHeight, when provided, must be a positive integer.'); + } + if (options.description !== undefined && !isAllowSharedBufferSource(options.description)) { + throw new TypeError('options.description, when provided, must be a buffer source.'); + } + if ( + options.hardwareAcceleration !== undefined + && !['no-preference', 'prefer-hardware', 'prefer-software'].includes(options.hardwareAcceleration) + ) { + throw new TypeError( + 'options.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or' + + ' \'prefer-software\'.', + ); + } + if (options.optimizeForLatency !== undefined && typeof options.optimizeForLatency !== 'boolean') { + throw new TypeError('options.optimizeForLatency, when provided, must be a boolean.'); + } +}; + +const validateAudioDecodingConfig = ( + codec: AudioCodec, + options: SetOptional, +) => { + if (!options || typeof options !== 'object') { + throw new TypeError('options must be an object.'); + } + if (options.codec !== undefined && typeof options.codec !== 'string') { + throw new TypeError('options.codec, when provided, must be a string.'); + } + if (options.codec !== undefined && inferCodecFromCodecString(options.codec) !== codec) { + throw new TypeError(`options.codec, when provided, must match the specified codec (${codec}).`); + } + if ( + options.numberOfChannels !== undefined + && (!Number.isInteger(options.numberOfChannels) || options.numberOfChannels <= 0) + ) { + throw new TypeError('options.numberOfChannels, when provided, must be a positive integer.'); + } + if ( + options.sampleRate !== undefined + && (!Number.isInteger(options.sampleRate) || options.sampleRate <= 0) + ) { + throw new TypeError('options.sampleRate, when provided, must be a positive integer.'); + } + if (options.description !== undefined && !isAllowSharedBufferSource(options.description)) { + throw new TypeError('options.description, when provided, must be a buffer source.'); + } +}; + +/** + * Checks if the browser is able to decode the given codec. + * @group Decoding + * @public + */ +export const canDecode = (codec: MediaCodec) => { + if ((VIDEO_CODECS as readonly string[]).includes(codec)) { + return canDecodeVideo(codec as VideoCodec); + } else if ((AUDIO_CODECS as readonly string[]).includes(codec)) { + return canDecodeAudio(codec as AudioCodec); + } + + return false; +}; + +/** + * Checks if the browser is able to decode the given video codec with the given parameters. + * @group Decoding + * @public + */ +export const canDecodeVideo = async ( + codec: VideoCodec, + options: SetOptional = {}, +) => { + if (!VIDEO_CODECS.includes(codec)) { + return false; + } + + validateVideoDecodingConfig(codec, options); + + const resolvedOptions: VideoDecoderConfig = { + ...options, + codedWidth: options.codedWidth ?? 1280, + codedHeight: options.codedHeight ?? 720, + codec: options.codec ?? buildVideoCodecString(codec, 1280, 720, 1e6), + }; + resolvedOptions.description ??= guessDescriptionForVideo(resolvedOptions); + + const key = JSON.stringify(resolvedOptions); + const memoized = canDecodeVideoMemo.get(key); + if (memoized) { + return memoized; + } + + const promise = (async () => { + if (customVideoDecoders.some(x => x.supports(codec, resolvedOptions))) { + return true; + } + if (typeof VideoDecoder === 'undefined') { + return false; + } + + const support = await VideoDecoder.isConfigSupported(resolvedOptions); + return support.supported === true; + })(); + canDecodeVideoMemo.set(key, promise); + + return promise; +}; + +/** + * Checks if the browser is able to decode the given audio codec with the given parameters. + * @group Decoding + * @public + */ +export const canDecodeAudio = async ( + codec: AudioCodec, + options: SetOptional = {}, +) => { + if (!AUDIO_CODECS.includes(codec)) { + return false; + } + + validateAudioDecodingConfig(codec, options); + + const resolvedOptions: AudioDecoderConfig = { + ...options, + numberOfChannels: options.numberOfChannels ?? 2, + sampleRate: options.sampleRate ?? 48000, + codec: options.codec ?? buildAudioCodecString(codec, 2, 48000), + }; + + if (resolvedOptions.description === undefined) { + const generatedDescription = guessDescriptionForAudio(resolvedOptions); + if (generatedDescription === false) { + return false; + } + + resolvedOptions.description = generatedDescription; + } + + const key = JSON.stringify(resolvedOptions); + const memoized = canDecodeAudioMemo.get(key); + if (memoized) { + return memoized; + } + + const promise = (async () => { + if (customAudioDecoders.some(x => x.supports(codec, resolvedOptions))) { + return true; + } + if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) { + return true; + } + if (typeof AudioDecoder === 'undefined') { + return false; + } + + const support = await AudioDecoder.isConfigSupported(resolvedOptions); + return support.supported === true; + })(); + canDecodeAudioMemo.set(key, promise); + + return promise; +}; + +/** + * Returns the list of all media codecs that can be decoded by the browser. + * @group Decoding + * @public + */ +export const getDecodableCodecs = async (): Promise => { + const [videoCodecs, audioCodecs] = await Promise.all([ + getDecodableVideoCodecs(), + getDecodableAudioCodecs(), + ]); + + return [...videoCodecs, ...audioCodecs]; +}; + +/** + * Returns the list of all video codecs that can be decoded by the browser. + * @group Decoding + * @public + */ +export const getDecodableVideoCodecs = async ( + checkedCodecs: VideoCodec[] = VIDEO_CODECS as unknown as VideoCodec[], + options?: SetOptional, +): Promise => { + const bools = await Promise.all(checkedCodecs.map(codec => canDecodeVideo(codec, options))); + return checkedCodecs.filter((_, i) => bools[i]); +}; + +/** + * Returns the list of all audio codecs that can be decoded by the browser. + * @group Decoding + * @public + */ +export const getDecodableAudioCodecs = async ( + checkedCodecs: AudioCodec[] = AUDIO_CODECS as unknown as AudioCodec[], + options?: SetOptional, +): Promise => { + const bools = await Promise.all(checkedCodecs.map(codec => canDecodeAudio(codec, options))); + return checkedCodecs.filter((_, i) => bools[i]); +}; diff --git a/src/encode.ts b/src/encode.ts index 06ac5b2..537e14c 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -25,6 +25,9 @@ import { customAudioEncoders, customVideoEncoders } from './custom-coder'; import { isFirefox } from './misc'; import { EncodedPacket } from './packet'; +const canEncodeVideoMemo = new Map>(); +const canEncodeAudioMemo = new Map>(); + /** * Configuration object that controls video encoding. Can be used to set codec, quality, and more. * @group Encoding @@ -494,38 +497,7 @@ export const canEncodeVideo = async ( } validateVideoEncodingAdditionalOptions(codec, restOptions); - let encoderConfig: VideoEncoderConfig | null = null; - - if (customVideoEncoders.length > 0) { - encoderConfig ??= buildVideoEncoderConfig({ - codec, - width, - height, - bitrate, - framerate: undefined, - ...restOptions, - }); - - if (customVideoEncoders.some(x => x.supports(codec, encoderConfig!))) { - // There's a custom encoder - return true; - } - } - - if (typeof VideoEncoder === 'undefined') { - return false; - } - - const hasOddDimension = width % 2 === 1 || height % 2 === 1; - if ( - hasOddDimension - && (codec === 'avc' || codec === 'hevc') - ) { - // Disallow odd dimensions for certain codecs - return false; - } - - encoderConfig ??= buildVideoEncoderConfig({ + const encoderConfig = buildVideoEncoderConfig({ codec, width, height, @@ -535,46 +507,74 @@ export const canEncodeVideo = async ( alpha: 'discard', // Since we handle alpha ourselves }); - const support = await VideoEncoder.isConfigSupported(encoderConfig); - if (!support.supported) { - return false; + const key = JSON.stringify(encoderConfig); + const memoized = canEncodeVideoMemo.get(key); + if (memoized) { + return memoized; } - if (isFirefox()) { - // isConfigSupported on Firefox appears to unreliably indicate if encoding will actually succeed. Therefore, we - // just try encoding a frame to see if it actually works. - // https://github.com/Vanilagy/mediabunny/issues/222 + const promise = (async () => { + if (customVideoEncoders.some(x => x.supports(codec, encoderConfig))) { + // There's a custom encoder + return true; + } + if (typeof VideoEncoder === 'undefined') { + return false; + } - // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor - return new Promise(async (resolve) => { - try { - const encoder = new VideoEncoder({ - output: () => {}, - error: () => resolve(false), - }); - encoder.configure(encoderConfig); + const hasOddDimension = width % 2 === 1 || height % 2 === 1; + if ( + hasOddDimension + && (codec === 'avc' || codec === 'hevc') + ) { + // Disallow odd dimensions for certain codecs + return false; + } - const frameData = new Uint8Array(width * height * 4); - const frame = new VideoFrame(frameData, { - format: 'RGBA', - codedWidth: width, - codedHeight: height, - timestamp: 0, - }); + const support = await VideoEncoder.isConfigSupported(encoderConfig); + if (!support.supported) { + return false; + } - encoder.encode(frame); - frame.close(); + if (isFirefox()) { + // isConfigSupported on Firefox appears to unreliably indicate if encoding will actually succeed. Therefore, + // we just try encoding a frame to see if it actually works. + // https://github.com/Vanilagy/mediabunny/issues/222 - await encoder.flush(); + // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor + return new Promise(async (resolve) => { + try { + const encoder = new VideoEncoder({ + output: () => {}, + error: () => resolve(false), + }); + encoder.configure(encoderConfig); + + const frameData = new Uint8Array(width * height * 4); + const frame = new VideoFrame(frameData, { + format: 'RGBA', + codedWidth: width, + codedHeight: height, + timestamp: 0, + }); + + encoder.encode(frame); + frame.close(); + + await encoder.flush(); + + resolve(true); + } catch { + resolve(false); + } + }); + } - resolve(true); - } catch { - resolve(false); - } - }); - } else { return true; - } + })(); + canEncodeVideoMemo.set(key, promise); + + return promise; }; /** @@ -611,32 +611,7 @@ export const canEncodeAudio = async ( } validateAudioEncodingAdditionalOptions(codec, restOptions); - let encoderConfig: AudioEncoderConfig | null = null; - - if (customAudioEncoders.length > 0) { - encoderConfig ??= buildAudioEncoderConfig({ - codec, - numberOfChannels, - sampleRate, - bitrate, - ...restOptions, - }); - - if (customAudioEncoders.some(x => x.supports(codec, encoderConfig!))) { - // There's a custom encoder - return true; - } - } - - if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) { - return true; // Because we encode these ourselves - } - - if (typeof AudioEncoder === 'undefined') { - return false; - } - - encoderConfig ??= buildAudioEncoderConfig({ + const encoderConfig = buildAudioEncoderConfig({ codec, numberOfChannels, sampleRate, @@ -644,8 +619,30 @@ export const canEncodeAudio = async ( ...restOptions, }); - const support = await AudioEncoder.isConfigSupported(encoderConfig); - return support.supported === true; + const key = JSON.stringify(encoderConfig); + const memoized = canEncodeAudioMemo.get(key); + if (memoized) { + return memoized; + } + + const promise = (async () => { + if (customAudioEncoders.some(x => x.supports(codec, encoderConfig))) { + // There's a custom encoder + return true; + } + if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) { + return true; // Because we encode these ourselves + } + if (typeof AudioEncoder === 'undefined') { + return false; + } + + const support = await AudioEncoder.isConfigSupported(encoderConfig); + return support.supported === true; + })(); + canEncodeAudioMemo.set(key, promise); + + return promise; }; /** diff --git a/src/hls/hls-misc.ts b/src/hls/hls-misc.ts index 5e47d31..dce2e15 100644 --- a/src/hls/hls-misc.ts +++ b/src/hls/hls-misc.ts @@ -18,7 +18,7 @@ export class AttributeList { inValue = true; } else if (char === ',' && !inQuotes) { if (key) { - this._attributes[key.toLowerCase()] = value; + this._attributes[key.trim().toLowerCase()] = value; } key = ''; diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 40d03e3..606c993 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -209,7 +209,10 @@ export class HlsSegmentedInput extends SegmentedInput { keyFormat: attributes.get('keyformat') ?? 'identity', }; } else { - throw new Error(`Unsupported encryption method '${method}'.`); + throw new Error( + `Unsupported encryption method '${method}'. If you think this method should be supported,` + + ` please raise an issue.`, + ); } } else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) { const value = line.slice(22); @@ -241,11 +244,11 @@ export class HlsSegmentedInput extends SegmentedInput { // backward from that tag (using EXTINF durations and/or media // timestamps) to associate dates with those segments." const lastSegment = last(segments)!; - const lastSegmentEnd = lastSegment.relativeTimestamp + lastSegment.duration; + const lastSegmentEnd = lastSegment.timestamp + lastSegment.duration; const offset = dateTimeSeconds - lastSegmentEnd; for (const segment of segments) { - segment.relativeTimestamp += offset; + segment.timestamp += offset; segment.relativeToUnixEpoch = true; } diff --git a/src/index.ts b/src/index.ts index b8e9fda..d6db2d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,14 @@ export { NON_PCM_AUDIO_CODECS, SUBTITLE_CODECS, } from './codec'; +export { + canDecode, + canDecodeVideo, + canDecodeAudio, + getDecodableCodecs, + getDecodableVideoCodecs, + getDecodableAudioCodecs, +} from './decode'; export { VideoEncodingConfig, VideoEncodingAdditionalOptions, @@ -119,6 +127,7 @@ export { Rational, Rectangle, Rotation, + SetOptional, SetRequired, asc, desc, diff --git a/src/misc.ts b/src/misc.ts index d8b1b1b..57d100b 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -463,6 +463,13 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON); */ export type SetRequired = T & Required>; +/** + * Sets all keys K of T to be optional. + * @group Miscellaneous + * @public + */ +export type SetOptional = Omit & Partial>; + /** * Merges two RequestInit objects with special handling for headers. * Headers are merged case-insensitively, but original casing is preserved. diff --git a/src/segment.ts b/src/segment.ts index b13cdb2..7cbe2d4 100644 --- a/src/segment.ts +++ b/src/segment.ts @@ -21,7 +21,7 @@ export type SegmentLocation = { export class Segment { input: SegmentedInput; location: SegmentLocation; - relativeTimestamp: number; + timestamp: number; relativeToUnixEpoch: boolean; duration: number; title: string | null; @@ -32,7 +32,7 @@ export class Segment { constructor( input: SegmentedInput, location: SegmentLocation, - relativeTimestamp: number, + timestamp: number, relativeToUnixEpoch: boolean, duration: number, title: string | null, @@ -42,7 +42,7 @@ export class Segment { ) { this.input = input; this.location = location; - this.relativeTimestamp = relativeTimestamp; + this.timestamp = timestamp; this.relativeToUnixEpoch = relativeToUnixEpoch; this.duration = duration; this.title = title; diff --git a/src/segmented-input.ts b/src/segmented-input.ts index acb47f3..88f718a 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -67,7 +67,7 @@ export abstract class SegmentedInput { async getSegmentAt(timestamp: number) { const segments = await this.getSegments(); - const index = binarySearchLessOrEqual(segments, timestamp, x => x.relativeTimestamp); + const index = binarySearchLessOrEqual(segments, timestamp, x => x.timestamp); if (index === -1) { return null; } @@ -175,24 +175,24 @@ class SegmentedInputDemuxer extends Demuxer { } if (firstSegment === segment) { - return firstSegment.relativeTimestamp - firstSegmentFirstTimestamp; + return firstSegment.timestamp - firstSegmentFirstTimestamp; } const segmentFirstTimestamp = await input.getFirstTimestamp(); - const segmentElapsed = segment.relativeTimestamp - firstSegment.relativeTimestamp; + const segmentElapsed = segment.timestamp - firstSegment.timestamp; const inputElapsed = segmentFirstTimestamp - firstSegmentFirstTimestamp; const difference = inputElapsed - segmentElapsed; if (Math.abs(difference) <= Math.min(0.25, segmentElapsed)) { // Heuristic // We're close enough - return firstSegment.relativeTimestamp - firstSegmentFirstTimestamp; + return firstSegment.timestamp - firstSegmentFirstTimestamp; } else { // Ideally, each segment has absolute timestamps that are relative to some outside clock which is // consistent across segments. This is often the case, but not always. Either the container format used is // not timestamped at all (like ADTS), or the segments are just fucky. In this case, use the segment's // relative timestamp to determine where we are, and completely offset out the segment's input start // timestamp. - return segment.relativeTimestamp - segmentFirstTimestamp; + return segment.timestamp - segmentFirstTimestamp; } } } @@ -278,7 +278,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { ), // The 1e8 assumes a max of 100 MB per second, highly unlikely to be hit, so this should guarantee // monotonically increasing sequence numbers across segments. - sequenceNumber: Math.floor(1e8 * segment.relativeTimestamp) + packet.sequenceNumber, + sequenceNumber: Math.floor(1e8 * segment.timestamp) + packet.sequenceNumber, }); this.packetInfos.set(modified, { diff --git a/src/source.ts b/src/source.ts index e504dce..0519ac5 100644 --- a/src/source.ts +++ b/src/source.ts @@ -641,27 +641,6 @@ export class UrlSource extends Source { // logic for that has vanished for now. Leaving a comment here if this becomes relevant again. } - /** @internal */ - private _getTotalLengthFromRangeResponse(response: Response) { - const contentRange = response.headers.get('Content-Range'); - if (contentRange) { - const match = /\/(\d+)/.exec(contentRange); - if (match) { - return Number(match[1]); - } - } - - const contentLength = response.headers.get('Content-Length'); - if (contentLength) { - return Number(contentLength); - } else { - throw new Error( - 'Partial HTTP response (status 206) must surface either Content-Range or' - + ' Content-Length header.', - ); - } - } - /** @internal */ _dispose() { this._orchestrator.dispose(); @@ -1847,6 +1826,7 @@ class ReadOrchestrator { const index = this.workers.indexOf(worker); assert(index !== -1); + worker.running = false; this.workers.splice(index, 1); if (this.fileSize === null) { diff --git a/todo.txt b/todo.txt index e26b64e..2a21225 100644 --- a/todo.txt +++ b/todo.txt @@ -1,15 +1,7 @@ -- "manifest" -> "playlist"? - Retaining Input instances for segments without clogging up memory indefinitely idea: discontinuities with extra decoder config on the packet. Also, why not just have the packet metadata on the packet? I think that would make things easier overall. -Also: for robustness, do a different track matching algorithm for hls playback. so not use pid but make it simpler like - if theres only 1 video track then its obvious yknow. Need to see if this is actually needed in the case of ext-x-discontinuity - -- EXT-X-MEDIA (DONE, but surface more metadata from it, like language and shit) - should it be passed down to the tracks?? -- getSegments() API? Any point in partially reading the playlist file? Idk - -- Timestamp across variants; i think the date should actually be used. Make the timestamp relative to the date? How does that play with timeResolution? - ALL_FORMATS but for HLS only