From 9b7fc127ef019242f81fb80f838a6bd014adc2b1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 19:28:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20Automatic=20bitrate=20scales=20to?= =?UTF-8?q?=20measured=20link=20capacity=20=E2=80=94=20probe=20ceiling=20+?= =?UTF-8?q?=20slow=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ABR ceiling was the negotiated start rate, so an 'Automatic' session was permanently boxed at the 20 Mbps default no matter the link — the most user-visible cap left after the transport work lifted the client receive ceiling to ~4.8 Gbps wire. - Startup link-capacity probe: ~2 s into an Automatic session the pump fires one speed-test burst (2 Gbps target, 800 ms) over the existing ProbeRequest machinery; delivered wire throughput x0.7 (FEC + variance headroom) becomes the controller's climb ceiling via set_ceiling(). Old hosts decline (all-zero reply) or never answer (a 6 s timeout clears the stuck probe state so LossReports resume) — the ceiling then stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out. - Slow start: until the first congestion signal, every cooled clean window DOUBLES the rate toward the ceiling (20 Mbps -> 640 Mbps in ~10 s) instead of +6% per ~10 s (which would have taken ~10 minutes). Any congestion signal ends it for good; classic AIMD takes over. - Faster, severity-aware AIMD: a SEVERE window (unrecoverable frame, jump-to-live flush, or >=6% loss) backs off x0.7 immediately instead of waiting two windows; ordinary congestion (2-6% loss, OWD rise) keeps the two-window fuse. Additive climbs need 6 clean windows (~4.5 s, was ~10 s); the change cooldown drops 3 s -> 1.5 s. - PUNKTFUNK_VBV_FRAMES now also scales the direct-NVENC VBV (Windows + Linux, previously hardwired to 1 frame) — parity with AMF/VAAPI/QSV. Each accepted step still costs an encoder rebuild + IDR on the host; in-place rate reconfigure (NvEncReconfigureEncoder / AMF dynamic properties / Vulkan per-frame RC) is the planned follow-up that makes stepping free. Controller tests rewritten to the new policy (severity classes, slow-start climb, ceiling semantics; 144 green). Co-Authored-By: Claude Fable 5 --- crates/punktfunk-core/src/abr.rs | 207 ++++++++++++++---- crates/punktfunk-core/src/client.rs | 75 +++++++ crates/punktfunk-host/src/encode.rs | 13 ++ .../src/encode/linux/nvenc_cuda.rs | 4 +- .../src/encode/windows/nvenc.rs | 4 +- 5 files changed, 254 insertions(+), 49 deletions(-) diff --git a/crates/punktfunk-core/src/abr.rs b/crates/punktfunk-core/src/abr.rs index e61bfca0..07bc7974 100644 --- a/crates/punktfunk-core/src/abr.rs +++ b/crates/punktfunk-core/src/abr.rs @@ -15,12 +15,16 @@ //! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind" //! evidence there is. //! -//! AIMD shape: two consecutive bad windows ⇒ multiplicative decrease (×0.7, floored); ~10 s of -//! clean windows ⇒ additive-ish increase (+~6 %, ceilinged at the session's starting rate — the -//! controller recovers *back to* what was negotiated, never beyond it). Changes are rate-limited -//! (each one costs the IDR the host's rebuilt encoder opens with) and the whole controller -//! disables itself against a host that never answers [`crate::quic::BitrateChanged`] (an older -//! build that ignores unknown control messages). +//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, or ≥6 % loss) backs off ×0.7 +//! immediately; ordinary congestion (heavy-but-recoverable loss, an OWD rise) needs two +//! consecutive bad windows. Recovery is two-mode: **slow start** — until the first congestion +//! signal the rate DOUBLES each clean window (cooldown-paced), which is how an Automatic session +//! climbs from the conservative start to the [`set_ceiling`](BitrateController::set_ceiling) +//! measured by the startup link-capacity probe in seconds instead of minutes — then classic +//! additive recovery (+~6 % after ~4.5 s clean, ceilinged). Changes are rate-limited (each one +//! costs the IDR the host's rebuilt encoder opens with) and the whole controller disables itself +//! against a host that never answers [`crate::quic::BitrateChanged`] (an older build that +//! ignores unknown control messages). use std::collections::VecDeque; use std::time::{Duration, Instant}; @@ -28,15 +32,21 @@ use std::time::{Duration, Instant}; /// Never ask for less than this — below it the stream is unusable anyway and the floor keeps a /// mis-measured window from cratering the session. const FLOOR_KBPS: u32 = 5_000; -/// Consecutive bad windows before a decrease — one window can be a scheduler blip or a single -/// Wi-Fi scan; two in a row (1.5 s) is a condition. +/// Consecutive bad windows before an ORDINARY decrease — one window can be a scheduler blip or a +/// single Wi-Fi scan; two in a row (1.5 s) is a condition. A SEVERE window skips the wait. const BAD_WINDOWS_TO_DECREASE: u32 = 2; -/// Consecutive clean windows before probing back up (~10 s at the 750 ms cadence): recovery is -/// deliberately much slower than backoff, classic AIMD. -const CLEAN_WINDOWS_TO_INCREASE: u32 = 13; +/// Window shard loss at/above which ONE window is enough to back off — 6 % is past any +/// blip/retry tail, and every 750 ms spent there is visible damage. Unrecoverable frames and +/// jump-to-live flushes are severe for the same reason. +const SEVERE_LOSS_PPM: u32 = 60_000; +/// Consecutive clean windows before probing back up in congestion-avoidance mode (~4.5 s at the +/// 750 ms cadence): recovery stays slower than backoff, classic AIMD. (Slow start ignores this — +/// it doubles on every cooled clean window until the first congestion signal.) +const CLEAN_WINDOWS_TO_INCREASE: u32 = 6; /// Minimum gap between requested changes — every accepted change costs an encoder rebuild + IDR -/// on the host, and back-to-back steps would outrun the ack/effect round trip. -const CHANGE_COOLDOWN: Duration = Duration::from_secs(3); +/// on the host today (in-place reconfigure is planned), and back-to-back steps would outrun the +/// ack/effect round trip. +const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500); /// Window shard loss beyond which the window counts bad even without an unrecoverable frame: /// 2 % sustained is congestion territory, not the random tail FEC exists for. const HEAVY_LOSS_PPM: u32 = 20_000; @@ -56,9 +66,14 @@ pub(crate) struct BitrateController { enabled: bool, /// The rate we believe the host encodes at (updated by acks; requests are not assumed). current_kbps: u32, - /// The session's starting (negotiated) rate — the recovery ceiling. + /// The climb ceiling: the negotiated start rate until the startup link-capacity probe + /// raises it via [`set_ceiling`](Self::set_ceiling) — that measurement is what lets an + /// Automatic session scale past its conservative start. ceiling_kbps: u32, floor_kbps: u32, + /// Slow start: true until the first congestion signal — clean windows DOUBLE the rate + /// (cooldown-paced) instead of the +6 % additive step. + probing: bool, /// Recent window mean OWDs (µs); the rolling min is the uncongested baseline. owd_means: VecDeque, bad_windows: u32, @@ -78,6 +93,7 @@ impl BitrateController { current_kbps: start_kbps, ceiling_kbps: start_kbps, floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)), + probing: true, owd_means: VecDeque::with_capacity(BASELINE_WINDOWS), bad_windows: 0, clean_windows: 0, @@ -86,6 +102,17 @@ impl BitrateController { } } + /// Raise the climb ceiling to a measured link capacity (the startup speed-test probe's + /// delivered throughput with headroom already subtracted by the caller). Without this call + /// the ceiling stays the negotiated start rate — exactly the old behavior. Never lowers: + /// a congested-moment measurement must not shrink authority below what was negotiated + /// (descent is the congestion signals' job). + pub(crate) fn set_ceiling(&mut self, kbps: u32) { + if self.enabled && kbps > self.ceiling_kbps { + self.ceiling_kbps = kbps; + } + } + /// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the /// encoder now targets, and any ack proves the host renegotiates (resets the silence counter). pub(crate) fn on_ack(&mut self, kbps: u32) { @@ -134,10 +161,16 @@ impl BitrateController { } None => false, }; - let bad = dropped > 0 || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || flushed; + // SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush) or + // loss far past any blip — one window is enough. Ordinary congestion (heavy-but- + // recoverable loss, an OWD rise) still needs two consecutive windows. + let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM; + let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad; if bad { self.bad_windows += 1; self.clean_windows = 0; + // Any congestion signal ends slow start for good — from here on, climbs are additive. + self.probing = false; } else { self.clean_windows += 1; self.bad_windows = 0; @@ -148,16 +181,27 @@ impl BitrateController { if !cooled { return None; } - if self.bad_windows >= BAD_WINDOWS_TO_DECREASE && self.current_kbps > self.floor_kbps { + if (self.bad_windows >= BAD_WINDOWS_TO_DECREASE || (severe && self.bad_windows >= 1)) + && self.current_kbps > self.floor_kbps + { let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps); self.bad_windows = 0; return self.request(next, now); } - if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE && self.current_kbps < self.ceiling_kbps - { - let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps); - self.clean_windows = 0; - return self.request(next, now); + if self.current_kbps < self.ceiling_kbps { + // Slow start: double on every cooled clean window until the first congestion signal + // (this is how an Automatic session reaches a probe-measured ceiling in seconds). + // Congestion avoidance: +~6 % after a sustained clean run. + if self.probing && self.clean_windows >= 1 { + let next = self.current_kbps.saturating_mul(2).min(self.ceiling_kbps); + self.clean_windows = 0; + return self.request(next, now); + } + if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE { + let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps); + self.clean_windows = 0; + return self.request(next, now); + } } None } @@ -204,44 +248,66 @@ mod tests { } #[test] - fn two_bad_windows_step_down_multiplicatively() { + fn two_ordinary_bad_windows_step_down_multiplicatively() { let mut c = BitrateController::new(20_000); let start = Instant::now(); - // One bad window is a blip — no reaction. - assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None); + // Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction. + assert_eq!(c.on_window(ticks(start, 0), 0, 25_000, None, false), None); // The second consecutive bad window backs off ×0.7. assert_eq!( - c.on_window(ticks(start, 1), 1, 0, None, false), + c.on_window(ticks(start, 1), 0, 25_000, None, false), Some(14_000) ); c.on_ack(14_000); // Still bad after the cooldown → another ×0.7 step from the ACKED rate. - assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None); // bad #1 again - assert_eq!(c.on_window(ticks(start, 7), 1, 0, None, false), Some(9_800)); + assert_eq!(c.on_window(ticks(start, 6), 0, 25_000, None, false), None); // bad #1 again + assert_eq!( + c.on_window(ticks(start, 7), 0, 25_000, None, false), + Some(9_800) + ); + } + + #[test] + fn severe_window_backs_off_immediately() { + // An unrecoverable frame (the user SAW a freeze) skips the two-window wait… + let mut c = BitrateController::new(20_000); + let start = Instant::now(); + assert_eq!( + c.on_window(ticks(start, 0), 1, 0, None, false), + Some(14_000) + ); + // …and so does a jump-to-live flush. + let mut c = BitrateController::new(20_000); + assert_eq!(c.on_window(ticks(start, 0), 0, 0, None, true), Some(14_000)); + // …and ≥6 % window loss. + let mut c = BitrateController::new(20_000); + assert_eq!( + c.on_window(ticks(start, 0), 0, 80_000, None, false), + Some(14_000) + ); } #[test] fn cooldown_blocks_back_to_back_steps() { let mut c = BitrateController::new(20_000); let start = Instant::now(); - assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None); assert_eq!( - c.on_window(ticks(start, 1), 1, 0, None, false), + c.on_window(ticks(start, 0), 1, 0, None, false), Some(14_000) ); c.on_ack(14_000); - // Two more bad windows land INSIDE the 3 s cooldown (ticks 2,3 = 1.5/2.25 s) → held. - assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), None); - assert_eq!(c.on_window(ticks(start, 3), 1, 0, None, false), None); + // A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown + // boundary (tick 2 = 1.5 s) it fires. + assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), None); + assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), Some(9_800)); } #[test] fn floor_is_never_crossed() { let mut c = BitrateController::new(6_000); let start = Instant::now(); - assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None); // ×0.7 of 6000 = 4200 < floor → clamped to 5000. - assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), Some(5_000)); + assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), Some(5_000)); c.on_ack(5_000); // At the floor, further bad windows request nothing. assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None); @@ -252,21 +318,76 @@ mod tests { fn sustained_clean_recovers_toward_ceiling_only() { let mut c = BitrateController::new(20_000); let start = Instant::now(); - assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None); assert_eq!( - c.on_window(ticks(start, 1), 1, 0, None, false), + c.on_window(ticks(start, 0), 1, 0, None, false), Some(14_000) ); c.on_ack(14_000); - // 13 clean windows → one additive step up (14000 + 14000/16 + 1 = 14876). - let up = run_clean(&mut c, start, 2, 13); + // The backoff ended slow start → additive recovery: 6 clean windows → one +~6 % step + // (14000 + 14000/16 + 1 = 14876). + let up = run_clean(&mut c, start, 2, 7); assert_eq!(up, Some(14_876)); c.on_ack(14_876); - // Fully recovered → clean windows at the ceiling stay quiet (never probe past start). + // Fully recovered → clean windows at the ceiling stay quiet (never probe past it). c.on_ack(20_000); assert_eq!(run_clean(&mut c, start, 40, 20), None); } + #[test] + fn slow_start_doubles_to_a_probed_ceiling_then_stops() { + let mut c = BitrateController::new(20_000); + // The startup link-capacity probe measured ~430 Mbps delivered → ×0.7 ceiling. + c.set_ceiling(300_000); + let start = Instant::now(); + // Every cooled clean window doubles until the ceiling caps the climb, then quiet. + let mut got = Vec::new(); + for i in 0..14 { + if let Some(k) = c.on_window(ticks(start, i), 0, 0, Some(10_000), false) { + c.on_ack(k); + got.push(k); + } + } + assert_eq!(got, vec![40_000, 80_000, 160_000, 300_000]); + } + + #[test] + fn first_congestion_ends_slow_start_for_good() { + let mut c = BitrateController::new(20_000); + c.set_ceiling(300_000); + let start = Instant::now(); + assert_eq!( + c.on_window(ticks(start, 0), 0, 0, Some(10_000), false), + Some(40_000) + ); + c.on_ack(40_000); + // Severe window → immediate ×0.7, and slow start is over. + assert_eq!( + c.on_window(ticks(start, 2), 1, 0, Some(10_000), false), + Some(28_000) + ); + c.on_ack(28_000); + // Clean again — but the next climb is additive, after the 6-window clean run. + let mut next = None; + for i in 3..12 { + next = c.on_window(ticks(start, i), 0, 0, Some(10_000), false); + if next.is_some() { + assert!(i >= 8, "additive climb must wait for the clean run"); + break; + } + } + assert_eq!(next, Some(29_751)); // 28000 + 28000/16 + 1 + } + + #[test] + fn set_ceiling_is_ignored_when_disabled_and_never_lowers() { + let mut c = BitrateController::new(0); + c.set_ceiling(1_000_000); + assert_eq!(c.on_window(Instant::now(), 0, 0, None, false), None); + let mut c = BitrateController::new(20_000); + c.set_ceiling(10_000); // below the negotiated start → ignored + assert_eq!(c.ceiling_kbps, 20_000); + } + #[test] fn owd_rise_alone_is_a_congestion_signal() { let mut c = BitrateController::new(20_000); @@ -304,12 +425,4 @@ mod tests { } assert_eq!(sent, MAX_UNACKED); } - - #[test] - fn flush_counts_as_a_bad_window() { - let mut c = BitrateController::new(20_000); - let start = Instant::now(); - assert_eq!(c.on_window(ticks(start, 0), 0, 0, None, true), None); - assert_eq!(c.on_window(ticks(start, 1), 0, 0, None, true), Some(14_000)); - } } diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index b02776ab..53282461 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -1952,6 +1952,22 @@ async fn worker_main(args: WorkerArgs) { } else { 0 }); + // Startup link-capacity probe (Automatic sessions): the controller's ceiling is the + // negotiated start rate — the conservative 20 Mbps default, historically a box Automatic + // could NEVER climb out of. One speed-test burst shortly after the stream settles + // measures what the link actually delivers; ×0.7 (headroom for FEC overhead + variance) + // becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero + // reply) or never answer (timeout clears the state so LossReports resume) — either way + // the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out. + const CAPACITY_PROBE_KBPS: u32 = 2_000_000; + const CAPACITY_PROBE_MS: u32 = 800; + const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2); + const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6); + let mut capacity_probe_at: Option = (bitrate_kbps == 0 + && resolved_bitrate_kbps > 0 + && std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0")) + .then(|| Instant::now() + CAPACITY_PROBE_DELAY); + let mut capacity_probe_deadline: Option = None; let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32); let mut flush_in_window = false; // Jump-to-live state (see the guard in the loop below): the clock-based over-bound run @@ -2006,6 +2022,65 @@ async fn worker_main(args: WorkerArgs) { } p.active && !p.done }; + // Fire the startup link-capacity probe once the stream has settled (see the constants + // above), and fold its measurement into the ABR ceiling when the result lands. + if let Some(at) = capacity_probe_at { + if Instant::now() >= at { + capacity_probe_at = None; + *pump_probe.lock().unwrap() = ProbeState { + active: true, + ..Default::default() + }; + if ctrl_tx + .try_send(CtrlRequest::Probe(ProbeRequest { + target_kbps: CAPACITY_PROBE_KBPS, + duration_ms: CAPACITY_PROBE_MS, + })) + .is_ok() + { + capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT); + tracing::info!( + target_kbps = CAPACITY_PROBE_KBPS, + duration_ms = CAPACITY_PROBE_MS, + "adaptive bitrate: startup link-capacity probe" + ); + } else { + pump_probe.lock().unwrap().active = false; // ctrl queue full — skip + } + } + } + if let Some(deadline) = capacity_probe_deadline { + let mut p = pump_probe.lock().unwrap(); + if p.done { + capacity_probe_deadline = None; + // An all-zero reply is a decline (old host / probe-less build) — keep the + // negotiated ceiling. Otherwise: delivered wire kbps × 0.7. + if p.host_duration_ms > 0 && p.delivered_bytes > 0 { + let delivered_kbps = (p.delivered_bytes.saturating_mul(8) + / p.host_duration_ms.max(1) as u64) + as u32; + let ceiling = delivered_kbps.saturating_mul(7) / 10; + abr.set_ceiling(ceiling); + tracing::info!( + delivered_kbps, + ceiling_kbps = ceiling, + "adaptive bitrate: link-capacity probe done — climb ceiling set" + ); + } else { + tracing::info!( + "adaptive bitrate: capacity probe declined — keeping negotiated ceiling" + ); + } + } else if Instant::now() >= deadline { + // The host never answered (a build that ignores ProbeRequest): clear the + // stuck-active state so LossReports resume, keep the negotiated ceiling. + p.active = false; + capacity_probe_deadline = None; + tracing::info!( + "adaptive bitrate: capacity probe timed out (old host?) — keeping negotiated ceiling" + ); + } + } if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL { // A no-op clock flush earlier in this window suspected a wall-clock step: fire // the mid-stream re-sync now (once — the 60 s periodic covers everything else). diff --git a/crates/punktfunk-host/src/encode.rs b/crates/punktfunk-host/src/encode.rs index 57f1d126..ca8751ff 100644 --- a/crates/punktfunk-host/src/encode.rs +++ b/crates/punktfunk-host/src/encode.rs @@ -332,6 +332,19 @@ impl Codec { } } +/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency +/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for +/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the +/// direct-NVENC paths (which used to hardwire 1 frame) to parity. Larger values let complex +/// frames borrow bits — better rate utilization at the cost of per-frame size variance. +pub(crate) fn vbv_frames_env() -> f64 { + std::env::var("PUNKTFUNK_VBV_FRAMES") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|v| v.is_finite() && *v > 0.0) + .unwrap_or(1.0) +} + /// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects /// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math /// overflow or the encoder open fail with an opaque NVENC code. A client can request any diff --git a/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs b/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs index 80339e0b..ecbe3aee 100644 --- a/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs +++ b/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs @@ -511,7 +511,9 @@ impl NvencCudaEncoder { cfg.rcParams.averageBitRate = bps; cfg.rcParams.maxBitRate = bps; if self.custom_vbv { - let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32; + // ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV). + let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env()) + .clamp(1.0, u32::MAX as f64) as u32; cfg.rcParams.vbvBufferSize = vbv; cfg.rcParams.vbvInitialDelay = vbv; } diff --git a/crates/punktfunk-host/src/encode/windows/nvenc.rs b/crates/punktfunk-host/src/encode/windows/nvenc.rs index 923d7509..032aabd1 100644 --- a/crates/punktfunk-host/src/encode/windows/nvenc.rs +++ b/crates/punktfunk-host/src/encode/windows/nvenc.rs @@ -734,7 +734,9 @@ impl NvencD3d11Encoder { // Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only // when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe). if self.custom_vbv { - let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32; + // ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV). + let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env()) + .clamp(1.0, u32::MAX as f64) as u32; cfg.rcParams.vbvBufferSize = vbv; cfg.rcParams.vbvInitialDelay = vbv; }