Skip to main content

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    /// Frames between IDR pictures. `1` = all-intra (every frame an IDR).
78    pub gop_size: u32,
79    /// Target bitrate in bits per second. `0` disables rate control (constant
80    /// QP); any positive value enables average-bitrate control, which varies the
81    /// per-frame QP around [`qp`](Self::qp) to converge on this rate.
82    pub bitrate: u32,
83    /// Frame rate (frames per second), used by rate control to turn the bitrate
84    /// target into a per-frame bit budget.
85    pub framerate: f32,
86    /// Number of reference frames the encoder may use for P-pictures (1..=16).
87    /// `1` keeps the single-reference bitstream; higher values let P-macroblocks
88    /// pick an older reference (`ref_idx`), helping occlusion/periodic motion.
89    pub num_ref_frames: u32,
90    /// Speed/quality trade-off. Defaults to [`Preset::Balanced`].
91    pub preset: Preset,
92    /// EXPERIMENT KNOB (hidden): use the asm (dct_four_t4 + quant_four_4x4) fast
93    /// path in the P_Skip free-check instead of the scalar twin. Byte-identical
94    /// either way; exists so A/B arms interleave in ONE binary (honest thermals).
95    #[doc(hidden)]
96    pub tune_skip_accel_check: bool,
97    /// EXPERIMENT KNOB (hidden): route inter-MB coding through the isolated,
98    /// coefficient-fused `encode_inter_mb_v2` path instead of the current
99    /// `encode_inter_mb`. Byte-identical output (gated), selectable at runtime so
100    /// the two implementations run side-by-side in ONE binary for honest A/B
101    /// timing on the coded path. See the `coded_path_ab` test.
102    #[doc(hidden)]
103    pub coded_path_v2: bool,
104    /// TUNING KNOB (hidden): scale on the Lagrangian λ = 0.85·2^((qp−12)/3) that
105    /// prices bits in the RD/mode/ME decisions. `1.0` = the standard H.264 model
106    /// (byte-identical default). The BD-rate harness sweeps this to calibrate the
107    /// rate weight; a content-adaptive dispatcher can vary it per frame.
108    #[doc(hidden)]
109    pub tune_lambda_scale: f64,
110    /// TUNING KNOB (hidden): the λ·bits penalty (in bits) added to the intra cost
111    /// in the fast/quality mode decision, biasing toward inter. `24.0` = default.
112    /// Higher → fewer intra MBs. Content-adaptive candidate (textured content).
113    #[doc(hidden)]
114    pub tune_intra_penalty: f64,
115    /// TUNING KNOB (hidden): content-adaptive cost-function dispatch. Fraction of
116    /// each frame's highest-VARIANCE MBs whose fast-preset mode decision uses the
117    /// rate-faithful SATD cost instead of cheap SAD (SAD is rate-blind on detailed
118    /// MBs). `0.0` = pure SAD (byte-identical default); `1.0` = all SATD.
119    #[doc(hidden)]
120    pub tune_satd_q: f64,
121    /// EXPERIMENT KNOB (hidden): force sub-pel motion refinement in the FAST
122    /// preset, which is otherwise integer-pel only. Exists to run the force-on
123    /// oracle per clip: content whose true displacement is sub-pixel (slow pans,
124    /// dollies) cannot be tracked by integer-pel ME, falls back to intra, and
125    /// codes very expensively. Bitstream-changing — BD-rate gated, not byte-exact.
126    #[doc(hidden)]
127    pub tune_subpel: bool,
128    /// Rate-distortion P_Skip decision. The default criterion skips only when the
129    /// residual quantizes to EXACTLY zero (a proof of freeness); this instead
130    /// compares `J = SSD + λ·bits` for the skip against the chosen coded mode, so
131    /// macroblocks with a small but non-zero residual can skip too. Measured
132    /// against x264 at matched QP, the exact-zero rule leaves 17-23 percentage
133    /// points of macroblocks coded that x264 skips (foreman 6.4% vs 23.6%,
134    /// in_to_tree 1.0% vs 24.1%) — while matching it exactly at both extremes
135    /// (akiyo 72.5 vs 73.6, mobile 1.0 vs 1.4), which is what proves the gap is
136    /// the CRITERION and not the machinery. Bitstream-changing; BD-rate gated.
137    #[doc(hidden)]
138    /// Quality preset's greedy P_Skip (openh264 `PredictSadSkip`): take the skip
139    /// when its luma SAD is under the neighbour-predicted threshold, without
140    /// pricing the coded alternative. An APPROXIMATE skip decision inside the
141    /// P-chain — the same class as [`Self::tune_rd_skip_fast_t`] — so it is
142    /// subject to the same propagation multiplier and exists as a knob to audit
143    /// that. `true` is the long-standing default behaviour.
144    /// Snap the full-pel diamond's centre to integer-pel before searching.
145    ///
146    /// The diamond walks WHOLE-pel offsets, but its seed is the neighbour MV
147    /// predictor, which is fractional — so a sub-pel seed drags every candidate in
148    /// the search through the 6-tap interpolation filter. Measured: 84-90% of all
149    /// SATD evaluations interpolate. Snapping makes the full-pel phase genuinely
150    /// full-pel (direct SATD against the reference), leaving only the sub-pel
151    /// refine to interpolate. The un-snapped seed is retained as a candidate, so
152    /// the search can never come out worse than its own starting point.
153    ///
154    /// Same fix already applied to the stall-rescue grid (2.21x -> 1.19x on zoom).
155    pub tune_me_snap: bool,
156    /// Walk the sub-pel refinement until it stops improving, instead of a single
157    /// 8-point pass per step. Independent of [`Self::tune_me_snap`] — measured
158    /// separately, because the two were first built coupled and the attribution
159    /// was ambiguous.
160    pub tune_me_subpel_iter: bool,
161    pub tune_greedy_skip: bool,
162    /// Minimum online FREE-skip percentage for [`Self::tune_greedy_skip`] to
163    /// engage, dispatched on exactly the signal that gates RD skip
164    /// ([`Self::tune_rd_skip_min_free`]). The greedy skip wins on temporally
165    /// redundant content and LOSES on detailed content (BD-SSIM: akiyo -0.59,
166    /// FourPeople -0.32 vs foreman +1.23) — the same sign-flip, separated by the
167    /// same signal. `None` resolves to 85 — the calibrated default, at which the
168    /// corpus regression on foreman (+1.23% BD-SSIM, previously shipping) becomes
169    /// 0.00 and nothing else regresses. `Some(0)` restores the old ungated
170    /// behaviour; `Some(101)` disables the greedy skip entirely.
171    pub tune_greedy_skip_min_free: Option<u32>,
172    pub tune_rd_skip: bool,
173    /// Minimum FREE-skip percentage, measured online over the frame so far, for
174    /// [`Self::tune_rd_skip`] to engage on the rest of that frame.
175    ///
176    /// `None` resolves per preset, because the signal's SCALE is preset-dependent:
177    /// sub-pel refinement predicts better, so it lifts the free-skip rate on ALL
178    /// content and the same absolute bar starts admitting content that loses.
179    /// Fast (no sub-pel) calibrates to 60; the sub-pel presets need 90. Each is
180    /// the smallest bar at which no corpus clip regresses on BD-PSNR or BD-SSIM.
181    /// `Some(0)` forces RD skip on everywhere (which LOSES badly on detailed
182    /// content); `Some(101)` disables it.
183    pub tune_rd_skip_min_free: Option<u32>,
184    /// Skip-gate on the null arm's cost, in units of lambda: when
185    /// `SSD(skip) <= lambda * T` the skip is taken WITHOUT trial-encoding the
186    /// coded arm at all.
187    ///
188    /// The RD skip decision has to encode the coded arm to price it, and 55-80%
189    /// of the time it then throws that encode away — the null arm wins. This is
190    /// the standard search-skip gate over that: it trades a small number of
191    /// decisions (the RD comparison would occasionally have coded) for not
192    /// encoding at all. `None`/`<= 0.0` disables it (every candidate is priced
193    /// exactly). Unlike the rest of the decision this is NOT byte-identical, so
194    /// it is BD-rate gated.
195    pub tune_rd_skip_fast_t: Option<f64>,
196    /// Number of B-frames between reference (I/P) anchors. `0` = no B-frames
197    /// (Constrained Baseline, byte-identical). `>0` requires Main profile (B is
198    /// illegal in Baseline) and activates the reorder pipeline: anchors are coded
199    /// ahead of the B-frames that reference them (L0 past + L1 future), and B-
200    /// frames are non-reference. WORK IN PROGRESS — see the B-frame build plan.
201    pub bframes: u32,
202    /// QP offset applied to B-frames (added to [`qp`](Self::qp)). B-frames are
203    /// non-reference, so their coding error never propagates — quantizing them
204    /// harder (a positive offset) spends the saved bits on the reference anchors.
205    /// Only used when `bframes > 0`. Default `2`.
206    pub bframe_qp_offset: i32,
207    /// Adaptive Quantization strength. Modulates the QP per macroblock by content:
208    /// flat/low-variance MBs (where blocking & banding are visible) get a FINER QP,
209    /// busy/high-variance MBs (where the eye masks error) a COARSER one — moving bits
210    /// to where they're seen, a perceptual (SSIM) win at ~neutral PSNR. The QP shift
211    /// is relative to the FRAME's mean log-variance (content-invariant), rate-
212    /// compensated, and its EFFECTIVE strength backs off automatically where the
213    /// log-variance spread is extreme (pathological synthetic content), so it never
214    /// regresses. Default **`1.0`** (on); `0.0` = off (uniform QP, byte-identical).
215    pub aq_strength: f64,
216    /// Content-adaptive B-frame ENABLE. When set (with `bframes > 0`), the encoder
217    /// measures the clip's temporal predictability (a cheap global-motion bi-
218    /// prediction residual) and codes B-frames ONLY when they'll help — smooth /
219    /// predictable motion, where bi-pred + spatial-direct are cheap. On busy content
220    /// it falls back to P-only, so B-frames never regress. Default `false`.
221    pub bframes_adaptive: bool,
222    /// Per-GOP I-frame QP cascade — the BASE offset for the classic `ip_ratio`
223    /// (added to [`qp`](Self::qp) on each GOP's I-frame; it's the root reference for
224    /// its whole GOP, so coding it finer propagates quality GOP-wide). Default `-3`.
225    /// In the B-capable batch path this is CONTENT-ADAPTIVE per GOP: predictable
226    /// GOPs — where the I-frame dominates the GOP's bits — deepen it up to 2 further
227    /// QP steps (calibrated: busy ≈ base, compressible ≈ base−2). `0` disables the
228    /// cascade entirely (byte-identical escape hatch). Constant-QP only.
229    pub i_qp_offset: i32,
230    /// CABAC entropy coding (PPS `entropy_coding_mode_flag = 1`, Main profile).
231    /// Codes ~5–17% smaller than CAVLC at matched quality (I- and P-slices; B-slice
232    /// CABAC pending). Default `false` (CAVLC — Constrained Baseline, unchanged).
233    pub cabac: bool,
234    /// `cabac_init_idc` (0..2) — selects one of 3 context-initialization tables for
235    /// P/B slices (I-slices always use the I preset). The best table is
236    /// content-dependent; `0` is the default. Signalled in the P/B slice header.
237    pub cabac_init_idc: u32,
238    /// Multiplier on the mode-decision Lagrangian (√λ) in the CABAC P/B path only.
239    /// CABAC codes ~9% fewer bits than the CAVLC-flavoured rate estimate the mode
240    /// decision uses, so the rate term is slightly over-weighted; this retunes it.
241    /// Default `1.0` (unchanged). CAVLC path is never affected.
242    pub cabac_lambda_scale: f64,
243    /// Quantizer dead-zone divisor override for the CABAC path (`F = 2^qbits/dz`).
244    /// A smaller divisor (bigger F) keeps more near-threshold coefficients — cheaper
245    /// under CABAC's context-coded residual than under CAVLC. `0` = use the standard
246    /// content-derived dead-zone (default, unchanged).
247    pub cabac_dz_div: i64,
248    /// CABAC trellis-quantization (RDOQ) strength. Each 4×4 residual coefficient is
249    /// RD-optimized (level vs level−1 minimizing `SSD + λ·R_cabac`, `λ` scaled by
250    /// this; ~8 calibrated). `0.0` = off. DEFAULT-ON for CABAC I-slices (frame-type
251    /// adaptive — P/B off, sparse residual gains ~0); CAVLC path always off.
252    pub cabac_rdoq: f64,
253    /// High-profile 8×8 transform (`transform_8x8_mode_flag`). When set, an intra
254    /// macroblock may use one 8×8 integer DCT per 8×8 block (I_8x8) instead of four
255    /// 4×4s — a per-MB RD choice that wins on smooth / large-structure content.
256    /// Requires High profile (profile_idc 100). CAVLC only (our decoder has no CABAC
257    /// 8×8). Default `false`.
258    pub transform_8x8: bool,
259    /// P_8x8 sub-partition motion: allow a P macroblock to split into four 8×8
260    /// partitions, each with its own motion vector (finer motion granularity on
261    /// complex / boundary motion). A per-MB RD choice vs 16×16/16×8/8×16, gated on the
262    /// heavy-16×16 motion-boundary signal. A NET WIN on real content (12-clip Derf
263    /// corpus: −0.23% mean BD, big wins on bus/mobile/flower; a rigorous 6-channel
264    /// discovery harvest proved no cheap gate beats default-on, oracle headroom only
265    /// 0.18%), so it is DEFAULT-ON for the Quality preset. Quality-only. `None` =
266    /// follow the preset (ON for Quality); `Some(b)` forces it either way. (8×4/4×8/4×4
267    /// sub-shapes within an 8×8 are a further split, not yet built.)
268    pub sub_8x8: Option<bool>,
269    /// Adaptive WIDE motion search: on flat source blocks (where the gradient-descent
270    /// diamond stalls at a plateau and misses the true MV) cover the ±16 neighbourhood
271    /// with a grid search instead; busy blocks keep the fast diamond. A big win on
272    /// smooth/low-motion content (the diamond's flat-surface failure), free on busy
273    /// content — content-adaptive (a per-frame coherence gate keeps it from regressing
274    /// even on pure pans), so it is DEFAULT-ON for the Quality preset. Quality-only.
275    /// `None` = follow the preset (ON for Quality); `Some(b)` forces it either way.
276    pub me_wide: Option<bool>,
277    /// Macroblock-tree lookahead adaptive QP (TEMPORAL AQ). A cheap forward pass over
278    /// each GOP's source frames propagates future-reference importance backward along
279    /// motion vectors and lowers the QP of heavily-referenced macroblocks — investing
280    /// bits where they pay off across many later frames. The complement to the spatial
281    /// [`aq_strength`](Self::aq_strength). Per-GOP-centered (rate-preserving). Applies
282    /// only in the batch (`encode_all`) constant-QP path, where the GOP's future frames
283    /// are available (a `bframes > 0` encode uses the reorder pipeline and ignores it).
284    ///
285    /// **Default `true` since 0.5.0** (H-37) — the gate cleared and the architectural
286    /// blocker is gone: the streaming path now carries a one-GOP lookahead queue, so
287    /// `encode()` + [`flush`](crate::Encoder::flush) is byte-identical to
288    /// `encode_all()`. Set `false` for zero added latency (one AU per `encode` call)
289    /// or for the pre-0.5.0 bytes. Evidence:
290    /// * BD: the 4-QP per-clip gate CLEARS with room to spare (akiyo −4.82%,
291    ///   foreman −3.13%, football −0.53%, bus −0.29%, mobile −0.24%,
292    ///   city_4cif +0.01% neutral) — the monotone non-regression bar, not a mean.
293    /// * Cost: content-INDEPENDENT at 16-21 candidate evaluations per macroblock per
294    ///   frame across that corpus (1.3× spread) ≈ 1-2% of a busy-clip encode. The
295    ///   per-clip "blowups" (+251%, +34%) were wall-clock artifacts of a drifting box.
296    ///
297    /// COST OF THE DEFAULT: `encode()` now returns a whole GOP's access units at once
298    /// (empty while the GOP fills), so end-to-end latency is up to `gop_size` frames
299    /// and **`flush()` is required at end of stream**. Batch callers
300    /// (`encode_all`) are unaffected — they already had the whole GOP.
301    pub mbtree: bool,
302    /// mb-tree QP-offset strength: `qp_offset = -strength · log2((intra+propagate)/intra)`.
303    /// Larger = more aggressive bit redistribution toward referenced MBs. Default `0.9`.
304    pub mbtree_strength: f64,
305    /// Resolution the mb-tree lookahead motion search runs at (see [`LookaheadMode`]).
306    /// Default [`HalfRes`](LookaheadMode::HalfRes) — fastest (~4× the lookahead), a small
307    /// BD-rate cost on fine detail; use [`Hybrid`](LookaheadMode::Hybrid) to recover
308    /// full-res quality at ~1.7×. Only relevant when [`mbtree`](Self::mbtree) is on.
309    pub mbtree_lookahead: LookaheadMode,
310}
311
312/// Escape hatch restoring the pre-U6 defaults (Constrained Baseline + CAVLC), so the
313/// previous bitstream is reproducible byte-for-byte for bisection and for callers that
314/// must remain Baseline-compatible.
315fn legacy_cavlc() -> bool {
316    use std::sync::OnceLock;
317    static L: OnceLock<bool> = OnceLock::new();
318    *L.get_or_init(|| std::env::var_os("RUSTY_H264_LEGACY_CAVLC").is_some())
319}
320
321impl EncoderConfig {
322    /// A minimal all-intra Constrained Baseline configuration at the given size.
323    pub fn new(width: usize, height: usize) -> Self {
324        Self {
325            width,
326            height,
327            // DEFAULT-ON as of the U6 measurement: CABAC is -9.00%/-8.83% BD-rate for
328            // 1.10-1.22x time on the 4-QP corpus — better value than any preset step in
329            // either encoder — so shipping CAVLC by default was leaving a large win on
330            // the table. CABAC requires Main profile, hence the profile default moves
331            // with it. `RUSTY_H264_LEGACY_CAVLC=1` restores the exact prior defaults
332            // (Constrained Baseline + CAVLC) as the escape hatch and bisection anchor.
333            profile: if legacy_cavlc() { Profile::ConstrainedBaseline } else { Profile::Main },
334            chroma: ChromaFormat::Yuv420,
335            level_idc: 30,
336            qp: 26,
337            gop_size: 1,
338            bitrate: 0,
339            framerate: 30.0,
340            num_ref_frames: 1,
341            preset: Preset::Fast,
342            tune_skip_accel_check: true,
343            coded_path_v2: false,
344            tune_lambda_scale: 1.0,
345            tune_intra_penalty: 24.0,
346            tune_satd_q: 0.5,
347            tune_subpel: false,
348            tune_me_snap: true,
349            tune_me_subpel_iter: true,
350            tune_greedy_skip: true,
351            tune_greedy_skip_min_free: None,
352            tune_rd_skip: false,
353            tune_rd_skip_min_free: None,
354            tune_rd_skip_fast_t: None,
355            aq_strength: 1.0,
356            bframes: 0,
357            bframe_qp_offset: 2,
358            bframes_adaptive: false,
359            // Calibrated per-GOP I-frame cascade (~x264 ip_ratio 1.4): a robust
360            // BD-rate win across content (clip240 P −0.6%, dpan B −7.3%, mixed
361            // −1.7%). Trades a few I-frame bits for GOP-wide propagated quality.
362            i_qp_offset: -3,
363            cabac: !legacy_cavlc(),
364            cabac_init_idc: 0,
365            cabac_lambda_scale: 1.0,
366            cabac_dz_div: 0,
367            cabac_rdoq: 8.0,
368            transform_8x8: false,
369            sub_8x8: None,
370            me_wide: None,
371            mbtree: true,
372            mbtree_strength: 0.9,
373            mbtree_lookahead: LookaheadMode::HalfRes,
374        }
375    }
376
377    /// Picture width rounded up to whole macroblocks.
378    pub fn mb_width(&self) -> usize {
379        self.width.div_ceil(16)
380    }
381
382    /// Picture height rounded up to whole macroblocks.
383    pub fn mb_height(&self) -> usize {
384        self.height.div_ceil(16)
385    }
386}