mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user