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
+4 -4
View File
@@ -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);
+4 -4
View File
@@ -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');
+4 -4
View File
@@ -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);
+2 -2
View File
@@ -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);
+29 -25
View File
@@ -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) => {
+5 -5
View File
@@ -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);
+3 -2
View File
@@ -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();
+92
View File
@@ -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);
};
+4 -4
View File
@@ -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);
+2 -2
View File
@@ -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);
+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');
});