Skip to main content

rusty_h264_encoder/
rc.rs

1//! Average-bitrate rate control with a look-ahead complexity model.
2//!
3//! A frame-level controller that varies the per-frame quantization parameter to
4//! converge the average bitrate on a target. It combines a **look-ahead
5//! complexity estimate** (a cheap pre-encode score of *this* frame — see
6//! [`crate::lookahead`]) with a **leaky-bucket buffer** (correct accumulated
7//! over/undershoot). Using the upcoming frame's own complexity, rather than a
8//! lagging average of past frames, spends bits where they are needed and keeps
9//! quality steadier across complexity changes. The decoder needs no cooperation:
10//! each frame's QP rides in its `slice_qp_delta`, which conformant decoders honour.
11//!
12//! The model rests on the observation that coded bits are roughly inversely
13//! proportional to the quantizer step `Qstep`, and proportional to a frame's
14//! complexity. So `k = bits · Qstep / complexity` is a slowly-varying constant we
15//! learn (per frame type), then invert against the look-ahead complexity to pick
16//! a QP for the frame's bit budget.
17
18#[allow(unused_imports)]
19use rusty_h264_common::fmath::{F32Ext as _, F64Ext as _};
20
21/// H.264 quantizer step for a QP: `Qstep` doubles every 6 QP (spec §8.6.1).
22/// The exponential lives in the [`crate::fastmath`] per-process table — QP is
23/// integral at every caller, so the table is exact (site 9 of the
24/// fast-transcendentals plan).
25fn qstep(qp: u8) -> f64 {
26    crate::fastmath::qstep_qp(qp)
27}
28
29/// Quantizer-curve compression (x264's `qcomp`): 0 = constant bits per frame
30/// (varying quality), 1 = constant quality (bits ∝ complexity). The default
31/// blend spends ~`complexity^qcomp` bits, smoothing quality across complexity
32/// changes without fully sacrificing rate efficiency.
33const QCOMP: f64 = 0.6;
34
35/// Frame-level average-bitrate controller.
36#[derive(Debug, Clone)]
37pub struct RateControl {
38    /// Channel drain per coded frame, `bitrate / framerate` (bits).
39    target_per_frame: f64,
40    /// Leaky-bucket capacity (bits); ~one second of output.
41    buffer_size: f64,
42    /// Current buffer occupancy (bits); steered toward half-full.
43    fullness: f64,
44    /// Base/fallback QP (the configured `qp`), used before the model calibrates.
45    base_qp: f64,
46    qp_min: f64,
47    qp_max: f64,
48    /// Learned `bits · Qstep / complexity` per frame type (`0` = uninitialized).
49    /// I- and P-frame complexity scores live in different domains (spatial vs
50    /// motion-compensated SATD), so their constants are tracked separately.
51    k_p: f64,
52    k_i: f64,
53    /// Smoothed look-ahead complexity per frame type, the reference point for
54    /// the complexity-proportional bit allocation (`0` = uninitialized).
55    avg_c_p: f64,
56    avg_c_i: f64,
57    /// Last QP actually used, to limit frame-to-frame swing.
58    last_qp: f64,
59}
60
61impl RateControl {
62    /// Builds a controller for `bitrate` bits/sec at `framerate` fps, with `qp`
63    /// as the base/fallback quality. QP is clamped to a sane window around it.
64    pub fn new(bitrate: u32, framerate: f32, qp: u8) -> Self {
65        let bitrate = bitrate as f64;
66        let framerate = (framerate as f64).max(1.0);
67        let target_per_frame = bitrate / framerate;
68        let buffer_size = bitrate.max(target_per_frame * 2.0); // ≥ ~1s, ≥ 2 frames
69        RateControl {
70            target_per_frame,
71            buffer_size,
72            fullness: buffer_size * 0.5,
73            base_qp: qp as f64,
74            qp_min: (qp as f64 - 18.0).max(10.0),
75            qp_max: (qp as f64 + 18.0).min(51.0),
76            k_p: 0.0,
77            k_i: 0.0,
78            avg_c_p: 0.0,
79            avg_c_i: 0.0,
80            last_qp: qp as f64,
81        }
82    }
83
84    /// Picks the QP for a frame given its look-ahead `complexity` score (`is_idr`
85    /// selects the I model and a quality bump, since every later frame predicts
86    /// from the IDR).
87    pub fn pick_qp(&self, is_idr: bool, complexity: f64) -> u8 {
88        // Buffer-adjusted bit budget: spend less when the bucket is filling,
89        // draining any accumulated deviation over roughly a buffer's worth.
90        let deviation = self.fullness - self.buffer_size * 0.5;
91        let frames_to_correct = (self.buffer_size / self.target_per_frame).max(4.0);
92        let buf_target = (self.target_per_frame - deviation / frames_to_correct)
93            .max(self.target_per_frame * 0.2);
94
95        // Complexity-proportional allocation: a frame `r×` the average complexity
96        // gets `r^qcomp ×` the budget (clamped so one frame can't drain the
97        // buffer). This is what holds quality steady across complexity changes.
98        let avg = if is_idr { self.avg_c_i } else { self.avg_c_p };
99        let budget = if avg > 0.0 {
100            buf_target * rusty_h264_common::fmath::powf((complexity / avg).clamp(0.25, 4.0), QCOMP)
101        } else {
102            buf_target
103        };
104
105        let k = if is_idr { self.k_i } else { self.k_p };
106        let qp = if k <= 0.0 {
107            // Not calibrated yet: lean on the base QP, nudged by buffer state.
108            self.base_qp + deviation / self.buffer_size * 8.0 - if is_idr { 2.0 } else { 0.0 }
109        } else {
110            // Predict this frame's bits·Qstep from its look-ahead complexity, then
111            // invert against the budget: Qstep = (k · complexity) / budget.
112            4.0 + 6.0 * rusty_h264_common::fmath::log2(k * complexity / budget)
113        };
114
115        // Limit per-frame swing for stable quality, then clamp to the window. A
116        // wider swing than the reactive model: the look-ahead means the change is
117        // driven by real complexity, not lag, so let it track more aggressively.
118        rusty_h264_common::fmath::round(
119            qp.clamp(self.last_qp - 6.0, self.last_qp + 6.0)
120                .clamp(self.qp_min, self.qp_max),
121        ) as u8
122    }
123
124    /// Feeds back the bits a frame actually cost at its chosen QP and complexity,
125    /// recalibrating `k = bits · Qstep / complexity` and the average complexity.
126    pub fn update(&mut self, is_idr: bool, bits: usize, qp: u8, complexity: f64) {
127        let k_new = bits as f64 * qstep(qp) / complexity.max(1.0);
128        let ema = |old: f64, new: f64| {
129            if old <= 0.0 {
130                new
131            } else {
132                0.5 * old + 0.5 * new
133            }
134        };
135        if is_idr {
136            self.k_i = ema(self.k_i, k_new);
137            self.avg_c_i = ema(self.avg_c_i, complexity);
138        } else {
139            self.k_p = ema(self.k_p, k_new);
140            self.avg_c_p = ema(self.avg_c_p, complexity);
141        }
142
143        // Leaky bucket: add what we coded, drain the channel allotment.
144        self.fullness =
145            (self.fullness + bits as f64 - self.target_per_frame).clamp(0.0, self.buffer_size);
146        self.last_qp = qp as f64;
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    #[allow(unused_imports)]
154    use alloc::{
155        boxed::Box,
156        format,
157        string::{String, ToString},
158        vec,
159        vec::Vec,
160    };
161    #[allow(unused_imports)]
162    use rusty_h264_common::once::OnceLock;
163
164    #[test]
165    fn qstep_doubles_every_six_qp() {
166        assert!((qstep(28) / qstep(22) - 2.0).abs() < 1e-9);
167    }
168
169    #[test]
170    fn raises_qp_when_overshooting() {
171        let mut rc = RateControl::new(1_000_000, 30.0, 26);
172        let c = 1.0e6; // fixed look-ahead complexity
173        let first = rc.pick_qp(true, c);
174        // Feed back frames far larger than the per-frame budget repeatedly.
175        for _ in 0..30 {
176            let qp = rc.pick_qp(false, c);
177            rc.update(false, (rc.target_per_frame as usize) * 4, qp, c);
178        }
179        assert!(
180            rc.pick_qp(false, c) > first,
181            "QP should climb to curb overshoot"
182        );
183    }
184
185    #[test]
186    fn lowers_qp_when_undershooting() {
187        let mut rc = RateControl::new(1_000_000, 30.0, 40);
188        let c = 1.0e6;
189        let start = rc.pick_qp(false, c);
190        for _ in 0..30 {
191            let qp = rc.pick_qp(false, c);
192            rc.update(false, (rc.target_per_frame as usize) / 8, qp, c);
193        }
194        assert!(
195            rc.pick_qp(false, c) < start,
196            "QP should fall to use the budget"
197        );
198    }
199
200    #[test]
201    fn complex_frame_not_given_more_quality_than_simple() {
202        // Once calibrated, with qcomp < 1 a more complex frame is coded at no
203        // higher quality (no lower QP) than a simpler one at the same budget.
204        let mut rc = RateControl::new(2_000_000, 30.0, 26);
205        for _ in 0..20 {
206            let qp = rc.pick_qp(false, 1.0e6);
207            rc.update(false, rc.target_per_frame as usize, qp, 1.0e6);
208        }
209        let simple = rc.pick_qp(false, 0.5e6);
210        let complex = rc.pick_qp(false, 4.0e6);
211        assert!(complex >= simple);
212    }
213}