diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts index b9b7ba1a..76585709 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -153,7 +153,7 @@ describe("native static layout command builders", () => { expect(args).toContain("-filter_complex"); expect(args).toContain( "color=c=0x101010:s=1920x1080:r=60:d=60.000,format=nv12,setrange=limited,hwupload_cuda[bg];" + - "[0:v]scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,hwupload_cuda[fg];" + + "[0:v]scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=a_dither,fps=60,hwupload_cuda[fg];" + "[bg][fg]overlay_cuda=192:108:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=60.000,setpts=PTS-STARTPTS[out]", ); expect(args).toContain("h264_nvenc"); @@ -179,7 +179,7 @@ describe("native static layout command builders", () => { expect(args).toEqual( expect.arrayContaining([ "-vf", - "vflip,scale=in_range=full:out_range=tv", + "vflip,scale=in_range=full:out_range=tv:sws_dither=a_dither", "-colorspace", "bt709", "-color_primaries", @@ -198,7 +198,7 @@ describe("native static layout command builders", () => { expect(args).toEqual( expect.arrayContaining([ "-vf", - "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", + "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=a_dither,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", "-map", "0:v:0", "-an", @@ -214,7 +214,7 @@ describe("native static layout command builders", () => { }); expect(args).toContain( - "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", + "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=a_dither,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", ); }); @@ -266,7 +266,9 @@ describe("native static layout command builders", () => { ); expect(filterComplex).toContain("[fgbase][mask]alphamerge[fg]"); expect(filterComplex).toContain("overlay=x=192:y=108:format=auto"); - expect(filterComplex).toContain("scale=in_range=full:out_range=tv,format=yuv420p[out]"); + expect(filterComplex).toContain( + "scale=in_range=full:out_range=tv:sws_dither=a_dither,format=yuv420p[out]", + ); expect(args).toContain("h264_nvenc"); expect(args).toEqual(expect.arrayContaining(["-pix_fmt", "yuv420p"])); expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS])); diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index c8bacce1..7d9e1369 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -20,9 +20,9 @@ export const FFMPEG_BT709_VIDEO_COLOR_ARGS = [ "tv", ] as const; -const FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER = "scale=in_range=auto:out_range=tv"; +const FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER = "scale=in_range=auto:out_range=tv:sws_dither=a_dither"; const FFMPEG_AUTO_TO_FULL_RANGE_FILTER = "scale=in_range=auto:out_range=full"; -const FFMPEG_FULL_TO_VIDEO_RANGE_FILTER = "scale=in_range=full:out_range=tv"; +const FFMPEG_FULL_TO_VIDEO_RANGE_FILTER = "scale=in_range=full:out_range=tv:sws_dither=a_dither"; export type NativeExportEncodingMode = "fast" | "balanced" | "quality"; @@ -311,7 +311,7 @@ export function buildNativeVideoExportArgs( "-i", "pipe:0", "-vf", - "vflip,scale=in_range=full:out_range=tv", + `vflip,${FFMPEG_FULL_TO_VIDEO_RANGE_FILTER}`, "-an", "-c:v", encoder, diff --git a/src/lib/exporter/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts index 301689dd..13b8a9be 100644 --- a/src/lib/exporter/exportBitrate.test.ts +++ b/src/lib/exporter/exportBitrate.test.ts @@ -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); }); }); diff --git a/src/lib/exporter/exportBitrate.ts b/src/lib/exporter/exportBitrate.ts index a82acb7a..1b585509 100644 --- a/src/lib/exporter/exportBitrate.ts +++ b/src/lib/exporter/exportBitrate.ts @@ -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); }