Add custom quality factors and quantizer bitrate mode (#448)

* Add custom quality factors and quantizer bitrate mode (closes #327)

* Fix quantizer mode fallback and per-frame quantizer edge cases

* Clean up quantizer mode tests

* Quantizer implementation

* Add quality tests

* Improve conversion codec error message, make test more lenient

* Add quantizer support blog post

---------

Co-authored-by: Vanilagy <[email protected]>
This commit is contained in:
Don Carignan
2026-07-30 17:24:59 +02:00
committed by GitHub
co-authored by Vanilagy
parent 1a99b0371d
commit c3df2a24e5
48 changed files with 1372 additions and 371 deletions
+2 -2
View File
@@ -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();
+4 -2
View File
@@ -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;
+97 -7
View File
@@ -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<void> {
@@ -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<void> {
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();
}