rusty_h264_encoder/config.rs
1//! Encoder configuration.
2
3use rusty_h264_common::{ChromaFormat, Profile};
4
5/// Speed/quality trade-off, in the spirit of x264's `-preset`. The bitstream is
6/// valid (and decodes bit-exactly) either way; only the encoder's effort differs.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum Preset {
9 /// **Fast** — built to mirror x264's fastest presets: mode decision
10 /// by cheap **SAD** estimation (no rate-distortion trial-encoding; SAD
11 /// auto-vectorizes to `psadbw`), `P_16x16`-only inter, `I_16x16`-only intra,
12 /// and **integer-pel** motion (no sub-pel `mc_luma` interpolation — profiling
13 /// showed it was ~55% of the encode). Much faster; larger files, and a little
14 /// quality lost on sub-pixel motion (none on integer/screen content).
15 Fast,
16 /// **Balanced** — [`Fast`](Self::Fast)'s decision path plus **sub-pel motion
17 /// refinement**, which `Fast` omits.
18 ///
19 /// Integer-pel motion cannot track sub-pixel displacement, so on slow pans and
20 /// dollies the residual stays large, the intra cost wins, and macroblocks fall
21 /// back to intra — which is very expensive. Measured over 4 QPs on four clips,
22 /// adding sub-pel to `Fast` is **−42% to −50% BD-rate** (PSNR and SSIM agree)
23 /// for ~2.3–3.1× the time. On fine-detail content it beats [`Quality`] on BOTH
24 /// size and speed (in_to_tree 26.6 vs 27.3 Mb/s at 7.2× the throughput), because
25 /// sub-pel — not the sub-partitions or the RD search — is what that content
26 /// needs.
27 ///
28 /// **This is the default.** Sub-pel costs ~2–3× the time, but a step on
29 /// x264's own preset ladder buys ~2–3% BD-rate for ~1.5× — so at −42..−50%
30 /// this is dramatically underpriced by comparison. `Fast` remains available
31 /// for throughput-critical use.
32 #[default]
33 Balanced,
34 /// **Quality** — full rate-distortion mode decision (every candidate
35 /// trial-encoded for real `J = SSD + λ·bits`), `16x8`/`8x16` sub-partitions,
36 /// and the full `I_4x4` intra search. Smaller files; much slower.
37 Quality,
38}
39
40/// Resolution the mb-tree lookahead motion search runs at (speed/quality lever).
41/// Measured on CIF (mb-tree BD-rate vs off / encode wall vs FullRes):
42/// FullRes mand −0.19% tsrc −1.80% (1.0×) · Hybrid −0.19% / −1.47% (~1.7×) ·
43/// HalfRes +0.12% / −1.28% (~4×).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum LookaheadMode {
46 /// Search AND score intra/inter costs at full resolution. Best quality, slowest
47 /// lookahead. The reference against which the others are measured.
48 FullRes,
49 /// Search the MV on 2×-downsampled planes (cheap), then REFINE + score the final
50 /// intra/inter cost at FULL resolution — recovers full-res quality (the half-res
51 /// loss was cost accuracy on blurred data, not the MV) at ~1.7× the speed. The
52 /// no-regression speed option.
53 Hybrid,
54 /// **Default** — search AND score at half resolution. Fastest lookahead (~4×), a
55 /// small BD-rate cost on fine-detail content (downsampling blurs the cost estimates).
56 #[default]
57 HalfRes,
58}
59
60/// Configuration for an [`crate::Encoder`].
61#[derive(Debug, Clone)]
62pub struct EncoderConfig {
63 /// Picture width in luma samples. Arbitrary (not restricted to /16).
64 pub width: usize,
65 /// Picture height in luma samples.
66 pub height: usize,
67 /// Target profile. Only [`Profile::ConstrainedBaseline`] is implemented.
68 pub profile: Profile,
69 /// Chroma format. Only [`ChromaFormat::Yuv420`] is implemented.
70 pub chroma: ChromaFormat,
71 /// `level_idc` (e.g. 30 = level 3.0). Caller is responsible for choosing a
72 /// level that admits the resolution/bitrate; not yet validated.
73 pub level_idc: u8,
74 /// Quantization parameter (0..=51). With rate control off this is the fixed
75 /// QP for every frame; with it on, the base/fallback QP and `pic_init_qp`.
76 pub qp: u8,
77 /// MAXIMUM frames between IDR pictures (x264's `keyint`). `1` = all-intra
78 /// (every frame an IDR). With [`scenecut`](Self::scenecut) active, IDRs
79 /// land at detected scene changes and this is only the forced-refresh
80 /// ceiling — exactly x264's model (its default keyint is 250).
81 pub gop_size: u32,
82 /// MINIMUM frames between IDR pictures (x264's `min-keyint`, default 25):
83 /// a scene cut closer than this to the previous IDR does not spend one.
84 /// Ignored by the forced `gop_size` refresh and by all-intra.
85 pub min_keyint: u32,
86 /// Scene-cut sensitivity (x264's `scenecut`, default 40; `0` = OFF —
87 /// fixed-cadence IDRs, byte-identical to the pre-scenecut encoder). A cut
88 /// fires when the frame-pair inter/intra activity ratio reaches
89 /// `1 - scenecut/100` (the x264 rule) — motion compensation recovering
90 /// less than `scenecut`% of the frame's spatial energy means the content
91 /// changed, not moved.
92 pub scenecut: u32,
93 /// Lookahead window in frames (x264's `rc-lookahead`, default 40): the
94 /// buffering bound for streaming mb-tree and the mb-tree window inside
95 /// long scenecut GOPs — a 250-frame GOP must not mean a 250-frame buffer.
96 pub lookahead: u32,
97 /// Target bitrate in bits per second. `0` disables rate control (constant
98 /// QP); any positive value enables average-bitrate control, which varies the
99 /// per-frame QP around [`qp`](Self::qp) to converge on this rate.
100 pub bitrate: u32,
101 /// Frame rate (frames per second), used by rate control to turn the bitrate
102 /// target into a per-frame bit budget.
103 pub framerate: f32,
104 /// Number of reference frames the encoder may use for P-pictures (1..=16).
105 /// `1` keeps the single-reference bitstream; higher values let P-macroblocks
106 /// pick an older reference (`ref_idx`), helping occlusion/periodic motion.
107 pub num_ref_frames: u32,
108 /// Speed/quality trade-off. Defaults to [`Preset::Balanced`].
109 pub preset: Preset,
110 /// EXPERIMENT KNOB (hidden): use the asm (dct_four_t4 + quant_four_4x4) fast
111 /// path in the P_Skip free-check instead of the scalar twin. Byte-identical
112 /// either way; exists so A/B arms interleave in ONE binary (honest thermals).
113 #[doc(hidden)]
114 pub tune_skip_accel_check: bool,
115 /// EXPERIMENT KNOB (hidden): route inter-MB coding through the isolated,
116 /// coefficient-fused `encode_inter_mb_v2` path instead of the current
117 /// `encode_inter_mb`. Byte-identical output (gated), selectable at runtime so
118 /// the two implementations run side-by-side in ONE binary for honest A/B
119 /// timing on the coded path. See the `coded_path_ab` test.
120 #[doc(hidden)]
121 pub coded_path_v2: bool,
122 /// TUNING KNOB (hidden): scale on the Lagrangian λ = 0.85·2^((qp−12)/3) that
123 /// prices bits in the RD/mode/ME decisions. `1.0` = the standard H.264 model
124 /// (byte-identical default). The BD-rate harness sweeps this to calibrate the
125 /// rate weight; a content-adaptive dispatcher can vary it per frame.
126 #[doc(hidden)]
127 pub tune_lambda_scale: f64,
128 /// TUNING KNOB (hidden): the λ·bits penalty (in bits) added to the intra cost
129 /// in the fast/quality mode decision, biasing toward inter. `24.0` = default.
130 /// Higher → fewer intra MBs. Content-adaptive candidate (textured content).
131 #[doc(hidden)]
132 pub tune_intra_penalty: f64,
133 /// TUNING KNOB (hidden): content-adaptive cost-function dispatch. Fraction of
134 /// each frame's highest-VARIANCE MBs whose fast-preset mode decision uses the
135 /// rate-faithful SATD cost instead of cheap SAD (SAD is rate-blind on detailed
136 /// MBs). `0.0` = pure SAD (byte-identical default); `1.0` = all SATD.
137 #[doc(hidden)]
138 pub tune_satd_q: f64,
139 /// EXPERIMENT KNOB (hidden): force sub-pel motion refinement in the FAST
140 /// preset, which is otherwise integer-pel only. Exists to run the force-on
141 /// oracle per clip: content whose true displacement is sub-pixel (slow pans,
142 /// dollies) cannot be tracked by integer-pel ME, falls back to intra, and
143 /// codes very expensively. Bitstream-changing — BD-rate gated, not byte-exact.
144 #[doc(hidden)]
145 pub tune_subpel: bool,
146 /// Rate-distortion P_Skip decision. The default criterion skips only when the
147 /// residual quantizes to EXACTLY zero (a proof of freeness); this instead
148 /// compares `J = SSD + λ·bits` for the skip against the chosen coded mode, so
149 /// macroblocks with a small but non-zero residual can skip too. Measured
150 /// against x264 at matched QP, the exact-zero rule leaves 17-23 percentage
151 /// points of macroblocks coded that x264 skips (foreman 6.4% vs 23.6%,
152 /// in_to_tree 1.0% vs 24.1%) — while matching it exactly at both extremes
153 /// (akiyo 72.5 vs 73.6, mobile 1.0 vs 1.4), which is what proves the gap is
154 /// the CRITERION and not the machinery. Bitstream-changing; BD-rate gated.
155 #[doc(hidden)]
156 /// Quality preset's greedy P_Skip (openh264 `PredictSadSkip`): take the skip
157 /// when its luma SAD is under the neighbour-predicted threshold, without
158 /// pricing the coded alternative. An APPROXIMATE skip decision inside the
159 /// P-chain — the same class as [`Self::tune_rd_skip_fast_t`] — so it is
160 /// subject to the same propagation multiplier and exists as a knob to audit
161 /// that. `true` is the long-standing default behaviour.
162 /// Snap the full-pel diamond's centre to integer-pel before searching.
163 ///
164 /// The diamond walks WHOLE-pel offsets, but its seed is the neighbour MV
165 /// predictor, which is fractional — so a sub-pel seed drags every candidate in
166 /// the search through the 6-tap interpolation filter. Measured: 84-90% of all
167 /// SATD evaluations interpolate. Snapping makes the full-pel phase genuinely
168 /// full-pel (direct SATD against the reference), leaving only the sub-pel
169 /// refine to interpolate. The un-snapped seed is retained as a candidate, so
170 /// the search can never come out worse than its own starting point.
171 ///
172 /// Same fix already applied to the stall-rescue grid (2.21x -> 1.19x on zoom).
173 pub tune_me_snap: bool,
174 /// Walk the sub-pel refinement until it stops improving, instead of a single
175 /// 8-point pass per step. Independent of [`Self::tune_me_snap`] — measured
176 /// separately, because the two were first built coupled and the attribution
177 /// was ambiguous.
178 pub tune_me_subpel_iter: bool,
179 pub tune_greedy_skip: bool,
180 /// Minimum online FREE-skip percentage for [`Self::tune_greedy_skip`] to
181 /// engage, dispatched on exactly the signal that gates RD skip
182 /// ([`Self::tune_rd_skip_min_free`]). The greedy skip wins on temporally
183 /// redundant content and LOSES on detailed content (BD-SSIM: akiyo -0.59,
184 /// FourPeople -0.32 vs foreman +1.23) — the same sign-flip, separated by the
185 /// same signal. `None` resolves to 85 — the calibrated default, at which the
186 /// corpus regression on foreman (+1.23% BD-SSIM, previously shipping) becomes
187 /// 0.00 and nothing else regresses. `Some(0)` restores the old ungated
188 /// behaviour; `Some(101)` disables the greedy skip entirely.
189 pub tune_greedy_skip_min_free: Option<u32>,
190 /// RD `B_Skip` strength, in units of lambda. **DEFAULT-ON at 48.0**;
191 /// `None`/`<=0` restores the previous exactly-free-only rule byte-identically.
192 ///
193 /// A B macroblock is skipped when direct WON the mode decision and its
194 /// prediction distortion is under `T*lambda` — i.e. the residual is not worth
195 /// its bits. Our previous rule demanded the residual quantize to EXACTLY zero,
196 /// which reaches 93.5% of B macroblocks on akiyo and 34.5% on foreman (at or
197 /// ABOVE x264) but collapses to 7.8% on mobile where x264 still finds 27.4%.
198 /// The deficit is BUSY-CONTENT-ONLY, so this is DISPATCHED on the online
199 /// free-skip rate of the frame ([`Self::tune_bskip_busy_pct`]) rather than
200 /// applied as a flat constant — on content where we already out-skip x264 it
201 /// stays a byte-identical no-op.
202 ///
203 /// 4-QP per-clip BD at T=48 (worst clip 0.00): mobile -0.51 PSNR / -0.96 SSIM,
204 /// foreman -0.30 / -0.34, bus -0.10 / -0.50, akiyo byte-identical.
205 pub tune_bskip_rd: Option<f64>,
206 /// Engage [`Self::tune_bskip_rd`] only while the frame's online FREE-skip rate
207 /// is below this percentage — the busy-content dispatch. Default 60.
208 pub tune_bskip_busy_pct: Option<usize>,
209 /// Minimum online DIRECT-WIN rate (percent of not-free B macroblocks where
210 /// direct won the mode decision) for [`Self::tune_bskip_rd`] to engage.
211 /// Default 10 — calibrated on the one corpus clip that regressed (football,
212 /// 7.0%) against the lowest-rate winner (foreman, 14.1%).
213 pub tune_bskip_dirwin_pct: Option<usize>,
214 /// Search B 16x8 / 8x16 partitions. x264 spends 13.5% of its B macroblocks
215 /// there; we had none, which is why the B bucket kept reading as a CODING gap
216 /// after every constant in it had been swept flat. DEFAULT ON: the 4-QP
217 /// per-clip table wins on all seven clips and both metrics with no sign flip
218 /// (BD-SSIM akiyo -0.17%, FourPeople -0.80%, tempete -1.66%, mobile -3.36%,
219 /// foreman -3.49%, bus -4.56%, football -7.09%), so there is nothing to
220 /// dispatch on -- the win simply concentrates on busy/high-motion content.
221 /// CABAC B path only; the CAVLC B path still emits 16x16 modes.
222 pub tune_b_split: bool,
223 pub tune_rd_skip: bool,
224 /// Minimum FREE-skip percentage, measured online over the frame so far, for
225 /// [`Self::tune_rd_skip`] to engage on the rest of that frame.
226 ///
227 /// `None` resolves per preset, because the signal's SCALE is preset-dependent:
228 /// sub-pel refinement predicts better, so it lifts the free-skip rate on ALL
229 /// content and the same absolute bar starts admitting content that loses.
230 /// Fast (no sub-pel) calibrates to 60; the sub-pel presets need 90. Each is
231 /// the smallest bar at which no corpus clip regresses on BD-PSNR or BD-SSIM.
232 /// `Some(0)` forces RD skip on everywhere (which LOSES badly on detailed
233 /// content); `Some(101)` disables it.
234 pub tune_rd_skip_min_free: Option<u32>,
235 /// Skip-gate on the null arm's cost, in units of lambda: when
236 /// `SSD(skip) <= lambda * T` the skip is taken WITHOUT trial-encoding the
237 /// coded arm at all.
238 ///
239 /// The RD skip decision has to encode the coded arm to price it, and 55-80%
240 /// of the time it then throws that encode away — the null arm wins. This is
241 /// the standard search-skip gate over that: it trades a small number of
242 /// decisions (the RD comparison would occasionally have coded) for not
243 /// encoding at all. `None`/`<= 0.0` disables it (every candidate is priced
244 /// exactly). Unlike the rest of the decision this is NOT byte-identical, so
245 /// it is BD-rate gated.
246 pub tune_rd_skip_fast_t: Option<f64>,
247 /// Number of B-frames between reference (I/P) anchors. `0` = no B-frames
248 /// (Constrained Baseline, byte-identical). `>0` requires Main profile (B is
249 /// illegal in Baseline) and activates the reorder pipeline: anchors are coded
250 /// ahead of the B-frames that reference them (L0 past + L1 future), and B-
251 /// frames are non-reference. WORK IN PROGRESS — see the B-frame build plan.
252 pub bframes: u32,
253 /// QP offset applied to B-frames (added to [`qp`](Self::qp)). B-frames are
254 /// non-reference, so their coding error never propagates — quantizing them
255 /// harder (a positive offset) spends the saved bits on the reference anchors.
256 /// Only used when `bframes > 0`. Default `2`.
257 pub bframe_qp_offset: i32,
258 /// Adaptive Quantization strength. Modulates the QP per macroblock by content:
259 /// flat/low-variance MBs (where blocking & banding are visible) get a FINER QP,
260 /// busy/high-variance MBs (where the eye masks error) a COARSER one — moving bits
261 /// to where they're seen, a perceptual (SSIM) win at ~neutral PSNR. The QP shift
262 /// is relative to the FRAME's mean log-variance (content-invariant), rate-
263 /// compensated, and its EFFECTIVE strength backs off automatically where the
264 /// log-variance spread is extreme (pathological synthetic content), so it never
265 /// regresses. Default **`1.0`** (on); `0.0` = off (uniform QP, byte-identical).
266 pub aq_strength: f64,
267 /// Content-adaptive B-frame ENABLE. When set (with `bframes > 0`), the encoder
268 /// measures the clip's temporal predictability (a cheap global-motion bi-
269 /// prediction residual) and codes B-frames ONLY when they'll help — smooth /
270 /// predictable motion, where bi-pred + spatial-direct are cheap. On busy content
271 /// it falls back to P-only, so B-frames never regress. Default `false`.
272 pub bframes_adaptive: bool,
273 /// B-pyramid (x264 parity — its default is `normal`): with 2+ B's per
274 /// anchor gap, the MIDDLE B is coded first as a REFERENCE (`nal_ref_idc
275 /// 2`, deblocked recon in the DPB, sliding-window marking) and the leaf
276 /// B's bracket against it — halving the leaf prediction distance. v1 is
277 /// CABAC-path (the default); CAVLC B stays leaf-only.
278 pub b_pyramid: bool,
279 /// Per-GOP I-frame QP cascade — the BASE offset for the classic `ip_ratio`
280 /// (added to [`qp`](Self::qp) on each GOP's I-frame; it's the root reference for
281 /// its whole GOP, so coding it finer propagates quality GOP-wide). Default `-3`.
282 /// In the B-capable batch path this is CONTENT-ADAPTIVE per GOP: predictable
283 /// GOPs — where the I-frame dominates the GOP's bits — deepen it up to 2 further
284 /// QP steps (calibrated: busy ≈ base, compressible ≈ base−2). `0` disables the
285 /// cascade entirely (byte-identical escape hatch). Constant-QP only.
286 pub i_qp_offset: i32,
287 /// CABAC entropy coding (PPS `entropy_coding_mode_flag = 1`, Main profile).
288 /// Codes ~5–17% smaller than CAVLC at matched quality (I- and P-slices; B-slice
289 /// CABAC pending). Default `false` (CAVLC — Constrained Baseline, unchanged).
290 pub cabac: bool,
291 /// Explicit weighted prediction for P slices (x264 parity — its `weightp`
292 /// defaults on). Per-slice, per-reference LUMA (w, offset) at denom 6,
293 /// estimated by a fade detector (DC-ratio fit, SAD-gated); identity
294 /// weights everywhere the estimator finds no gain, so non-fade content
295 /// pays only the table's few header bits per slice. Chroma is unweighted
296 /// (flag 0) — matching what x264's own weightp streams carry.
297 pub weightp: bool,
298 /// `cabac_init_idc` (0..2) — selects one of 3 context-initialization tables for
299 /// P/B slices (I-slices always use the I preset). The best table is
300 /// content-dependent; `0` is the default. Signalled in the P/B slice header.
301 pub cabac_init_idc: u32,
302 /// Multiplier on the mode-decision Lagrangian (√λ) in the CABAC P/B path only.
303 /// CABAC codes ~9% fewer bits than the CAVLC-flavoured rate estimate the mode
304 /// decision uses, so the rate term is slightly over-weighted; this retunes it.
305 /// Default `1.0` (unchanged). CAVLC path is never affected.
306 pub cabac_lambda_scale: f64,
307 /// ME lambda scale used on NORMAL-texture content, dispatched by the frame's
308 /// median source macroblock variance. `None` = no dispatch (always
309 /// [`Self::cabac_lambda_scale`]). **DEFAULT None — the dispatch is OFF.**
310 ///
311 /// The texture gate works for what it was built for: it holds mobile (median MB
312 /// variance 1554) at the conservative value, byte-identically, where a flat 1.8
313 /// costs +0.42% BD-SSIM. But it does NOT clear the monotone bar, because `bus`
314 /// regresses at EVERY high value tried (1.4 +0.35, 1.6 +0.09, 1.8 +0.29 BD-PSNR
315 /// vs 1.25) and bus's texture (454) sits BELOW football's (583), which WANTS the
316 /// high value — so no threshold on this signal separates them. Kept as an
317 /// opt-in knob with the machinery intact; needs a second, motion-flavoured term
318 /// before it can be default-on.
319 pub tune_lme_hi: Option<f64>,
320 /// Median source MB variance at or above which the conservative
321 /// [`Self::cabac_lambda_scale`] is used instead of [`Self::tune_lme_hi`].
322 /// **Default 650.** Measured medians: akiyo 61, foreman 219, city 300, bus 454,
323 /// football 583, **tempete 746**, mobile 1554. 650 sits between football (583,
324 /// which WANTS the high lambda) and tempete (746, which regresses on BD-SSIM at
325 /// it) — the third loser, found only after the first two were gated. An earlier
326 /// 800 let tempete through by 54 points.
327 pub tune_lme_tex_thresh: Option<i64>,
328 /// Global-MC residual at or above which the conservative
329 /// [`Self::cabac_lambda_scale`] is used. Default 26.0 — measured residuals are
330 /// akiyo 1.5, foreman 9.6, city 12.4, mobile 19.5, football 24.8, **bus 27.5**;
331 /// **Default 20.0.** Measured residuals: akiyo 1.5, foreman 9.6, city 12.4,
332 /// mobile 19.5, football 24.8, bus 27.5. At 20 bus is clean (0.00 BD-PSNR /
333 /// -0.03 BD-SSIM); a looser 24-26 leaves bus slightly positive because the
334 /// per-frame residual straddles it. The cost of 20 is football's win (-0.57 ->
335 /// -0.01): its per-frame residual also crosses 20, so it is held conservative.
336 /// Deliberate — protecting a regressor outranks capturing a win.
337 pub tune_lme_motion_thresh: Option<f64>,
338 /// OPT-IN, BD-gate pending (Great Gate P1 machinery — docs/great-gate.md §6 P2):
339 /// the population-shaped PER-MB form of the [`Self::tune_lme_tex_thresh`] texture
340 /// veto. `Some(q)` routes each P frame's top-`q` fraction of highest-variance MBs
341 /// to the conservative [`Self::cabac_lambda_scale`] individually (per-frame
342 /// percentile — the routed fraction is content-invariant by construction, where
343 /// the absolute median test cannot separate bus 454 from football 583, which want
344 /// opposite values). The motion veto stays frame-level; B slices keep the
345 /// frame-median form. `None` (default) = the frame-level veto exactly,
346 /// byte-identical. Env override for sweep arms: `RFF_LME_Q`.
347 pub tune_lme_q: Option<f64>,
348 /// Quantizer dead-zone divisor override for the CABAC path (`F = 2^qbits/dz`).
349 /// A smaller divisor (bigger F) keeps more near-threshold coefficients — cheaper
350 /// under CABAC's context-coded residual than under CAVLC. `0` = use the standard
351 /// content-derived dead-zone (default, unchanged).
352 pub cabac_dz_div: i64,
353 /// CABAC trellis-quantization (RDOQ) strength. Each 4×4 residual coefficient is
354 /// RD-optimized (level vs level−1 minimizing `SSD + λ·R_cabac`, `λ` scaled by
355 /// this; ~8 calibrated). `0.0` = off. DEFAULT-ON for CABAC I-slices (frame-type
356 /// adaptive — P/B off, sparse residual gains ~0); CAVLC path always off.
357 pub cabac_rdoq: f64,
358 /// Trellis (RDOQ) strength for CABAC **P slices** — SHIPPED as a CONTENT
359 /// DISPATCH (default 32.0), applied only where `grain_signature()` or
360 /// `is_screen()` fires; every other clip stays byte-identical to off. A
361 /// flat default is REFUTED (sign flips by content — see the default's
362 /// comment below). The original structure-adaptive prediction ("a P is a
363 /// reference, expect wash-or-loss") held for natural content and is
364 /// exactly why the gate exists. `0.0` = off everywhere.
365 pub cabac_rdoq_p: f64,
366 /// Trellis (RDOQ) strength for CABAC **B slices** — DEFAULT-ON
367 /// unconditionally (32.0; 6/6 clips win, zero losers — see the default's
368 /// comment). Non-reference, so the structure-adaptive law says the trade
369 /// is clean (nothing depends on a B's reconstruction). `0.0` = off.
370 pub cabac_rdoq_b: f64,
371 /// OPT-IN, BD-gate pending (Great Gate P3.3): search 8x4/4x8/4x4
372 /// sub-partitions inside P_8x8 (CABAC quality path, single-ref). The
373 /// decoder has parsed these since bring-up; the encoder emits them only
374 /// when this is set. `false` (default) = byte-identical. Env sweep arm:
375 /// `RFF_SUB8X8_SPLIT=1`. Default-on decision DEFERRED until the 4-wide
376 /// MC/cost kernels land (census #8 -- the scalar fall-through would
377 /// double-charge the feature's speed cost).
378 pub tune_sub8x8_split: bool,
379 /// PROBE (Great Gate P3.3 gate): price the sub-8x8 SPLIT-vs-8x8 decision in
380 /// the RD currency (`SSD_recon + lambda*bits`, both arms fully planned)
381 /// instead of the SATD proxy. Only meaningful with
382 /// [`Self::tune_sub8x8_split`]. Costs two extra macroblock plans per split
383 /// candidate — a probe, not a shipping speed point.
384 pub tune_sub8_rd: bool,
385 /// PROBE (Great Gate P3 RD-pricing #2): price the INTRA-vs-INTER decision
386 /// by `SSD_recon + lambda*bits` (both candidates planned for real) instead
387 /// of the SATD proxy plus the fitted [`Self::tune_intra_penalty`]. That
388 /// penalty is itself a correction for this proxy's bias, so if the probe
389 /// wins the penalty should be re-swept (probably toward 0 — the P2 lambda
390 /// campaign already found 0 better on all three clips). Costs one extra
391 /// trial-encode plus one extra MB plan per coded macroblock.
392 pub tune_intra_rd: bool,
393 /// PROBE (Great Gate P3 RD-pricing #3): price the PARTITION SHAPE decision
394 /// (16x16 / 16x8 / 8x16 / P_8x8) by `SSD_recon + lambda*bits` instead of
395 /// the SATD proxy. The third SATD-priced default-on site. Costs one full
396 /// macroblock plan per candidate shape.
397 pub tune_shape_rd: bool,
398 /// Price the inter RD trials with the macroblock's ACTUAL quantizer rather
399 /// than the slice's frame-level one. AQ (`aq_strength`, default 1.0) and
400 /// mb-tree rewrite QP per macroblock; a frame-level lambda misprices rate by
401 /// `2^((qp_frame-qp_mb)/3)` at every RD site, worst on the high-variance
402 /// macroblocks AQ moves furthest. `false` restores the frame-lambda form for
403 /// A/B (the arm must PIN the value, never rely on an absent override).
404 pub tune_rd_lambda_mb: bool,
405 /// High-profile 8×8 transform (`transform_8x8_mode_flag`). When set, an intra
406 /// macroblock may use one 8×8 integer DCT per 8×8 block (I_8x8) instead of four
407 /// 4×4s — a per-MB RD choice that wins on smooth / large-structure content.
408 /// Requires High profile (profile_idc 100). CAVLC only (our decoder has no CABAC
409 /// 8×8). Default `false`.
410 pub transform_8x8: bool,
411 /// P_8x8 sub-partition motion: allow a P macroblock to split into four 8×8
412 /// partitions, each with its own motion vector (finer motion granularity on
413 /// complex / boundary motion). A per-MB RD choice vs 16×16/16×8/8×16, gated on the
414 /// heavy-16×16 motion-boundary signal. A NET WIN on real content (12-clip Derf
415 /// corpus: −0.23% mean BD, big wins on bus/mobile/flower; a rigorous 6-channel
416 /// discovery harvest proved no cheap gate beats default-on, oracle headroom only
417 /// 0.18%), so it is DEFAULT-ON for the Quality preset. Quality-only. `None` =
418 /// follow the preset (ON for Quality); `Some(b)` forces it either way. (8×4/4×8/4×4
419 /// sub-shapes within an 8×8 are a further split, not yet built.)
420 pub sub_8x8: Option<bool>,
421 /// Adaptive WIDE motion search: on flat source blocks (where the gradient-descent
422 /// diamond stalls at a plateau and misses the true MV) cover the ±16 neighbourhood
423 /// with a grid search instead; busy blocks keep the fast diamond. A big win on
424 /// smooth/low-motion content (the diamond's flat-surface failure), free on busy
425 /// content — content-adaptive (a per-frame coherence gate keeps it from regressing
426 /// even on pure pans), so it is DEFAULT-ON for the Quality preset. Quality-only.
427 /// `None` = follow the preset (ON for Quality); `Some(b)` forces it either way.
428 pub me_wide: Option<bool>,
429 /// Macroblock-tree lookahead adaptive QP (TEMPORAL AQ). A cheap forward pass over
430 /// each GOP's source frames propagates future-reference importance backward along
431 /// motion vectors and lowers the QP of heavily-referenced macroblocks — investing
432 /// bits where they pay off across many later frames. The complement to the spatial
433 /// [`aq_strength`](Self::aq_strength). Per-GOP-centered (rate-preserving). Applies
434 /// only in the batch (`encode_all`) constant-QP path, where the GOP's future frames
435 /// are available (a `bframes > 0` encode uses the reorder pipeline and ignores it).
436 ///
437 /// **Default `true` since 0.5.0** (H-37) — the gate cleared and the architectural
438 /// blocker is gone: the streaming path now carries a one-GOP lookahead queue, so
439 /// `encode()` + [`flush`](crate::Encoder::flush) is byte-identical to
440 /// `encode_all()`. Set `false` for zero added latency (one AU per `encode` call)
441 /// or for the pre-0.5.0 bytes. Evidence:
442 /// * BD: the 4-QP per-clip gate CLEARS with room to spare (akiyo −4.82%,
443 /// foreman −3.13%, football −0.53%, bus −0.29%, mobile −0.24%,
444 /// city_4cif +0.01% neutral) — the monotone non-regression bar, not a mean.
445 /// * Cost: content-INDEPENDENT at 16-21 candidate evaluations per macroblock per
446 /// frame across that corpus (1.3× spread) ≈ 1-2% of a busy-clip encode. The
447 /// per-clip "blowups" (+251%, +34%) were wall-clock artifacts of a drifting box.
448 ///
449 /// COST OF THE DEFAULT: `encode()` now returns a whole GOP's access units at once
450 /// (empty while the GOP fills), so end-to-end latency is up to `gop_size` frames
451 /// and **`flush()` is required at end of stream**. Batch callers
452 /// (`encode_all`) are unaffected — they already had the whole GOP.
453 pub mbtree: bool,
454 /// Minimum dispersion of mb-tree's own propagation offsets for it to apply
455 /// at all (the DIFFERENTIATION LATCH — see `mbtree.rs`). Below this the
456 /// offsets carry no information and are zeroed, which is byte-identical to
457 /// mb-tree off. `0.0` disables the latch (ungated, pre-gate behaviour).
458 pub mbtree_spread_min: f64,
459 /// mb-tree QP-offset strength: `qp_offset = -strength · log2((intra+propagate)/intra)`.
460 /// Larger = more aggressive bit redistribution toward referenced MBs. Default `0.9`.
461 pub mbtree_strength: f64,
462 /// Resolution the mb-tree lookahead motion search runs at (see [`LookaheadMode`]).
463 /// Default [`HalfRes`](LookaheadMode::HalfRes) — fastest (~4× the lookahead), a small
464 /// BD-rate cost on fine detail; use [`Hybrid`](LookaheadMode::Hybrid) to recover
465 /// full-res quality at ~1.7×. Only relevant when [`mbtree`](Self::mbtree) is on.
466 pub mbtree_lookahead: LookaheadMode,
467}
468
469/// Host-only convenience: with `RUSTY_H264_LEGACY_CAVLC` set, [`EncoderConfig::new`]
470/// is [`EncoderConfig::baseline`] — Constrained Baseline + CAVLC for a caller that
471/// must stay Baseline-compatible without touching a field. A chip never reads it:
472/// without `std` there is no environment and the knob reads as unset.
473fn legacy_cavlc() -> bool {
474 rusty_h264_common::cached_knob!(
475 bool,
476 rusty_h264_common::knob("RUSTY_H264_LEGACY_CAVLC").is_some()
477 )
478}
479
480/// Bytes the encoder holds for a configuration, as a function of width and
481/// height, so a firmware can pick a picture size that fits before it is
482/// flashed. See [`EncoderConfig::memory_estimate`].
483#[derive(Clone, Copy, Debug, PartialEq, Eq)]
484pub struct MemoryEstimate {
485 /// One reference picture: three coded-size (MB-aligned) reconstruction
486 /// planes plus its per-4×4-block motion field.
487 pub per_ref_frame: usize,
488 /// Reference pictures held (`num_ref_frames`, at least one).
489 pub ref_frames: usize,
490 /// Per-picture coder state: the reconstruction under construction, the
491 /// non-zero-coefficient maps, per-macroblock mode and cost arrays.
492 pub mb_arrays: usize,
493 /// Half-pel plane cache, three filtered planes plus a padded copy per
494 /// reference — built lazily and **only when the search is not
495 /// [`Preset::Fast`]**, so the chip configuration never pays it.
496 pub hpel_cache: usize,
497 /// Per-call scratch: the slice bit buffer, the access unit, and the source
498 /// frames a lookahead, scene-cut detector or AQ probe retains.
499 pub scratch: usize,
500 /// The sum.
501 pub total: usize,
502}
503
504impl EncoderConfig {
505 /// The chip configuration: **Constrained Baseline** with CAVLC, no 8×8
506 /// transform, no B-frames, one reference, [`Preset::Fast`], no lookahead,
507 /// no scene cut and no mb-tree — one access unit out per frame in, nothing
508 /// buffered, no copy of the source, the smallest memory footprint, and the
509 /// profile every decoder accepts. [`Encoder::encode_into`] writes it
510 /// straight into the caller's buffer.
511 ///
512 /// This is what `rusty_esp_video` runs on an ESP32 and what
513 /// `rff -c:v h264 -profile baseline -preset fast` selects on the host, so
514 /// the two produce the same bytes at the same GOP, bitrate and QP. Set
515 /// `gop_size`/`min_keyint`, `framerate`, `bitrate` and `qp` on the result.
516 ///
517 /// The `RUSTY_H264_LEGACY_CAVLC` environment knob is a host-only
518 /// convenience that makes [`new`](Self::new) return this constructor;
519 /// `tests/legacy_knob.rs` pins the two byte-identical. A chip has no
520 /// environment; this constructor is a field.
521 pub fn baseline(width: usize, height: usize) -> Self {
522 let mut cfg = Self::defaults(width, height);
523 cfg.profile = Profile::ConstrainedBaseline;
524 cfg.chroma = ChromaFormat::Yuv420;
525 cfg.cabac = false;
526 cfg.transform_8x8 = false;
527 cfg.bframes = 0;
528 cfg.num_ref_frames = 1;
529 cfg.preset = Preset::Fast;
530 cfg.lookahead = 0;
531 cfg.scenecut = 0;
532 // mb-tree weighs a macroblock by how much the FUTURE references it;
533 // with no lookahead there is no future, its window is one frame and
534 // every offset is zero — but the streaming path would still copy
535 // each frame into the lookahead queue and run the propagation. Off,
536 // then: same bytes (`tests/chip_api.rs` pins it), no copy, no work.
537 cfg.mbtree = false;
538 cfg
539 }
540
541 /// How much memory this configuration makes the encoder hold, by part.
542 ///
543 /// A model, not a measurement: the `rusty_h264-memprobe` crate holds it to
544 /// within ±25% of the bytes a counting allocator sees on the host while a
545 /// P-frame is coded (2026-09-02, x86-64: QVGA `baseline()` 683 KB
546 /// measured vs 641 KB modelled; QVGA `Preset::Balanced` 1062 KB vs 1089 KB;
547 /// 100×60 69 KB vs 74 KB). Sizes are for the coded (MB-aligned) picture,
548 /// so a 320×240 frame counts as 320×240 and a 100×60 frame as 112×64.
549 pub fn memory_estimate(&self) -> MemoryEstimate {
550 let (mb_w, mb_h) = (self.mb_width(), self.mb_height());
551 let mbs = mb_w * mb_h;
552 let (cw, ch) = (mb_w * 16, mb_h * 16);
553 let planes = cw * ch + 2 * (mb_w * 8) * (mb_h * 8);
554 // Per 4×4 block: List-0 mv `(i32, i32)` + `ref_idx: i32`.
555 let motion = mbs * 16 * (8 + 4);
556 let per_ref_frame = planes + motion;
557 let ref_frames = self.num_ref_frames.max(1) as usize;
558 let hpel_cache = if self.preset == Preset::Fast {
559 0
560 } else {
561 let pad = rusty_h264_common::inter::HPEL_PAD_DEFAULT;
562 ref_frames * 4 * (cw + 2 * pad) * (ch + 2 * pad)
563 };
564 // The picture being coded (its own planes) plus the coder's maps:
565 // nnz per 4×4 luma block and per 2×2 chroma block, and ~128 B per MB
566 // of mode/cost/skip/motion state.
567 let mb_arrays = planes + mbs * 16 + 2 * mbs * 4 + mbs * 128;
568 let frame_bytes = self.width * self.height * 3 / 2;
569 let lookahead_frames = if self.mbtree && self.bframes == 0 && self.bitrate == 0 {
570 self.lookahead.max(1) as usize
571 } else {
572 0
573 };
574 let retained = lookahead_frames
575 + usize::from(self.scenecut > 0)
576 + usize::from(self.aq_strength > 0.0 && self.gop_size > 1);
577 let scratch = (self.width * self.height / 2 + 4096) // slice bit buffer
578 + self.width * self.height / 4 // the access unit, generous
579 + retained * frame_bytes
580 + 16 * 1024; // fixed: cost tables, SPS/PPS, the encoder itself
581 let total = ref_frames * per_ref_frame + hpel_cache + mb_arrays + scratch;
582 MemoryEstimate {
583 per_ref_frame,
584 ref_frames,
585 mb_arrays,
586 hpel_cache,
587 scratch,
588 total,
589 }
590 }
591
592 /// A minimal all-intra Constrained Baseline configuration at the given size.
593 pub fn new(width: usize, height: usize) -> Self {
594 if legacy_cavlc() {
595 return Self::baseline(width, height);
596 }
597 Self::defaults(width, height)
598 }
599
600 /// The defaults themselves, with no environment in the way.
601 fn defaults(width: usize, height: usize) -> Self {
602 Self {
603 width,
604 height,
605 // DEFAULT-ON as of the U6 measurement: CABAC is -9.00%/-8.83% BD-rate for
606 // 1.10-1.22x time on the 4-QP corpus — better value than any preset step in
607 // either encoder — so shipping CAVLC by default was leaving a large win on
608 // the table. CABAC requires Main profile, hence the profile default moves
609 // with it. `RUSTY_H264_LEGACY_CAVLC=1` is `baseline()` (see `new`).
610 // HIGH by default, matching x264. High is required to signal
611 // transform_8x8_mode_flag at all, and the 8x8 transform is now default-on
612 // (below).
613 profile: Profile::High,
614 chroma: ChromaFormat::Yuv420,
615 level_idc: 30,
616 qp: 26,
617 // x264 parity (keyint 250 / min-keyint 25 / scenecut 40 /
618 // rc-lookahead 40): IDRs at scene changes, forced refresh at 250.
619 // The old default of 1 (all-intra) was the "slowest, largest
620 // possible" trap the CLI's own comment complains about.
621 gop_size: 250,
622 min_keyint: 25,
623 scenecut: 40,
624 lookahead: 40,
625 bitrate: 0,
626 framerate: 30.0,
627 // x264 parity (its --ref default is 3): multi-ref P measured
628 // -8.03% BD-rate on foreman at refs 3 vs 1 (refs_ab harness,
629 // multiref campaign). The ref_bits prune in `best_part` bounds the
630 // added search cost; `--refs 1` remains the bisection anchor.
631 num_ref_frames: 3,
632 preset: Preset::Fast,
633 tune_skip_accel_check: true,
634 coded_path_v2: false,
635 tune_lambda_scale: 1.0,
636 tune_intra_penalty: 24.0,
637 tune_satd_q: 0.5,
638 tune_subpel: false,
639 tune_me_snap: true,
640 tune_me_subpel_iter: true,
641 tune_greedy_skip: true,
642 tune_greedy_skip_min_free: None,
643 tune_bskip_rd: Some(48.0),
644 tune_bskip_busy_pct: None,
645 tune_bskip_dirwin_pct: None,
646 tune_b_split: true,
647 tune_rd_skip: false,
648 tune_rd_skip_min_free: None,
649 tune_rd_skip_fast_t: None,
650 aq_strength: 1.0,
651 bframes: 0,
652 bframe_qp_offset: 3,
653 bframes_adaptive: false,
654 b_pyramid: true,
655 // Calibrated per-GOP I-frame cascade (~x264 ip_ratio 1.4): a robust
656 // BD-rate win across content (clip240 P −0.6%, dpan B −7.3%, mixed
657 // −1.7%). Trades a few I-frame bits for GOP-wide propagated quality.
658 i_qp_offset: -3,
659 cabac: true,
660 weightp: true,
661 cabac_init_idc: 0,
662 cabac_lambda_scale: 1.25,
663 tune_lme_hi: Some(1.6),
664 tune_lme_tex_thresh: None,
665 tune_lme_motion_thresh: Some(20.0),
666 tune_lme_q: None,
667 cabac_dz_div: 0,
668 cabac_rdoq: 8.0,
669 // CONTENT-GATED, not a flat default. A flat value is refuted: at 16 it
670 // loses on 3 of 6 clips, at 32 on 4 of 6. But the sign FLIPS hard by
671 // content -- at 32, grain -30.12% and screen -12.11% while akiyo +3.27,
672 // foreman +3.74, harbour +5.02. Its own doc predicted this ("P frames ARE
673 // references... expect a wash-or-loss until propagation-weighted"): on
674 // grain and screen the residual is noise or flat runs that propagate
675 // nothing, so the reference-structure objection does not apply there.
676 // `plan`-side gate in mb16.rs restricts it to those two classes; every
677 // other clip stays byte-identical.
678 cabac_rdoq_p: 32.0,
679 // DEFAULT-ON at 16 on 2026-08-09. Its own doc said "BD-gate pending"; the
680 // gate was never runnable from the CLI, so it sat at 0 unmeasured while
681 // B frames owned 49-85% of the inter rate excess on every content class
682 // (docs/WHYS-p-frames.md D2b). Measured, 6/6 clips win, ZERO losers:
683 // screen -0.76 akiyo -1.27 foreman -1.21
684 // harbour -0.66 mobile -1.05 grain -5.40 (BD-SSIM vs 0)
685 // Cost is +1.6% encode CPU on real 300-frame 720p, and FLAT in strength
686 // -- the trellis runs either way, the strength only moves the decision
687 // threshold. (codec-measurement 18: a trellis once shipped on "+3.1%"
688 // and cost +144% on real content. Priced here on real content.)
689 //
690 // RAISED 16 -> 32 once the full 6-clip table was run: 0/6 losers, -0.23%
691 // to -3.19% BD-SSIM against 16. 64 is REFUTED -- it makes grain explode to
692 // +61.95% while other clips keep improving, so the knob has an optimum and
693 // grain reaches it first.
694 cabac_rdoq_b: 32.0,
695 // DEFAULT-ON 2026-08-06 (docs/gate-ledger.md sub8x8-split): the split
696 // search PLUS its RD pricing — the two are a package, since the
697 // SATD-priced split search alone is a net LOSER (7W/13L/3N) and only
698 // becomes 10W/9N/0L once the decision is priced in the RD currency.
699 // Cost is MEASURED and accepted, not unknown: 5.59x CPU on the
700 // quality preset (prototype-grade — exhaustive unpruned search, no
701 // 4-wide kernels, one duplicate plan per split MB; the best_part
702 // campaign is the follow-up). `tune_sub8x8_split=false` restores the
703 // pre-P3.3 bytes exactly.
704 tune_sub8x8_split: true,
705 tune_sub8_rd: true,
706 // DEFAULT-ON 2026-08-06 (docs/gate-ledger.md intra-rd-grain): grain-
707 // gated, so byte-identical (1.00x) off grain and 1.327x ON grain for
708 // -4.73 PSNR / -5.22 SSIM — a better trade than a preset step.
709 tune_intra_rd: true,
710 tune_shape_rd: true,
711 tune_rd_lambda_mb: false,
712 // DEFAULT-ON since 2026-08-08, matching x264. Measured per clip across
713 // all-intra / I+P / I+P+B (bench/t8_default.py) with inter-8x8 off:
714 // wins up to -1.90% BD-SSIM (akiyo) and -0.77% (FourPeople), worst cell
715 // +0.34%. Baseline/Constrained Baseline cannot signal it.
716 transform_8x8: true,
717 sub_8x8: None,
718 me_wide: None,
719 mbtree: true,
720 // Expressed in the STRENGTH-INVARIANT unit (see mbtree.rs): the
721 // fitted value was 1.0 against the raw RMS at the default strength 0.9.
722 // LATCH DISABLED 2026-08-08 (was 1.0/0.9). The spread latch turns mb-tree
723 // off for a GOP whose offsets look undifferentiated. Audited on the
724 // content it actually fires on and it was COSTING us: harbour_4cif
725 // -0.88%, foreman_cif -1.20% BD-SSIM to disable it, mobile_cif +0.01%
726 // (neutral), and byte-identical on every clip it does not fire on.
727 // Sweeping it the other way is catastrophic — at 2.0 it suppresses
728 // mb-tree on akiyo (+9.16%) and FourPeople (+6.32%), which is the
729 // measure of how much mb-tree is worth where it works. A latch whose
730 // every measured firing is a loss is not a guard.
731 mbtree_spread_min: 0.0,
732 mbtree_strength: 0.9,
733 mbtree_lookahead: LookaheadMode::HalfRes,
734 }
735 }
736
737 /// Picture width rounded up to whole macroblocks.
738 pub fn mb_width(&self) -> usize {
739 self.width.div_ceil(16)
740 }
741
742 /// Picture height rounded up to whole macroblocks.
743 pub fn mb_height(&self) -> usize {
744 self.height.div_ceil(16)
745 }
746}