![]()
diff --git a/docs/blog/mediabunny-now-supports-hls.md b/docs/blog/mediabunny-now-supports-hls.md
index 24d379e..49fc127 100644
--- a/docs/blog/mediabunny-now-supports-hls.md
+++ b/docs/blog/mediabunny-now-supports-hls.md
@@ -196,18 +196,18 @@ const output = new Output({
// Full resolution video
const videoSourceFull = new MediaStreamVideoTrackSource(displayTrack, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
}, { timestampBase: 'unix' });
// 480p video
const videoSource480p = new MediaStreamVideoTrackSource(displayTrack, {
codec: 'avc',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: { height: 480 },
}, { timestampBase: 'unix' });
// Audio
const audioSource = new MediaStreamAudioTrackSource(micTrack, {
codec: 'aac',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
}, { timestampBase: 'unix' });
// The "unix" stuff ensures that #EXT-X-PROGRAM-DATE-TIME gets generated
diff --git a/docs/blog/quantizer-support.md b/docs/blog/quantizer-support.md
new file mode 100644
index 0000000..80696d5
--- /dev/null
+++ b/docs/blog/quantizer-support.md
@@ -0,0 +1,125 @@
+---
+title: Quantizer support in Mediabunny v1.52.0
+description: Mediabunny v1.52.0 adds quantizer-based video encoding for AVC, HEVC, VP9, and AV1, enabling constant-quality video encoding.
+publishedOn: Jul 30, 2026
+publishedOnIso: "2026-07-30"
+author: Vanilagy
+authorImage: /vani.png
+authorLink: https://github.com/Vanilagy
+authorSubtitle: Creator of Mediabunny
+headerImage: /crf.png
+excerpt: Mediabunny v1.52.0 adds quantizer-based video encoding for AVC, HEVC, VP9, and AV1. Quality levels now mean constant quality instead of constant bitrate, and existing code gets this improvement for free.
+---
+
+
+
+
![]()
+
+
{{ $frontmatter.publishedOn }}
+
+
{{ $frontmatter.title }}
+
+
+
+Mediabunny [v1.52.0](https://github.com/Vanilagy/mediabunny/releases/tag/v1.52.0) ships with quantizer encoding support for AVC, HEVC, VP9, and AV1!
+
+## What the hell is a quantizer
+
+The quantizer is the knob inside a video encoder that tells it how much data to throw away for compression. A low quantizer keeps basically everything, a high quantizer throws most things away.
+
+This works via rounding: Frames (or the delta between frames) are transformed to a bunch of integer coefficients, and these coefficients then get *divided* by the quantizer (I'm simplifying a little). Since you're working in integer space, a lot of numbers end up rounding to zero and get completely compressed away. When it's time to decode again, all of these numbers are *multiplied* by the quantizer again. This brings them roughly back to where they started originally, but since rounding took place, data loss has occurred. This is why these are considered "lossy" codecs. The reason this works so well is because humans can barely notice that data has been thrown away.
+
+In contrast to setting the encoding *bitrate* (which gives you a roughly constant byte size per frame), setting the *quantizer* gives you a roughly constant *quality* per frame. This is often the more desirable behavior as you enable the codec to spend more bits when it has to (in complex scenes), and allow it to stay lean for simple scenes where not much is changing.
+
+You may know this concept from FFmpeg's CRF (constant-rate factor), which is just another name for the same thing.
+
+## Using it
+
+If you're already encoding with a quality level, you're done - this requires no code change:
+
+
+
+```ts
+const conversion = await Conversion.init({
+ input,
+ output,
+ video: {
+ bitrate: QUALITY_MEDIUM,
+ },
+});
+await conversion.execute();
+```
+
+
+
+Old Mediabunny estimated a reasonable bitrate for the desired quality at the given dimensions, and that was it. New Mediabunny actually ensures that the quality is, in fact, medium-ly good for every frame and thus throughout the entire video.
+
+If you want, you can control it more directly by pinning an exact quantizer value. The scale is codec-native, with lower meaning better: 0-51 for AVC/HEVC, 0-63 for VP9, and 0-255 for AV1.
+
+
+
+```ts
+video: {
+ codec: 'avc',
+ bitrate: new Quality({ quantizer: 26 }),
+}
+```
+
+
+
+You can even change the quantizer per frame when driving the encoder yourself, for example to spend more bits on frames you care about:
+
+
+
+```ts
+const source = new VideoSampleSource({
+ codec: 'avc',
+ bitrate: new Quality({ quantizer: 26 }),
+});
+
+// Later, per sample:
+await source.add(sample, { avc: { quantizer: 40 } });
+```
+
+
+
+Quantizer encoding works in the browser via WebCodecs, and in Node.js via [@mediabunny/server](../guide/extensions/server), which now drives FFmpeg's encoders in constant-quantizer mode. For environments where quantizer mode is not supported but a subjective quality is used (like `QUALITY_MEDIUM`), it automatically falls back to bitrate mode.
+
+## New Quality API
+
+Alongside this, the quality API got a general cleanup. Everything that affects encoding compression now flows through a single `Quality` class, which you can construct from a named level, a custom 0-1 value, or explicit rate control parameters:
+
+
+
+```ts
+new Quality('medium'); // Named level
+new Quality(0.85); // Custom level
+new Quality({ bitrate: 1e6 }); // Explicit bitrate, VBR
+new Quality({ bitrate: 1e6, bitrateMode: 'constant' }); // CBR
+new Quality({ quantizer: 26 }); // Explicit quantizer
+new Quality({ quantizer: 26, bitrate: 1e6 }); // Quantizer with bitrate fallback
+```
+
+
+
+To truly welcome the new `Quality` class into Mediabunny, all fields previously named `bitrate` are now deprecated. The preferred field is now called `quality`:
+
+
+
+```ts
+const conversion = await Conversion.init({
+ input,
+ output,
+ video: {
+ quality: new Quality('medium'),
+ },
+});
+```
+
+
+
+## Detailed documentation
+
+...can be found in [the guide](../guide/media-sources#encoding-quality) and in the [API docs](../api/Quality).
\ No newline at end of file
diff --git a/docs/codec-registry/av1.md b/docs/codec-registry/av1.md
index 95a4b36..5804a7c 100644
--- a/docs/codec-registry/av1.md
+++ b/docs/codec-registry/av1.md
@@ -35,3 +35,7 @@ The full codec string begins with the prefix `'av01.'`, with a variable-length s
## `VideoDecoderConfig` description
`description` is not used for this codec.
+
+## Quantizer range
+
+AV1 has quantizer support and supports quantizer values in the range [0, 255].
\ No newline at end of file
diff --git a/docs/codec-registry/avc.md b/docs/codec-registry/avc.md
index 3bef0c7..0c67cb2 100644
--- a/docs/codec-registry/avc.md
+++ b/docs/codec-registry/avc.md
@@ -42,4 +42,8 @@ The full codec string begins with the prefix `'avc1.'` or `'avc3.'`, with a suff
If the bitstream is in the _canonical_ (length-prefixed) format, `description` must be an `AVCDecoderConfigurationRecord` as defined in [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html) Section 5.3.3.1.
-If the bitstream is in the _Annex B_ format, `description` must be undefined.
\ No newline at end of file
+If the bitstream is in the _Annex B_ format, `description` must be undefined.
+
+## Quantizer range
+
+AVC has quantizer support and supports quantizer values in the range [0, 51].
\ No newline at end of file
diff --git a/docs/codec-registry/hevc.md b/docs/codec-registry/hevc.md
index 5d3111c..0928c66 100644
--- a/docs/codec-registry/hevc.md
+++ b/docs/codec-registry/hevc.md
@@ -43,3 +43,7 @@ The full codec string begins with the prefix `'hev1.'` or `'hvc1.'`, with a vari
If the bitstream is in the _canonical_ (length-prefixed) format, `description` must be an `HEVCDecoderConfigurationRecord` as defined in [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html) Section 8.3.3.1.
If the bitstream is in the _Annex B_ format, `description` must be undefined.
+
+## Quantizer range
+
+HEVC has quantizer support and supports quantizer values in the range [0, 51].
\ No newline at end of file
diff --git a/docs/codec-registry/vp8.md b/docs/codec-registry/vp8.md
index 5847833..f55f7c1 100644
--- a/docs/codec-registry/vp8.md
+++ b/docs/codec-registry/vp8.md
@@ -37,3 +37,7 @@ If the packet's type is `'key'`, then the packet is expected to contain a frame
## `VideoDecoderConfig` description
`description` is not used for this codec.
+
+## Quantizer range
+
+VP8 has no quantizer support.
\ No newline at end of file
diff --git a/docs/codec-registry/vp9.md b/docs/codec-registry/vp9.md
index ca51fe4..88f24a3 100644
--- a/docs/codec-registry/vp9.md
+++ b/docs/codec-registry/vp9.md
@@ -35,3 +35,7 @@ The full codec string begins with the prefix `'vp09.'`, with a variable-length s
## `VideoDecoderConfig` description
`description` is not used for this codec.
+
+## Quantizer range
+
+VP9 has quantizer support and supports quantizer values in the range [0, 63].
\ No newline at end of file
diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md
index 169131e..38440d8 100644
--- a/docs/guide/converting-media-files.md
+++ b/docs/guide/converting-media-files.md
@@ -165,7 +165,7 @@ type ConversionVideoOptions = {
crop?: { left: number; top: number; width: number; height: number };
frameRate?: number;
codec?: VideoCodec;
- bitrate?: number | Quality;
+ quality?: Quality;
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
keyFrameInterval?: number;
@@ -233,7 +233,7 @@ The `frameRate` property can be used to set the frame rate of the output video i
Use the `codec` property to control the codec of the output track. This should be set to a [codec](./supported-formats-and-codecs#video-codecs) supported by the output file, or else the track will be [discarded](#discarded-tracks).
-Use the `bitrate` property to control the bitrate of the output video. For example, you can use this field to compress the video track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value.
+Use the `quality` property to control the quality of the output video. For example, you can use this field to compress the video track. See [Encoding quality](./media-sources#encoding-quality) for more. If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `new Quality('high')` will be used as the value.
Use the `keyFrameInterval` property to control the maximum interval in seconds between key frames in the output video. Setting this fields forces a transcode.
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
@@ -280,7 +280,7 @@ You can set the `audio` property in the conversion options to configure the conv
type ConversionAudioOptions = {
discard?: boolean;
codec?: AudioCodec;
- bitrate?: number | Quality;
+ quality?: Quality;
numberOfChannels?: number;
sampleRate?: number;
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
@@ -325,7 +325,7 @@ The `sampleRate` property controls the sample rate in Hz (e.g., 44100, 48000). I
Use the `codec` property to control the codec of the output track. This should be set to a [codec](./supported-formats-and-codecs#audio-codecs) supported by the output file, or else the track will be [discarded](#discarded-tracks).
-Use the `bitrate` property to control the bitrate of the output audio. For example, you can use this field to compress the audio track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value.
+Use the `quality` property to control the quality of the output audio. For example, you can use this field to compress the audio track. See [Encoding quality](./media-sources#encoding-quality) for more. If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `new Quality('high')` will be used as the value.
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
@@ -387,9 +387,9 @@ const conversion = await Conversion.init({
input,
output,
video: [
- { height: 1080, bitrate: QUALITY_HIGH },
- { height: 720, bitrate: QUALITY_MEDIUM },
- { height: 480, bitrate: QUALITY_LOW },
+ { height: 1080, quality: new Quality('high') },
+ { height: 720, quality: new Quality('medium') },
+ { height: 480, quality: new Quality('low') },
],
});
```
@@ -576,7 +576,10 @@ const conversion = await Conversion.init({
});
// Add our own audio track directly
-const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: 128e3 });
+const audioSource = new AudioBufferSource({
+ codec: 'aac',
+ quality: new Quality({ bitrate: 128e3 }),
+});
output.addAudioTrack(audioSource);
// Start the output
diff --git a/docs/guide/extensions/server.md b/docs/guide/extensions/server.md
index 282ff56..9d3754a 100644
--- a/docs/guide/extensions/server.md
+++ b/docs/guide/extensions/server.md
@@ -56,7 +56,7 @@ registerMediabunnyServer({
Here, we set up a simple media compression server in Node.js. The client's request body is streamed to Mediabunny, the media gets processed, and the output is streamed directly to the disk. Memory usage is O(1) due to pipelining, and an overly fast uploader is automatically slowed down due to stream backpressure.
```ts
-import { ALL_FORMATS, Conversion, FilePathTarget, Input, Mp4OutputFormat, Output, QUALITY_MEDIUM, ReadableStreamSource } from "mediabunny";
+import { ALL_FORMATS, Conversion, FilePathTarget, Input, Mp4OutputFormat, Output, Quality, ReadableStreamSource } from "mediabunny";
import { registerMediabunnyServer } from "@mediabunny/server";
import { Readable } from "node:stream";
import http from "node:http";
@@ -84,7 +84,7 @@ const server = http.createServer(async (req, res) => {
video: async track => ({
codec: 'avc',
height: Math.min(720, await track.getDisplayHeight()),
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
}),
});
await conversion.execute();
diff --git a/docs/guide/media-sources.md b/docs/guide/media-sources.md
index 7949261..277edd4 100644
--- a/docs/guide/media-sources.md
+++ b/docs/guide/media-sources.md
@@ -50,9 +50,8 @@ All video sources that handle encoding internally require you to specify a `Vide
```ts
type VideoEncodingConfig = {
codec: VideoCodec;
- bitrate: number | Quality;
+ quality: Quality;
alpha?: 'discard' | 'keep';
- bitrateMode?: 'constant' | 'variable';
latencyMode?: 'quality' | 'realtime';
keyFrameInterval?: number;
fullCodecString?: string;
@@ -84,11 +83,10 @@ type VideoEncodingConfig = {
};
```
- `codec`: The [video codec](./supported-formats-and-codecs#video-codecs) used for encoding.
-- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
+- `quality`: The desired quality of the encoded video. See [Encoding quality](#encoding-quality).
- `alpha`: What to do with alpha data contained in the video samples.
- `'discard'` (default): Only the samples' color data is kept; the video is opaque.
- `'keep'`: The samples' alpha data is also encoded as side data. Make sure to pair this mode with a container format that supports transparency (such as WebM or Matroska).
-- `bitrateMode`: Can be used to control constant vs. variable bitrate.
- `latencyMode`: The latency mode as specified by the WebCodecs API. Browsers default to `quality`. Media stream-driven video sources will automatically use the `realtime` setting.
- `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 2 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks.
- `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [Mediabunny Codec Registry](/codec-registry/overview). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
@@ -117,8 +115,7 @@ All audio sources that handle encoding internally require you to specify an `Aud
```ts
type AudioEncodingConfig = {
codec: AudioCodec;
- bitrate?: number | Quality;
- bitrateMode?: 'constant' | 'variable';
+ quality?: Quality;
fullCodecString?: string;
transform?: {
@@ -139,8 +136,7 @@ type AudioEncodingConfig = {
};
```
- `codec`: The [audio codec](./supported-formats-and-codecs#audio-codecs) used for encoding. Can be omitted for uncompressed PCM codecs.
-- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
-- `bitrateMode`: Can be used to control constant vs. variable bitrate.
+- `quality`: The desired quality of the encoded audio; unused for PCM codecs. See [Encoding quality](#encoding-quality).
- `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [Mediabunny Codec Registry](/codec-registry/overview). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `transform`: Optional transformations to apply to the audio samples before they are passed to the encoder.
- `numberOfChannels`: The desired number of output channels to up/downmix to.
@@ -149,18 +145,59 @@ type AudioEncodingConfig = {
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
-### Subjective qualities
+### Encoding quality
-Mediabunny provides five subjective quality options as an alternative to manually providing a bitrate. From a subjective quality, a bitrate will be calculated internally based on the codec and track information (width, height, sample rate, ...).
+Mediabunny provides the [`Quality`](../api/Quality) class as a way to describe the desired encoding quality, i.e. how much the media should be compressed.
+
+#### Qualitative quality
+
+Qualitative qualities are defined abstractly will automatically map to an underlying bitrate or quantizer based on codec and media parameters (such as video dimensions).
```ts
-import {
- QUALITY_VERY_LOW,
- QUALITY_LOW,
- QUALITY_MEDIUM,
- QUALITY_HIGH,
- QUALITY_VERY_HIGH,
-} from 'mediabunny';
+import { Quality } from 'mediabunny';
+
+// A Quality can be created from these five pre-defined values
+new Quality('very-low');
+new Quality('low');
+new Quality('medium');
+new Quality('high');
+new Quality('very-high');
+
+// Or from a quality value from 0 to 1. Here, 0 represents the worst quality and
+// 1 the best.
+new Quality(0.6);
+```
+
+#### Quantitative quality
+
+Quantitative qualities allow explicit control over compression parameters.
+
+```ts
+import { Quality } from 'mediabunny';
+
+// 1 Mbps bitrate (variable)
+new Quality({
+ bitrate: 1e6,
+});
+
+// 1 Mbps bitrate (constant)
+new Quality({
+ bitrate: 1e6,
+ bitrateMode: 'constant',
+});
+
+// Specific quantizer value to use. The valid range of values depends on each
+// codec and is defined in the Mediabunny Codec Registry. Throws if quantizer
+// mode is unavailable.
+new Quality({
+ quantizer: 32,
+});
+
+// Quantizer and bitrate combined, with bitrate used as a fallback
+new Quality({
+ quantizer: 32,
+ bitrate: 1e6,
+});
```
## Video sources
@@ -172,11 +209,11 @@ Video sources feed data to video tracks on an `Output`. They all extend the abst
This source takes [video samples](./packets-and-samples#videosample), encodes them, and passes the encoded data to the output.
```ts
-import { VideoSampleSource } from 'mediabunny';
+import { VideoSampleSource, Quality } from 'mediabunny';
const sampleSource = new VideoSampleSource({
codec: 'avc',
- bitrate: 1e6,
+ quality: new Quality({ bitrate: 1e6 }),
});
await sampleSource.add(videoSample);
@@ -191,11 +228,11 @@ await sampleSource.add(videoSample, { keyFrame: true });
This source simplifies a common pattern: A single canvas is repeatedly updated in a render loop and each frame is added to the output file.
```ts
-import { CanvasSource, QUALITY_MEDIUM } from 'mediabunny';
+import { CanvasSource, Quality } from 'mediabunny';
const canvasSource = new CanvasSource(canvasElement, {
codec: 'av1',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
await canvasSource.add(0.0, 0.1); // Timestamp, duration (in seconds)
@@ -211,7 +248,7 @@ await canvasSource.add(0.3, 0.1, { keyFrame: true });
This is a source for use with the [Media Capture and Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API). Use this source if you want to pipe a real-time video source (such as a webcam or screen recording) to an output file.
```ts
-import { MediaStreamVideoTrackSource } from 'mediabunny';
+import { MediaStreamVideoTrackSource, Quality } from 'mediabunny';
// Get the user's screen
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
@@ -219,7 +256,7 @@ const videoTrack = stream.getVideoTracks()[0];
const videoTrackSource = new MediaStreamVideoTrackSource(videoTrack, {
codec: 'vp9',
- bitrate: 1e7,
+ quality: new Quality({ bitrate: 1e7 }),
});
// Make sure to allow any internal errors to properly bubble up
@@ -345,11 +382,11 @@ Audio sources feed data to audio tracks on an `Output`. They all extend the abst
This source takes [audio samples](./packets-and-samples#audiosample), encodes them, and passes the encoded data to the output.
```ts
-import { AudioSampleSource } from 'mediabunny';
+import { AudioSampleSource, Quality } from 'mediabunny';
const sampleSource = new AudioSampleSource({
codec: 'aac',
- bitrate: 128e3,
+ quality: new Quality({ bitrate: 128e3 }),
});
await sampleSource.add(audioSample);
@@ -361,11 +398,11 @@ audioSample.close(); // If it's not needed anymore
This source directly accepts instances of `AudioBuffer` as data, simplifying usage with the Web Audio API. The first AudioBuffer will be played at timestamp 0, and any subsequent AudioBuffer will be appended after all previous AudioBuffers.
```ts
-import { AudioBufferSource, QUALITY_MEDIUM } from 'mediabunny';
+import { AudioBufferSource, Quality } from 'mediabunny';
const bufferSource = new AudioBufferSource({
codec: 'opus',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
await bufferSource.add(audioBuffer1);
@@ -378,7 +415,7 @@ await bufferSource.add(audioBuffer3);
This is a source for use with the [Media Capture and Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API). Use this source if you want to pipe a real-time audio source (such as a microphone or audio from the user's computer) to an output file.
```ts
-import { MediaStreamAudioTrackSource } from 'mediabunny';
+import { MediaStreamAudioTrackSource, Quality } from 'mediabunny';
// Get the user's microphone
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -386,7 +423,7 @@ const audioTrack = stream.getAudioTracks()[0];
const audioTrackSource = new MediaStreamAudioTrackSource(audioTrack, {
codec: 'opus',
- bitrate: 128e3,
+ quality: new Quality({ bitrate: 128e3 }),
});
// Make sure to allow any internal errors to properly bubble up
diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md
index c0a62e5..b947878 100644
--- a/docs/guide/quick-start.md
+++ b/docs/guide/quick-start.md
@@ -226,12 +226,12 @@ Check out [`EncodedPacketSink`](./media-sinks#encodedpacketsink) for the full do
```ts
import {
- Output,
- BufferTarget,
- Mp4OutputFormat,
- CanvasSource,
AudioBufferSource,
- QUALITY_HIGH,
+ BufferTarget,
+ CanvasSource,
+ Mp4OutputFormat,
+ Output,
+ Quality,
} from 'mediabunny';
// An Output represents a new media file
@@ -243,14 +243,14 @@ const output = new Output({
// Example: add a video track driven by a canvas
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
// Example: add an audio track driven by AudioBuffers
const audioSource = new AudioBufferSource({
codec: 'aac',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addAudioTrack(audioSource);
@@ -350,12 +350,12 @@ await uploadComplete;
```ts
import {
- Output,
BufferTarget,
- WebMOutputFormat,
- MediaStreamVideoTrackSource,
MediaStreamAudioTrackSource,
- QUALITY_MEDIUM
+ MediaStreamVideoTrackSource,
+ Output,
+ Quality,
+ WebMOutputFormat,
} from 'mediabunny';
const userMedia = await navigator.mediaDevices.getUserMedia({
@@ -373,7 +373,7 @@ const output = new Output({
if (videoTrack) {
const source = new MediaStreamVideoTrackSource(videoTrack, {
codec: 'vp9',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
output.addVideoTrack(source);
}
@@ -381,7 +381,7 @@ if (videoTrack) {
if (audioTrack) {
const source = new MediaStreamAudioTrackSource(audioTrack, {
codec: 'opus',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
output.addAudioTrack(source);
}
@@ -402,11 +402,11 @@ await output.finalize();
```ts
import {
- Output,
- WebMOutputFormat,
BufferTarget,
CanvasSource,
- QUALITY_MEDIUM,
+ Output,
+ Quality,
+ WebMOutputFormat,
} from 'mediabunny';
const output = new Output({
@@ -420,7 +420,7 @@ const context = canvas.getContext('2d', { alpha: true })!;
const source = new CanvasSource(canvas, {
codec: 'vp9',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
alpha: 'keep', // => Also encode alpha data
});
output.addVideoTrack(source);
@@ -545,10 +545,10 @@ await conversion.execute();
```ts
import {
+ Conversion,
Input,
Output,
- Conversion,
- QUALITY_LOW,
+ Quality,
} from 'mediabunny';
const input = new Input(...);
@@ -560,11 +560,11 @@ const conversion = await Conversion.init({
tracks: 'primary', // Keep only the first track of each type
video: {
width: 480, // Resize to 480p
- bitrate: QUALITY_LOW,
+ quality: new Quality('low'),
},
audio: {
numberOfChannels: 1, // Resample to mono
- bitrate: QUALITY_LOW,
+ quality: new Quality('low'),
},
trim: {
// Let's keep only the first 60 seconds
diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md
index 75b9f9f..d420bf0 100644
--- a/docs/guide/supported-formats-and-codecs.md
+++ b/docs/guide/supported-formats-and-codecs.md
@@ -131,14 +131,14 @@ Video codecs are checked using 1280x720 @1Mbps, while audio codecs are checked u
You can also check encodability using specific configurations:
```ts
-import { canEncodeVideo, canEncodeAudio } from 'mediabunny';
+import { canEncodeVideo, canEncodeAudio, Quality } from 'mediabunny';
canEncodeVideo('hevc', {
- width: 1920, height: 1080, bitrate: 1e7
+ width: 1920, height: 1080, quality: new Quality({ bitrate: 1e7 })
}); // => Promise
canEncodeAudio('aac', {
- numberOfChannels: 1, sampleRate: 44100, bitrate: 192e3
+ numberOfChannels: 1, sampleRate: 44100, quality: new Quality({ bitrate: 192e3 })
}); // => Promise
```
@@ -153,6 +153,7 @@ import {
getEncodableVideoCodecs,
getEncodableAudioCodecs,
getEncodableSubtitleCodecs,
+ Quality,
} from 'mediabunny';
getEncodableCodecs(); // => Promise
@@ -164,7 +165,7 @@ getEncodableSubtitleCodecs(); // => Promise
// Here, we check which of AVC, HEVC and VP8 can be encoded at 1920x1080 @10Mbps:
getEncodableVideoCodecs(
['avc', 'hevc', 'vp8'],
- { width: 1920, height: 1080, bitrate: 1e7 },
+ { width: 1920, height: 1080, quality: new Quality({ bitrate: 1e7 }) },
); // => Promise
```
@@ -176,6 +177,7 @@ import {
getFirstEncodableVideoCodec,
getFirstEncodableAudioCodec,
getFirstEncodableSubtitleCodec,
+ Quality,
} from 'mediabunny';
getFirstEncodableVideoCodec(['avc', 'vp9', 'av1']); // => Promise
@@ -183,7 +185,7 @@ getFirstEncodableAudioCodec(['opus', 'aac']); // => Promise
getFirstEncodableVideoCodec(
['avc', 'hevc', 'vp8'],
- { width: 1920, height: 1080, bitrate: 1e7 },
+ { width: 1920, height: 1080, quality: new Quality({ bitrate: 1e7 }) },
); // => Promise
```
diff --git a/docs/guide/writing-hls.md b/docs/guide/writing-hls.md
index e9a8659..b7602ac 100644
--- a/docs/guide/writing-hls.md
+++ b/docs/guide/writing-hls.md
@@ -218,11 +218,11 @@ Let's suppose we have a 1080p main video stream. We want to provide a 1080p, 720
```ts
const source1080p = new VideoSampleSource({
codec: 'avc',
- bitrate: QUALITY_VERY_HIGH,
+ quality: new Quality('very-high'),
});
const source720p = new VideoSampleSource({
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
transform: {
// Frames will be automatically resized to 720p before being encoded
height: 720,
@@ -230,14 +230,14 @@ const source720p = new VideoSampleSource({
});
const source480p = new VideoSampleSource({
codec: 'avc',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: {
height: 480,
},
});
const source360p = new VideoSampleSource({
codec: 'avc',
- bitrate: QUALITY_LOW,
+ quality: new Quality('low'),
transform: {
height: 360,
},
@@ -269,10 +269,10 @@ const conversion = await Conversion.init({
input,
output,
video: [
- { height: 1080, bitrate: QUALITY_VERY_HIGH },
- { height: 720, bitrate: QUALITY_HIGH },
- { height: 480, bitrate: QUALITY_MEDIUM },
- { height: 360, bitrate: QUALITY_LOW },
+ { height: 1080, quality: new Quality('very-high') },
+ { height: 720, quality: new Quality('high') },
+ { height: 480, quality: new Quality('medium') },
+ { height: 360, quality: new Quality('low') },
],
});
```
diff --git a/docs/guide/writing-media-files.md b/docs/guide/writing-media-files.md
index 1c0137a..b371a2a 100644
--- a/docs/guide/writing-media-files.md
+++ b/docs/guide/writing-media-files.md
@@ -90,19 +90,19 @@ As an example, let's add two tracks to our output:
- An audio track driven by the user's microphone input, encoded using AAC
```ts
-import { CanvasSource, MediaStreamAudioTrackSource } from 'mediabunny';
+import { CanvasSource, MediaStreamAudioTrackSource, Quality } from 'mediabunny';
// Assuming `canvasElement` exists
const videoSource = new CanvasSource(canvasElement, {
codec: 'avc',
- bitrate: 1e6, // 1 Mbps
+ quality: new Quality({ bitrate: 1e6 }), // 1 Mbps
});
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioStreamTrack = stream.getAudioTracks()[0];
const audioSource = new MediaStreamAudioTrackSource(audioStreamTrack, {
codec: 'aac',
- bitrate: 128e3, // 128 kbps
+ quality: new Quality({ bitrate: 128e3 }), // 128 kbps
});
output.addVideoTrack(videoSource, { frameRate: 30 });
diff --git a/docs/index.md b/docs/index.md
index a932805..61ed0f9 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -233,14 +233,14 @@ const output = new Output({
// Add video, driven by a canvas
const videoSource = new CanvasSource(canvas, {
codec: 'av1',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
// Add audio, driven by audio buffers
const audioSource = new AudioBufferSource({
codec: 'opus',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addAudioTrack(audioSource);
diff --git a/docs/public/crf.png b/docs/public/crf.png
new file mode 100644
index 0000000..b4c9dc7
Binary files /dev/null and b/docs/public/crf.png differ
diff --git a/examples/file-compression/file-compression.ts b/examples/file-compression/file-compression.ts
index 1e4bf44..5f9370b 100644
--- a/examples/file-compression/file-compression.ts
+++ b/examples/file-compression/file-compression.ts
@@ -1,13 +1,13 @@
import {
- Input,
ALL_FORMATS,
BlobSource,
- UrlSource,
- Output,
BufferTarget,
- Mp4OutputFormat,
Conversion,
- QUALITY_VERY_LOW,
+ Input,
+ Mp4OutputFormat,
+ Output,
+ Quality,
+ UrlSource,
} from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerProresDecoder } from '@mediabunny/prores';
@@ -72,11 +72,11 @@ const compressFile = async (resource: File | string) => {
tracks: 'primary', // Keep only one track per type
video: {
width: 320, // Height will be deduced automatically to retain aspect ratio
- bitrate: QUALITY_VERY_LOW,
+ quality: new Quality('very-low'),
},
audio: {
codec: 'opus',
- bitrate: QUALITY_VERY_LOW,
+ quality: new Quality('very-low'),
},
});
diff --git a/examples/hls-transcoding/hls-transcoding.ts b/examples/hls-transcoding/hls-transcoding.ts
index c2f9d67..fc13c1d 100644
--- a/examples/hls-transcoding/hls-transcoding.ts
+++ b/examples/hls-transcoding/hls-transcoding.ts
@@ -1,19 +1,15 @@
import {
- Input,
ALL_FORMATS,
BlobSource,
- UrlSource,
+ BufferTarget,
+ Conversion,
+ HlsOutputFormat,
+ Input,
+ MpegTsOutputFormat,
Output,
PathedTarget,
- BufferTarget,
- HlsOutputFormat,
- MpegTsOutputFormat,
- Conversion,
- QUALITY_VERY_HIGH,
- QUALITY_HIGH,
- QUALITY_MEDIUM,
- QUALITY_LOW,
- QUALITY_VERY_LOW,
+ Quality,
+ UrlSource,
} from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerProresDecoder } from '@mediabunny/prores';
@@ -101,14 +97,14 @@ const convertToHls = async (resource: File | string) => {
output,
tracks: 'primary', // Use only the primary video and audio tracks of the input
video: [
- { codec: 'avc', height: 1080, bitrate: QUALITY_VERY_HIGH },
- { codec: 'avc', height: 720, bitrate: QUALITY_HIGH },
- { codec: 'avc', height: 480, bitrate: QUALITY_MEDIUM },
- { codec: 'avc', height: 360, bitrate: QUALITY_LOW },
- { codec: 'avc', height: 240, bitrate: QUALITY_VERY_LOW },
+ { codec: 'avc', height: 1080, quality: new Quality('very-high') },
+ { codec: 'avc', height: 720, quality: new Quality('high') },
+ { codec: 'avc', height: 480, quality: new Quality('medium') },
+ { codec: 'avc', height: 360, quality: new Quality('low') },
+ { codec: 'avc', height: 240, quality: new Quality('very-low') },
],
audio: [
- { codec: 'aac', bitrate: QUALITY_HIGH },
+ { codec: 'aac', quality: new Quality('high') },
],
});
diff --git a/examples/live-recording/live-recording.ts b/examples/live-recording/live-recording.ts
index d3d97a3..8165d91 100644
--- a/examples/live-recording/live-recording.ts
+++ b/examples/live-recording/live-recording.ts
@@ -4,7 +4,7 @@ import {
MediaStreamAudioTrackSource,
Mp4OutputFormat,
Output,
- QUALITY_MEDIUM,
+ Quality,
StreamTarget,
} from 'mediabunny';
@@ -48,7 +48,7 @@ const startRecording = async () => {
context.fillRect(0, 0, canvas.width, canvas.height);
const audioIsEncodable = await canEncodeAudio('opus', {
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
let audioTrack: MediaStreamAudioTrack | null = null;
@@ -99,7 +99,7 @@ const startRecording = async () => {
// Add the video track, with the canvas as the source
videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
keyFrameInterval: 0.5,
latencyMode: 'realtime', // Allow the encoder to skip frames to keep up with real-time constraints
});
@@ -109,7 +109,7 @@ const startRecording = async () => {
// Add the audio track, with the media stream track as the source
const audioSource = new MediaStreamAudioTrackSource(audioTrack, {
codec: 'opus',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
audioSource.errorPromise.catch(cancelRecording); // Make sure errors are bubbled up
diff --git a/examples/procedural-generation/procedural-generation.ts b/examples/procedural-generation/procedural-generation.ts
index ca4d90d..b156283 100644
--- a/examples/procedural-generation/procedural-generation.ts
+++ b/examples/procedural-generation/procedural-generation.ts
@@ -1,13 +1,13 @@
import {
- Output,
- BufferTarget,
- Mp4OutputFormat,
- CanvasSource,
AudioBufferSource,
- QUALITY_HIGH,
+ BufferTarget,
+ CanvasSource,
getFirstEncodableAudioCodec,
getFirstEncodableVideoCodec,
+ Mp4OutputFormat,
+ Output,
OutputFormat,
+ Quality,
} from 'mediabunny';
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
@@ -102,7 +102,7 @@ const generateVideo = async () => {
// For video, we use a CanvasSource for convenience, as we're rendering to a canvas
const canvasSource = new CanvasSource(renderCanvas, {
codec: videoCodec,
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(canvasSource, { frameRate });
@@ -117,7 +117,7 @@ const generateVideo = async () => {
if (audioCodec) {
audioBufferSource = new AudioBufferSource({
codec: audioCodec,
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addAudioTrack(audioBufferSource);
} else {
diff --git a/packages/server/README.md b/packages/server/README.md
index f545c4b..fe6d950 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -60,7 +60,7 @@ registerMediabunnyServer({
Here, we set up a simple media compression server in Node.js. The client's request body is streamed to Mediabunny, the media gets processed, and the output is streamed directly to the disk. Memory usage is O(1) due to pipelining, and an overly fast uploader is automatically slowed down due to stream backpressure.
```ts
-import { ALL_FORMATS, Conversion, FilePathTarget, Input, Mp4OutputFormat, Output, QUALITY_MEDIUM, ReadableStreamSource } from "mediabunny";
+import { ALL_FORMATS, Conversion, FilePathTarget, Input, Mp4OutputFormat, Output, Quality, ReadableStreamSource } from "mediabunny";
import { registerMediabunnyServer } from "@mediabunny/server";
import { Readable } from "node:stream";
import http from "node:http";
@@ -88,7 +88,7 @@ const server = http.createServer(async (req, res) => {
video: async track => ({
codec: 'avc',
height: Math.min(720, await track.getDisplayHeight()),
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
}),
});
await conversion.execute();
diff --git a/packages/server/src/audio-encoder.ts b/packages/server/src/audio-encoder.ts
index 27eb759..9b94106 100644
--- a/packages/server/src/audio-encoder.ts
+++ b/packages/server/src/audio-encoder.ts
@@ -11,7 +11,7 @@ import {
AudioSample,
CustomAudioEncoder,
type MaybePromise,
- QUALITY_MEDIUM,
+ Quality,
EncodedPacket,
} from 'mediabunny';
import * as NodeAv from 'node-av';
@@ -112,7 +112,9 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
codecContext.codecId = CODEC_TO_CODEC_ID[this.codec]!;
codecContext.sampleFormat = sampleFormat;
codecContext.timeBase = new NodeAv.Rational(1, this.config.sampleRate);
- codecContext.bitRate = BigInt(this.config.bitrate ?? QUALITY_MEDIUM._toAudioBitrate(this.codec) ?? 0);
+ codecContext.bitRate = BigInt(
+ this.config.bitrate ?? new Quality('medium')._toAudioBitrate(this.codec) ?? 0,
+ );
if (this.config.bitrateMode === 'constant') {
codecContext.rcMinRate = codecContext.bitRate;
diff --git a/packages/server/src/video-encoder.ts b/packages/server/src/video-encoder.ts
index a37b4ff..628ef07 100644
--- a/packages/server/src/video-encoder.ts
+++ b/packages/server/src/video-encoder.ts
@@ -9,7 +9,7 @@
import {
CustomVideoEncoder,
type MaybePromise,
- QUALITY_MEDIUM,
+ Quality,
VideoCodec,
VideoSample,
EncodedPacket,
@@ -60,6 +60,7 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
lastBuffer: Buffer | null = null;
packetEmitted = false;
lastScalerKey: string | null = null;
+ quantizer: number | null = null;
// Bookkeeping to restore the original timing information
preciseTimings: {
@@ -71,10 +72,14 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
}[] = [];
static override supports(codec: VideoCodec, config: VideoEncoderConfig): boolean {
+ if (config.bitrateMode === 'quantizer') {
+ return codec === 'avc' || codec === 'hevc' || codec === 'vp9' || codec === 'av1';
+ }
+
return (
codec === 'avc' || codec === 'hevc' || codec === 'vp8' || codec === 'vp9' || codec === 'av1'
|| codec === 'prores'
- ) && config.bitrateMode !== 'quantizer';
+ );
}
async init(): Promise {
@@ -106,7 +111,13 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
} else if (this.config.hardwareAcceleration === 'prefer-software') {
codec = getSoftwareCodec();
} else {
- codec = (await getHardwareEncoderCodec(codecId)) ?? getSoftwareCodec();
+ let hardwareCodec = await getHardwareEncoderCodec(codecId);
+ if (hardwareCodec && this.config.bitrateMode === 'quantizer' && !hardwareCodec.name?.endsWith('_nvenc')) {
+ // NVENC is the only hardware encoder we know how to drive in constant-quantizer mode
+ hardwareCodec = null;
+ }
+
+ codec = hardwareCodec ?? getSoftwareCodec();
}
if (!codec) {
@@ -115,7 +126,11 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
this.avCodec = codec;
- await this.createCodecContext();
+ if (this.config.bitrateMode !== 'quantizer') {
+ // In quantizer mode, the codec context is instead created lazily on the first encode, once the quantizer
+ // value is known
+ await this.createCodecContext();
+ }
}
async createCodecContext() {
@@ -156,9 +171,14 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
codecContext.timeBase = new NodeAv.Rational(1, 1e6);
codecContext.gopSize = 60;
codecContext.framerate = new NodeAv.Rational(Math.round(this.config.framerate ?? 0) || 30, 1);
- codecContext.bitRate = BigInt(
- this.config.bitrate ?? QUALITY_MEDIUM._toVideoBitrate(this.codec, this.config.width, this.config.height),
- );
+ // In quantizer mode, the quantizer dictates the rate; a target bitrate would put encoders in the wrong rate
+ // control mode
+ codecContext.bitRate = this.config.bitrateMode === 'quantizer'
+ ? 0n
+ : BigInt(
+ this.config.bitrate ?? new Quality('medium')
+ ._toVideoBitrate(this.codec, this.config.width, this.config.height),
+ );
codecContext.sampleAspectRatio = new NodeAv.Rational(pixelAspectRatio.num, pixelAspectRatio.den);
if (this.config.bitrateMode === 'constant') {
@@ -210,6 +230,41 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
codecContext.setOption('forced-idr', '1');
}
+ if (this.config.bitrateMode === 'quantizer') {
+ assert(this.quantizer !== null);
+
+ // Map the quantizer from the scale used by Mediabunny to the scale expected by the specific FFmpeg encoder
+ let mapped: number;
+ if (this.avCodec.name === 'libaom-av1' || this.avCodec.name === 'libsvtav1') {
+ // Mediabunny uses AV1's quantizer index (0-255), while these encoders expect the 0-63 quantizer scale
+ mapped = Math.round(this.quantizer / 4);
+ } else {
+ // Everything else lines up with Mediabunny directly
+ mapped = this.quantizer;
+ }
+
+ // Put the encoder into its constant-quantizer mode
+ if (this.avCodec.name === 'libx264' || this.avCodec.name === 'libx265') {
+ codecContext.setOption('qp', String(mapped));
+ } else if (this.avCodec.name === 'libvpx-vp9' || this.avCodec.name === 'libaom-av1') {
+ codecContext.setOption('crf', String(mapped));
+ } else if (this.avCodec.name === 'libsvtav1') {
+ // qp (unlike crf) selects true constant-quantizer mode
+ codecContext.setOption('qp', String(mapped));
+ } else if (this.avCodec.name === 'librav1e') {
+ codecContext.setOption('qp', String(mapped));
+ } else if (this.avCodec.name?.endsWith('_nvenc')) {
+ codecContext.setOption('rc', 'constqp');
+ codecContext.setOption('qp', String(mapped));
+ } else {
+ throw new Error(`Encoder '${this.avCodec.name}' cannot be used for quantizer-based encoding.`);
+ }
+
+ // Also pin the quantizer range so crf-based encoders (libvpx, libaom) hold the quantizer truly constant
+ codecContext.qMin = mapped;
+ codecContext.qMax = mapped;
+ }
+
if (this.codec === 'prores') {
// Pick the encoder profile from the requested ProRes four-character code
const profile = PRORES_FOURCC_TO_PROFILE[this.config.codec as ProresFourCc];
@@ -225,6 +280,41 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
}
async encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise {
+ if (this.config.bitrateMode === 'quantizer') {
+ let quantizer: number | null | undefined;
+ if (this.codec === 'avc') {
+ quantizer = options.avc?.quantizer;
+ } else if (this.codec === 'hevc') {
+ quantizer = options.hevc?.quantizer;
+ } else if (this.codec === 'vp9') {
+ quantizer = options.vp9?.quantizer;
+ } else {
+ quantizer = options.av1?.quantizer;
+ }
+ assert(quantizer !== undefined && quantizer !== null);
+
+ if (this.codecContext !== null && quantizer !== this.quantizer) {
+ // Almost no FFmpeg encoder supports changing the quantizer past init, so drain the current encoder
+ // and start a fresh one. Dirty but what else are you gonna do
+ const ret = await this.codecContext.sendFrame(null);
+ NodeAv.FFmpegError.throwIfError(ret, 'Send frame');
+
+ while (true) {
+ const receiveRet = await this.codecContext.receivePacket(this.packet);
+ if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
+ break;
+ }
+
+ this.receivePacket(receiveRet);
+ }
+
+ this.codecContext.freeContext();
+ this.codecContext = null;
+ }
+
+ this.quantizer = quantizer;
+ }
+
if (this.codecContext === null) {
await this.createCodecContext();
}
diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts
index 8e0ad28..319daf5 100644
--- a/scripts/generate-api-docs.ts
+++ b/scripts/generate-api-docs.ts
@@ -1598,8 +1598,12 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const isSimpleUnion = resolvedType.isUnion() && resolvedType.types.every(t =>
t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean),
);
+ // For unions of named types, the properties are documented on the referenced types themselves, so
+ // listing (the intersection of) them here would just be redundant
+ const isUnionOfTypeReferences = ts.isUnionTypeNode(declaration.type)
+ && declaration.type.types.every(t => ts.isTypeReferenceNode(t));
- if (!isPrimitive && !isSimpleUnion) {
+ if (!isPrimitive && !isSimpleUnion && !isUnionOfTypeReferences) {
const typeProperties = typeChecker.getPropertiesOfType(resolvedType);
typeProperties.forEach((prop) => {
diff --git a/src/conversion.ts b/src/conversion.ts
index d9ba4b0..f2f1ca6 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -9,6 +9,7 @@
import {
AUDIO_CODECS,
AudioCodec,
+ MediaCodec,
NON_PCM_AUDIO_CODECS,
VIDEO_CODECS,
VideoCodec,
@@ -18,7 +19,7 @@ import {
getEncodableAudioCodecs,
getFirstEncodableVideoCodec,
Quality,
- QUALITY_HIGH,
+ resolveQuality,
VideoEncodingConfig,
} from './encode';
import { Input } from './input';
@@ -210,7 +211,12 @@ export type ConversionVideoOptions = {
frameRate?: number;
/** The desired output video codec. */
codec?: VideoCodec;
- /** The desired bitrate of the output video. */
+ /** The desired quality of the output video. */
+ quality?: Quality;
+ /**
+ * The desired bitrate of the output video.
+ * @deprecated Use `quality` instead.
+ */
bitrate?: number | Quality;
/**
* Whether to discard or keep the transparency information of the input video. The default is `'discard'`. Note that
@@ -290,7 +296,12 @@ export type ConversionAudioOptions = {
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
/** The desired output audio codec. */
codec?: AudioCodec;
- /** The desired bitrate of the output audio. */
+ /** The desired quality of the output audio. */
+ quality?: Quality;
+ /**
+ * The desired bitrate of the output audio.
+ * @deprecated Use `quality` instead.
+ */
bitrate?: number | Quality;
/** When `true`, audio will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
@@ -342,11 +353,15 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions) => {
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`,
);
}
- if (
- videoOptions?.bitrate !== undefined
- && !(videoOptions.bitrate instanceof Quality)
- && (!Number.isInteger(videoOptions.bitrate) || videoOptions.bitrate <= 0)
- ) {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const bitrate = videoOptions?.bitrate;
+ if (videoOptions?.quality !== undefined && !(videoOptions.quality instanceof Quality)) {
+ throw new TypeError('options.video.quality, when provided, must be a Quality.');
+ }
+ if (videoOptions?.quality !== undefined && bitrate !== undefined) {
+ throw new TypeError('options.video.quality and options.video.bitrate cannot both be provided.');
+ }
+ if (bitrate !== undefined && !(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
}
if (
@@ -450,11 +465,15 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions) => {
`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`,
);
}
- if (
- audioOptions?.bitrate !== undefined
- && !(audioOptions.bitrate instanceof Quality)
- && (!Number.isInteger(audioOptions.bitrate) || audioOptions.bitrate <= 0)
- ) {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const bitrate = audioOptions?.bitrate;
+ if (audioOptions?.quality !== undefined && !(audioOptions.quality instanceof Quality)) {
+ throw new TypeError('options.audio.quality, when provided, must be a Quality.');
+ }
+ if (audioOptions?.quality !== undefined && bitrate !== undefined) {
+ throw new TypeError('options.audio.quality and options.audio.bitrate cannot both be provided.');
+ }
+ if (bitrate !== undefined && !(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
}
if (
@@ -1051,25 +1070,30 @@ export class Conversion {
const codecs = this.discardedTracks.flatMap((x) => {
if (x.reason === 'discarded_by_user') return [];
+ let supportedCodecs: MediaCodec[];
if (x.track.type === 'video') {
- return this.output.format.getSupportedVideoCodecs();
+ supportedCodecs = this.output.format.getSupportedVideoCodecs();
} else if (x.track.type === 'audio') {
- return this.output.format.getSupportedAudioCodecs();
+ supportedCodecs = this.output.format.getSupportedAudioCodecs();
} else {
- return this.output.format.getSupportedSubtitleCodecs();
+ supportedCodecs = this.output.format.getSupportedSubtitleCodecs();
}
+
+ // If the user requested a specific codec, only that codec was ever attempted
+ return supportedCodecs.filter(codec => !x.trackOptions.codec || codec === x.trackOptions.codec);
});
const uniqueCodecs = [...new Set(codecs)];
if (uniqueCodecs.length === 1) {
elements.push(
- `\nTracks were discarded because your environment is not able to encode '${uniqueCodecs[0]}'.`,
+ `\nTracks were discarded because your environment is not able to encode '${uniqueCodecs[0]}'`
+ + ' with the provided parameters.',
);
} else {
elements.push(
- '\nTracks were discarded because your environment is not able to encode any of the following'
- + ` codecs: ${uniqueCodecs.map(x => `'${x}'`).join(', ')}.`,
+ '\nTracks were discarded because your environment is not able to encode any of the codecs'
+ + ` ${uniqueCodecs.map(x => `'${x}'`).join(', ')} with the provided parameters.`,
);
}
@@ -1339,6 +1363,8 @@ export class Conversion {
|| !!trackOptions.frameRate
|| trackOptions.keyFrameInterval !== undefined
|| trackOptions.process !== undefined
+ || trackOptions.quality !== undefined
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
|| trackOptions.bitrate !== undefined
|| !videoCodecs.includes(sourceCodec)
|| (trackOptions.codec && trackOptions.codec !== sourceCodec)
@@ -1410,7 +1436,9 @@ export class Conversion {
videoCodecs = videoCodecs.filter(codec => codec === trackOptions.codec);
}
- const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const quality = resolveQuality(trackOptions.quality, trackOptions.bitrate)
+ ?? new Quality('high');
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, {
width: trackOptions.process && trackOptions.processedWidth
@@ -1419,7 +1447,7 @@ export class Conversion {
height: trackOptions.process && trackOptions.processedHeight
? trackOptions.processedHeight
: height,
- bitrate,
+ quality,
});
if (!encodableCodec) {
this.discardedTracks.push({
@@ -1432,7 +1460,7 @@ export class Conversion {
const encodingConfig: VideoEncodingConfig = {
codec: encodableCodec,
- bitrate,
+ quality,
keyFrameInterval: trackOptions.keyFrameInterval,
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
alpha,
@@ -1598,6 +1626,8 @@ export class Conversion {
let audioCodecs = this.output.format.getSupportedAudioCodecs();
if (
!trackOptions.forceTranscode
+ && !trackOptions.quality
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
&& !trackOptions.bitrate
&& numberOfChannels === originalNumberOfChannels
&& sampleRate === originalSampleRate
@@ -1664,7 +1694,9 @@ export class Conversion {
audioCodecs = audioCodecs.filter(codec => codec === trackOptions.codec);
}
- const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const quality = resolveQuality(trackOptions.quality, trackOptions.bitrate)
+ ?? new Quality('high');
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
numberOfChannels: trackOptions.process && trackOptions.processedNumberOfChannels
@@ -1673,7 +1705,7 @@ export class Conversion {
sampleRate: trackOptions.process && trackOptions.processedSampleRate
? trackOptions.processedSampleRate
: sampleRate,
- bitrate,
+ quality,
});
if (
@@ -1688,7 +1720,7 @@ export class Conversion {
const encodableCodecsWithDefaultParams = await getEncodableAudioCodecs(audioCodecs, {
numberOfChannels: FALLBACK_NUMBER_OF_CHANNELS,
sampleRate: FALLBACK_SAMPLE_RATE,
- bitrate,
+ quality,
});
const nonPcmCodec = encodableCodecsWithDefaultParams
@@ -1714,7 +1746,7 @@ export class Conversion {
const encodingConfig: AudioEncodingConfig = {
codec: codecOfChoice,
- bitrate,
+ quality,
transform: {
sampleFormat: trackOptions.sampleFormat,
process: trackOptions.process,
diff --git a/src/encode.ts b/src/encode.ts
index 386ce72..1aacad2 100644
--- a/src/encode.ts
+++ b/src/encode.ts
@@ -22,7 +22,7 @@ import {
VideoCodec,
} from './codec';
import { customAudioEncoders, customVideoEncoders } from './custom-coder';
-import { isFirefox, MaybePromise, Rotation } from './misc';
+import { assert, clamp, isFirefox, lerp, MaybePromise, Rotation } from './misc';
import { EncodedPacket } from './packet';
import { AudioSample, CropRectangle, validateCropRectangle, VideoSample, VideoSampleResource } from './sample';
@@ -37,11 +37,13 @@ export const canEncodeAudioMemo = new Map>();
export type VideoEncodingConfig = {
/** The video codec that should be used for encoding the video samples (frames). */
codec: VideoCodec;
+ /** The desired quality of the encoded video. */
+ quality?: Quality;
/**
- * The target bitrate for the encoded video, in bits per second. Alternatively, a subjective {@link Quality} can
- * be provided.
+ * The target bitrate for the encoded video, in bits per second. Alternatively, a {@link Quality} can be provided.
+ * @deprecated Use `quality` instead.
*/
- bitrate: number | Quality;
+ bitrate?: number | Quality;
/**
* The interval, in seconds, of how often frames are encoded as a key frame. The default is 2 seconds. Frequent key
* frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them
@@ -150,8 +152,19 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
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.');
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const bitrate = config.bitrate;
+ if (config.quality === undefined && bitrate === undefined) {
+ throw new TypeError('config.quality must be provided.');
+ }
+ if (config.quality !== undefined && bitrate !== undefined) {
+ throw new TypeError('config.quality and config.bitrate cannot both be provided.');
+ }
+ if (config.quality !== undefined && !(config.quality instanceof Quality)) {
+ throw new TypeError('config.quality, when provided, must be a Quality.');
+ }
+ if (bitrate !== undefined && !(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
+ throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.');
}
if (
config.keyFrameInterval !== undefined
@@ -256,7 +269,11 @@ export type VideoEncodingAdditionalOptions = {
* pair this mode with a container format that supports transparency (such as WebM or Matroska).
*/
alpha?: 'discard' | 'keep';
- /** Configures the bitrate mode; defaults to `'variable'`. */
+ /**
+ * Configures the bitrate mode used for bitrate-based encoding; defaults to `'variable'`. A bitrate mode set
+ * directly on a {@link Quality} takes precedence over this field.
+ * @deprecated Specify the bitrate mode in the {@link Quality} instead.
+ */
bitrateMode?: 'constant' | 'variable';
/**
* The latency mode used by the encoder; controls the performance-quality tradeoff.
@@ -295,7 +312,9 @@ export const validateVideoEncodingAdditionalOptions = (codec: VideoCodec, option
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)) {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const bitrateMode = options.bitrateMode;
+ if (bitrateMode !== undefined && !['constant', 'variable'].includes(bitrateMode)) {
throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.');
}
if (options.latencyMode !== undefined && !['quality', 'realtime'].includes(options.latencyMode)) {
@@ -326,33 +345,58 @@ export const validateVideoEncodingAdditionalOptions = (codec: VideoCodec, option
}
};
-export const buildVideoEncoderConfig = (options: {
+export type VideoEncoderConfigCandidate = {
+ config: VideoEncoderConfig;
+ quantizer: number | null; // Since the actual config doesn't contain the quantizer
+};
+
+export type VideoRateControl = {
+ quantizer: number | null;
+ bitrate: number;
+ bitrateMode: 'constant' | 'variable' | 'quantizer';
+};
+
+/**
+ * Builds the encoder configs to attempt, in order of preference. Multiple configs are returned when a Quality can be
+ * satisfied by multiple rate control methods (quantizer-based encoding with a bitrate-based fallback).
+ */
+export const buildVideoEncoderConfigs = (options: {
codec: VideoCodec;
width: number;
height: number;
- bitrate: number | Quality;
+ quality: Quality;
framerate: number | undefined;
squarePixelWidth?: number;
squarePixelHeight?: number;
-} & VideoEncodingAdditionalOptions): VideoEncoderConfig => {
- const resolvedBitrate = options.bitrate instanceof Quality
- ? options.bitrate._toVideoBitrate(options.codec, options.width, options.height)
- : options.bitrate;
+} & VideoEncodingAdditionalOptions): VideoEncoderConfigCandidate[] => {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const fallbackBitrateMode = options.bitrateMode;
- return {
+ const rateControl = options.quality._toVideoRateControl(
+ options.codec,
+ options.width,
+ options.height,
+ fallbackBitrateMode,
+ );
+
+ const buildConfig = (
+ bitrate: number | undefined,
+ bitrateMode: 'constant' | 'variable' | 'quantizer',
+ bitrateEstimate: number,
+ ): VideoEncoderConfig => ({
codec: options.fullCodecString ?? buildVideoCodecString(
options.codec,
options.width,
options.height,
- resolvedBitrate,
+ bitrateEstimate,
options.alpha === 'keep',
),
width: options.width,
height: options.height,
displayWidth: options.squarePixelWidth,
displayHeight: options.squarePixelHeight,
- bitrate: resolvedBitrate,
- bitrateMode: options.bitrateMode,
+ bitrate,
+ bitrateMode,
alpha: options.alpha ?? 'discard',
framerate: options.framerate,
latencyMode: options.latencyMode,
@@ -360,7 +404,27 @@ export const buildVideoEncoderConfig = (options: {
scalabilityMode: options.scalabilityMode,
contentHint: options.contentHint,
...getVideoEncoderConfigExtension(options.codec),
- };
+ });
+
+ const candidates: VideoEncoderConfigCandidate[] = [];
+
+ if (rateControl.quantizer !== null) {
+ candidates.push({
+ config: buildConfig(undefined, 'quantizer', rateControl.bitrate),
+ quantizer: rateControl.quantizer,
+ });
+ }
+
+ if (rateControl.bitrateMode !== 'quantizer') {
+ candidates.push({
+ config: buildConfig(rateControl.bitrate, rateControl.bitrateMode, rateControl.bitrate),
+ quantizer: null,
+ });
+ }
+
+ assert(candidates.length > 0);
+
+ return candidates;
};
/**
@@ -372,8 +436,12 @@ export type AudioEncodingConfig = {
/** The audio codec that should be used for encoding the audio samples. */
codec: AudioCodec;
/**
- * The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective {@link Quality} can
- * be provided. Required for compressed audio codecs, unused for PCM codecs.
+ * The desired quality of the encoded audio. Required for compressed audio codecs, unused for PCM codecs.
+ */
+ quality?: Quality;
+ /**
+ * The target bitrate for the encoded audio, in bits per second. Alternatively, a {@link Quality} can be provided.
+ * @deprecated Use `quality` instead.
*/
bitrate?: number | Quality;
@@ -426,17 +494,22 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
if (!AUDIO_CODECS.includes(config.codec)) {
throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`);
}
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const bitrate = config.bitrate;
if (
- config.bitrate === undefined
+ config.quality === undefined
+ && bitrate === undefined
&& !((PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
) {
- throw new TypeError('config.bitrate must be provided for compressed audio codecs.');
+ throw new TypeError('config.quality must be provided for compressed audio codecs.');
}
- if (
- config.bitrate !== undefined
- && !(config.bitrate instanceof Quality)
- && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)
- ) {
+ if (config.quality !== undefined && bitrate !== undefined) {
+ throw new TypeError('config.quality and config.bitrate cannot both be provided.');
+ }
+ if (config.quality !== undefined && !(config.quality instanceof Quality)) {
+ throw new TypeError('config.quality, when provided, must be a Quality.');
+ }
+ if (bitrate !== undefined && !(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.');
}
if (config.transform !== undefined) {
@@ -484,7 +557,10 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
* @public
*/
export type AudioEncodingAdditionalOptions = {
- /** Configures the bitrate mode. */
+ /**
+ * Configures the bitrate mode. A bitrate mode set directly on a {@link Quality} takes precedence over this field.
+ * @deprecated Specify the bitrate mode in the {@link Quality} instead.
+ */
bitrateMode?: 'constant' | 'variable';
/**
* The full codec string as specified in the Mediabunny Codec Registry. This string must match the codec
@@ -497,7 +573,9 @@ export const validateAudioEncodingAdditionalOptions = (codec: AudioCodec, option
if (!options || typeof options !== 'object') {
throw new TypeError('Encoding options must be an object.');
}
- if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const bitrateMode = options.bitrateMode;
+ if (bitrateMode !== undefined && !['constant', 'variable'].includes(bitrateMode)) {
throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.');
}
if (options.fullCodecString !== undefined && typeof options.fullCodecString !== 'string') {
@@ -514,11 +592,10 @@ export const buildAudioEncoderConfig = (options: {
codec: AudioCodec;
numberOfChannels: number;
sampleRate: number;
- bitrate?: number | Quality;
+ quality?: Quality;
} & AudioEncodingAdditionalOptions): AudioEncoderConfig => {
- const resolvedBitrate = options.bitrate instanceof Quality
- ? options.bitrate._toAudioBitrate(options.codec)
- : options.bitrate;
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const fallbackBitrateMode = options.bitrateMode;
return {
codec: options.fullCodecString ?? buildAudioCodecString(
@@ -528,47 +605,231 @@ export const buildAudioEncoderConfig = (options: {
),
numberOfChannels: options.numberOfChannels,
sampleRate: options.sampleRate,
- bitrate: resolvedBitrate,
- bitrateMode: options.bitrateMode,
+ bitrate: options.quality?._toAudioBitrate(options.codec),
+ bitrateMode: options.quality?._bitrateMode ?? fallbackBitrateMode,
...getAudioEncoderConfigExtension(options.codec),
};
};
/**
- * Represents a subjective media quality level.
+ * A named qualitative quality level.
+ * @group Encoding
+ * @public
+ */
+export type QualityLevel = 'very-low' | 'low' | 'medium' | 'high' | 'very-high';
+
+/**
+ * Quality options expressing a qualitative (subjective) quality level.
+ * @group Encoding
+ * @public
+ */
+export type QualitativeQualityOptions = {
+ /**
+ * A qualitative quality level. Either a number ranging from 0 to 1, where 0 means worst and 1 means best quality,
+ * or one of five named levels ('very-low', 'low', 'medium', 'high', 'very-high'), which map to 0, 0.25, 0.5, 0.75
+ * and 1, respectively.
+ *
+ * Values outside the [0, 1] range are also allowed for extreme behavior, but might break on certain systems.
+ *
+ * Internally, either bitrate- or quantizer-driven encoding will be used, depending on availability and settings.
+ */
+ quality: number | QualityLevel;
+ /**
+ * When true, the quality level always maps to a bitrate, even if quantizer-based encoding is available. Useful
+ * when a predictable output size matters more than constant quality.
+ */
+ preferBitrate?: boolean;
+ /** The bitrate mode to use when encoding resolves to bitrate-based encoding. */
+ bitrateMode?: 'constant' | 'variable';
+};
+
+/**
+ * Quality options expressing quantitative rate control: an explicit bitrate, an explicit quantizer, or both.
+ * @group Encoding
+ * @public
+ */
+export type QuantitativeQualityOptions = {
+ /**
+ * An explicit bitrate in bits per second. When set, this bitrate is used for encoding. It also acts as the
+ * fallback in case a specified quantizer cannot be used.
+ */
+ bitrate?: number;
+ /** The bitrate mode to use when encoding resolves to bitrate-based encoding. */
+ bitrateMode?: 'constant' | 'variable';
+ /**
+ * An explicit quantizer value used for quantizer-based video encoding; lower values mean higher quality. The valid
+ * range depends on the codec and is defined in the
+ * [Mediabunny Codec Registry](https://mediabunny.dev/codec-registry/overview). This option is like FFmpeg's
+ * constant-rate factor (CRF).
+ *
+ * If the quantizer cannot be used due to missing support, then it will throw, unless `bitrate` is defined as a
+ * fallback.
+ */
+ quantizer?: number;
+};
+
+/**
+ * Options describing a desired encoding quality.
+ * @group Encoding
+ * @public
+ */
+export type QualityOptions = QualitativeQualityOptions | QuantitativeQualityOptions;
+
+/**
+ * Represents a desired encoding quality. Can express a qualitative quality level, an explicit bitrate, an explicit
+ * quantizer value, or a combination thereof.
* @group Encoding
* @public
*/
export class Quality {
/** @internal */
- _factor: number;
-
+ _quality: number | undefined;
/** @internal */
- constructor(factor: number) {
- this._factor = factor;
+ _preferBitrate: boolean;
+ /** @internal */
+ _bitrate: number | undefined;
+ /** @internal */
+ _quantizer: number | undefined;
+ /** @internal */
+ _bitrateMode: 'constant' | 'variable' | undefined;
+
+ constructor(options: QualityOptions | number | QualityLevel) {
+ if (typeof options === 'number' || typeof options === 'string') {
+ // Shorthand for directly specifying a qualitative quality level
+ options = { quality: options };
+ }
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) {
+ throw new TypeError('options.bitrateMode, when provided, must be \'constant\' or \'variable\'.');
+ }
+
+ if ('quality' in options) {
+ if (
+ typeof options.quality === 'string'
+ ? !(options.quality in QUALITY_LEVELS)
+ : (typeof options.quality !== 'number' || Number.isNaN(options.quality))
+ ) {
+ throw new TypeError(
+ 'options.quality must be a number, or one of \'very-low\', \'low\', \'medium\', \'high\''
+ + ' or \'very-high\'.',
+ );
+ }
+ if (options.preferBitrate !== undefined && typeof options.preferBitrate !== 'boolean') {
+ throw new TypeError('options.preferBitrate, when provided, must be a boolean.');
+ }
+ if ('bitrate' in options || 'quantizer' in options) {
+ throw new TypeError('options.quality cannot be combined with options.bitrate or options.quantizer.');
+ }
+
+ this._quality = typeof options.quality === 'string'
+ ? QUALITY_LEVELS[options.quality]
+ : options.quality;
+ this._preferBitrate = options.preferBitrate ?? false;
+ this._bitrate = undefined;
+ this._quantizer = undefined;
+ } else {
+ if (options.bitrate !== undefined && (!Number.isInteger(options.bitrate) || options.bitrate <= 0)) {
+ throw new TypeError('options.bitrate, when provided, must be a positive integer.');
+ }
+ if (options.quantizer !== undefined && (!Number.isInteger(options.quantizer) || options.quantizer < 0)) {
+ throw new TypeError('options.quantizer, when provided, must be a non-negative integer.');
+ }
+ if (options.bitrate === undefined && options.quantizer === undefined) {
+ throw new TypeError('At least one of options.bitrate or options.quantizer must be set.');
+ }
+ if ('preferBitrate' in options) {
+ throw new TypeError('options.preferBitrate can only be combined with options.quality.');
+ }
+
+ this._quality = undefined;
+ this._preferBitrate = false;
+ this._bitrate = options.bitrate;
+ this._quantizer = options.quantizer;
+ }
+
+ this._bitrateMode = options.bitrateMode;
+ }
+
+ /**
+ * Determines the rate control methods usable for the given codec.
+ * @internal
+ */
+ _toVideoRateControl(
+ codec: VideoCodec,
+ width: number,
+ height: number,
+ fallbackBitrateMode: 'constant' | 'variable' | undefined,
+ ): VideoRateControl {
+ const quantizerSupport = VIDEO_CODEC_QUANTIZER_SUPPORT[codec];
+
+ let quantizer: number | null = null;
+ let bitrateMode: 'constant' | 'variable' | 'quantizer' = this._bitrateMode ?? fallbackBitrateMode ?? 'variable';
+
+ if (this._quantizer !== undefined) {
+ // An explicit quantizer demands quantizer-based encoding, with an explicit bitrate (if any) being the
+ // only permitted fallback
+ if (!quantizerSupport) {
+ if (this._bitrate === undefined) {
+ throw new Error(
+ `Codec '${codec}' does not support quantizer-based encoding. Provide a bitrate in the Quality`
+ + ` to define a fallback.`,
+ );
+ }
+ } else if (this._quantizer < quantizerSupport.min || this._quantizer > quantizerSupport.max) {
+ if (this._bitrate === undefined) {
+ throw new Error(
+ `Quantizer ${this._quantizer} is out of range for codec '${codec}'; must be between`
+ + ` ${quantizerSupport.min} and ${quantizerSupport.max}.`,
+ );
+ }
+ } else {
+ quantizer = this._quantizer;
+ if (this._bitrate === undefined) {
+ bitrateMode = 'quantizer';
+ }
+ }
+ } else if (this._bitrate === undefined && quantizerSupport && !this._preferBitrate) {
+ // A qualitative quality level is set; offer quantizer-based encoding since the codec supports it. Since
+ // the quality may lie outside the 0-1 range, we clamp the result to the codec's legal quantizer range.
+ assert(this._quality !== undefined);
+ quantizer = clamp(
+ Math.round(lerp(quantizerSupport.worst, quantizerSupport.best, this._quality)),
+ quantizerSupport.min,
+ quantizerSupport.max,
+ );
+ }
+
+ let bitrate: number;
+ if (this._bitrate !== undefined) {
+ bitrate = this._bitrate;
+ } else {
+ let quality = this._quality;
+ if (quality === undefined) {
+ // Map the quantizer back onto the quality scale to derive a fitting bitrate estimate
+ assert(quantizer !== null && quantizerSupport);
+ quality = clamp(
+ (quantizer - quantizerSupport.worst) / (quantizerSupport.best - quantizerSupport.worst),
+ 0,
+ 1,
+ );
+ }
+
+ bitrate = computeVideoBitrate(codec, width, height, qualityToBitrateFactor(quality));
+ }
+
+ return { quantizer, bitrate, bitrateMode };
}
/** @internal */
_toVideoBitrate(codec: VideoCodec, width: number, height: number) {
- const pixels = width * height;
- const referencePixels = 1920 * 1080;
- const referenceBitrate = 3_000_000;
- const scaleFactor = Math.pow(pixels / referencePixels, 0.95); // Slight non-linear scaling
- const baseBitrate = referenceBitrate * scaleFactor;
+ if (this._bitrate !== undefined) {
+ return this._bitrate;
+ }
- const codecEfficiencyFactors: Record = {
- 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
- prores: 220_000_000 / referenceBitrate, // Apple ProRes white paper claims 220 Mbps for 1080p 422 HQ @30Hz
- };
-
- const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec];
- const finalBitrate = codecAdjustedBitrate * this._factor;
-
- return Math.ceil(finalBitrate / 1000) * 1000;
+ assert(this._quality !== undefined);
+ return computeVideoBitrate(codec, width, height, qualityToBitrateFactor(this._quality));
}
/** @internal */
@@ -577,6 +838,19 @@ export class Quality {
return undefined;
}
+ if (this._bitrate !== undefined) {
+ return this._bitrate;
+ }
+
+ if (this._quality === undefined) {
+ throw new Error(
+ 'This Quality defines neither a quality level nor a bitrate and therefore cannot be used for audio'
+ + ' encoding.',
+ );
+ }
+
+ const factor = qualityToBitrateFactor(this._quality);
+
const baseRates = {
aac: 128000, // 128kbps base for AAC
opus: 64000, // 64kbps base for Opus
@@ -591,7 +865,7 @@ export class Quality {
throw new Error(`Unhandled codec: ${codec}`);
}
- let finalBitrate = baseBitrate * this._factor;
+ let finalBitrate = baseBitrate * factor;
if (codec === 'aac') {
// AAC only works with specific bitrates, let's find the closest
@@ -615,36 +889,126 @@ export class Quality {
}
}
+const QUALITY_LEVELS: Record = {
+ 'very-low': 0,
+ 'low': 0.25,
+ 'medium': 0.5,
+ 'high': 0.75,
+ 'very-high': 1,
+};
+
+// best and worse define the reasonable range
+const VIDEO_CODEC_QUANTIZER_SUPPORT: Partial> = {
+ avc: { min: 0, max: 51, worst: 41, best: 16 },
+ hevc: { min: 0, max: 51, worst: 41, best: 16 },
+ vp9: { min: 0, max: 63, worst: 52, best: 20 },
+ av1: { min: 0, max: 255, worst: 208, best: 80 },
+};
+
+/**
+ * Maps the qualitative 0-1 quality scale to a bitrate multiplier. The curve is a least-squares exponential fit through
+ * the multipliers historically used by the predefined quality levels (0.3, 0.6, 1, 2, 4).
+ */
+const qualityToBitrateFactor = (quality: number) => 0.3 * Math.exp(2.5538 * quality);
+
+const computeVideoBitrate = (codec: VideoCodec, width: number, height: number, factor: number) => {
+ const pixels = width * height;
+ const referencePixels = 1920 * 1080;
+ const referenceBitrate = 3_000_000;
+ const scaleFactor = Math.pow(pixels / referencePixels, 0.95); // Slight non-linear scaling
+ const baseBitrate = referenceBitrate * scaleFactor;
+
+ const codecEfficiencyFactors: Record = {
+ 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
+ prores: 220_000_000 / referenceBitrate, // Apple ProRes white paper claims 220 Mbps for 1080p 422 HQ @30Hz
+ };
+
+ const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec];
+ const finalBitrate = codecAdjustedBitrate * factor;
+
+ return Math.ceil(finalBitrate / 1000) * 1000;
+};
+
+/** Builds the per-frame encode options that carry the quantizer value for the given codec. */
+export const buildQuantizerEncodeOptions = (codec: VideoCodec, quantizer: number): VideoEncoderEncodeOptions => {
+ if (codec === 'avc') {
+ return { avc: { quantizer } };
+ } else if (codec === 'hevc') {
+ return { hevc: { quantizer } };
+ } else if (codec === 'vp9') {
+ return { vp9: { quantizer } };
+ } else if (codec === 'av1') {
+ return { av1: { quantizer } };
+ }
+
+ assert(false);
+};
+
+// Adds missing per-frame encode options
+declare global {
+ interface VideoEncoderEncodeOptions {
+ hevc?: VideoEncoderEncodeOptionsForHevc;
+ vp9?: VideoEncoderEncodeOptionsForVp9;
+ av1?: VideoEncoderEncodeOptionsForAv1;
+ }
+
+ interface VideoEncoderEncodeOptionsForHevc {
+ quantizer?: number | null;
+ }
+
+ interface VideoEncoderEncodeOptionsForVp9 {
+ quantizer?: number | null;
+ }
+
+ interface VideoEncoderEncodeOptionsForAv1 {
+ quantizer?: number | null;
+ }
+}
+
/**
* Represents a very low media quality.
+ * @deprecated Use `new Quality('very-low')` instead.
* @group Encoding
* @public
*/
-export const QUALITY_VERY_LOW = /* #__PURE__ */ new Quality(0.3);
+export const QUALITY_VERY_LOW = /* #__PURE__ */ new Quality('very-low');
/**
* Represents a low media quality.
+ * @deprecated Use `new Quality('low')` instead.
* @group Encoding
* @public
*/
-export const QUALITY_LOW = /* #__PURE__ */ new Quality(0.6);
+export const QUALITY_LOW = /* #__PURE__ */ new Quality('low');
/**
* Represents a medium media quality.
+ * @deprecated Use `new Quality('medium')` instead.
* @group Encoding
* @public
*/
-export const QUALITY_MEDIUM = /* #__PURE__ */ new Quality(1);
+export const QUALITY_MEDIUM = /* #__PURE__ */ new Quality('medium');
/**
* Represents a high media quality.
+ * @deprecated Use `new Quality('high')` instead.
* @group Encoding
* @public
*/
-export const QUALITY_HIGH = /* #__PURE__ */ new Quality(2);
+export const QUALITY_HIGH = /* #__PURE__ */ new Quality('high');
/**
* Represents a very high media quality.
+ * @deprecated Use `new Quality('very-high')` instead.
* @group Encoding
* @public
*/
-export const QUALITY_VERY_HIGH = /* #__PURE__ */ new Quality(4);
+export const QUALITY_VERY_HIGH = /* #__PURE__ */ new Quality('very-high');
/**
* Checks if the browser is able to encode the given codec.
@@ -673,13 +1037,17 @@ export const canEncodeVideo = async (
options: {
width?: number;
height?: number;
+ quality?: Quality;
+ /** @deprecated Use `quality` instead. */
bitrate?: number | Quality;
} & VideoEncodingAdditionalOptions = {},
) => {
const {
width = 1280,
height = 720,
- bitrate = 1e6,
+ quality,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ bitrate,
...restOptions
} = options;
@@ -692,31 +1060,48 @@ export const canEncodeVideo = async (
if (!Number.isInteger(height) || height <= 0) {
throw new TypeError('height must be a positive integer.');
}
- if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
+ if (quality !== undefined && !(quality instanceof Quality)) {
+ throw new TypeError('quality, when provided, must be a Quality.');
+ }
+ if (quality !== undefined && bitrate !== undefined) {
+ throw new TypeError('quality and bitrate cannot both be provided.');
+ }
+ if (bitrate !== undefined && !(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 resolvedQuality = resolveQuality(quality, bitrate) ?? new Quality({ bitrate: 1e6 });
- const key = JSON.stringify(encoderConfig);
+ let candidates: VideoEncoderConfigCandidate[];
+ try {
+ candidates = buildVideoEncoderConfigs({
+ codec,
+ width,
+ height,
+ quality: resolvedQuality,
+ framerate: undefined,
+ ...restOptions,
+ alpha: 'discard', // Since we handle alpha ourselves
+ });
+ } catch {
+ // The requested rate control cannot be used with this codec (e.g. a quantizer with no fallback bitrate on a
+ // codec without quantizer support)
+ return false;
+ }
+
+ const key = JSON.stringify(candidates);
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;
+ for (const { config } of candidates) {
+ if (customVideoEncoders.some(x => x.supports(codec, config))) {
+ // There's a custom encoder
+ return true;
+ }
}
if (typeof VideoEncoder === 'undefined') {
return false;
@@ -731,24 +1116,28 @@ export const canEncodeVideo = async (
return false;
}
- const support = await VideoEncoder.isConfigSupported(encoderConfig);
- if (!support.supported) {
- return false;
- }
+ for (const { config, quantizer } of candidates) {
+ const support = await VideoEncoder.isConfigSupported(config);
+ if (!support.supported) {
+ continue;
+ }
+
+ if (!isFirefox()) {
+ return true;
+ }
- 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) => {
+ const success = await new Promise(async (resolve) => {
try {
const encoder = new VideoEncoder({
output: () => {},
error: () => resolve(false),
});
- encoder.configure(encoderConfig);
+ encoder.configure(config);
const frameData = new Uint8Array(width * height * 4);
const frame = new VideoFrame(frameData, {
@@ -758,7 +1147,10 @@ export const canEncodeVideo = async (
timestamp: 0,
});
- encoder.encode(frame);
+ encoder.encode(
+ frame,
+ quantizer !== null ? buildQuantizerEncodeOptions(codec, quantizer) : undefined,
+ );
frame.close();
await encoder.flush();
@@ -768,9 +1160,13 @@ export const canEncodeVideo = async (
resolve(false);
}
});
+
+ if (success) {
+ return true;
+ }
}
- return true;
+ return false;
})();
canEncodeVideoMemo.set(key, promise);
@@ -787,13 +1183,17 @@ export const canEncodeAudio = async (
options: {
numberOfChannels?: number;
sampleRate?: number;
+ quality?: Quality;
+ /** @deprecated Use `quality` instead. */
bitrate?: number | Quality;
} & AudioEncodingAdditionalOptions = {},
) => {
const {
numberOfChannels = 2,
sampleRate = 48000,
- bitrate = 128e3,
+ quality,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ bitrate,
...restOptions
} = options;
@@ -806,16 +1206,24 @@ export const canEncodeAudio = async (
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
throw new TypeError('sampleRate must be a positive integer.');
}
- if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
+ if (quality !== undefined && !(quality instanceof Quality)) {
+ throw new TypeError('quality, when provided, must be a Quality.');
+ }
+ if (quality !== undefined && bitrate !== undefined) {
+ throw new TypeError('quality and bitrate cannot both be provided.');
+ }
+ if (bitrate !== undefined && !(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('bitrate must be a positive integer.');
}
validateAudioEncodingAdditionalOptions(codec, restOptions);
+ const resolvedQuality = resolveQuality(quality, bitrate) ?? new Quality({ bitrate: 128e3 });
+
const encoderConfig = buildAudioEncoderConfig({
codec,
numberOfChannels,
sampleRate,
- bitrate,
+ quality: resolvedQuality,
...restOptions,
});
@@ -845,6 +1253,21 @@ export const canEncodeAudio = async (
return promise;
};
+/**
+ * Resolves the `quality` and deprecated `bitrate` fields from the public API into a {@link Quality}, the norm used
+ * internally.
+ */
+export const resolveQuality = (quality: Quality | undefined, bitrate: number | Quality | undefined) => {
+ if (quality !== undefined) {
+ return quality;
+ }
+ if (bitrate === undefined) {
+ return undefined;
+ }
+
+ return bitrate instanceof Quality ? bitrate : new Quality({ bitrate });
+};
+
/**
* Checks if the browser is able to encode the given subtitle codec.
* @group Encoding
@@ -883,6 +1306,8 @@ export const getEncodableVideoCodecs = async (
options?: {
width?: number;
height?: number;
+ quality?: Quality;
+ /** @deprecated Use `quality` instead. */
bitrate?: number | Quality;
},
): Promise => {
@@ -900,6 +1325,8 @@ export const getEncodableAudioCodecs = async (
options?: {
numberOfChannels?: number;
sampleRate?: number;
+ quality?: Quality;
+ /** @deprecated Use `quality` instead. */
bitrate?: number | Quality;
},
): Promise => {
@@ -929,6 +1356,8 @@ export const getFirstEncodableVideoCodec = async (
options?: {
width?: number;
height?: number;
+ quality?: Quality;
+ /** @deprecated Use `quality` instead. */
bitrate?: number | Quality;
},
): Promise => {
@@ -951,6 +1380,8 @@ export const getFirstEncodableAudioCodec = async (
options?: {
numberOfChannels?: number;
sampleRate?: number;
+ quality?: Quality;
+ /** @deprecated Use `quality` instead. */
bitrate?: number | Quality;
},
): Promise => {
diff --git a/src/index.ts b/src/index.ts
index 8933d0e..ff9e5f9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -122,10 +122,19 @@ export {
getFirstEncodableAudioCodec,
getFirstEncodableSubtitleCodec,
Quality,
+ type QualityOptions,
+ type QualitativeQualityOptions,
+ type QuantitativeQualityOptions,
+ type QualityLevel,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
QUALITY_VERY_LOW,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
QUALITY_LOW,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
QUALITY_MEDIUM,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
QUALITY_HIGH,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
QUALITY_VERY_HIGH,
} from './encode';
export {
diff --git a/src/media-source.ts b/src/media-source.ts
index e51379b..67d872b 100644
--- a/src/media-source.ts
+++ b/src/media-source.ts
@@ -58,9 +58,12 @@ import {
import {
AudioEncodingConfig,
buildAudioEncoderConfig,
- buildVideoEncoderConfig,
+ buildQuantizerEncodeOptions,
+ buildVideoEncoderConfigs,
+ resolveQuality,
validateAudioEncodingConfig,
validateVideoEncodingConfig,
+ VideoEncoderConfigCandidate,
VideoEncodingConfig,
} from './encode';
import { AudioResampler } from './resample';
@@ -255,6 +258,9 @@ class VideoEncoderWrapper {
private customEncoderCallSerializer = new CallSerializer();
private customEncoderQueueSize = 0;
+ // Set when the encoder uses quantizer-based rate control; carries the quantizer value applied to each frame
+ private defaultEncodeOptions: VideoEncoderEncodeOptions = {};
+
// Alpha stuff
private alphaEncoder: VideoEncoder | null = null;
private splitter: ColorAlphaSplitter | null = null;
@@ -497,7 +503,11 @@ class VideoEncoderWrapper {
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2;
const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval);
- const mergedEncodeOptions = { ...sampleToEncode.encodeOptions, ...encodeOptions };
+ const mergedEncodeOptions = {
+ ...this.defaultEncodeOptions,
+ ...sampleToEncode.encodeOptions,
+ ...encodeOptions,
+ };
const finalEncodeOptions = {
...mergedEncodeOptions,
@@ -638,20 +648,98 @@ class VideoEncoderWrapper {
private ensureEncoder(videoSample: VideoSample) {
this.ensureEncoderPromise = (async () => {
- const encoderConfig = buildVideoEncoderConfig({
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const quality = resolveQuality(this.encodingConfig.quality, this.encodingConfig.bitrate);
+ assert(quality !== undefined);
+
+ const candidates = buildVideoEncoderConfigs({
...this.encodingConfig,
+ quality,
width: videoSample.codedWidth,
height: videoSample.codedHeight,
squarePixelWidth: videoSample.squarePixelWidth,
squarePixelHeight: videoSample.squarePixelHeight,
framerate: this.source._connectedTrack?.metadata.frameRate,
});
- this.encodingConfig.onEncoderConfig?.(encoderConfig);
- const MatchingCustomEncoder = customVideoEncoders.find(x => x.supports(
- this.encodingConfig.codec,
- encoderConfig,
- ));
+ // Try the candidate configs in order of preference until we find one that is supported
+ let selected: VideoEncoderConfigCandidate | null = null;
+ let MatchingCustomEncoder: (typeof customVideoEncoders)[number] | undefined;
+
+ for (const candidate of candidates) {
+ const candidateConfig = candidate.config;
+ this.encodingConfig.onEncoderConfig?.(candidateConfig);
+
+ MatchingCustomEncoder = customVideoEncoders.find(x => x.supports(
+ this.encodingConfig.codec,
+ candidateConfig,
+ ));
+ if (MatchingCustomEncoder) {
+ selected = candidate;
+ break;
+ }
+
+ if (typeof VideoEncoder === 'undefined') {
+ continue;
+ }
+
+ candidateConfig.alpha = 'discard'; // Since we handle alpha ourselves
+
+ if (this.encodingConfig.alpha === 'keep') {
+ // Encoding alpha requires using two parallel encoders, so we need to make sure they stay in sync
+ // and that neither of them drops frames. Setting latencyMode to 'quality' achieves this, because
+ // "User Agents MUST not drop frames to achieve the target bitrate and/or framerate."
+ candidateConfig.latencyMode = 'quality';
+ }
+
+ const hasOddDimension = candidateConfig.width % 2 === 1 || candidateConfig.height % 2 === 1;
+ if (
+ hasOddDimension
+ && (this.encodingConfig.codec === 'avc' || this.encodingConfig.codec === 'hevc')
+ ) {
+ // Throw a special error for this case as it gets hit often
+ throw new Error(
+ `The dimensions ${candidateConfig.width}x${candidateConfig.height} are not supported for codec`
+ + ` '${this.encodingConfig.codec}'; both width and height must be even numbers. Make sure to`
+ + ` round your dimensions to the nearest even number.`,
+ );
+ }
+
+ const support = await VideoEncoder.isConfigSupported(candidateConfig);
+ if (support.supported) {
+ selected = candidate;
+ break;
+ }
+ }
+
+ if (!selected) {
+ if (typeof VideoEncoder === 'undefined') {
+ throw new Error('VideoEncoder is not supported by this browser.');
+ }
+
+ // The candidates only differ in their rate control, so we describe them as one config with a
+ // slash-separated list of the attempted rate control methods
+ const firstConfig = candidates[0]!.config;
+ const rateControls = candidates.map(({ config, quantizer }) =>
+ quantizer !== null ? `quantizer ${quantizer}` : `${config.bitrate} bps`,
+ );
+
+ throw new Error(
+ `This specific encoder configuration (${firstConfig.codec}, ${rateControls.join(' / ')},`
+ + ` ${firstConfig.width}x${firstConfig.height}, hardware acceleration:`
+ + ` ${firstConfig.hardwareAcceleration ?? 'no-preference'}) is not supported by this browser.`
+ + ` Consider using another codec or changing your video parameters.`,
+ );
+ }
+
+ const encoderConfig = selected.config;
+ if (selected.quantizer !== null) {
+ // The chosen config uses quantizer-based rate control, so each frame must carry the quantizer value
+ this.defaultEncodeOptions = buildQuantizerEncodeOptions(
+ this.encodingConfig.codec,
+ selected.quantizer,
+ );
+ }
if (MatchingCustomEncoder) {
// @ts-expect-error "Can't create instance of abstract class 🤓"
@@ -685,42 +773,6 @@ class VideoEncoderWrapper {
await this.customEncoder.init();
} else {
- if (typeof VideoEncoder === 'undefined') {
- throw new Error('VideoEncoder is not supported by this browser.');
- }
-
- encoderConfig.alpha = 'discard'; // Since we handle alpha ourselves
-
- if (this.encodingConfig.alpha === 'keep') {
- // Encoding alpha requires using two parallel encoders, so we need to make sure they stay in sync
- // and that neither of them drops frames. Setting latencyMode to 'quality' achieves this, because
- // "User Agents MUST not drop frames to achieve the target bitrate and/or framerate."
- encoderConfig.latencyMode = 'quality';
- }
-
- const hasOddDimension = encoderConfig.width % 2 === 1 || encoderConfig.height % 2 === 1;
- if (
- hasOddDimension
- && (this.encodingConfig.codec === 'avc' || this.encodingConfig.codec === 'hevc')
- ) {
- // Throw a special error for this case as it gets hit often
- throw new Error(
- `The dimensions ${encoderConfig.width}x${encoderConfig.height} are not supported for codec`
- + ` '${this.encodingConfig.codec}'; both width and height must be even numbers. Make sure to`
- + ` round your dimensions to the nearest even number.`,
- );
- }
-
- const support = await VideoEncoder.isConfigSupported(encoderConfig);
- if (!support.supported) {
- throw new Error(
- `This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps,`
- + ` ${encoderConfig.width}x${encoderConfig.height}, hardware acceleration:`
- + ` ${encoderConfig.hardwareAcceleration ?? 'no-preference'}) is not supported by this browser.`
- + ` Consider using another codec or changing your video parameters.`,
- );
- }
-
/** Queue of color chunks waiting for their alpha counterpart. */
const colorChunkQueue: {
chunk: EncodedVideoChunk;
@@ -805,6 +857,7 @@ class VideoEncoderWrapper {
if (alphaFrame) {
this.alphaEncoder.encode(alphaFrame, {
+ ...this.defaultEncodeOptions,
// Crucial: The alpha frame is forced to be a key frame whenever the color frame
// also is. Without this, playback can glitch and even crash in some browsers.
// This is the reason why the two encoders are wired in series and not in parallel.
@@ -2104,10 +2157,14 @@ class AudioEncoderWrapper {
this.ensureEncoderPromise = (async () => {
const { numberOfChannels, sampleRate } = audioSample;
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const quality = resolveQuality(this.encodingConfig.quality, this.encodingConfig.bitrate);
+
const encoderConfig = buildAudioEncoderConfig({
numberOfChannels,
sampleRate,
...this.encodingConfig,
+ quality,
});
this.encodingConfig.onEncoderConfig?.(encoderConfig);
diff --git a/src/misc.ts b/src/misc.ts
index 6c923df..bd6979d 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -431,6 +431,10 @@ export const clamp = (value: number, min: number, max: number) => {
return Math.max(min, Math.min(max, value));
};
+export const lerp = (from: number, to: number, t: number) => {
+ return from + (to - from) * t;
+};
+
export const UNDETERMINED_LANGUAGE = 'und';
export const roundIfAlmostInteger = (value: number) => {
diff --git a/test/browser/cmaf.test.ts b/test/browser/cmaf.test.ts
index 036da0c..9676975 100644
--- a/test/browser/cmaf.test.ts
+++ b/test/browser/cmaf.test.ts
@@ -3,7 +3,7 @@ import { Output } from '../../src/output.js';
import { CmafOutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { CanvasSource } from '../../src/media-source.js';
-import { QUALITY_HIGH } from '../../src/encode.js';
+import { Quality } from '../../src/encode.js';
import { Input } from '../../src/input.js';
import { BufferSource } from '../../src/source.js';
import { ALL_FORMATS } from '../../src/input-format.js';
@@ -17,7 +17,7 @@ test('CMAF throws without initTarget', async () => {
const canvas = new OffscreenCanvas(640, 480);
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
@@ -44,7 +44,7 @@ test('CMAF with video track', async () => {
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
@@ -97,7 +97,7 @@ test('CMAF with empty video track', async () => {
const canvas = new OffscreenCanvas(640, 480);
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
diff --git a/test/browser/conversion.test.ts b/test/browser/conversion.test.ts
index 955ab0a..8cdabc7 100644
--- a/test/browser/conversion.test.ts
+++ b/test/browser/conversion.test.ts
@@ -15,7 +15,7 @@ import { Conversion, ConversionCanceledError } from '../../src/conversion.js';
import { assert } from '../../src/misc.js';
import { InputVideoTrack } from '../../src/input-track.js';
import { CanvasSource, EncodedAudioPacketSource } from '../../src/media-source.js';
-import { QUALITY_HIGH } from '../../src/encode.js';
+import { Quality } from '../../src/encode.js';
import { EncodedPacket } from '../../src/packet.js';
test('Rotation is baked in when rerendering', async () => {
@@ -161,7 +161,7 @@ test('HLS track assignability is kept #1', async () => {
ctx.fillStyle = 'red';
ctx.fillRect(100, 100, 200, 200);
- const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH });
+ const videoSource = new CanvasSource(canvas, { codec: 'avc', quality: new Quality('high') });
output.addVideoTrack(videoSource);
const audioSource = new EncodedAudioPacketSource('aac');
@@ -240,7 +240,7 @@ test('HLS track assignability is kept #2', async () => {
const a = new OutputTrackGroup();
const b = new OutputTrackGroup();
- const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH });
+ const videoSource = new CanvasSource(canvas, { codec: 'avc', quality: new Quality('high') });
output.addVideoTrack(videoSource, { group: a });
const audioSource = new EncodedAudioPacketSource('aac');
@@ -319,7 +319,7 @@ test('HLS track assignability can be overridden', async () => {
const a = new OutputTrackGroup();
const b = new OutputTrackGroup();
- const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH });
+ const videoSource = new CanvasSource(canvas, { codec: 'avc', quality: new Quality('high') });
output.addVideoTrack(videoSource, { group: a });
const audioSource = new EncodedAudioPacketSource('aac');
diff --git a/test/browser/encode-dimensions.test.ts b/test/browser/encode-dimensions.test.ts
index 2a7320c..6e86e19 100644
--- a/test/browser/encode-dimensions.test.ts
+++ b/test/browser/encode-dimensions.test.ts
@@ -3,7 +3,7 @@ import { Output } from '../../src/output.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
import { NullTarget } from '../../src/target.js';
import { VideoSampleSource } from '../../src/media-source.js';
-import { canEncodeVideo, QUALITY_HIGH } from '../../src/encode.js';
+import { canEncodeVideo, Quality } from '../../src/encode.js';
import { VideoSample } from '../../src/sample.js';
test('Odd video dimensions fail for AVC', async () => {
@@ -14,7 +14,7 @@ test('Odd video dimensions fail for AVC', async () => {
const source = new VideoSampleSource({
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(source);
@@ -36,7 +36,7 @@ test('Odd video dimensions fail for HEVC', async () => {
const source = new VideoSampleSource({
codec: 'hevc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(source);
@@ -58,7 +58,7 @@ test('Odd video dimensions pass for VP9', async () => {
const source = new VideoSampleSource({
codec: 'vp9',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(source);
diff --git a/test/browser/hls-output.test.ts b/test/browser/hls-output.test.ts
index f231276..0a9b4fc 100644
--- a/test/browser/hls-output.test.ts
+++ b/test/browser/hls-output.test.ts
@@ -3,7 +3,7 @@ import { Output } from '../../src/output.js';
import { HlsOutputFormat, MpegTsOutputFormat } from '../../src/output-format.js';
import { BufferTarget, PathedTarget } from '../../src/target.js';
import { CanvasSource } from '../../src/media-source.js';
-import { QUALITY_HIGH } from '../../src/encode.js';
+import { Quality } from '../../src/encode.js';
test('HLS output, key frames aligning with segment boundaries by default', async () => {
let playlistText: string | null = null;
@@ -23,7 +23,7 @@ test('HLS output, key frames aligning with segment boundaries by default', async
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
diff --git a/test/browser/media-sources.test.ts b/test/browser/media-sources.test.ts
index 7742163..fbae31f 100644
--- a/test/browser/media-sources.test.ts
+++ b/test/browser/media-sources.test.ts
@@ -4,7 +4,7 @@ import { Mp4OutputFormat, WebMOutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { AudioSampleSource, VideoSampleSource } from '../../src/media-source.js';
import { AudioSample, VideoSample } from '../../src/sample.js';
-import { QUALITY_MEDIUM } from '../../src/encode.js';
+import { Quality } from '../../src/encode.js';
import { Input } from '../../src/input.js';
import { ALL_FORMATS } from '../../src/input-format.js';
import { BufferSource } from '../../src/source.js';
@@ -14,7 +14,7 @@ import { InputAudioTrack, InputVideoTrack } from '../../src/input-track.js';
test('VideoSampleSource, normal usage', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM },
+ { codec: 'vp8', quality: new Quality('medium') },
[{ width: 100, height: 100 }, { width: 100, height: 100 }, { width: 100, height: 100 }],
);
@@ -35,7 +35,7 @@ test('VideoSampleSource, .close() should be idempotent after finalize()', async
const videoSource = new VideoSampleSource({
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
output.addVideoTrack(videoSource);
@@ -63,7 +63,7 @@ test('VideoSampleSource, changing input dimensions throws with deny (default)',
const videoSource = new VideoSampleSource({
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
});
output.addVideoTrack(videoSource);
@@ -82,7 +82,7 @@ test('VideoSampleSource, changing input dimensions throws with deny (default)',
test('VideoSampleSource, changing input dimensions with passThrough preserves per-frame dimensions', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, sizeChangeBehavior: 'passThrough' },
+ { codec: 'vp8', quality: new Quality('medium'), sizeChangeBehavior: 'passThrough' },
[{ width: 100, height: 100 }, { width: 200, height: 150 }],
);
@@ -101,7 +101,7 @@ test(
async () => {
for (const behavior of ['fill', 'contain', 'cover'] as const) {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, sizeChangeBehavior: behavior },
+ { codec: 'vp8', quality: new Quality('medium'), sizeChangeBehavior: behavior },
[{ width: 100, height: 100 }, { width: 200, height: 150 }],
);
@@ -115,7 +115,7 @@ test(
test('VideoSampleSource, same-sized frames with width and height set', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { width: 50, height: 80, fit: 'fill' } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { width: 50, height: 80, fit: 'fill' } },
[{ width: 100, height: 100 }, { width: 100, height: 100 }],
);
@@ -127,7 +127,7 @@ test('VideoSampleSource, same-sized frames with width and height set', async ()
test('VideoSampleSource, same-sized frames with rotation set to 90', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { rotate: 90 } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { rotate: 90 } },
[{ width: 200, height: 100 }, { width: 200, height: 100 }],
);
@@ -139,7 +139,11 @@ test('VideoSampleSource, same-sized frames with rotation set to 90', async () =>
test('VideoSampleSource, same-sized frames with rotation, width and height', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { rotate: 90, width: 50, height: 80, fit: 'contain' } },
+ {
+ codec: 'vp8',
+ quality: new Quality('medium'),
+ transform: { rotate: 90, width: 50, height: 80, fit: 'contain' },
+ },
[{ width: 200, height: 100 }, { width: 200, height: 100 }],
);
@@ -151,7 +155,7 @@ test('VideoSampleSource, same-sized frames with rotation, width and height', asy
test('VideoSampleSource, changing dimensions with passThrough and rotation 90', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, sizeChangeBehavior: 'passThrough', transform: { rotate: 90 } },
+ { codec: 'vp8', quality: new Quality('medium'), sizeChangeBehavior: 'passThrough', transform: { rotate: 90 } },
[{ width: 200, height: 100 }, { width: 300, height: 150 }],
);
@@ -169,7 +173,7 @@ test('VideoSampleSource, changing dimensions with passThrough, width and height
const buffer = await encodeFrames(
{
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
sizeChangeBehavior: 'passThrough',
transform: {
width: 50,
@@ -189,7 +193,7 @@ test('VideoSampleSource, changing dimensions with passThrough, width and height
test('VideoSampleSource, encoding rotated video frames', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM },
+ { codec: 'vp8', quality: new Quality('medium') },
[{ width: 200, height: 100, rotation: 90 }, { width: 200, height: 100, rotation: 90 }],
);
@@ -204,7 +208,7 @@ test('VideoSampleSource, encoding rotated video frames', async () => {
test('VideoSampleSource, encoding rotated video frames with forced transform', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { force: true } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { force: true } },
[{ width: 200, height: 100, rotation: 90 }, { width: 200, height: 100, rotation: 90 }],
);
@@ -219,7 +223,7 @@ test('VideoSampleSource, encoding rotated video frames with forced transform', a
test('VideoSampleSource, transform.process identity function', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { process: sample => sample } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { process: sample => sample } },
[{ width: 100, height: 100 }, { width: 100, height: 100 }, { width: 100, height: 100 }],
);
@@ -235,7 +239,7 @@ test('VideoSampleSource, transform.process manual resize', async () => {
const buffer = await encodeFrames(
{
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: {
process: (sample) => {
const canvas = new OffscreenCanvas(60, 40);
@@ -262,7 +266,7 @@ test('VideoSampleSource, transform.process receives pre-transformed frames', asy
const buffer = await encodeFrames(
{
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: {
width: 50,
height: 80,
@@ -297,7 +301,7 @@ test('VideoSampleSource, transform.process drops all frames after the first', as
const buffer = await encodeFrames(
{
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: {
process: (sample) => {
if (frameIndex++ > 0) {
@@ -318,7 +322,7 @@ test('VideoSampleSource, transform.process expands every frame into two', async
const buffer = await encodeFrames(
{
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: {
process: (sample) => {
const t = sample.timestamp;
@@ -348,7 +352,7 @@ test('VideoSampleSource, transform.process expands every frame into two', async
test('VideoSampleSource, transform.frameRate normalizes variable-rate input to fixed rate', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { frameRate: 10 } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { frameRate: 10 } },
[
{ width: 100, height: 100, timestamp: 0, duration: 0.15 },
{ width: 100, height: 100, timestamp: 0.15, duration: 0.1 },
@@ -366,7 +370,7 @@ test('VideoSampleSource, transform.frameRate normalizes variable-rate input to f
test('VideoSampleSource, transform.frameRate pads gaps by repeating last frame', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { frameRate: 10 } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { frameRate: 10 } },
[
{ width: 100, height: 100, timestamp: 0, duration: 0.1 },
{ width: 100, height: 100, timestamp: 0.3, duration: 0.1 },
@@ -383,7 +387,7 @@ test('VideoSampleSource, transform.frameRate pads gaps by repeating last frame',
test('VideoSampleSource, transform.frameRate deduplicates frames in the same slot', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { frameRate: 10 } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { frameRate: 10 } },
[
{ width: 100, height: 100, timestamp: 0, duration: 0.03 },
{ width: 100, height: 100, timestamp: 0.03, duration: 0.03 },
@@ -400,7 +404,7 @@ test('VideoSampleSource, transform.frameRate deduplicates frames in the same slo
test('VideoSampleSource, transform.frameRate final padding fills remaining duration', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { frameRate: 10 } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { frameRate: 10 } },
[
{ width: 100, height: 100, timestamp: 0, duration: 0.5 },
],
@@ -415,7 +419,7 @@ test('VideoSampleSource, transform.frameRate final padding fills remaining durat
test('VideoSampleSource, transform.frameRate skipping and padding combined', async () => {
const buffer = await encodeFrames(
- { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { frameRate: 10 } },
+ { codec: 'vp8', quality: new Quality('medium'), transform: { frameRate: 10 } },
[
{ width: 100, height: 100, timestamp: 0, duration: 0.02 },
{ width: 100, height: 100, timestamp: 0.02, duration: 0.02 },
@@ -437,7 +441,7 @@ test('VideoSampleSource, transform.frameRate works with transform', async () =>
const buffer = await encodeFrames(
{
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: { width: 50, height: 50, fit: 'fill', frameRate: 10 },
},
[
@@ -461,7 +465,7 @@ test('VideoSampleSource, transform.frameRate works with process', async () => {
const buffer = await encodeFrames(
{
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
transform: {
frameRate: 10,
process: (sample) => {
diff --git a/test/browser/mpeg-ts-muxing.test.ts b/test/browser/mpeg-ts-muxing.test.ts
index 2d54762..9e0d2c7 100644
--- a/test/browser/mpeg-ts-muxing.test.ts
+++ b/test/browser/mpeg-ts-muxing.test.ts
@@ -6,7 +6,7 @@ import { Output } from '../../src/output.js';
import { MpegTsOutputFormat } from '../../src/output-format.js';
import { BufferTarget, StreamTarget, StreamTargetChunk } from '../../src/target.js';
import { CanvasSource, EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js';
-import { QUALITY_HIGH } from '../../src/encode.js';
+import { Quality } from '../../src/encode.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
import { assert } from '../../src/misc.js';
import { Conversion } from '../../src/conversion.js';
@@ -59,7 +59,7 @@ test('MPEG-TS muxing with AVC and AAC', async () => {
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
@@ -351,7 +351,7 @@ test('MPEG-TS muxing with no data', async () => {
const canvas = new OffscreenCanvas(640, 480);
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
@@ -381,7 +381,7 @@ test('MPEG-TS muxing with video only', async () => {
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
@@ -690,7 +690,7 @@ test('MPEG-TS muxing with StreamTarget', async () => {
const videoSource = new CanvasSource(canvas, {
codec: 'avc',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
});
output.addVideoTrack(videoSource);
diff --git a/test/browser/ogg-muxer.test.ts b/test/browser/ogg-muxer.test.ts
index 215dc7d..45effc6 100644
--- a/test/browser/ogg-muxer.test.ts
+++ b/test/browser/ogg-muxer.test.ts
@@ -4,6 +4,7 @@ import { OggOutputFormat } from '../../src/output-format.js';
import { BufferTarget, NullTarget } from '../../src/target.js';
import { AudioBufferSource, EncodedAudioPacketSource } from '../../src/media-source.js';
import { EncodedPacket } from '../../src/packet.js';
+import { Quality } from '../../src/encode.js';
import { assert } from '../../src/misc.js';
import { Input } from '../../src/input.js';
import { BufferSource } from '../../src/source.js';
@@ -27,7 +28,7 @@ test('maximumPageDuration option', async () => {
target: new NullTarget(),
});
- const audioSource = new AudioBufferSource({ codec: 'opus', bitrate: 64000 });
+ const audioSource = new AudioBufferSource({ codec: 'opus', quality: new Quality({ bitrate: 64000 }) });
output.addAudioTrack(audioSource);
await output.start();
@@ -49,7 +50,7 @@ test('maximumPageDuration option', async () => {
target: new NullTarget(),
});
- const audioSource = new AudioBufferSource({ codec: 'opus', bitrate: 64000 });
+ const audioSource = new AudioBufferSource({ codec: 'opus', quality: new Quality({ bitrate: 64000 }) });
output.addAudioTrack(audioSource);
await output.start();
diff --git a/test/browser/quality.test.ts b/test/browser/quality.test.ts
new file mode 100644
index 0000000..c60474e
--- /dev/null
+++ b/test/browser/quality.test.ts
@@ -0,0 +1,92 @@
+import { expect, test } from 'vitest';
+import { Input } from '../../src/input.js';
+import { UrlSource } from '../../src/source.js';
+import { ALL_FORMATS } from '../../src/input-format.js';
+import { Output } from '../../src/output.js';
+import { MkvOutputFormat, Mp4OutputFormat } from '../../src/output-format.js';
+import { BufferTarget } from '../../src/target.js';
+import { Conversion } from '../../src/conversion.js';
+import { canEncodeVideo, Quality, QualityLevel } from '../../src/encode.js';
+import { VideoCodec } from '../../src/codec.js';
+
+const QUALITY_LEVELS: QualityLevel[] = ['very-low', 'low', 'medium', 'high', 'very-high'];
+
+for (const level of QUALITY_LEVELS) {
+ test(`AVC, qualitative quality '${level}'`, { timeout: 10_000 }, async () => {
+ await convertVideo(new Quality(level), 'avc');
+ });
+}
+
+test('AVC, custom qualitative quality', { timeout: 10_000 }, async () => {
+ await convertVideo(new Quality(0.85), 'avc');
+});
+
+test('AVC, qualitative quality with preferBitrate', { timeout: 10_000 }, async () => {
+ await convertVideo(new Quality({ quality: 0.5, preferBitrate: true }), 'avc');
+});
+
+test('AVC, explicit bitrate', { timeout: 10_000 }, async () => {
+ await convertVideo(new Quality({ bitrate: 1_000_000 }), 'avc');
+});
+
+test('AVC, explicit bitrate with constant bitrate mode', { timeout: 10_000 }, async () => {
+ await convertVideo(new Quality({ bitrate: 1_000_000, bitrateMode: 'constant' }), 'avc');
+});
+
+test('AVC, explicit quantizer', { timeout: 10_000 }, async () => {
+ // No bitrate fallback here, so environments without quantizer support are expected to reject the track
+ await convertVideo(new Quality({ quantizer: 30 }), 'avc', true);
+});
+
+test('AVC, explicit quantizer with bitrate fallback', { timeout: 10_000 }, async () => {
+ await convertVideo(new Quality({ quantizer: 30, bitrate: 1_000_000 }), 'avc');
+});
+
+// Medium quality prefers quantizer-based encoding, so this hits the quantizer path for every codec that supports it
+const TESTED_VIDEO_CODECS: VideoCodec[] = ['avc', 'hevc', 'vp9', 'av1', 'vp8'];
+
+for (const codec of TESTED_VIDEO_CODECS) {
+ test(`Medium quality with codec '${codec}'`, { timeout: 10_000 }, async () => {
+ const quality = new Quality('medium');
+ if (!await canEncodeVideo(codec, { quality })) {
+ // The environment can't encode this codec at all; nothing to test
+ return;
+ }
+
+ await convertVideo(quality, codec);
+ });
+}
+
+/** Converts the first two seconds of the test video using the given quality and codec. */
+const convertVideo = async (quality: Quality, codec: VideoCodec, allowMissingQuantizerSupport = false) => {
+ using input = new Input({
+ source: new UrlSource('/video.mp4'),
+ formats: ALL_FORMATS,
+ });
+
+ const output = new Output({
+ // Matroska supports all video codecs we test here
+ format: codec === 'avc' ? new Mp4OutputFormat() : new MkvOutputFormat(),
+ target: new BufferTarget(),
+ });
+
+ const conversion = await Conversion.init({
+ input,
+ output,
+ trim: { start: 0, end: 2 },
+ video: { codec, quality },
+ audio: { discard: true },
+ });
+
+ if (allowMissingQuantizerSupport && !conversion.isValid) {
+ // The environment can't do quantizer-based encoding; a correctly-explained rejection also satisfies the test
+ await expect(conversion.execute()).rejects.toThrow(
+ `not able to encode '${codec}' with the provided parameters`,
+ );
+ return;
+ }
+
+ await conversion.execute();
+
+ expect(conversion.utilizedTracks.some(x => x.isVideoTrack())).toBe(true);
+};
diff --git a/test/browser/transparency.test.ts b/test/browser/transparency.test.ts
index 3c0ebc3..7d1cf5f 100644
--- a/test/browser/transparency.test.ts
+++ b/test/browser/transparency.test.ts
@@ -7,7 +7,7 @@ import { Output } from '../../src/output.js';
import { WebMOutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { CanvasSource, VideoSampleSource } from '../../src/media-source.js';
-import { canEncodeVideo, QUALITY_HIGH } from '../../src/encode.js';
+import { canEncodeVideo, Quality } from '../../src/encode.js';
import { VideoSample } from '../../src/sample.js';
import { Conversion } from '../../src/conversion.js';
@@ -96,7 +96,7 @@ const encodeTransparentVideoTest = async () => {
const source = new CanvasSource(canvas, {
codec: 'vp9',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
alpha: 'keep',
});
output.addVideoTrack(source);
@@ -203,7 +203,7 @@ test('Can encode video with alternating transparency', async () => {
const source = new VideoSampleSource({
codec: 'vp9',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
alpha: 'keep',
});
output.addVideoTrack(source);
@@ -270,7 +270,7 @@ test('Can encode transparent video with odd dimensions', async () => {
const source = new CanvasSource(canvas, {
codec: 'vp9',
- bitrate: QUALITY_HIGH,
+ quality: new Quality('high'),
alpha: 'keep',
});
output.addVideoTrack(source);
diff --git a/test/browser/video-samples.test.ts b/test/browser/video-samples.test.ts
index 9f6c424..dd76bfa 100644
--- a/test/browser/video-samples.test.ts
+++ b/test/browser/video-samples.test.ts
@@ -4,7 +4,7 @@ import { VideoSampleSource } from '../../src/media-source.js';
import { Output } from '../../src/output.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
-import { QUALITY_MEDIUM } from '../../src/encode.js';
+import { Quality } from '../../src/encode.js';
import { PacketType } from '../../src/packet.js';
test('allocationSize', async () => {
@@ -516,7 +516,7 @@ const encodeAndAssertPacketTypes = async (
let i = 0;
const videoSource = new VideoSampleSource({
codec: 'vp8',
- bitrate: QUALITY_MEDIUM,
+ quality: new Quality('medium'),
onEncodedPacket: packet => expect(packet.type).toBe(expectedPacketTypes[i++]),
});
output.addVideoTrack(videoSource);
diff --git a/test/node/aac-encoder-extension.test.ts b/test/node/aac-encoder-extension.test.ts
index 9679c12..f1b1efe 100644
--- a/test/node/aac-encoder-extension.test.ts
+++ b/test/node/aac-encoder-extension.test.ts
@@ -4,7 +4,7 @@ import { BufferSource } from '../../src/source.js';
import { ALL_FORMATS } from '../../src/input-format.js';
import { Output } from '../../src/output.js';
import { BufferTarget } from '../../src/target.js';
-import { canEncode } from '../../src/encode.js';
+import { canEncode, Quality } from '../../src/encode.js';
import { AudioSampleSource } from '../../src/media-source.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
@@ -47,7 +47,7 @@ test('AAC encoding', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'aac', bitrate: 128000 });
+ const audioSource = new AudioSampleSource({ codec: 'aac', quality: new Quality({ bitrate: 128000 }) });
output.addAudioTrack(audioSource);
await output.start();
@@ -98,7 +98,7 @@ test('AAC with huge timestamps', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'aac', bitrate: 128000 });
+ const audioSource = new AudioSampleSource({ codec: 'aac', quality: new Quality({ bitrate: 128000 }) });
output.addAudioTrack(audioSource);
await output.start();
diff --git a/test/node/ac3.test.ts b/test/node/ac3.test.ts
index 7b5638a..dff45e3 100644
--- a/test/node/ac3.test.ts
+++ b/test/node/ac3.test.ts
@@ -8,7 +8,7 @@ import { MpegTsOutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { Conversion } from '../../src/conversion.js';
import { AC3_REGISTRATION_DESCRIPTOR, EAC3_REGISTRATION_DESCRIPTOR } from '../../src/codec-data.js';
-import { canEncode } from '../../src/encode.js';
+import { canEncode, Quality } from '../../src/encode.js';
import { AudioSampleSink, EncodedPacketSink } from '../../src/media-sink.js';
import { AudioSampleSource } from '../../src/media-source.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
@@ -248,7 +248,7 @@ test('AC-3 encoding', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'ac3', bitrate: 192000 });
+ const audioSource = new AudioSampleSource({ codec: 'ac3', quality: new Quality({ bitrate: 192000 }) });
output.addAudioTrack(audioSource);
await output.start();
@@ -298,7 +298,7 @@ test('E-AC-3 encoding', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'eac3', bitrate: 192000 });
+ const audioSource = new AudioSampleSource({ codec: 'eac3', quality: new Quality({ bitrate: 192000 }) });
output.addAudioTrack(audioSource);
await output.start();
@@ -350,7 +350,7 @@ test('AC-3 with huge timestamps', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'ac3', bitrate: 192000 });
+ const audioSource = new AudioSampleSource({ codec: 'ac3', quality: new Quality({ bitrate: 192000 }) });
output.addAudioTrack(audioSource);
await output.start();
@@ -401,7 +401,7 @@ test('E-AC-3 with huge timestamps', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'eac3', bitrate: 192000 });
+ const audioSource = new AudioSampleSource({ codec: 'eac3', quality: new Quality({ bitrate: 192000 }) });
output.addAudioTrack(audioSource);
await output.start();
diff --git a/test/node/mp3-encoder-extension.test.ts b/test/node/mp3-encoder-extension.test.ts
index 4b3e58d..9d2c0f8 100644
--- a/test/node/mp3-encoder-extension.test.ts
+++ b/test/node/mp3-encoder-extension.test.ts
@@ -4,7 +4,7 @@ import { BufferSource } from '../../src/source.js';
import { ALL_FORMATS } from '../../src/input-format.js';
import { Output } from '../../src/output.js';
import { BufferTarget } from '../../src/target.js';
-import { canEncode } from '../../src/encode.js';
+import { canEncode, Quality } from '../../src/encode.js';
import { AudioSampleSource } from '../../src/media-source.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
import { Mp3OutputFormat, Mp4OutputFormat } from '../../src/output-format.js';
@@ -47,7 +47,7 @@ test('MP3 encoding', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'mp3', bitrate: 128_000 });
+ const audioSource = new AudioSampleSource({ codec: 'mp3', quality: new Quality({ bitrate: 128_000 }) });
output.addAudioTrack(audioSource);
await output.start();
@@ -99,7 +99,7 @@ test('MP3 with huge timestamps', async () => {
target: new BufferTarget(),
});
- const audioSource = new AudioSampleSource({ codec: 'mp3', bitrate: 128_000 });
+ const audioSource = new AudioSampleSource({ codec: 'mp3', quality: new Quality({ bitrate: 128_000 }) });
output.addAudioTrack(audioSource);
await output.start();
diff --git a/test/node/server-extension.test.ts b/test/node/server-extension.test.ts
index c8fca31..88033d2 100644
--- a/test/node/server-extension.test.ts
+++ b/test/node/server-extension.test.ts
@@ -32,7 +32,7 @@ import { Mp4OutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { Conversion } from '../../src/conversion.js';
import { VideoSampleSource } from '../../src/media-source.js';
-import { QUALITY_HIGH } from '../../src/encode.js';
+import { buildQuantizerEncodeOptions, Quality } from '../../src/encode.js';
import { AvFrameVideoSampleResource } from '../../packages/server/src/video-sample.js';
import { AvFrameAudioSampleResource } from '../../packages/server/src/audio-sample.js';
import { toAvFrame } from '../../packages/server/src/index.js';
@@ -474,6 +474,34 @@ describe('Video', async () => {
});
});
+ const QUANTIZER_TEST_CODECS = [
+ { codec: 'avc', name: 'AVC', low: 10, high: 45 },
+ { codec: 'hevc', name: 'HEVC', low: 10, high: 45 },
+ { codec: 'vp9', name: 'VP9', low: 10, high: 55 },
+ { codec: 'av1', name: 'AV1', low: 40, high: 220 },
+ ] as const;
+
+ for (const { codec, name, low, high } of QUANTIZER_TEST_CODECS) {
+ test(`${name} quantizer encode`, { timeout: 20_000 }, async () => {
+ const lowQuantizerPackets = await quantizerEncodeTest(codec, () => low);
+ const highQuantizerPackets = await quantizerEncodeTest(codec, () => high);
+
+ // A lower quantizer means higher quality, which shows in the encoded size
+ expect(lowQuantizerPackets.reduce((sum, packet) => sum + packet.data.byteLength, 0))
+ .toBeGreaterThan(highQuantizerPackets.reduce((sum, packet) => sum + packet.data.byteLength, 0));
+ });
+
+ test(`${name} mid-stream quantizer change`, { timeout: 20_000 }, async () => {
+ const packets = await quantizerEncodeTest(codec, i => i < 5 ? low : high);
+
+ // Changing the quantizer forces a fresh encoder stream, which begins with a new key frame
+ expect(packets[5]!.type).toBe('key');
+
+ expect(packets.slice(0, 5).reduce((sum, packet) => sum + packet.data.byteLength, 0))
+ .toBeGreaterThan(packets.slice(5).reduce((sum, packet) => sum + packet.data.byteLength, 0));
+ });
+ }
+
test('ProRes encode', async () => {
// ProRes can't be decoded by node-av's wrapper here, so this is encode-only.
const encoder = new NodeAvVideoEncoder();
@@ -598,7 +626,7 @@ describe('Video', async () => {
const source = new VideoSampleSource({
codec: 'prores',
- bitrate: QUALITY_HIGH,
+ quality: new Quality({ quality: 0.75, preferBitrate: true }),
alpha: 'keep',
});
output.addVideoTrack(source);
@@ -781,6 +809,59 @@ describe('Video', async () => {
}
};
+ const quantizerEncodeTest = async (codec: VideoCodec, getQuantizer: (frameIndex: number) => number) => {
+ const width = 640;
+ const height = 360;
+
+ const encoder = new NodeAvVideoEncoder();
+ // @ts-expect-error Readonly
+ encoder.codec = codec;
+ // @ts-expect-error Readonly
+ encoder.config = {
+ codec: buildVideoCodecString(codec, width, height, 1e6, false),
+ width,
+ height,
+ bitrateMode: 'quantizer',
+ } satisfies VideoEncoderConfig;
+
+ const packets: EncodedPacket[] = [];
+
+ // @ts-expect-error Readonly
+ encoder.onPacket = (packet: EncodedPacket) => {
+ packets.push(packet);
+ };
+
+ await encoder.init();
+
+ // Noise, so that quality differences clearly show in the encoded size
+ const data = new Uint8Array(width * height * 4);
+ let seed = 123456789;
+ for (let i = 0; i < data.length; i++) {
+ seed = (seed * 48271) % 2147483647;
+ data[i] = seed & 0xff;
+ }
+
+ for (let i = 0; i < 10; i++) {
+ using sample = new VideoSample(data, {
+ format: 'RGBX',
+ codedWidth: width,
+ codedHeight: height,
+ timestamp: i / 30,
+ duration: 1 / 30,
+ });
+
+ await encoder.encode(sample, buildQuantizerEncodeOptions(codec, getQuantizer(i)));
+ }
+
+ await encoder.flush();
+ await encoder.close();
+
+ expect(packets).toHaveLength(10);
+ expect(packets[0]!.type).toBe('key');
+
+ return packets;
+ };
+
test('AVC conversion roundtrip', { timeout: 20_000 }, async () => {
await conversionRoundtrip('avc');
});