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