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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user