Tune export quality for web delivery

Use practical bitrate targets across export backends and add dithering when converting full-range canvas frames to BT.709 video range.
This commit is contained in:
webadderall
2026-08-31 12:25:12 +10:00
parent d27aac7719
commit 621006ff71
4 changed files with 63 additions and 93 deletions
+14 -12
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import { getMp4ExportBitrate, getSourceQualityBitrate } from "./exportBitrate";
describe("export bitrate policy", () => {
it("keeps source-quality exports at a fuller screen-recording bitrate", () => {
expect(getSourceQualityBitrate(1920, 1080)).toBe(30_000_000);
it("uses the web-delivery source-quality caps", () => {
expect(getSourceQualityBitrate(1280, 720)).toBe(8_000_000);
expect(getSourceQualityBitrate(1920, 1080)).toBe(12_000_000);
expect(getSourceQualityBitrate(3840, 2160)).toBe(45_000_000);
expect(
getMp4ExportBitrate({
width: 1920,
@@ -12,7 +14,7 @@ describe("export bitrate policy", () => {
quality: "source",
encodingMode: "quality",
}),
).toBe(30_000_000);
).toBe(12_000_000);
expect(
getMp4ExportBitrate({
width: 1920,
@@ -21,7 +23,7 @@ describe("export bitrate policy", () => {
quality: "source",
encodingMode: "balanced",
}),
).toBe(22_500_000);
).toBe(9_600_000);
});
it("raises high-resolution 60fps source-quality exports above the 30fps budget", () => {
@@ -41,9 +43,9 @@ describe("export bitrate policy", () => {
frameRate: 60,
});
expect(thirtyFpsBitrate).toBe(50_000_000);
expect(thirtyFpsBitrate).toBe(20_555_556);
expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate);
expect(sixtyFpsBitrate).toBe(70_710_678);
expect(sixtyFpsBitrate).toBe(29_069_946);
});
it("keeps modern native static-layout source exports high enough for screen text", () => {
@@ -56,7 +58,7 @@ describe("export bitrate policy", () => {
encodingMode: "balanced",
useModernNativeStaticLayout: true,
}),
).toBe(22_500_000);
).toBe(9_600_000);
expect(
getMp4ExportBitrate({
width: 1920,
@@ -66,7 +68,7 @@ describe("export bitrate policy", () => {
encodingMode: "quality",
useModernNativeStaticLayout: true,
}),
).toBe(30_000_000);
).toBe(12_000_000);
});
it("scales modern native static-layout source exports at 60fps", () => {
@@ -87,9 +89,9 @@ describe("export bitrate policy", () => {
frameRate: 60,
});
expect(thirtyFpsBitrate).toBe(30_000_000);
expect(thirtyFpsBitrate).toBe(12_000_000);
expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate);
expect(sixtyFpsBitrate).toBe(42_426_407);
expect(sixtyFpsBitrate).toBe(16_970_563);
});
it("does not raise fast exports when the requested bitrate is already lower than the cap", () => {
@@ -102,7 +104,7 @@ describe("export bitrate policy", () => {
encodingMode: "fast",
useModernNativeStaticLayout: true,
}),
).toBe(3_000_000);
).toBe(6_000_000);
});
it("scales the modern native cap with output pixel rate", () => {
@@ -115,6 +117,6 @@ describe("export bitrate policy", () => {
encodingMode: "quality",
useModernNativeStaticLayout: true,
}),
).toBe(72_000_000);
).toBe(45_000_000);
});
});
+39 -73
View File
@@ -1,30 +1,49 @@
import type { ExportEncodingMode, ExportMp4FrameRate, ExportQuality } from "./types";
const MIN_MP4_BITRATE = 2_000_000;
const REFERENCE_PIXEL_RATE = 1920 * 1080 * 30;
const REFERENCE_FRAME_RATE = 30;
const HD_PIXELS = 1280 * 720;
const FULL_HD_PIXELS = 1920 * 1080;
const UHD_PIXELS = 3840 * 2160;
function interpolateBitrate(
totalPixels: number,
startPixels: number,
endPixels: number,
startBitrate: number,
endBitrate: number,
): number {
const progress = Math.max(
0,
Math.min(1, (totalPixels - startPixels) / (endPixels - startPixels)),
);
return Math.round(startBitrate + (endBitrate - startBitrate) * progress);
}
export function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number {
switch (encodingMode) {
case "fast":
return 0.1;
return 0.5;
case "quality":
return 1;
case "balanced":
default:
return 0.75;
return 0.8;
}
}
export function getSourceQualityBitrate(width: number, height: number): number {
const totalPixels = width * height;
if (totalPixels > 2560 * 1440) {
return 80_000_000;
if (totalPixels <= HD_PIXELS) {
return 8_000_000;
}
if (totalPixels > 1920 * 1080) {
return 50_000_000;
if (totalPixels <= FULL_HD_PIXELS) {
return 12_000_000;
}
return 30_000_000;
if (totalPixels >= UHD_PIXELS) {
return 45_000_000;
}
return interpolateBitrate(totalPixels, FULL_HD_PIXELS, UHD_PIXELS, 12_000_000, 45_000_000);
}
function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQuality): number {
@@ -33,13 +52,16 @@ function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQ
}
const totalPixels = width * height;
if (totalPixels <= 1280 * 720) {
return 10_000_000;
if (totalPixels <= HD_PIXELS) {
return 5_000_000;
}
if (totalPixels <= 1920 * 1080) {
return 20_000_000;
if (totalPixels <= FULL_HD_PIXELS) {
return 8_000_000;
}
return 30_000_000;
if (totalPixels >= UHD_PIXELS) {
return 35_000_000;
}
return interpolateBitrate(totalPixels, FULL_HD_PIXELS, UHD_PIXELS, 8_000_000, 35_000_000);
}
function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number {
@@ -50,42 +72,6 @@ function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number {
return Math.sqrt(Math.max(1, frameRate / REFERENCE_FRAME_RATE));
}
function getModernNativeStaticLayoutBitrateCap(
width: number,
height: number,
frameRate: ExportMp4FrameRate,
quality: ExportQuality,
): number {
const referenceCap =
quality === "source"
? 36_000_000
: quality === "high"
? 28_000_000
: quality === "good"
? 20_000_000
: 14_000_000;
const pixelRateScale = Math.max((width * height * frameRate) / REFERENCE_PIXEL_RATE, 0.1);
return Math.round(referenceCap * Math.sqrt(pixelRateScale));
}
function getModernNativeStaticLayoutBitrateFloor(
width: number,
height: number,
frameRate: ExportMp4FrameRate,
quality: ExportQuality,
): number {
const referenceFloor =
quality === "source"
? 22_000_000
: quality === "high"
? 16_000_000
: quality === "good"
? 12_000_000
: 8_000_000;
const pixelRateScale = Math.max((width * height * frameRate) / REFERENCE_PIXEL_RATE, 0.1);
return Math.round(referenceFloor * Math.sqrt(pixelRateScale));
}
export function getMp4ExportBitrate(options: {
width: number;
height: number;
@@ -99,29 +85,9 @@ export function getMp4ExportBitrate(options: {
getFrameRateBitrateMultiplier(options.frameRate) *
getEncodingModeBitrateMultiplier(options.encodingMode),
);
const nativeStaticLayoutBitrate =
options.useModernNativeStaticLayout && options.encodingMode !== "fast"
? Math.max(
requestedBitrate,
getModernNativeStaticLayoutBitrateFloor(
options.width,
options.height,
options.frameRate,
options.quality,
),
)
: requestedBitrate;
const cappedBitrate = options.useModernNativeStaticLayout
? Math.min(
nativeStaticLayoutBitrate,
getModernNativeStaticLayoutBitrateCap(
options.width,
options.height,
options.frameRate,
options.quality,
),
)
: requestedBitrate;
return Math.max(MIN_MP4_BITRATE, cappedBitrate);
// Keep every backend on the same delivery bitrate policy. Native static-layout
// exports previously applied a second set of floors and caps that could more
// than double the requested web-delivery target.
return Math.max(MIN_MP4_BITRATE, requestedBitrate);
}