From 700c7a8c23ba569c5e1fbf6b26fcb2fd8d59193c Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 12 Nov 2026 07:57:36 -0800 Subject: [PATCH] =?UTF-8?q?fix(encoder):=20ask=20for=20CBR=20=E2=80=94=20t?= =?UTF-8?q?he=20capped-VBR=20triad=20was=20never=20a=20constant=20rate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream was configured as "@8 Mbps" and averaged 5.1. The cause is the argument set, not the machine and not YouTube: `-b:v K -maxrate K -bufsize 2K` is capped VBR. Measured on the bundled BtbN build with a static 640x480 frame at -b:v 2000k: default rate control 9,858 bytes / 8 s = 9 kbps -rc cbr 2,001,989 bytes / 8 s = 1,951 kbps -rc vbr 9,858 bytes / 8 s = 9 kbps On high-motion testsrc2 the same target produced 2006 kbps (CBR) against 1741 (VBR): VBR undershoots by however easy the picture is, and a composited scene with a still background is very easy. Spelling is per encoder, verified rather than assumed: - h264_nvenc, h264_amf: `-rc cbr`. - h264_qsv: nothing. It has no `-rc`, and libavcodec/qsvenc.c (select_rc_mode, FFmpeg d263fce2b2) selects MFX_RATECONTROL_CBR when bitrate == maxrate — which the triad already sets. Passing -rc here would be an unknown option. - libopenh264: nothing. `-rc_mode bitrate` changed no measurable output on this build (3,832 vs 3,830 bytes), so it stays out rather than adding a line whose effect nobody can demonstrate. Reference: YouTube's encoder spec requires CBR (support.google.com/youtube/answer/2853702). 668 tests pass, build 0 warnings. --- Services/Encoder/FfmpegArgs.cs | 20 ++++++++++++++++++++ docs/ai/output.md | 7 +++++++ ytLive.Tests/FfmpegEncoderTests.cs | 27 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/Services/Encoder/FfmpegArgs.cs b/Services/Encoder/FfmpegArgs.cs index f5d79c3..fa6cd24 100644 --- a/Services/Encoder/FfmpegArgs.cs +++ b/Services/Encoder/FfmpegArgs.cs @@ -77,9 +77,29 @@ public static class FfmpegArgs args.AddRange(["-vf", $"scale={options.Width}:{options.Height}:flags=bilinear,setsar=1"]); } args.AddRange(["-c:v", videoEncoder]); + AddRateControl(args, videoEncoder); args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k", "-bufsize", $"{options.BitrateKbps * 2}k"]); args.AddRange(["-g", gop.ToString(), "-keyint_min", gop.ToString(), "-sc_threshold", "0", "-bf", "0", "-pix_fmt", "yuv420p"]); } + + /// + /// Constant bitrate, spelled the one way each encoder understands. + /// + /// Why this is not a shared flag: the -b:v/-maxrate/-bufsize triad is capped VBR, + /// not CBR — measured 2026-11-12 on the bundled BtbN build, a static frame at -b:v 2000k + /// produced 9 kbps with NVENC's default rate control and 1951 kbps with -rc cbr. + /// That is the gap behind an @8 stream that averaged 5.1 Mbps. + /// + /// Per encoder: NVENC and AMF take an explicit mode. QSV has no -rc and already selects + /// CBR when bitrate == maxrate (libavcodec/qsvenc.c, select_rc_mode). OpenH264's + /// -rc_mode bitrate made no measurable difference on this build, so it is deliberately not + /// passed. YouTube's encoder spec requires CBR (support.google.com/youtube/answer/2853702). + /// + private static void AddRateControl(List args, string videoEncoder) + { + if (videoEncoder is "h264_nvenc" or "h264_amf") + args.AddRange(["-rc", "cbr"]); + } } diff --git a/docs/ai/output.md b/docs/ai/output.md index d8ddb2e..b8cbf20 100644 --- a/docs/ai/output.md +++ b/docs/ai/output.md @@ -91,6 +91,13 @@ mixer writes IEEE-float stereo to a Windows named pipe** (`-f f32le -ar 48000 -a anullsrc` silence in the TASK 8 audio milestone) + explicit `-map 0:v -map 1:a` + `-c:v -b:v K -maxrate K -bufsize 2K` + **`-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p`** (≤4s keyframes, closed GOP, H.264 compliance) + `-c:a aac -ar 48000 -ac 2 -f flv `. +**Rate control is CBR and it is per-encoder** (`FfmpegArgs.AddRateControl`). The `-b:v/-maxrate/-bufsize` +triad is **capped VBR, not CBR**: measured 2026-11-12 on the bundled BtbN build, a static frame at +`-b:v 2000k` produced **9 kbps** under NVENC's default and **1951 kbps** with `-rc cbr` — the cause of an +@8 stream that averaged 5.1 Mbps. NVENC and AMF are passed `-rc cbr`; **QSV has no `-rc`** and selects CBR +itself when `bitrate == maxrate` (`libavcodec/qsvenc.c`, `select_rc_mode`), so the triad is its whole ask; +OpenH264's `-rc_mode bitrate` changed nothing measurable here and is not passed. YouTube's encoder spec +requires CBR. Never assume `-maxrate` equals CBR — it does not. **Record output (TASK 18):** `FfmpegArgs.Build` emits one self-contained block per output, each its own `-map 0:v -map 1:a` + codec tags (`AddVideoTags` helper). Stream block = `-f flv `; record block = `-f mp4 `. `EncoderOptions.StreamEnabled`/`RecordEnabled`/`RecordPath` gate each block, so diff --git a/ytLive.Tests/FfmpegEncoderTests.cs b/ytLive.Tests/FfmpegEncoderTests.cs index a930131..123f096 100644 --- a/ytLive.Tests/FfmpegEncoderTests.cs +++ b/ytLive.Tests/FfmpegEncoderTests.cs @@ -316,6 +316,33 @@ public class FfmpegEncoderTests Assert.Equal("240", args[keyint + 1]); } + [Fact] + public void Args_AskForConstantBitrateWhereTheEncoderNeedsTelling() + { + var options = new EncoderOptions { RtmpUrl = "rtmp://x/y", BitrateKbps = 12000 }; + + // Both hardware encoders own a rate-control switch; without it the target is an average to + // undershoot (measured: 9 kbps against a 2000k target on a static frame). + foreach (var encoder in new[] { "h264_nvenc", "h264_amf" }) + { + var args = FfmpegArgs.Build(options, encoder).ToArray(); + var rc = Array.IndexOf(args, "-rc"); + Assert.True(rc >= 0, $"{encoder} must be told the rate-control mode"); + Assert.Equal("cbr", args[rc + 1]); + } + + // QSV has no -rc: libavcodec/qsvenc.c selects CBR when bitrate == maxrate, so the triad alone + // is the request. Passing -rc there would be an unknown option. + var qsv = FfmpegArgs.Build(options, "h264_qsv").ToArray(); + Assert.DoesNotContain("-rc", qsv); + Assert.Equal(qsv[Array.IndexOf(qsv, "-b:v") + 1], qsv[Array.IndexOf(qsv, "-maxrate") + 1]); + + // OpenH264 gains nothing measurable from -rc_mode bitrate; it stays out of the command line. + var openh264 = FfmpegArgs.Build(options, "libopenh264").ToArray(); + Assert.DoesNotContain("-rc", openh264); + Assert.DoesNotContain("-rc_mode", openh264); + } + [Fact] public void Args_IncludeInputOutputAndCompliance() {