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}