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
+3 -3
View File
@@ -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();
+5 -5
View File
@@ -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();
+3 -3
View File
@@ -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();
+83 -2
View File
@@ -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');
});