mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
546 lines (545 loc) • 23.8 kB
JavaScript
/*!
* 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, buildAudioCodecString, buildVideoCodecString, getAudioEncoderConfigExtension, getVideoEncoderConfigExtension, inferCodecFromCodecString, PCM_AUDIO_CODECS, SUBTITLE_CODECS, VIDEO_CODECS, } from './codec.js';
import { customAudioEncoders, customVideoEncoders } from './custom-coder.js';
import { isFirefox } from './misc.js';
import { validateCropRectangle } from './sample.js';
export const canEncodeVideoMemo = new Map();
export const canEncodeAudioMemo = new Map();
export const validateVideoEncodingConfig = (config) => {
if (!config || typeof config !== 'object') {
throw new TypeError('Encoding config must be an object.');
}
if (!VIDEO_CODECS.includes(config.codec)) {
throw new TypeError(`Invalid video codec '${config.codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`);
}
if (!(config.bitrate instanceof Quality) && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) {
throw new TypeError('config.bitrate must be a positive integer or a quality.');
}
if (config.keyFrameInterval !== undefined
&& (!Number.isFinite(config.keyFrameInterval) || config.keyFrameInterval < 0)) {
throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.');
}
if (config.sizeChangeBehavior !== undefined
&& !['deny', 'passThrough', 'fill', 'contain', 'cover'].includes(config.sizeChangeBehavior)) {
throw new TypeError('config.sizeChangeBehavior, when provided, must be \'deny\', \'passThrough\', \'fill\', \'contain\''
+ ' or \'cover\'.');
}
if (config.transform !== undefined) {
if (typeof config.transform !== 'object' || !config.transform) {
throw new TypeError('config.transform, when provided, must be an object.');
}
if (config.transform.width !== undefined
&& (!Number.isInteger(config.transform.width) || config.transform.width <= 0)) {
throw new TypeError('config.transform.width, when provided, must be a positive integer.');
}
if (config.transform.height !== undefined
&& (!Number.isInteger(config.transform.height) || config.transform.height <= 0)) {
throw new TypeError('config.transform.height, when provided, must be a positive integer.');
}
if (config.transform.fit !== undefined && !['fill', 'contain', 'cover'].includes(config.transform.fit)) {
throw new TypeError('config.transform.fit, when provided, must be one of "fill", "contain", or "cover".');
}
if (config.transform.width !== undefined
&& config.transform.height !== undefined
&& config.transform.fit === undefined
&& !['fill', 'contain', 'cover'].includes(config.sizeChangeBehavior)) {
throw new TypeError('When both config.transform.width and config.transform.height are provided, config.transform.fit'
+ ' must also be provided.');
}
if (config.transform.fit !== undefined
&& ['fill', 'contain', 'cover'].includes(config.sizeChangeBehavior)
&& config.transform.fit !== config.sizeChangeBehavior) {
throw new TypeError('config.transform.fit, when provided, cannot differ from config.sizeChangeBehavior when'
+ ' config.sizeChangeBehavior is \'fill\', \'contain\' or \'cover\', as sizeChangeBehavior already'
+ ' determines the fitting algorithm.');
}
if (config.transform.rotate !== undefined && ![0, 90, 180, 270].includes(config.transform.rotate)) {
throw new TypeError('config.transform.rotate, when provided, must be 0, 90, 180 or 270.');
}
if (config.transform.crop !== undefined) {
validateCropRectangle(config.transform.crop, 'config.transform.');
}
if (config.transform.process !== undefined && typeof config.transform.process !== 'function') {
throw new TypeError('config.transform.process, when provided, must be a function.');
}
if (config.transform.frameRate !== undefined
&& (!Number.isFinite(config.transform.frameRate) || config.transform.frameRate <= 0)) {
throw new TypeError('config.transform.frameRate, when provided, must be a finite positive number.');
}
if (config.transform.force !== undefined && typeof config.transform.force !== 'boolean') {
throw new TypeError('config.transform.force, when provided, must be a boolean.');
}
}
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedPacket, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
validateVideoEncodingAdditionalOptions(config.codec, config);
};
export const validateVideoEncodingAdditionalOptions = (codec, options) => {
if (!options || typeof options !== 'object') {
throw new TypeError('Encoding options must be an object.');
}
if (options.alpha !== undefined && !['discard', 'keep'].includes(options.alpha)) {
throw new TypeError('options.alpha, when provided, must be \'discard\' or \'keep\'.');
}
if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) {
throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.');
}
if (options.latencyMode !== undefined && !['quality', 'realtime'].includes(options.latencyMode)) {
throw new TypeError('latencyMode, when provided, must be \'quality\' or \'realtime\'.');
}
if (options.fullCodecString !== undefined && typeof options.fullCodecString !== 'string') {
throw new TypeError('fullCodecString, when provided, must be a string.');
}
if (options.fullCodecString !== undefined && inferCodecFromCodecString(options.fullCodecString) !== codec) {
throw new TypeError(`fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`);
}
if (options.hardwareAcceleration !== undefined
&& !['no-preference', 'prefer-hardware', 'prefer-software'].includes(options.hardwareAcceleration)) {
throw new TypeError('hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or'
+ ' \'prefer-software\'.');
}
if (options.scalabilityMode !== undefined && typeof options.scalabilityMode !== 'string') {
throw new TypeError('scalabilityMode, when provided, must be a string.');
}
if (options.contentHint !== undefined && typeof options.contentHint !== 'string') {
throw new TypeError('contentHint, when provided, must be a string.');
}
};
export const buildVideoEncoderConfig = (options) => {
const resolvedBitrate = options.bitrate instanceof Quality
? options.bitrate._toVideoBitrate(options.codec, options.width, options.height)
: options.bitrate;
return {
codec: options.fullCodecString ?? buildVideoCodecString(options.codec, options.width, options.height, resolvedBitrate),
width: options.width,
height: options.height,
displayWidth: options.squarePixelWidth,
displayHeight: options.squarePixelHeight,
bitrate: resolvedBitrate,
bitrateMode: options.bitrateMode,
alpha: options.alpha ?? 'discard',
framerate: options.framerate,
latencyMode: options.latencyMode,
hardwareAcceleration: options.hardwareAcceleration,
scalabilityMode: options.scalabilityMode,
contentHint: options.contentHint,
...getVideoEncoderConfigExtension(options.codec),
};
};
export const validateAudioEncodingConfig = (config) => {
if (!config || typeof config !== 'object') {
throw new TypeError('Encoding config must be an object.');
}
if (!AUDIO_CODECS.includes(config.codec)) {
throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`);
}
if (config.bitrate === undefined
&& !(PCM_AUDIO_CODECS.includes(config.codec) || config.codec === 'flac')) {
throw new TypeError('config.bitrate must be provided for compressed audio codecs.');
}
if (config.bitrate !== undefined
&& !(config.bitrate instanceof Quality)
&& (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) {
throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.');
}
if (config.transform !== undefined) {
if (typeof config.transform !== 'object' || !config.transform) {
throw new TypeError('config.transform, when provided, must be an object.');
}
if (config.transform.numberOfChannels !== undefined
&& (!Number.isInteger(config.transform.numberOfChannels) || config.transform.numberOfChannels <= 0)) {
throw new TypeError('config.transform.numberOfChannels, when provided, must be a positive integer.');
}
if (config.transform.sampleRate !== undefined
&& (!Number.isInteger(config.transform.sampleRate) || config.transform.sampleRate <= 0)) {
throw new TypeError('config.transform.sampleRate, when provided, must be a positive integer.');
}
if (config.transform.sampleFormat !== undefined
&& !['u8', 's16', 's32', 'f32'].includes(config.transform.sampleFormat)) {
throw new TypeError('config.transform.sampleFormat, when provided, must be one of: u8, s16, s32, f32.');
}
if (config.transform.process !== undefined && typeof config.transform.process !== 'function') {
throw new TypeError('config.transform.process, when provided, must be a function.');
}
}
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedPacket, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
validateAudioEncodingAdditionalOptions(config.codec, config);
};
export const validateAudioEncodingAdditionalOptions = (codec, options) => {
if (!options || typeof options !== 'object') {
throw new TypeError('Encoding options must be an object.');
}
if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) {
throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.');
}
if (options.fullCodecString !== undefined && typeof options.fullCodecString !== 'string') {
throw new TypeError('fullCodecString, when provided, must be a string.');
}
if (options.fullCodecString !== undefined && inferCodecFromCodecString(options.fullCodecString) !== codec) {
throw new TypeError(`fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`);
}
};
export const buildAudioEncoderConfig = (options) => {
const resolvedBitrate = options.bitrate instanceof Quality
? options.bitrate._toAudioBitrate(options.codec)
: options.bitrate;
return {
codec: options.fullCodecString ?? buildAudioCodecString(options.codec, options.numberOfChannels, options.sampleRate),
numberOfChannels: options.numberOfChannels,
sampleRate: options.sampleRate,
bitrate: resolvedBitrate,
bitrateMode: options.bitrateMode,
...getAudioEncoderConfigExtension(options.codec),
};
};
/**
* Represents a subjective media quality level.
* @group Encoding
* @public
*/
export class Quality {
/** @internal */
constructor(factor) {
this._factor = factor;
}
/** @internal */
_toVideoBitrate(codec, width, height) {
const pixels = width * height;
const codecEfficiencyFactors = {
avc: 1.0, // H.264/AVC (baseline)
hevc: 0.6, // H.265/HEVC (~40% more efficient than AVC)
vp9: 0.6, // Similar to HEVC
av1: 0.4, // ~60% more efficient than AVC
vp8: 1.2, // Slightly less efficient than AVC
};
const referencePixels = 1920 * 1080;
const referenceBitrate = 3000000;
const scaleFactor = Math.pow(pixels / referencePixels, 0.95); // Slight non-linear scaling
const baseBitrate = referenceBitrate * scaleFactor;
const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec];
const finalBitrate = codecAdjustedBitrate * this._factor;
return Math.ceil(finalBitrate / 1000) * 1000;
}
/** @internal */
_toAudioBitrate(codec) {
if (PCM_AUDIO_CODECS.includes(codec) || codec === 'flac') {
return undefined;
}
const baseRates = {
aac: 128000, // 128kbps base for AAC
opus: 64000, // 64kbps base for Opus
mp3: 160000, // 160kbps base for MP3
vorbis: 64000, // 64kbps base for Vorbis
ac3: 384000, // 384kbps base for AC-3
eac3: 192000, // 192kbps base for E-AC-3
};
const baseBitrate = baseRates[codec];
if (!baseBitrate) {
throw new Error(`Unhandled codec: ${codec}`);
}
let finalBitrate = baseBitrate * this._factor;
if (codec === 'aac') {
// AAC only works with specific bitrates, let's find the closest
const validRates = [96000, 128000, 160000, 192000];
finalBitrate = validRates.reduce((prev, curr) => Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev);
}
else if (codec === 'opus' || codec === 'vorbis') {
finalBitrate = Math.max(6000, finalBitrate);
}
else if (codec === 'mp3') {
const validRates = [
8000, 16000, 24000, 32000, 40000, 48000, 64000, 80000,
96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000,
];
finalBitrate = validRates.reduce((prev, curr) => Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev);
}
return Math.round(finalBitrate / 1000) * 1000;
}
}
/**
* Represents a very low media quality.
* @group Encoding
* @public
*/
export const QUALITY_VERY_LOW = /* #__PURE__ */ new Quality(0.3);
/**
* Represents a low media quality.
* @group Encoding
* @public
*/
export const QUALITY_LOW = /* #__PURE__ */ new Quality(0.6);
/**
* Represents a medium media quality.
* @group Encoding
* @public
*/
export const QUALITY_MEDIUM = /* #__PURE__ */ new Quality(1);
/**
* Represents a high media quality.
* @group Encoding
* @public
*/
export const QUALITY_HIGH = /* #__PURE__ */ new Quality(2);
/**
* Represents a very high media quality.
* @group Encoding
* @public
*/
export const QUALITY_VERY_HIGH = /* #__PURE__ */ new Quality(4);
/**
* Checks if the browser is able to encode the given codec.
* @group Encoding
* @public
*/
export const canEncode = (codec) => {
if (VIDEO_CODECS.includes(codec)) {
return canEncodeVideo(codec);
}
else if (AUDIO_CODECS.includes(codec)) {
return canEncodeAudio(codec);
}
else if (SUBTITLE_CODECS.includes(codec)) {
return canEncodeSubtitles(codec);
}
throw new TypeError(`Unknown codec '${codec}'.`);
};
/**
* Checks if the browser is able to encode the given video codec with the given parameters.
* @group Encoding
* @public
*/
export const canEncodeVideo = async (codec, options = {}) => {
const { width = 1280, height = 720, bitrate = 1e6, ...restOptions } = options;
if (!VIDEO_CODECS.includes(codec)) {
return false;
}
if (!Number.isInteger(width) || width <= 0) {
throw new TypeError('width must be a positive integer.');
}
if (!Number.isInteger(height) || height <= 0) {
throw new TypeError('height must be a positive integer.');
}
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('bitrate must be a positive integer or a quality.');
}
validateVideoEncodingAdditionalOptions(codec, restOptions);
const encoderConfig = buildVideoEncoderConfig({
codec,
width,
height,
bitrate,
framerate: undefined,
...restOptions,
alpha: 'discard', // Since we handle alpha ourselves
});
const key = JSON.stringify(encoderConfig);
const memoized = canEncodeVideoMemo.get(key);
if (memoized) {
return memoized;
}
const promise = (async () => {
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;
}
const support = await VideoEncoder.isConfigSupported(encoderConfig);
if (!support.supported) {
return false;
}
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
// 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);
}
});
}
return true;
})();
canEncodeVideoMemo.set(key, promise);
return promise;
};
/**
* Checks if the browser is able to encode the given audio codec with the given parameters.
* @group Encoding
* @public
*/
export const canEncodeAudio = async (codec, options = {}) => {
const { numberOfChannels = 2, sampleRate = 48000, bitrate = 128e3, ...restOptions } = options;
if (!AUDIO_CODECS.includes(codec)) {
return false;
}
if (!Number.isInteger(numberOfChannels) || numberOfChannels <= 0) {
throw new TypeError('numberOfChannels must be a positive integer.');
}
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
throw new TypeError('sampleRate must be a positive integer.');
}
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('bitrate must be a positive integer.');
}
validateAudioEncodingAdditionalOptions(codec, restOptions);
const encoderConfig = buildAudioEncoderConfig({
codec,
numberOfChannels,
sampleRate,
bitrate,
...restOptions,
});
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.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;
};
/**
* Checks if the browser is able to encode the given subtitle codec.
* @group Encoding
* @public
*/
export const canEncodeSubtitles = async (codec) => {
if (!SUBTITLE_CODECS.includes(codec)) {
return false;
}
return true;
};
/**
* Returns the list of all media codecs that can be encoded by the browser.
* @group Encoding
* @public
*/
export const getEncodableCodecs = async () => {
const [videoCodecs, audioCodecs, subtitleCodecs] = await Promise.all([
getEncodableVideoCodecs(),
getEncodableAudioCodecs(),
getEncodableSubtitleCodecs(),
]);
return [...videoCodecs, ...audioCodecs, ...subtitleCodecs];
};
/**
* Returns the list of all video codecs that can be encoded by the browser.
* @group Encoding
* @public
*/
export const getEncodableVideoCodecs = async (checkedCodecs = VIDEO_CODECS, options) => {
const bools = await Promise.all(checkedCodecs.map(codec => canEncodeVideo(codec, options)));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the list of all audio codecs that can be encoded by the browser.
* @group Encoding
* @public
*/
export const getEncodableAudioCodecs = async (checkedCodecs = AUDIO_CODECS, options) => {
const bools = await Promise.all(checkedCodecs.map(codec => canEncodeAudio(codec, options)));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the list of all subtitle codecs that can be encoded by the browser.
* @group Encoding
* @public
*/
export const getEncodableSubtitleCodecs = async (checkedCodecs = SUBTITLE_CODECS) => {
const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the first video codec from the given list that can be encoded by the browser.
* @group Encoding
* @public
*/
export const getFirstEncodableVideoCodec = async (checkedCodecs, options) => {
for (const codec of checkedCodecs) {
if (await canEncodeVideo(codec, options)) {
return codec;
}
}
return null;
};
/**
* Returns the first audio codec from the given list that can be encoded by the browser.
* @group Encoding
* @public
*/
export const getFirstEncodableAudioCodec = async (checkedCodecs, options) => {
for (const codec of checkedCodecs) {
if (await canEncodeAudio(codec, options)) {
return codec;
}
}
return null;
};
/**
* Returns the first subtitle codec from the given list that can be encoded by the browser.
* @group Encoding
* @public
*/
export const getFirstEncodableSubtitleCodec = async (checkedCodecs) => {
for (const codec of checkedCodecs) {
if (await canEncodeSubtitles(codec)) {
return codec;
}
}
return null;
};