Skip to main content

rusty_h264_encoder/
lib.rs

1//! Pure-Rust H.264 encoder — raw I420 frames in, a conformant Annex-B stream out.
2//!
3//! Every frame it emits decodes **bit-exactly under ffmpeg across QP 0–51**,
4//! intra and inter. The crate is `#![forbid(unsafe_code)]`; the optional SIMD
5//! kernels behind the `asm` feature keep their `unsafe` quarantined in
6//! `rusty_h264-accel`, so that guarantee holds either way.
7//!
8//! Coding tools, default-on: `I_16x16`/`I_4x4`/`I_PCM` intra with λ-based
9//! RD/SATD mode decision; P-frames (`P_Skip`, 16×16/16×8/8×16) with quarter-pel
10//! motion compensation, rate-aware ME and a multi-reference DPB; **CABAC**
11//! entropy coding (Main profile — set `RUSTY_H264_LEGACY_CAVLC=1` to restore
12//! the Constrained Baseline + CAVLC bitstream byte-for-byte); **adaptive
13//! quantization**; the per-GOP I-frame QP cascade; in-loop deblocking; and
14//! average-bitrate rate control. Opt-in via [`EncoderConfig`]: B-frames (fixed
15//! or content-adaptive), the 8×8 transform, mb-tree temporal AQ, sub-8×8
16//! partitions and RD `P_Skip`.
17//!
18//! [`Preset`] picks the speed/quality trade-off — `Fast` (SAD, integer-pel),
19//! `Balanced` (adds sub-pel refinement; the default) or `Quality` (full RD
20//! trial-encode). The bitstream is valid either way; only the effort differs.
21//!
22//! ```
23//! use rusty_h264_encoder::{Encoder, EncoderConfig};
24//! use rusty_h264_common::YuvFrame;
25//!
26//! let cfg = EncoderConfig::new(16, 16);
27//! let mut enc = Encoder::new(cfg).unwrap();
28//! let frame = YuvFrame::black(16, 16);
29//! // The default config carries a lookahead (mb-tree), so `encode()` may
30//! // buffer — `flush()` at end of stream is part of the streaming contract.
31//! let mut bitstream = enc.encode(&frame);
32//! bitstream.extend_from_slice(&enc.flush());
33//! assert!(!bitstream.is_empty());
34//! ```
35
36pub mod bitacct;
37mod cabac;
38mod config;
39mod fastmath;
40mod lookahead;
41pub mod mb16;
42mod mbtree;
43
44/// Prometheus telemetry hooks — the CABAC entropy-bin tap for offline
45/// probability-law discovery by the private Prometheus refinery (CASC
46/// campaign). Opt-in behind the `prometheus-telemetry` feature; the
47/// production build is byte-identical without it (and with it — the tap
48/// only observes the emit path, never steers it).
49#[cfg(feature = "prometheus-telemetry")]
50pub mod telemetry;
51#[cfg(feature = "prometheus-telemetry")]
52pub mod prometheus_telemetry {
53    pub use crate::telemetry::{enable, p_zero_q8, take, CabacBin, SliceTap};
54}
55
56/// Lookahead candidate evaluations so far (mb-tree cost instrument, H-36) — a
57/// deterministic stand-in for wall time, which this box cannot measure at the
58/// precision the content effect needs. `reset` before an encode, read after.
59pub fn mbtree_satd_calls() -> u64 {
60    mbtree::SATD_CALLS.load(std::sync::atomic::Ordering::Relaxed)
61}
62/// Zeroes [`mbtree_satd_calls`].
63pub fn mbtree_satd_reset() {
64    mbtree::SATD_CALLS.store(0, std::sync::atomic::Ordering::Relaxed)
65}
66/// Gate fire-rate census (Tier 1 of the gate-regression harness): `(fired,
67/// seen)` per tracked gate, in [`gate_census_names`] order. Deterministic —
68/// one run is the verdict. See `signals::census`.
69/// Per-GOP mb-tree gate telemetry (Front-B harvest seam).
70pub use mbtree::gopstats;
71
72pub fn gate_census() -> Vec<(u64, u64)> {
73    signals::census::snapshot().to_vec()
74}
75/// Per-gate `(fired, seen)` split by the macroblock's TRANSFORM SIZE:
76/// `[0]` = macroblocks coded 4x4, `[1]` = coded 8x8, each in [`gate_census_names`]
77/// order. A LABEL on the existing counters, not a new gate — it answers whether a
78/// per-transform-size threshold could ever be worth fitting, before one is.
79pub fn gate_census_by_t8() -> [Vec<(u64, u64)>; 2] {
80    let s = signals::census::snapshot_by_t8();
81    [s[0].to_vec(), s[1].to_vec()]
82}
83/// Per-reference LUMA weight estimation for one P picture — the weightp fade
84/// detector (x264-parity `weightp`). A DC-ratio fit at denom 6 per reference,
85/// kept only when a subsampled zero-MV SAD improves by >1% (the x264-style
86/// keep test); identity `(64, 0)` otherwise, so non-fade content's slices
87/// carry only the table's flag bits. LUMA only — matching the streams x264's
88/// own weightp emits (its chroma stays unweighted there too).
89fn estimate_luma_weights(cfg: &EncoderConfig, frame: &YuvFrame, refs: &[RefFrame]) -> Vec<(i32, i32)> {
90    let cw = cfg.mb_width() * 16;
91    let (w, h) = (frame.width.min(cw), frame.height);
92    // The CURRENT frame's subsample grid is identical for every reference, yet
93    // it was re-walked per ref — twice per ref when the keep test ran (up to 6
94    // full grid passes at the default refs = 3). One pass now collects the sum
95    // AND the samples; each reference likewise caches its samples on its means
96    // pass so the keep test re-reads a compact buffer instead of re-striding
97    // the plane. Same samples in the same row-major order: BIT-IDENTICAL.
98    let mut cur: Vec<u8> = Vec::new();
99    let mut sc = 0u64;
100    let mut y = 0;
101    while y < h {
102        // Row slice (`w <= frame.width` by construction): the loop guard
103        // `x < w == row.len()` lets LLVM discharge the indexing, where the
104        // multiplied form re-proved bounds per sample.
105        let row = &frame.y[y * frame.width..][..w];
106        let mut x = 0;
107        while x < w {
108            let p = row[x];
109            sc += p as u64;
110            cur.push(p);
111            x += 4;
112        }
113        y += 4;
114    }
115    let n = cur.len() as u64;
116    let mut rbuf: Vec<u8> = Vec::with_capacity(cur.len());
117    refs.iter()
118        .map(|r| {
119            // Subsampled reference mean (every 4th pixel, both axes).
120            rbuf.clear();
121            let mut sr = 0u64;
122            let mut y = 0;
123            while y < h {
124                let row = &r.y[y * cw..][..w]; // w <= cw by construction
125                let mut x = 0;
126                while x < w {
127                    let p = row[x];
128                    sr += p as u64;
129                    rbuf.push(p);
130                    x += 4;
131                }
132                y += 4;
133            }
134            if n == 0 || sr == 0 {
135                return (64, 0);
136            }
137            let (mc, mr) = (sc as f64 / n as f64, sr as f64 / n as f64);
138            let lw = ((mc * 64.0 / mr).round() as i32).clamp(1, 127);
139            let lo = ((mc - (lw as f64) * mr / 64.0).round() as i32).clamp(-128, 127);
140            if (lw, lo) == (64, 0) {
141                return (64, 0);
142            }
143            // Keep test: the weighted reference must actually predict better.
144            let (mut sad_u, mut sad_w) = (0u64, 0u64);
145            for (&c, &rr) in cur.iter().zip(rbuf.iter()) {
146                let (c, rr) = (c as i32, rr as i32);
147                let rw = (((rr * lw + 32) >> 6) + lo).clamp(0, 255);
148                sad_u += c.abs_diff(rr) as u64;
149                sad_w += c.abs_diff(rw) as u64;
150            }
151            if sad_w * 100 < sad_u * 99 { (lw, lo) } else { (64, 0) }
152        })
153        .collect()
154}
155
156/// B-frame gate signal probe (harness surface for the bframes-v2 dispatch
157/// fit): per-GOP `(bi_residual_1gap, gmc_residual, mgain, dcfrac, is_screen,
158/// grain_signature)` — the same estimators the shipping gates consult, on the
159/// GOP's leading frames. Frame dimensions must be MB multiples (probe use).
160pub fn bframes_gate_signals(
161    cfg: &EncoderConfig,
162    frames: &[YuvFrame],
163) -> (f64, f64, f64, f64, bool, bool) {
164    let (w, h) = (cfg.width, cfg.height);
165    let bi = gop_bi_residual(frames, w, h, 1);
166    if frames.len() < 2 || w % 16 != 0 || h % 16 != 0 {
167        return (bi, f64::INFINITY, 0.0, 0.0, false, false);
168    }
169    let sig = signals::FrameSignals::new(&frames[1].y, w, w / 16, h / 16, Some(&frames[0].y));
170    let (mg, dc) = sig.mgain_dc();
171    (bi, sig.gmc_residual(), mg, dc, sig.is_screen(), sig.grain_signature())
172}
173
174/// Scene-cut pair ratios for a frame sequence (calibration probe surface —
175/// the same detector `segment_gops` consults; index `i` is the pair
176/// `(frames[i], frames[i+1])`).
177pub fn scene_cut_ratios(cfg: &EncoderConfig, frames: &[YuvFrame]) -> Vec<f64> {
178    lookahead::all_pair_ratios(cfg, frames)
179}
180
181/// Deterministic WORK counts (`best_part`, `mb_plan`, `mb_coded`) — the speed
182/// instrument that needs no pinning. See `signals::census`.
183pub fn gate_work() -> Vec<u64> {
184    signals::census::work_snapshot().to_vec()
185}
186/// Names for [`gate_work`], same order.
187pub fn gate_work_names() -> &'static [&'static str] {
188    &signals::census::WORK_NAMES
189}
190/// Names for [`gate_census`], same order.
191pub fn gate_census_names() -> &'static [&'static str] {
192    &signals::census::NAMES
193}
194/// Zeroes the gate census.
195pub fn gate_census_reset() {
196    signals::census::reset()
197}
198
199/// LIVENESS tap: dump `gate,fired,seen` to `$RFF_CENSUS_CSV`, once, at the end
200/// of an encode. No-op when the env var is unset.
201///
202/// `fired` says a gate routed a unit. `seen` says its decision site was
203/// CONSULTED AT ALL — and that second number is the one no refit harness here
204/// could previously read. Without it, three states are indistinguishable:
205///
206/// * consulted, never routed  -> the corpus lacks the content (extend it)
207/// * NEVER CONSULTED          -> the path is dead (fix the configuration)
208/// * routed, output unchanged -> the arm is a no-op (delete the gate)
209///
210/// They have opposite fixes, so collapsing them sends you to the wrong work.
211/// The case that motivated this: `sub8_grain` sits behind `num_refs == 1`, so
212/// an audit run at `--refs 3` measured a gate that was switched off and
213/// reported it as neutral. A hand-written comment caught that one; this makes
214/// it mechanical.
215///
216/// Why here and not `gatecheck`: that binary reads the same counters, but it
217/// builds its OWN `EncoderConfig`, so its numbers describe a different encode
218/// than the one a refit run is judging — which is exactly how the `--refs 3`
219/// mismatch survived. This tap fires from the same process, same flags, same
220/// encode the harness is measuring.
221pub fn gate_census_dump_csv() {
222    use std::fmt::Write as _;
223    use std::io::Write as _;
224    let Ok(path) = std::env::var("RFF_CENSUS_CSV") else {
225        return;
226    };
227    let snap = signals::census::snapshot();
228    let mut s = String::from("gate,fired,seen\n");
229    for (i, name) in signals::census::NAMES.iter().enumerate() {
230        let _ = writeln!(s, "{name},{},{}", snap[i].0, snap[i].1);
231    }
232    if let Ok(mut f) = std::fs::File::create(&path) {
233        let _ = f.write_all(s.as_bytes());
234    }
235}
236
237mod mvd_cost_tab;
238mod params;
239mod rc;
240mod signals;
241mod slice;
242
243pub use crate::mb16::{EXT_MV, ME_PROBE, MVCMP, MVCMP_FRAME};
244
245/// Test-only surface for gating the CABAC *encoder* against the decoder's parser.
246#[doc(hidden)]
247pub mod cabac_enc_test {
248    pub use crate::cabac::CabacEncoder;
249    pub use crate::mb16::b_part_mb_type;
250    pub use crate::mb16::cb_cbp;
251    pub use crate::mb16::cb_mb_qp_delta;
252    pub use crate::mb16::cb_mb_type_b;
253    pub use crate::mb16::cb_ref_idx;
254}
255pub use config::{EncoderConfig, LookaheadMode, Preset};
256pub use params::{Pps, Sps};
257pub use rc::RateControl;
258
259use rusty_h264_common::{BitWriter, ChromaFormat, NalUnit, NalUnitType, Profile, YuvFrame};
260
261/// Errors that can arise constructing or driving the encoder.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub enum EncodeError {
264    /// A feature outside the implemented Constrained Baseline subset was asked for.
265    Unsupported(&'static str),
266    /// The supplied frame's dimensions or plane sizes don't match the config.
267    FrameMismatch,
268}
269
270impl core::fmt::Display for EncodeError {
271    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
272        match self {
273            EncodeError::Unsupported(s) => write!(f, "unsupported: {s}"),
274            EncodeError::FrameMismatch => write!(f, "frame dimensions do not match encoder config"),
275        }
276    }
277}
278
279impl std::error::Error for EncodeError {}
280
281/// A Constrained Baseline H.264 encoder.
282#[derive(Debug)]
283pub struct Encoder {
284    cfg: EncoderConfig,
285    sps: Sps,
286    pps: Pps,
287    /// Count of frames fed so far; drives IDR placement via `gop_size`.
288    frame_index: u32,
289    /// `frame_num` of the next picture (resets to 0 at each IDR).
290    next_frame_num: u32,
291    /// Index of the current picture within its GOP (0 at IDR), for POC.
292    gop_index: u32,
293    /// Decoded-picture buffer: recent **deblocked** reconstructions (coded size),
294    /// most-recent first, used as inter references (`ref_idx` 0 = front).
295    refs: Vec<RefFrame>,
296    /// Average-bitrate controller; `None` for constant-QP encoding.
297    rc: Option<RateControl>,
298    /// Per-MB QP offset for the NEXT `encode()` (mb-tree temporal AQ). Set by the
299    /// batch path before each frame; consumed (and cleared) by `try_encode`. Empty /
300    /// `None` → no offset (byte-identical).
301    pending_qpo: Option<Vec<i32>>,
302    /// AQ grain probe for the NEXT frame IF it is an IDR: the previous
303    /// display-order SOURCE frame (docs/gate-ledger.md aq-grain-veto — an IDR
304    /// has no coding reference, so the veto's temporal signals read
305    /// source-vs-source). Set by the batch paths, consumed per frame; `None`
306    /// (streaming, or the stream's first frame) → the veto fails open.
307    pending_aq_probe: Option<YuvFrame>,
308    /// Frames held by the streaming lookahead (mb-tree needs a whole GOP before it
309    /// can assign any of its QPs). Drained a GOP at a time by `try_encode`, and at
310    /// end of stream by `flush`.
311    la_queue: Vec<YuvFrame>,
312    /// Frames coded since the last IDR (0 = the next frame IS an IDR). The
313    /// scenecut counter that replaced `frame_index % gop_size` — cadence is no
314    /// longer periodic once cuts place IDRs (x264-parity keyint/min-keyint).
315    since_idr: u32,
316    /// One-shot IDR request (scene cut, or a batch segment boundary). Consumed
317    /// by `encode_direct`.
318    force_idr: bool,
319    /// Previous display-order SOURCE frame, retained by the streaming path for
320    /// the causal scene-cut pair AND as the cut-IDR's AQ grain probe. `None`
321    /// with scenecut off (no clone cost on the anchor path).
322    last_src: Option<YuvFrame>,
323    /// `last_src`'s detector preparation (coded + half-res planes), carried so
324    /// each pair ratio preps only the NEW frame — the previous frame was
325    /// prepped by the last call (`None` on the first pair; rebuilt on demand).
326    last_prep: Option<mbtree::PairPrep>,
327    /// The two most recent scene-cut pair ratios (the spike-rule baseline),
328    /// most recent first. `1.0` = no history yet (nothing can spike over it).
329    cut_hist: [f64; 2],
330}
331
332impl Drop for Encoder {
333    fn drop(&mut self) {
334        // Dropping with frames still buffered means the caller never flushed and has
335        // silently lost the tail of its stream. Loud in debug, free in release.
336        debug_assert!(
337            self.la_queue.is_empty() || std::thread::panicking(),
338            "Encoder dropped with {} frame(s) still in the lookahead queue — call flush()",
339            self.la_queue.len()
340        );
341    }
342}
343
344/// A reference picture: deblocked reconstruction at coded (MB-grid) resolution.
345/// Stored now (4a); read by motion compensation in 4b.
346#[derive(Clone, Debug)]
347#[allow(dead_code)]
348pub(crate) struct RefFrame {
349    // 16-byte aligned (moved from the encoder's aligned rec planes) so the openh264
350    // MC asm can load aligned reference row chunks.
351    pub y: rusty_h264_common::aligned::AlignedBytes,
352    pub u: rusty_h264_common::aligned::AlignedBytes,
353    pub v: rusty_h264_common::aligned::AlignedBytes,
354    /// Picture Order Count — the DISPLAY position. B ref-lists order L0/L1 by POC
355    /// relative to the current picture; P ignores it.
356    pub poc: i32,
357    /// The picture's `frame_num` (reference frames only advance it).
358    pub frame_num: u32,
359    /// Per-4×4-block List-0 motion (raster, `mb_w*4` wide). Populated for anchors;
360    /// read as the co-located picture (`RefPicList1[0]`) when deriving a B-frame's
361    /// spatial-direct `colZeroFlag`. `ref_idx == -1` marks intra/uncoded blocks.
362    pub mv: Vec<(i32, i32)>,
363    pub ref_idx: Vec<i32>,
364    /// List-1 motion of the picture (b-pyramid: a REFERENCE B can be the
365    /// co-located picture, and its L1-only blocks read from here — the exact
366    /// List-1 colZeroFlag defect the decoder already root-caused and fixed;
367    /// the encoder's direct derivation must mirror it or pyramid recon
368    /// drifts). EMPTY for P/I references (no List 1 exists there).
369    pub mv1: Vec<(i32, i32)>,
370    pub ref_idx1: Vec<i32>,
371    /// Blocks-wide (`mb_w*4`), so the co-located index is `by*w4 + bx`.
372    pub w4: usize,
373    /// Cached half-pel luma planes, built on first sub-pel motion-search use.
374    ///
375    /// ENCODER-SIDE ONLY, and lazily: the motion search makes ~300 `mc_luma` calls
376    /// per macroblock while final reconstruction makes ~1, so this pays enormously
377    /// in the search and would be pure tax anywhere else. `Arc` so cloning a
378    /// `RefFrame` (the DPB does) does not copy three frame-sized planes.
379    pub hpel: std::sync::OnceLock<std::sync::Arc<rusty_h264_common::inter::HpelPlanes>>,
380}
381
382impl RefFrame {
383    /// The half-pel planes for this picture, filtering them once on first use.
384    pub(crate) fn hpel(&self, cw: usize, ch: usize) -> &rusty_h264_common::inter::HpelPlanes {
385        self.hpel.get_or_init(|| {
386            std::sync::Arc::new(rusty_h264_common::inter::build_hpel_planes(&self.y, cw, ch))
387        })
388    }
389}
390
391/// Sets the sub-pel refinement pattern (U1) for subsequent encodes in this process.
392/// 0 = 8-point ring + iterate, 1 = 4-point diamond + iterate, 2 = 8-point single
393/// pass, 3 = 4-point single pass. Exposed so the pattern can be A/B'd inside ONE
394/// binary, which is the only comparison this machine can resolve.
395/// Enables/disables the U1 online sub-pel dispatcher for subsequent encodes.
396/// Sets the λ-normalised partition-split search threshold (U2). 0 = off.
397/// Enables the U5-struct deferred sub-pel refinement (search all partition shapes at
398/// full-pel, refine only the winner). Bitstream-changing → BD-gated.
399/// Descent B: ME cost-path census [interior-fullpel, edge-fullpel, sub-pel].
400#[cfg(feature = "profile")]
401pub fn satdpath_snapshot() -> Vec<u64> { crate::mb16::satdpath::snapshot() }
402#[cfg(not(feature = "profile"))]
403pub fn satdpath_snapshot() -> Vec<u64> { Vec::new() }
404#[cfg(feature = "profile")]
405pub fn satdpath_reset() { crate::mb16::satdpath::reset() }
406#[cfg(not(feature = "profile"))]
407pub fn satdpath_reset() {}
408
409/// Descent D-2: sub-pel evaluations that re-price an already-priced MV.
410#[cfg(feature = "profile")]
411pub fn spstats_redundant() -> u64 { crate::mb16::spstats::redundant_count() }
412#[cfg(not(feature = "profile"))]
413pub fn spstats_redundant() -> u64 { 0 }
414
415/// Descent D: sub-pel ring census (profile builds only).
416#[cfg(feature = "profile")]
417pub fn spstats_snapshot() -> (Vec<u64>, Vec<u64>) { crate::mb16::spstats::snapshot() }
418#[cfg(not(feature = "profile"))]
419pub fn spstats_snapshot() -> (Vec<u64>, Vec<u64>) { (Vec::new(), Vec::new()) }
420#[cfg(feature = "profile")]
421pub fn spstats_reset() { crate::mb16::spstats::reset() }
422#[cfg(not(feature = "profile"))]
423pub fn spstats_reset() {}
424
425/// Default diamond rung mask (`[16,8,4]`).
426pub const DIA_DEFAULT_MASK: u32 = crate::mb16::DIA_DEFAULT;
427
428/// Descent A: select which rungs of the [64,32,16,8,4] diamond ladder to walk.
429pub fn set_dia_mask(m: u32) { crate::mb16::set_dia_mask(m) }
430/// Track-B B2: SAD-domain full-pel search phase (SATD from sub-pel on) — x264's
431/// cost split. Bitstream-changing; BD-gated; off = byte-identical to pre-B2.
432pub fn set_me_sadfp(on: bool) { crate::mb16::set_me_sadfp(on) }
433/// B2 mode: 0 off, 1 dispatched per frame by the `b2_mgain` probe, 2 force-on.
434pub fn set_me_sadfp_mode(m: u32) { crate::mb16::set_me_sadfp_mode(m) }
435/// Fixed-centre batched diamond passes (both cost domains). Off = cascade.
436pub fn set_me_fc(on: bool) { crate::mb16::set_me_fc(on) }
437/// H-13 split-dispatch threshold in milli-units of the mgain probe (0 = always
438/// search splits, byte-identical to pre-gate). Default 30 (= 0.03).
439pub fn set_split_mg(milli: u32) { crate::mb16::set_split_mg(milli) }
440/// H-23: smooth (x264-shape) mvd cost model in ME. Off = Exp-Golomb step fn.
441pub fn set_mv_smooth(on: bool) { crate::mb16::set_mv_smooth(on) }
442/// H-24 mv-cost mode: 0 off, 1 dispatched per frame by mgain, 2 force-on.
443pub fn set_mv_smooth_mode(m: u32) { crate::mb16::set_mv_smooth_mode(m) }
444/// Fixed-centre batched HALF-PEL sub-pel ring (satd_x4p). Off = cascade.
445pub fn set_sp_fc(on: bool) { crate::mb16::set_sp_fc(on) }
446
447/// The x264-style SUB-PEL EFFORT LADDER (H-10): one level selects a priced
448/// (ring pattern × iteration budget) rung — closing the ~24-vs-9 eval-count gap
449/// vs x264 as a BUDGET choice instead of a blanket cut.
450///
451/// 5 = ring8, iterate to convergence (the quality preset's default — max effort);
452/// 4 = ring8, ≤3 iterations/step; 3 = ring8, ≤2 iterations/step;
453/// 2 = ring8, single pass (= today's balanced preset); 1 = ring4, single pass.
454/// Levels ≥5 restore the defaults. Equivalent env knobs: `RFF_SUBPEL_PAT` +
455/// `RFF_SP_MAXIT`.
456pub fn set_subme(level: u32) {
457    let (pat, cap) = match level {
458        1 => (3, 0),
459        2 => (2, 0),
460        3 => (0, 2),
461        4 => (0, 3),
462        _ => (0, 0),
463    };
464    set_subpel_pattern(pat);
465    crate::mb16::set_sp_maxit(cap);
466}
467
468/// The SUPERFAST-CLASS rung (H-11/H-12): the Quality preset at x264 superfast's
469/// partition SHAPE — P16×16-only (splits gated off), everything else (sub-pel
470/// ladder, B2 dispatch) at defaults. Measured fair-run on foreman: **1.81× faster
471/// than default quality and STILL −0.9% BD vs x264 superfast itself.** The
472/// further effort cuts (subme 2 + SAD-fp force) were measured and REJECTED from
473/// this rung: no speed on top of shape-only (0.27× vs 0.28×) while costing BD
474/// (+1.9% foreman / +8.4% bus) — compose them manually via `set_subme` /
475/// `set_me_sadfp_mode` if wanted. Split-heavy content (bus-class) pays more at
476/// this rung; the per-frame split DISPATCH (H-11 next-brick b) is the eventual
477/// no-tax answer. Env twin: `RFF_SPLIT_T=10000000`.
478pub fn set_turbo(on: bool) {
479    set_split_t(if on { 10_000_000 } else { 0 });
480}
481/// Track-B B3: sub-pel iteration budget (0 = unlimited = byte-identical) — the
482/// bounded walk x264's subme levels have; pairs with B2. BD-gated.
483pub fn set_sp_maxit(n: u32) { crate::mb16::set_sp_maxit(n) }
484
485/// Descent A: diamond per-step evaluation census (profile builds only).
486#[cfg(feature = "profile")]
487pub fn diastats_snapshot() -> Vec<(u64, u64)> { crate::mb16::diastats::snapshot() }
488#[cfg(not(feature = "profile"))]
489pub fn diastats_snapshot() -> Vec<(u64, u64)> { Vec::new() }
490#[cfg(feature = "profile")]
491pub fn diastats_reset() { crate::mb16::diastats::reset() }
492#[cfg(not(feature = "profile"))]
493pub fn diastats_reset() {}
494
495pub fn set_defer_subpel(on: bool) {
496    crate::mb16::DEFER_SUBPEL.store(if on { 1 } else { 0 }, std::sync::atomic::Ordering::Relaxed);
497}
498
499pub fn set_split_t(t: u32) {
500    crate::mb16::SPLIT_T.store(t, std::sync::atomic::Ordering::Relaxed);
501}
502
503pub fn set_subpel_dispatch(on: bool) {
504    crate::mb16::SP_DISPATCH.store(if on { 1 } else { 0 }, std::sync::atomic::Ordering::Relaxed);
505}
506
507pub fn set_subpel_pattern(p: u32) {
508    crate::mb16::SUBPEL_PAT.store(p, std::sync::atomic::Ordering::Relaxed);
509}
510
511impl Encoder {
512    /// Creates an encoder, validating that the configuration is within the
513    /// implemented subset.
514    pub fn new(cfg: EncoderConfig) -> Result<Self, EncodeError> {
515        if !matches!(
516            cfg.profile,
517            Profile::ConstrainedBaseline | Profile::Baseline | Profile::Main | Profile::High
518        ) {
519            return Err(EncodeError::Unsupported("unsupported profile"));
520        }
521        // The 8x8 transform is a High-profile feature, available under BOTH entropy
522        // coders since R6: `transform_size_8x8_flag` at both of its syntax positions
523        // (ctxIdxOffset 399) plus the ctxBlockCat-5 residual writer (sig 402 / last
524        // 417 / levels 426, and NO coded_block_flag -- presence comes from
525        // CodedBlockPatternLuma).
526        //
527        // CLAMPED, not refused. It is default-ON, so a caller who narrows the profile
528        // to Main is asking for Main-compatible output, not asking for an error -- and
529        // refusing here would make `EncoderConfig::new()` plus `profile = Main`, an
530        // entirely reasonable pair, fail outright. A profile is a compatibility
531        // ceiling; High-only tools clamp to it, exactly as `-profile:v` behaves
532        // elsewhere. The CLI promotes to High whenever `--transform-8x8 1` is passed,
533        // so an explicit request is never silently dropped there.
534        let mut cfg = cfg;
535        if cfg.transform_8x8 && !matches!(cfg.profile, Profile::High) {
536            cfg.transform_8x8 = false;
537        }
538        // B-frames are illegal in Baseline / Constrained Baseline (the decoder
539        // enforces this too). HIGH is a superset of Main and permits B slices —
540        // this used to demand Main exactly, which rejected the perfectly legal
541        // High + B-frames combination and blocked the 8x8 + B measurement.
542        // R6-5: 8x8 + B-frames. The B rule for transform_size_8x8_flag is derived
543        // in `plan_inter_mb` (`allow_t8`) and mirrored at the B emit: with
544        // direct_8x8_inference_flag = 0, a B_Direct_16x16 macroblock may not carry
545        // the flag, so the plan must not pick 8x8 for one. Gating at PLAN time (not
546        // emit time) is what keeps our reconstruction and the decoder's in step.
547        if cfg.bframes > 0 && !matches!(cfg.profile, Profile::Main | Profile::High) {
548            return Err(EncodeError::Unsupported("B-frames require Main or High profile"));
549        }
550        if cfg.chroma != ChromaFormat::Yuv420 {
551            return Err(EncodeError::Unsupported("only 4:2:0 chroma"));
552        }
553        if cfg.width == 0 || cfg.height == 0 || cfg.width % 2 != 0 || cfg.height % 2 != 0 {
554            return Err(EncodeError::Unsupported("dimensions must be positive and even"));
555        }
556        let sps = Sps::from_config(&cfg);
557        let pps = Pps::from_config(&cfg);
558        let rc = (cfg.bitrate > 0).then(|| RateControl::new(cfg.bitrate, cfg.framerate, cfg.qp));
559        Ok(Self {
560            cfg,
561            sps,
562            pps,
563            frame_index: 0,
564            next_frame_num: 0,
565            gop_index: 0,
566            refs: Vec::new(),
567            rc,
568            pending_qpo: None,
569            pending_aq_probe: None,
570            la_queue: Vec::new(),
571            since_idr: 0,
572            force_idr: false,
573            last_src: None,
574            last_prep: None,
575            cut_hist: [1.0, 1.0],
576        })
577    }
578
579    /// Sets the per-MB QP offset applied to the NEXT [`encode`](Self::encode) call
580    /// (mb-tree temporal AQ). One entry per macroblock (raster). Consumed once.
581    pub(crate) fn set_pending_qpo(&mut self, qpo: Vec<i32>) {
582        self.pending_qpo = Some(qpo);
583    }
584
585    /// Sets the AQ grain probe (the previous display-order SOURCE frame) for the
586    /// NEXT call if it codes an IDR. Consumed once per frame either way.
587    pub(crate) fn set_aq_probe(&mut self, f: YuvFrame) {
588        self.pending_aq_probe = Some(f);
589    }
590
591    /// The active configuration.
592    pub fn config(&self) -> &EncoderConfig {
593        &self.cfg
594    }
595
596    /// Encodes one frame, returning zero or more Annex-B access units. IDR
597    /// placement follows the keyint model (`gop_size` ceiling, `min_keyint`,
598    /// scene cuts), each IDR prefixed with SPS/PPS.
599    ///
600    /// With a lookahead feature active (mb-tree, on by default) frames buffer
601    /// until a window fills, so a call may return EMPTY bytes — call
602    /// [`flush`](Self::flush) at end of stream to drain the tail.
603    pub fn encode(&mut self, frame: &YuvFrame) -> Vec<u8> {
604        self.try_encode(frame).expect("frame matched config")
605    }
606
607    /// Fallible [`encode`](Self::encode).
608    ///
609    /// With a lookahead feature active (currently mb-tree, on by default in the
610    /// constant-QP path) this BUFFERS: mb-tree needs a whole GOP of future frames
611    /// before it can assign any of their QPs, so the returned `Vec` is empty while
612    /// the GOP fills and then carries that entire GOP's access units at once. The
613    /// concatenation of every return value plus [`flush`](Self::flush) is exactly
614    /// what [`encode_all`](Self::encode_all) produces — byte for byte.
615    ///
616    /// **You must call [`flush`](Self::flush) at end of stream** or the final
617    /// partial GOP is never emitted. (A debug build asserts if the encoder is
618    /// dropped with frames still buffered.) For zero added latency set
619    /// `cfg.mbtree = false`, which restores one-AU-per-call behaviour.
620    pub fn try_encode(&mut self, frame: &YuvFrame) -> Result<Vec<u8>, EncodeError> {
621        // CAUSAL scene-cut detection (x264 keyint parity), BEFORE any
622        // buffering: the pair is (previous source, this source) — exactly the
623        // pair `segment_gops` reads in the batch path, so streaming == batch
624        // holds under cuts. On a cut: flush whatever the lookahead holds (an
625        // mb-tree window must not straddle an IDR), then request the IDR and
626        // hand the detector's retained frame over as the grain probe.
627        if self.cfg.scenecut > 0 && self.cfg.gop_size > 1 {
628            if self.last_src.is_some() {
629                // History advances on EVERY pair (the spike baseline must see
630                // the pairs before a legal cut position too — this is what
631                // keeps streaming == batch, whose `segment_gops` scores the
632                // same pairs). Two exact reductions, mirroring the batch
633                // scorer's rolling lazy cursor:
634                //  * prep only the NEW frame — the previous frame's coded +
635                //    half-res planes were built by the last call (`last_prep`);
636                //  * skip the ratio entirely while no decision can read it: a
637                //    decision fires at counter >= min_keyint and consults this
638                //    pair's ratio for at most the two following frames, so a
639                //    pair at counter < min_keyint - 2 is UNREADABLE. Its slot
640                //    rolls a placeholder that is never consulted (at the
641                //    earliest decision, both baseline slots came from counters
642                //    >= min_keyint - 2 — computed).
643                let counter = self.since_idr + self.la_queue.len() as u32;
644                let minki = self.cfg.min_keyint.max(1);
645                let r = if counter + 2 >= minki {
646                    let cur_prep = mbtree::pair_prep(&self.cfg, frame);
647                    let prev_prep = match self.last_prep.take() {
648                        Some(p) => p,
649                        None => {
650                            mbtree::pair_prep(&self.cfg, self.last_src.as_ref().expect("guarded"))
651                        }
652                    };
653                    let r = mbtree::pair_ratio_prepped(&self.cfg, &cur_prep, &prev_prep);
654                    self.last_prep = Some(cur_prep);
655                    r
656                } else {
657                    self.last_prep = None;
658                    1.0 // placeholder: unreadable (see above)
659                };
660                let (p1, p2) = (self.cut_hist[0], self.cut_hist[1]);
661                self.cut_hist = [r, p1];
662                if counter >= minki && lookahead::is_scene_cut(&self.cfg, r, p1, p2) {
663                    let flushed = self.try_flush()?;
664                    self.force_idr = true;
665                    self.pending_aq_probe = self.last_src.take();
666                    self.last_src = Some(frame.clone());
667                    let mut out = flushed;
668                    out.extend_from_slice(&self.try_encode_inner(frame)?);
669                    return Ok(out);
670                }
671            }
672            self.last_src = Some(frame.clone());
673        }
674        self.try_encode_inner(frame)
675    }
676
677    fn try_encode_inner(&mut self, frame: &YuvFrame) -> Result<Vec<u8>, EncodeError> {
678        if !self.lookahead_active() {
679            return self.encode_direct(frame);
680        }
681        if frame.width != self.cfg.width || frame.height != self.cfg.height || !frame.is_valid() {
682            return Err(EncodeError::FrameMismatch);
683        }
684        self.la_queue.push(frame.clone());
685        // mb-tree's window: bounded by `lookahead` (x264's rc-lookahead — a
686        // 250-frame keyint must not mean a 250-frame buffer) and by the
687        // frames REMAINING until the forced IDR, so a window never straddles
688        // an IDR and ends exactly where the batch path's segment does (the
689        // streaming == batch identity under the keyint model). `since_idr %
690        // gop` treats the just-completed GOP (`since_idr == gop`) as a fresh
691        // one — the next frame IS the IDR.
692        let gop = self.cfg.gop_size.max(1);
693        let rem_to_idr = (gop - (self.since_idr % gop)) as usize;
694        let window = (self.cfg.lookahead.max(1) as usize).min(rem_to_idr);
695        if self.la_queue.len() >= window {
696            self.emit_lookahead_gop()
697        } else {
698            Ok(Vec::new())
699        }
700    }
701
702    /// True when a feature needs future frames, so [`try_encode`] must buffer.
703    /// B-frames already refuse the streaming API, and rate control drives its own
704    /// sequential path, so mb-tree in constant-QP mode is the only case.
705    fn lookahead_active(&self) -> bool {
706        self.cfg.mbtree && self.cfg.bframes == 0 && self.cfg.bitrate == 0
707    }
708
709    /// Codes every buffered frame with mb-tree's per-GOP QP offsets and returns
710    /// their access units concatenated. Identical to what an `encode_all` worker
711    /// does for the same GOP.
712    fn emit_lookahead_gop(&mut self) -> Result<Vec<u8>, EncodeError> {
713        let frames = std::mem::take(&mut self.la_queue);
714        let offs = mbtree::gop_qp_offsets(&self.cfg, &frames, self.cfg.mbtree_strength);
715        let mut out = Vec::new();
716        for (i, f) in frames.iter().enumerate() {
717            if let Some(o) = offs.get(i) {
718                self.pending_qpo = Some(o.clone());
719            }
720            out.extend_from_slice(&self.encode_direct(f)?);
721        }
722        Ok(out)
723    }
724
725    /// Emits any frames still held by the lookahead queue (end of stream).
726    ///
727    /// Returns the trailing access units, or empty when nothing is buffered — so it
728    /// is always safe to call, including when no lookahead feature is active.
729    pub fn flush(&mut self) -> Vec<u8> {
730        self.try_flush().expect("buffered frames matched the config when accepted")
731    }
732
733    /// Fallible [`flush`](Self::flush).
734    pub fn try_flush(&mut self) -> Result<Vec<u8>, EncodeError> {
735        if self.la_queue.is_empty() {
736            return Ok(Vec::new());
737        }
738        self.emit_lookahead_gop()
739    }
740
741    /// The unbuffered single-frame path: codes `frame` immediately. This is what the
742    /// batch path's workers call, since they compute the lookahead themselves.
743    fn encode_direct(&mut self, frame: &YuvFrame) -> Result<Vec<u8>, EncodeError> {
744        let _g = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::Total);
745        if frame.width != self.cfg.width || frame.height != self.cfg.height || !frame.is_valid() {
746            return Err(EncodeError::FrameMismatch);
747        }
748
749        // B-frames need lookahead (a future anchor coded before the B), which the
750        // one-frame-in streaming API can't provide — use `encode_all` for B.
751        if self.cfg.bframes > 0 {
752            return Err(EncodeError::Unsupported("B-frames need encode_all (lookahead)"));
753        }
754        // GOP placement (x264 keyint model): an IDR when forced (scene cut or
755        // batch segment boundary), at stream start / after reset, or when the
756        // since-IDR counter reaches `gop_size` (the keyint ceiling). The
757        // counter replaced `frame_index % gop_size` — cadence is not periodic
758        // once cuts place IDRs; with `scenecut = 0` the counter reproduces the
759        // modulo exactly (the bisection anchor).
760        let forced = std::mem::take(&mut self.force_idr);
761        let is_idr = self.cfg.gop_size <= 1
762            || forced
763            || self.since_idr == 0
764            || self.since_idr >= self.cfg.gop_size;
765        if is_idr {
766            self.gop_index = 0;
767            self.next_frame_num = 0;
768            self.refs.clear();
769        }
770        let frame_num = self.next_frame_num;
771        let poc_lsb = (2 * self.gop_index) % 256;
772        // mb-tree per-MB QP offset for this frame (empty = none / byte-identical).
773        let qpo = self.pending_qpo.take().unwrap_or_default();
774        // AQ grain probe (IDR only; consumed unconditionally so it cannot go stale).
775        let aq_probe = self.pending_aq_probe.take();
776
777        // Rate control (if enabled) chooses this frame's QP from a cheap
778        // look-ahead complexity estimate; otherwise the QP is fixed.
779        let complexity = if self.rc.is_some() {
780            lookahead::complexity(&self.cfg, frame, if is_idr { None } else { self.refs.first() })
781        } else {
782            0.0
783        };
784        let qp = match &self.rc {
785            Some(rc) => rc.pick_qp(is_idr, complexity),
786            // Constant-QP: apply the per-GOP I-frame cascade offset (0 by default →
787            // byte-identical). Keeps the P-only path consistent with `code_picture`.
788            None if is_idr => (self.cfg.qp as i32 + self.cfg.i_qp_offset).clamp(0, 51) as u8,
789            None => self.cfg.qp,
790        };
791
792        let mut out = Vec::new();
793        // Pre-size the slice writer to a generous fraction of the raw frame so the
794        // CAVLC hot loop never reallocs mid-frame (byte-identical; just capacity).
795        let mut w = BitWriter::with_capacity(self.cfg.width * self.cfg.height / 2 + 4096);
796        let (nal_type, mut reference) = if is_idr {
797            // SPS/PPS precede every IDR so the stream is independently decodable.
798            self.sps.to_nal().write_annex_b(&mut out);
799            self.pps.to_nal().write_annex_b(&mut out);
800            slice::write_idr_slice_header(&mut w, &self.cfg, qp);
801            // The batch paths park the previous source frame in
802            // `pending_aq_probe` (taken above); pure streaming callers have no
803            // previous frame retained — the grain veto fails open there.
804            let r = if self.cfg.cabac {
805                mb16::encode_slice_data_cabac_intra(&mut w, &self.cfg, frame, qp, &qpo, aq_probe.as_ref())
806            } else {
807                mb16::encode_slice_data(&mut w, &self.cfg, frame, qp, false, &[], &qpo, aq_probe.as_ref(), &[])
808            };
809            (NalUnitType::IdrSlice, r)
810        } else {
811            // weightp (x264 parity): estimate per-reference luma weights; the
812            // slice header's table and the coder's post-MC apply must be the
813            // SAME list or the stream and the recon disagree.
814            let wp: Vec<(i32, i32)> = if self.cfg.weightp {
815                estimate_luma_weights(&self.cfg, frame, &self.refs)
816            } else {
817                Vec::new()
818            };
819            let wp_hdr = if self.cfg.weightp { Some(wp.as_slice()) } else { None };
820            slice::write_p_slice_header(&mut w, &self.cfg, qp, frame_num, poc_lsb, self.refs.len(), wp_hdr);
821            let r = if self.cfg.cabac {
822                mb16::encode_slice_data_cabac_p(&mut w, &self.cfg, frame, qp, &self.refs, &qpo, &wp)
823            } else {
824                mb16::encode_slice_data(&mut w, &self.cfg, frame, qp, true, &self.refs, &qpo, None, &wp)
825            };
826            (NalUnitType::NonIdrSlice, r)
827        };
828        // POC/frame_num carried on the reference so B-frame ref-lists (when enabled)
829        // can order L0/L1 by display position. Unused on the P-only path.
830        reference.poc = 2 * self.gop_index as i32;
831        reference.frame_num = frame_num;
832        let slice_bytes = w.into_bytes();
833        // Feed the coded slice size (the picture's own bits) back to the controller.
834        if let Some(rc) = &mut self.rc {
835            rc.update(is_idr, slice_bytes.len() * 8, qp, complexity);
836        }
837        {
838            let _g = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::EncNal);
839            NalUnit::new(3, nal_type, slice_bytes).write_annex_b(&mut out);
840        }
841
842        // The deblocked reconstruction enters the DPB (most-recent first), which
843        // is kept to `max_num_ref_frames` by a sliding window.
844        self.refs.insert(0, reference);
845        self.refs.truncate(self.cfg.num_ref_frames.max(1) as usize);
846        self.frame_index += 1;
847        self.gop_index += 1;
848        self.next_frame_num = (self.next_frame_num + 1) % 16;
849        // SELF-FILL the AQ grain probe: if this encoder's NEXT frame opens a GOP,
850        // retain THIS source frame as its probe (one clone per GOP, none on other
851        // frames). Sequential paths (streaming, RC) get IDR coverage for free and
852        // byte-match the batch workers, which set the same frame externally
853        // (fresh encoder per GOP — no state to self-fill from).
854        self.since_idr = if is_idr { 1 } else { self.since_idr + 1 };
855        if self.cfg.gop_size > 1 && self.since_idr >= self.cfg.gop_size {
856            self.pending_aq_probe = Some(frame.clone());
857        }
858        Ok(out)
859    }
860
861    /// Batch-encodes every frame, returning one Annex-B access unit per frame.
862    ///
863    /// At constant QP the GOPs are independent — each begins with an IDR that
864    /// resets the DPB, `frame_num` and POC, and SPS/PPS precede every IDR — so they
865    /// are encoded **in parallel across CPU cores** and the result is
866    /// **byte-identical** to calling [`encode`](Self::encode) frame-by-frame. With
867    /// rate control enabled the per-frame QP depends on history, so this falls back
868    /// to sequential encoding. Within a GOP, P-frames are inherently sequential
869    /// (each predicts from the previous reconstruction); the parallelism is across
870    /// GOPs, so it scales with the number of GOPs in the clip.
871    pub fn encode_all(&self, frames: &[YuvFrame]) -> Result<Vec<Vec<u8>>, EncodeError> {
872        for f in frames {
873            if f.width != self.cfg.width || f.height != self.cfg.height || !f.is_valid() {
874                return Err(EncodeError::FrameMismatch);
875            }
876        }
877        // SUB-PEL GRAIN VETO, decided ONCE for the sequence (grain is a property of the
878        // SOURCE, not of a frame). Sub-pel interpolates, and on grain it interpolates
879        // NOISE. `RFF_GRAIN_SUBPEL=0` opts out.
880        let grain_seq = self.cfg.preset != Preset::Fast
881            && std::env::var("RFF_GRAIN_SUBPEL").map(|v| v != "0").unwrap_or(true)
882            && frames.len() >= 2
883            && {
884                // CODED-size planes, not the raw display planes: FrameSignals
885                // walks the MB grid (`mb_h*16` rows), and a display height
886                // that is not a multiple of 16 (1080p!) is SHORTER than that —
887                // this exact line crashed on blue_sky_1080p the first time a
888                // non-MB-multiple clip went through `encode_all` (pre-existing;
889                // surfaced by the bframes-v2 holdout run).
890                let cl0 = mbtree::coded_luma(&self.cfg, &frames[0]);
891                let cl1 = mbtree::coded_luma(&self.cfg, &frames[1]);
892                crate::signals::FrameSignals::new(
893                    &cl1,
894                    self.cfg.mb_width() * 16,
895                    self.cfg.mb_width(),
896                    self.cfg.mb_height(),
897                    Some(&cl0),
898                )
899                .grain_signature()
900            };
901        let _guard = mb16::SeqFastPath::set(grain_seq);
902        // B-frames need a reorder pipeline (code the future anchor before the B's
903        // that reference it) — a separate sequential path.
904        if self.cfg.bframes > 0 {
905            // Content-adaptive dispatch, PER GOP (codec-content-adaptive-dispatch):
906            // code B-frames only in GOPs whose motion is predictable enough to pay,
907            // so a mixed clip gets B on its smooth segments and P on its busy ones.
908            // Scene-cut segmentation first (x264 keyint model) — every per-GOP
909            // signal below is per-SEGMENT, so the dispatch decides on real
910            // scene units instead of arbitrary fixed windows.
911            let seg_starts = lookahead::segment_gops(&self.cfg, frames);
912            let seg_range = |k: usize| {
913                (seg_starts[k], seg_starts.get(k + 1).copied().unwrap_or(frames.len()))
914            };
915            let n_gops = seg_starts.len();
916            let (w, h) = (self.cfg.width, self.cfg.height);
917            // One cheap per-GOP signal drives BOTH content-adaptive knobs: the B/P
918            // structure dispatch AND the I-frame QP-cascade depth.
919            let gop_sig: Vec<f64> = (0..n_gops)
920                .map(|g| {
921                    let (s, e) = seg_range(g);
922                    gop_bi_residual(&frames[s..e], w, h, 1)
923                })
924                .collect();
925            // bframes-v2 dispatch: the SEGMENT level carries only the vetoes
926            // (screen content — the +1.19% leak class, whose bi-residual is
927            // near zero and sails under any threshold); the bi-residual
928            // decision itself moved to the ANCHOR-GAP level inside
929            // `encode_all_bframes`, where crew-class episodic content (flash
930            // gaps P, calm gaps B) is separable in a way no clip-level scalar
931            // was.
932            let gop_fav: Vec<bool> = if self.cfg.bframes_adaptive {
933                (0..n_gops)
934                    .map(|g| {
935                        let (s, e) = seg_range(g);
936                        if e - s < 2 {
937                            return bframes_favorable(gop_sig[g]);
938                        }
939                        let cl0 = mbtree::coded_luma(&self.cfg, &frames[s]);
940                        let cl1 = mbtree::coded_luma(&self.cfg, &frames[s + 1]);
941                        let cw = self.cfg.mb_width() * 16;
942                        let sig = signals::FrameSignals::new(
943                            &cl1,
944                            cw,
945                            self.cfg.mb_width(),
946                            self.cfg.mb_height(),
947                            Some(&cl0),
948                        );
949                        !sig.is_screen()
950                    })
951                    .collect()
952            } else {
953                vec![true; n_gops]
954            };
955            let gop_iqp: Vec<i32> = gop_sig.iter().map(|&s| gop_iqp_offset(s, self.cfg.i_qp_offset)).collect();
956            let gop_bqp: Vec<i32> = gop_sig.iter().map(|&s| gop_bframe_qp_offset(s, self.cfg.bframe_qp_offset)).collect();
957            // Adaptive B-COUNT: how many B's per anchor gap. Fixed `bframes` unless
958            // `auto`, where the 2-gap/1-gap bi-residual RATIO picks it — content that
959            // survives wider anchor spacing (low ratio) carries more cheap B's; simple
960            // translation (high ratio) wants a single equidistant B.
961            let bcount = if self.cfg.bframes_adaptive {
962                adaptive_bcount(frames, w, h, self.cfg.bframes as usize)
963            } else {
964                self.cfg.bframes as usize
965            };
966            if gop_fav.iter().any(|&f| f) {
967                return Ok(self.encode_all_bframes(frames, bcount, &seg_starts, &gop_fav, &gop_iqp, &gop_bqp));
968            }
969            // No GOP is B-favorable → pure P-only (byte-identical to bframes=0).
970            let mut pcfg = self.cfg.clone();
971            pcfg.bframes = 0;
972            return Encoder::new(pcfg)?.encode_all(frames);
973        }
974        // Rate control threads state across frames → it must stay sequential. mb-tree
975        // runs in RC mode too: per-GOP lookahead → per-MB offsets (per-GOP centered, so
976        // rate-neutral per GOP), and the controller supplies each frame's base QP.
977        // (MEASURED: routing the cross-frame allocation through the RC's complexity
978        // instead of centering was worse — the centered offsets carry it correctly.)
979        if self.cfg.bitrate > 0 {
980            let mut enc = Encoder::new(self.cfg.clone())?;
981            // Same segmentation as the CQP path (scene-cut IDRs under the
982            // keyint ceiling), mb-tree windowed to `lookahead` within each
983            // segment; segment starts force the IDR through the controller's
984            // sequential encoder.
985            let seg_starts = lookahead::segment_gops(&self.cfg, frames);
986            let offs: Vec<Vec<i32>> = if self.cfg.mbtree {
987                seg_starts
988                    .iter()
989                    .enumerate()
990                    .flat_map(|(k, &s)| {
991                        let end = seg_starts.get(k + 1).copied().unwrap_or(frames.len());
992                        frames[s..end].chunks(self.cfg.lookahead.max(1) as usize)
993                    })
994                    .flat_map(|w| mbtree::gop_qp_offsets(&self.cfg, w, self.cfg.mbtree_strength))
995                    .collect()
996            } else {
997                Vec::new()
998            };
999            let mut next_seg = 1usize; // seg_starts[0] == 0 is the natural first IDR
1000            return frames
1001                .iter()
1002                .enumerate()
1003                .map(|(i, f)| {
1004                    if seg_starts.get(next_seg) == Some(&i) {
1005                        enc.force_idr = true;
1006                        next_seg += 1;
1007                    }
1008                    if let Some(qpo) = offs.get(i) {
1009                        enc.pending_qpo = Some(qpo.clone());
1010                    }
1011                    // Bypass the streaming lookahead buffer: this path supplies the
1012                    // offsets itself, so buffering here would double-compute them.
1013                    enc.encode_direct(f)
1014                })
1015                .collect();
1016        }
1017        // Variable GOP segmentation (x264 keyint model): scene-cut driven IDRs
1018        // under the `gop_size` ceiling. With `scenecut = 0` this IS
1019        // `chunks(gop_size)` — byte-identical to the fixed-cadence encoder.
1020        let seg_starts = lookahead::segment_gops(&self.cfg, frames);
1021        let gops: Vec<&[YuvFrame]> = seg_starts
1022            .iter()
1023            .enumerate()
1024            .map(|(k, &s)| &frames[s..seg_starts.get(k + 1).copied().unwrap_or(frames.len())])
1025            .collect();
1026        if gops.is_empty() || frames.is_empty() {
1027            return Ok(Vec::new());
1028        }
1029        let n = std::env::var("RUSTY_THREADS")
1030            .ok()
1031            .and_then(|v| v.parse().ok())
1032            .or_else(|| std::thread::available_parallelism().map(|n| n.get()).ok())
1033            .unwrap_or(1)
1034            .min(gops.len());
1035        // Each GOP is encoded with a fresh encoder (an IDR resets all state), so
1036        // GOPs distribute across `n` worker threads with no shared mutable state.
1037        let mut out: Vec<Option<Vec<Vec<u8>>>> = (0..gops.len()).map(|_| None).collect();
1038        let cfg = &self.cfg;
1039        let gops_ref = &gops;
1040        std::thread::scope(|s| {
1041            let handles: Vec<_> = (0..n)
1042                .map(|t| {
1043                    s.spawn(move || {
1044                        let mut local = Vec::new();
1045                        let mut i = t;
1046                        while i < gops_ref.len() {
1047                            let mut enc = Encoder::new(cfg.clone()).expect("config");
1048                            // mb-tree temporal AQ: a per-GOP lookahead over the GOP's
1049                            // source frames yields per-frame per-MB QP offsets (the GOP
1050                            // is the natural window — the IDR resets references). Off →
1051                            // empty → byte-identical.
1052                            // mb-tree windows of ≤ `lookahead` frames within
1053                            // the segment (a 250-frame scenecut GOP must not
1054                            // be one propagation window); aligned to the
1055                            // segment start, exactly like the streaming path.
1056                            let offs: Vec<Vec<i32>> = if cfg.mbtree {
1057                                gops_ref[i]
1058                                    .chunks(cfg.lookahead.max(1) as usize)
1059                                    .flat_map(|w| mbtree::gop_qp_offsets(cfg, w, cfg.mbtree_strength))
1060                                    .collect()
1061                            } else {
1062                                Vec::new()
1063                            };
1064                            // The GOP's IDR probes grain against the PREVIOUS GOP's
1065                            // last source frame — `gops_ref` is the full shared
1066                            // slice, so this is identical under any thread count,
1067                            // and it is exactly the frame `encode_direct`'s
1068                            // self-fill would have retained in a sequential run
1069                            // (the documented streaming==batch invariant). The
1070                            // stream's FIRST IDR fails open on every path — pure
1071                            // streaming cannot see frame 1 at frame 0.
1072                            if i > 0 {
1073                                if let Some(pf) = gops_ref[i - 1].last() {
1074                                    enc.set_aq_probe(pf.clone());
1075                                }
1076                            }
1077                            let aus: Vec<Vec<u8>> = gops_ref[i]
1078                                .iter()
1079                                .enumerate()
1080                                .map(|(fi, f)| {
1081                                    if let Some(o) = offs.get(fi) {
1082                                        enc.set_pending_qpo(o.clone());
1083                                    }
1084                                    enc.encode_direct(f).expect("frame matched config")
1085                                })
1086                                .collect();
1087                            local.push((i, aus));
1088                            i += n;
1089                        }
1090                        local
1091                    })
1092                })
1093                .collect();
1094            for h in handles {
1095                for (i, aus) in h.join().expect("encode worker panicked") {
1096                    out[i] = Some(aus);
1097                }
1098            }
1099        });
1100        Ok(out.into_iter().flatten().flatten().collect())
1101    }
1102
1103    /// B-frame reorder pipeline (sequential). Produces access units in **coding
1104    /// order** (the decoder reorders to display order by POC). Structure: an IDR
1105    /// at each `gop_size` boundary, a P anchor every `bframes+1` frames within a
1106    /// GOP, `bframes` non-reference B-frames between consecutive anchors, and the
1107    /// last frame forced to an anchor so trailing B's always have a future
1108    /// reference. Each anchor is coded before the B's that reference it.
1109    /// `gop_favorable[g]` (content-adaptive): GOP `g` codes B-frames only when
1110    /// `true`; a `false` GOP is coded all-P (every frame an anchor) so busy segments
1111    /// of a mixed clip don't regress. Non-adaptive callers pass all-`true`.
1112    fn encode_all_bframes(&self, frames: &[YuvFrame], bcount: usize, seg_starts: &[usize], gop_favorable: &[bool], gop_iqp: &[i32], gop_bqp: &[i32]) -> Vec<Vec<u8>> {
1113        let n = frames.len();
1114        if n == 0 {
1115            return Vec::new();
1116        }
1117        let step = bcount.max(1) + 1; // B's per anchor gap + 1 (adaptive in `auto`)
1118        // Per-display-index segment map (variable GOPs under scenecut; with
1119        // scenecut=0 the segments are the old fixed `gop_size` chunks and every
1120        // derived quantity below reproduces the old `% gop` arithmetic).
1121        let mut seg_of = vec![0usize; n];
1122        let mut seg_start_of = vec![0usize; n];
1123        {
1124            let mut k = 0usize;
1125            for d in 0..n {
1126                if k + 1 < seg_starts.len() && seg_starts[k + 1] == d {
1127                    k += 1;
1128                }
1129                seg_of[d] = k;
1130                seg_start_of[d] = seg_starts[k];
1131            }
1132        }
1133        // A B-capable config: Main profile + ≥2 refs so the DPB holds both anchors.
1134        let mut cfg = self.cfg.clone();
1135        cfg.num_ref_frames = cfg.num_ref_frames.max(if cfg.b_pyramid { 3 } else { 2 }); // pyramid: 2 anchors + 1 B-ref must fit the DPB
1136        let sps = Sps::from_config(&cfg);
1137        let pps = Pps::from_config(&cfg);
1138
1139        // Anchor display-indices: IDR at GOP starts, P anchors every `step`, plus
1140        // the frame right before each IDR boundary and the clip's last frame — a
1141        // trailing B with no future reference IN ITS OWN GOP would otherwise be
1142        // coded after the next GOP's IDR (which clears the DPB), losing its anchors.
1143        let mut is_anchor = vec![false; n];
1144        // Segment-OUTER derivation: the favorability lookup, segment start and
1145        // next-boundary test hoist to once per SEGMENT (each was a bounds-
1146        // checked lookup per frame), and `off % step` — an integer divide per
1147        // frame, `step` being runtime-valued — becomes a rolling phase counter
1148        // (`phase == 0` exactly when `(d - seg_start) % step == 0`). A
1149        // non-favorable GOP is coded all-P (every frame an anchor); a
1150        // favorable one uses the B structure.
1151        for (k, &s) in seg_starts.iter().enumerate() {
1152            let e = seg_starts.get(k + 1).copied().unwrap_or(n).min(n);
1153            if !gop_favorable.get(k).copied().unwrap_or(true) {
1154                is_anchor[s..e].fill(true);
1155                continue;
1156            }
1157            // `d + 1 == next segment start` marked the frame right before each
1158            // IDR boundary; the clip's own end is handled by the line below.
1159            let boundary = k + 1 < seg_starts.len();
1160            let mut phase = 0usize;
1161            for d in s..e {
1162                is_anchor[d] = phase == 0 || (boundary && d + 1 == e);
1163                phase += 1;
1164                if phase == step {
1165                    phase = 0;
1166                }
1167            }
1168        }
1169        is_anchor[n - 1] = true;
1170        // bframes-v2: PER-GAP favorability (adaptive mode only). Each anchor
1171        // gap is priced by its OWN bi-prediction residual; an unfavorable gap
1172        // codes all-P while its neighbours keep their B's — episodic content
1173        // (crew's camera flashes, one busy passage of a calm clip) dispatches
1174        // at the scale the phenomenon actually has. Fixed `--bframes N` keeps
1175        // x264's flat structure.
1176        if self.cfg.bframes_adaptive {
1177            let (w, h) = (self.cfg.width, self.cfg.height);
1178            // Frame means, MEMOIZED across pairs and gaps: consecutive pairs
1179            // share a frame and adjacent gaps share their anchor, so the eager
1180            // per-pair closure computed every interior mean twice (and anchor
1181            // means once per adjoining gap). NaN = not yet computed; the
1182            // sample count is `ceil(len/64)` — exactly what the counting loop
1183            // produced. Same sums, same divide: BIT-IDENTICAL, and the
1184            // short-circuiting `any` still computes no mean it never reads.
1185            fn memo_mean(frames: &[YuvFrame], means: &mut [f64], x: usize) -> f64 {
1186                if means[x].is_nan() {
1187                    let f = &frames[x];
1188                    let mut s = 0u64;
1189                    let mut i = 0;
1190                    while i < f.y.len() {
1191                        s += f.y[i] as u64;
1192                        i += 64;
1193                    }
1194                    let c = f.y.len().div_ceil(64) as u64;
1195                    means[x] = s as f64 / c.max(1) as f64;
1196                }
1197                means[x]
1198            }
1199            let mut means = vec![f64::NAN; n];
1200            let mut a = 0usize;
1201            while a + 1 < n {
1202                if !is_anchor[a] {
1203                    a += 1;
1204                    continue;
1205                }
1206                // The gap = frames (a, next_anchor); slice inclusive of both ends.
1207                let mut b = a + 1;
1208                while b < n && !is_anchor[b] {
1209                    b += 1;
1210                }
1211                if b > a + 1 && b < n {
1212                    // FLASH VETO, per pair: a camera flash is a global DC jump
1213                    // between adjacent frames — B-averaging across it blends
1214                    // two exposures (crew's +5.72% at fixed B; the bi-residual
1215                    // alone let its calmer flash gaps through at +3.34%). A
1216                    // subsampled mean-luma delta is ~free and pair-precise in
1217                    // a way no clip-level scalar was. Gradual fades move ~1
1218                    // level per frame and stay far under the threshold.
1219                    let flash = (a..b).any(|x| {
1220                        (memo_mean(frames, &mut means, x) - memo_mean(frames, &mut means, x + 1))
1221                            .abs()
1222                            > 2.5
1223                    });
1224                    let res = gop_bi_residual(&frames[a..=b], w, h, 1);
1225                    if flash || !bframes_favorable(res) {
1226                        for x in a + 1..b {
1227                            is_anchor[x] = true;
1228                        }
1229                    }
1230                }
1231                a = b;
1232            }
1233        }
1234
1235        // mb-tree temporal AQ over the ANCHOR reference chain: B-frames are
1236        // non-reference leaves (mb-tree offsets them at ~0 anyway), so the lookahead
1237        // runs over each GOP's anchor sub-sequence — the frames that actually form the
1238        // reference chain — and only anchors receive an offset. `mbtree_off[d]` is that
1239        // anchor's per-MB offset (empty for B's / when off → byte-identical).
1240        let mbtree_off: Vec<Vec<i32>> = if cfg.mbtree {
1241            let mut off = vec![Vec::new(); n];
1242            for (k, &s) in seg_starts.iter().enumerate() {
1243                let seg_end = seg_starts.get(k + 1).copied().unwrap_or(n).min(n);
1244                let anchors: Vec<usize> = (s..seg_end).filter(|&d| is_anchor[d]).collect();
1245                // Anchor chains windowed to `lookahead` (a 250-frame scenecut
1246                // segment's chain must not be one propagation window).
1247                for aw in anchors.chunks(cfg.lookahead.max(1) as usize) {
1248                    // Borrowed window (gop_qp_offsets_refs): no per-anchor frame
1249                    // deep-clones. `offs` is owned — MOVE the rows into place
1250                    // instead of cloning a per-MB Vec per anchor.
1251                    let aframes: Vec<&YuvFrame> = aw.iter().map(|&d| &frames[d]).collect();
1252                    let offs = mbtree::gop_qp_offsets_refs(&cfg, &aframes, cfg.mbtree_strength);
1253                    for (o, &d) in offs.into_iter().zip(aw.iter()) {
1254                        off[d] = o;
1255                    }
1256                }
1257            }
1258            off
1259        } else {
1260            Vec::new()
1261        };
1262
1263        // b-pyramid (x264 `normal` parity): in gaps carrying 2+ B's, the
1264        // display-middle B becomes a REFERENCE — coded right after its future
1265        // anchor, entered into the DPB, so the remaining leaves bracket
1266        // against it (the nearest-POC L0/L1 selection finds it naturally) at
1267        // half the prediction distance. CABAC-path v1.
1268        let pyramid = cfg.b_pyramid && cfg.cabac && step >= 3;
1269        let mut is_bref = vec![false; n];
1270        // Coding order: each anchor (display order), then — pyramid — the
1271        // gap's reference B, then the leaf B's.
1272        let mut order: Vec<usize> = Vec::with_capacity(n);
1273        let mut prev: Option<usize> = None;
1274        for d in 0..n {
1275            if !is_anchor[d] {
1276                continue;
1277            }
1278            order.push(d);
1279            if let Some(p) = prev {
1280                if pyramid && d - p > 2 {
1281                    let m = (p + d) / 2;
1282                    is_bref[m] = true;
1283                    order.push(m);
1284                    order.extend(((p + 1)..d).filter(|&x| x != m));
1285                } else {
1286                    order.extend((p + 1)..d);
1287                }
1288            }
1289            prev = Some(d);
1290        }
1291
1292        let mut dpb: Vec<RefFrame> = Vec::new();
1293        let mut aus: Vec<Vec<u8>> = Vec::with_capacity(n);
1294        let mut frame_num: u32 = 0;
1295        for &d in &order {
1296            let is_idr = d == seg_start_of[d];
1297            if is_idr {
1298                dpb.clear();
1299                frame_num = 0;
1300            }
1301            let is_b = !is_anchor[d];
1302            let bref = is_b && is_bref[d];
1303            let poc = ((d - seg_start_of[d]) as i32) * 2; // POC = display position within the GOP
1304            let iqp = gop_iqp.get(seg_of[d]).copied().unwrap_or(cfg.i_qp_offset);
1305            let bqp_leaf = gop_bqp.get(seg_of[d]).copied().unwrap_or(cfg.bframe_qp_offset);
1306            // A REFERENCE B must not take the full "quantize harder, nothing
1307            // depends on it" leaf offset — leaves predict FROM it. Half, like
1308            // x264's pyramid B-ref QP sitting between P and leaf-B.
1309            let bqp = if bref { (bqp_leaf + 1) / 2 } else { bqp_leaf };
1310            let qpo: &[i32] = mbtree_off.get(d).map(|v| v.as_slice()).unwrap_or(&[]);
1311            // AQ grain probe for IDRs: an IDR has no coding reference, but the AQ
1312            // grain veto needs a temporal signal, and the PREVIOUS SOURCE frame is
1313            // an even better probe than a reconstruction (no quantization in the
1314            // loop). The first frame of the stream has none — the veto fails open.
1315            let aq_probe = if is_idr && d > 0 { frames.get(d - 1) } else { None };
1316            let (au, recon) =
1317                code_picture(&cfg, &sps, &pps, &frames[d], is_idr, is_b, bref, poc, frame_num, &dpb, iqp, bqp, qpo, aq_probe);
1318            aus.push(au);
1319            if !is_b {
1320                if let Some(r) = recon {
1321                    dpb.insert(0, r);
1322                    dpb.truncate(cfg.num_ref_frames as usize);
1323                }
1324                frame_num = (frame_num + 1) % 16;
1325            } else if let Some(r) = recon {
1326                // Reference B: into the DPB, frame_num advances (reference
1327                // pictures only) — mirroring the decoder's sliding window.
1328                dpb.insert(0, r);
1329                dpb.truncate(cfg.num_ref_frames as usize);
1330                frame_num = (frame_num + 1) % 16;
1331            }
1332        }
1333        aus
1334    }
1335}
1336
1337/// Codes ONE picture (IDR / P anchor / B) with explicit POC + frame_num + DPB.
1338/// Returns the access unit and, for reference pictures, the reconstruction to add
1339/// to the DPB (B-frames are non-reference → `None`). `dpb` is most-recent-first.
1340#[allow(clippy::too_many_arguments)]
1341fn code_picture(
1342    cfg: &EncoderConfig,
1343    sps: &Sps,
1344    pps: &Pps,
1345    frame: &YuvFrame,
1346    is_idr: bool,
1347    is_b: bool,
1348    b_is_ref: bool,
1349    poc: i32,
1350    frame_num: u32,
1351    dpb: &[RefFrame],
1352    i_qp_offset: i32,
1353    b_qp_offset: i32,
1354    qpo: &[i32],
1355    aq_probe: Option<&YuvFrame>,
1356) -> (Vec<u8>, Option<RefFrame>) {
1357    let mut out = Vec::new();
1358    let mut w = BitWriter::with_capacity(cfg.width * cfg.height / 2 + 4096);
1359    let poc_lsb = (poc as u32) & 0xFF; // log2_max_pic_order_cnt_lsb = 8
1360    // Per-GOP QP cascade, both offsets content-adaptive: B-frames are non-reference →
1361    // quantize HARDER (`b_qp_offset`, deeper on very predictable GOPs); the GOP's
1362    // I-frame is the root reference → quantize FINER (`i_qp_offset`, deeper on
1363    // predictable GOPs where the I dominates the bits).
1364    let qp = if is_b {
1365        (cfg.qp as i32 + b_qp_offset).clamp(0, 51) as u8
1366    } else if is_idr {
1367        (cfg.qp as i32 + i_qp_offset).clamp(0, 51) as u8
1368    } else {
1369        cfg.qp
1370    };
1371    let (nal_type, nal_ref_idc, recon) = if is_idr {
1372        sps.to_nal().write_annex_b(&mut out);
1373        pps.to_nal().write_annex_b(&mut out);
1374        slice::write_idr_slice_header(&mut w, cfg, qp);
1375        let mut r = if cfg.cabac {
1376            mb16::encode_slice_data_cabac_intra(&mut w, cfg, frame, qp, qpo, aq_probe)
1377        } else {
1378            mb16::encode_slice_data(&mut w, cfg, frame, qp, false, &[], qpo, aq_probe, &[])
1379        };
1380        r.poc = poc;
1381        r.frame_num = frame_num;
1382        (NalUnitType::IdrSlice, 3u8, Some(r))
1383    } else if is_b {
1384        // B is non-reference. We signal one active reference per list: L0[0] =
1385        // nearest PAST anchor (highest poc < current), L1[0] = nearest FUTURE anchor
1386        // (lowest poc > current) — the heads of the decoder's POC-ordered B lists.
1387        let l0 = dpb.iter().filter(|r| r.poc < poc).max_by_key(|r| r.poc);
1388        let l1 = dpb.iter().filter(|r| r.poc > poc).min_by_key(|r| r.poc);
1389        // b-pyramid: a reference B is CABAC-path v1 (the default config); the
1390        // CAVLC B coder stays leaf-only and `b_is_ref` is never set for it.
1391        let as_ref = b_is_ref && cfg.cabac && l0.is_some() && l1.is_some();
1392        slice::write_b_slice_header(&mut w, cfg, qp, frame_num, poc_lsb, 1, 1, as_ref);
1393        let mut b_recon: Option<RefFrame> = None;
1394        match (l0, l1) {
1395            // Leaf B's are non-reference — mb-tree offsets them at 0 anyway, so
1396            // `qpo` is `&[]` here (the anchor reference chain carries the temporal AQ).
1397            (Some(l0), Some(l1)) if cfg.cabac => {
1398                b_recon = mb16::encode_slice_data_cabac_b(&mut w, cfg, frame, qp, poc, l0, l1, &[], as_ref);
1399            }
1400            (Some(l0), Some(l1)) => {
1401                mb16::encode_slice_data_b(&mut w, cfg, frame, qp, poc, l0, l1, &[]);
1402            }
1403            // A B with no bracketing anchor pair can't be List-0/1 coded; fall back
1404            // to an all-B_Skip slice (spatial-direct) so the stream stays legal.
1405            _ => {
1406                let n = cfg.mb_width() * cfg.mb_height();
1407                if cfg.cabac {
1408                    mb16::encode_all_skip_b_cabac(&mut w, cfg, qp, n);
1409                } else {
1410                    w.write_ue(n as u32);
1411                    w.rbsp_trailing_bits();
1412                }
1413            }
1414        }
1415        if as_ref {
1416            if let Some(r) = &mut b_recon {
1417                r.poc = poc;
1418                r.frame_num = frame_num;
1419            }
1420            (NalUnitType::NonIdrSlice, 2u8, b_recon)
1421        } else {
1422            (NalUnitType::NonIdrSlice, 0u8, None)
1423        }
1424    } else {
1425        // P anchor: L0 = the DPB (past anchors), ordered most-recent-first. Both CAVLC
1426        // and CABAC now code ref_idx_l0 (cb_ref_idx / parse_ref_idx_cabac), so a P slice
1427        // searches + signals the full DPB (`--refs N`) under either entropy coder.
1428        let p_dpb: &[RefFrame] = dpb;
1429        let wp: Vec<(i32, i32)> = if cfg.weightp {
1430            estimate_luma_weights(cfg, frame, p_dpb)
1431        } else {
1432            Vec::new()
1433        };
1434        let wp_hdr = if cfg.weightp { Some(wp.as_slice()) } else { None };
1435        slice::write_p_slice_header(&mut w, cfg, qp, frame_num, poc_lsb, p_dpb.len(), wp_hdr);
1436        let mut r = if cfg.cabac {
1437            mb16::encode_slice_data_cabac_p(&mut w, cfg, frame, qp, p_dpb, qpo, &wp)
1438        } else {
1439            mb16::encode_slice_data(&mut w, cfg, frame, qp, true, dpb, qpo, None, &wp)
1440        };
1441        r.poc = poc;
1442        r.frame_num = frame_num;
1443        (NalUnitType::NonIdrSlice, 3u8, Some(r))
1444    };
1445    let slice_bytes = w.into_bytes();
1446    NalUnit::new(nal_ref_idc, nal_type, slice_bytes).write_annex_b(&mut out);
1447    (out, recon)
1448}
1449
1450/// The B-favorability threshold on the per-GOP signal (`gop_bi_residual`): below it,
1451/// motion is predictable enough that B-frames pay AND the I-frame dominates the GOP's
1452/// bits (so it wants a deeper QP cascade); above it the GOP is busy.
1453const BI_THRESH: f64 = 4.0;
1454
1455/// Whether a GOP's temporal residual makes B-frames pay (predictable motion).
1456/// B-favorability threshold on the bi-prediction residual, evaluated PER
1457/// ANCHOR GAP (bframes-v2). Refit 2026-08-26 from the 12-clip BD truth table:
1458/// the original 4.0 (shared with the QP-cascade ramps, which keep it) captured
1459/// only the near-static winners and left five pan/texture/noise clips worth
1460/// -11..-17% each on the table (mobile 7.98, shields 8.07, grain 7.61, city
1461/// 5.87, tempete 4.69 all WIN at fixed B); the documented fastmotion losers
1462/// sit above (football 15.1, park_joy 9.1, crowd_run 8.3). 8.2 splits them.
1463/// The per-GAP unit (not per clip) is what handles crew: its flash gaps read
1464/// unfavorable and code P while its calm gaps take the B win — the clip-level
1465/// scalars could not separate crew from tempete (0.006 apart in dcfrac, a
1466/// margin that is fitting noise, refused). Holdout-gated on six unseen clips.
1467const B_GAP_THRESH: f64 = 8.2;
1468
1469fn bframes_favorable(residual: f64) -> bool {
1470    residual < B_GAP_THRESH
1471}
1472
1473/// Content-adaptive per-GOP I-frame QP offset (the ip_ratio cascade, DISPATCHED by
1474/// content). `base` is the busy-GOP offset (`cfg.i_qp_offset`, default −3); a
1475/// predictable GOP — where the I-frame is a large fraction of the GOP's bits, so
1476/// investing in it pays outsized — gets up to 2 QP steps FINER, ramping from `base`
1477/// at the threshold to `base−2` at residual 0. Calibrated: busy ≈ −3, compressible
1478/// ≈ −5 (−11.6% vs −7.3% at −3). `base == 0` (the opt-out) disables it entirely so
1479/// the byte-identical escape hatch survives.
1480fn gop_iqp_offset(residual: f64, base: i32) -> i32 {
1481    if base == 0 {
1482        return 0;
1483    }
1484    let bonus = (2.0 * ((BI_THRESH - residual) / BI_THRESH).clamp(0.0, 1.0)).round() as i32;
1485    base - bonus
1486}
1487
1488/// Content-adaptive per-GOP B-frame QP offset. B-frames are non-reference, so on a
1489/// VERY predictable GOP (bi-pred + spatial-direct nail them → tiny residual) they can
1490/// be quantized much HARDER for near-free bits. But the optimum is KNIFE-EDGE in the
1491/// signal — measured ~+8 at residual 0.10 yet ~+2 by residual 0.29 (and a heavy LOSS
1492/// at +12 there) — so unlike the I-cascade this ramp is STEEP and confined to the
1493/// near-perfect-motion regime: `base` (default +2) everywhere, boosted up to +4 only
1494/// as residual → 0 (decaying to `base` by ~0.3/px). Deliberately conservative — it
1495/// helps near-static / clean-pan content and must never touch the common range.
1496fn gop_bframe_qp_offset(residual: f64, base: i32) -> i32 {
1497    const RAMP: f64 = 0.3; // residual above this gets no boost (steep — see calibration)
1498    let boost = (4.0 * ((RAMP - residual) / RAMP).clamp(0.0, 1.0)).round() as i32;
1499    base + boost
1500}
1501
1502/// Adaptive B-COUNT (B-frames per anchor gap) for `auto` mode. The RATIO of the
1503/// 2-gap to 1-gap bi-prediction residual measures how fast bi-pred degrades as the
1504/// anchor spacing widens: LOW ratio (content survives wider gaps) carries MORE cheap
1505/// non-reference B's; HIGH ratio (simple translation — degrades fast, so wider anchors
1506/// cost more than the extra B's save) wants a single equidistant B. Calibrated on
1507/// pans/zoom: ratio ≥ 1.8 → 1, ≥ 1.4 → 2, else 3. Capped at `max_b` (the `auto` cap).
1508/// TEMPORAL PREDICTABILITY probe (Great Gate P3 item 4). Returns the
1509/// `2-gap / 1-gap` motion-compensated residual ratio for a frame window --
1510/// the axis the mb-tree dispatch has been waiting on.
1511///
1512/// Why THIS signal for mb-tree specifically: mb-tree lowers QP on blocks whose
1513/// quality PROPAGATES to the frames that reference them. That model assumes the
1514/// referenced pixels are still there N frames later. On a pan they translate out
1515/// of frame, so propagation decays and the lookahead over-credits the block. The
1516/// ratio measures exactly that decay -- how much worse prediction gets when the
1517/// reference gap doubles -- where `lv_spread`/`flat_run` (the REFUSED candidate)
1518/// are spatial statistics that merely correlate with panning on this corpus.
1519/// `f64::INFINITY` when the window is too short to measure.
1520pub fn temporal_decay_ratio(frames: &[YuvFrame], w: usize, h: usize) -> f64 {
1521    let g1 = gop_bi_residual(frames, w, h, 1);
1522    let g2 = gop_bi_residual(frames, w, h, 2);
1523    if !g1.is_finite() || !g2.is_finite() {
1524        return f64::INFINITY;
1525    }
1526    g2 / g1.max(1e-3)
1527}
1528
1529fn adaptive_bcount(frames: &[YuvFrame], w: usize, h: usize, max_b: usize) -> usize {
1530    let cap = max_b.clamp(1, 3);
1531    let g1 = gop_bi_residual(frames, w, h, 1);
1532    let g2 = gop_bi_residual(frames, w, h, 2);
1533    if !g1.is_finite() || !g2.is_finite() {
1534        return 1;
1535    }
1536    let ratio = g2 / g1.max(1e-3);
1537    // Calibrated on this encoder's (subsampled global-ME) ratios: a simple
1538    // translation degrades to ~1.5 (→ 1 B), predictable-under-wide-gaps content sits
1539    // ~1.3 or below (→ 3 B).
1540    let c = if ratio >= 1.4 { 1 } else if ratio >= 1.3 { 2 } else { 3 };
1541    c.clamp(1, cap)
1542}
1543
1544/// Cheap content signal for the content-adaptive dispatch: the mean per-pixel
1545/// residual of a coarse GLOBAL-motion BI-prediction, over a subsample of interior
1546/// frames. Low = temporally predictable (bi-pred + spatial-direct cheap → B-frames
1547/// WIN, and the I-frame dominates → deeper QP cascade); high = busy motion.
1548/// `f64::INFINITY` when the GOP is too short to measure (treated as busy).
1549///
1550/// Global (not block) ME keeps it O(pixels)-cheap and biases toward "coherent
1551/// motion", which is what spatial-direct/skip exploit. Thresholds calibrated on
1552/// extremes (pan ~0.03/px, high-motion ~12.3/px); refine on a corpus.
1553fn gop_bi_residual(frames: &[YuvFrame], w: usize, h: usize, gap: usize) -> f64 {
1554    let n = frames.len();
1555    if n < 2 * gap + 1 || w < 48 || h < 48 {
1556        return f64::INFINITY;
1557    }
1558    // Subsampled SAD of `cur` vs `rf` shifted by (dx,dy): interior pixels only
1559    // (|shift| ≤ 15 stays in-bounds, no clamping), every 4th pixel for speed.
1560    let sad = |cur: &[u8], rf: &[u8], dx: isize, dy: isize| -> u64 {
1561        let mut s = 0u64;
1562        let mut y = 16;
1563        while y < h - 16 {
1564            let cbase = (y * w) as isize;
1565            let rbase = ((y as isize + dy) * w as isize) + dx;
1566            let mut x = 16isize;
1567            while x < (w - 16) as isize {
1568                let c = cur[(cbase + x) as usize] as i32;
1569                let r = rf[(rbase + x) as usize] as i32;
1570                s += (c - r).unsigned_abs() as u64;
1571                x += 8;
1572            }
1573            y += 8;
1574        }
1575        s
1576    };
1577    // Coarse global ME: ±12 step 4, then refine ±3 step 1.
1578    let global_me = |cur: &[u8], rf: &[u8]| -> (isize, isize) {
1579        let (mut best, mut bc) = ((0isize, 0isize), u64::MAX);
1580        let mut dy = -12;
1581        while dy <= 12 {
1582            let mut dx = -12;
1583            while dx <= 12 {
1584                let c = sad(cur, rf, dx, dy);
1585                if c < bc {
1586                    bc = c;
1587                    best = (dx, dy);
1588                }
1589                dx += 4;
1590            }
1591            dy += 4;
1592        }
1593        // The refine window re-evaluates its own CENTRE — the coarse best,
1594        // whose cost `bc` already carries. `c < bc` is strict and `bc` only
1595        // decreases, so that re-visit can never win: skipping it is
1596        // decision-identical and saves one full subsampled-SAD call per
1597        // `global_me`. The range expressions stay on `best` (NOT hoisted):
1598        // the inner range re-reads the current `best.0` per row exactly as it
1599        // always did, so the search visits the same points minus the centre.
1600        let centre = best;
1601        for dy in best.1 - 3..=best.1 + 3 {
1602            for dx in best.0 - 3..=best.0 + 3 {
1603                if (dx, dy) == centre {
1604                    continue;
1605                }
1606                let c = sad(cur, rf, dx, dy);
1607                if c < bc {
1608                    bc = c;
1609                    best = (dx, dy);
1610                }
1611            }
1612        }
1613        best
1614    };
1615    let mut n_samp = 0usize;
1616    {
1617        let mut y = 16;
1618        while y < h - 16 {
1619            let mut x = 16;
1620            while x < w - 16 {
1621                n_samp += 1;
1622                x += 8;
1623            }
1624            y += 8;
1625        }
1626    }
1627    let step = (n / 5).max(1);
1628    let (mut total, mut cnt) = (0f64, 0usize);
1629    // `gap` frames each side (1 = adjacent, for the B/P dispatch; 2 probes how well
1630    // bi-prediction survives WIDER anchor spacing, for the adaptive B-count).
1631    let mut d = gap;
1632    while d < n - gap {
1633        let (cur, past, fut) = (&frames[d].y, &frames[d - gap].y, &frames[d + gap].y);
1634        let (mpx, mpy) = global_me(cur, past);
1635        let (mfx, mfy) = global_me(cur, fut);
1636        let mut bi = 0u64;
1637        let mut y = 16;
1638        while y < h - 16 {
1639            let mut x = 16isize;
1640            while x < (w - 16) as isize {
1641                let c = cur[y * w + x as usize] as i32;
1642                let p = past[((y as isize + mpy) * w as isize + x + mpx) as usize] as i32;
1643                let f = fut[((y as isize + mfy) * w as isize + x + mfx) as usize] as i32;
1644                bi += (c - ((p + f + 1) >> 1)).unsigned_abs() as u64;
1645                x += 8;
1646            }
1647            y += 8;
1648        }
1649        total += bi as f64 / n_samp as f64;
1650        cnt += 1;
1651        d += step;
1652    }
1653    if cnt > 0 {
1654        total / cnt as f64
1655    } else {
1656        f64::INFINITY
1657    }
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use super::*;
1663
1664    #[test]
1665    fn rejects_unsupported_config() {
1666        // R6 made High + 8x8 + CABAC a SUPPORTED combination (transform_size_8x8_flag
1667        // at both syntax positions + the ctxBlockCat-5 residual writer), verified
1668        // pixel-identical against ffmpeg. This test used to assert it was rejected;
1669        // asserting it is ACCEPTED is what keeps the capability from silently
1670        // regressing behind a re-added guard.
1671        let mut cfg = EncoderConfig::new(16, 16);
1672        cfg.profile = Profile::High;
1673        cfg.transform_8x8 = true;
1674        cfg.cabac = true;
1675        assert!(Encoder::new(cfg).is_ok(), "High + 8x8 + CABAC must be accepted");
1676        // Narrowing the profile CLAMPS the 8x8 transform rather than failing: it is
1677        // default-on, and `EncoderConfig::new()` + `profile = Main` must stay a valid
1678        // pair. Assert the encoder builds AND that the PPS does not advertise a tool
1679        // Main cannot carry.
1680        let mut cfg = EncoderConfig::new(16, 16);
1681        cfg.profile = Profile::Main;
1682        cfg.transform_8x8 = true;
1683        let enc = Encoder::new(cfg).expect("Main + 8x8 must clamp, not fail");
1684        assert!(!enc.cfg.transform_8x8, "8x8 must be cleared when the profile cannot signal it");
1685    }
1686
1687    #[test]
1688    fn encodes_access_unit_with_sps_pps_idr() {
1689        use rusty_h264_common::nal::split_annex_b;
1690        let cfg = EncoderConfig::new(32, 32);
1691        let mut enc = Encoder::new(cfg).unwrap();
1692        let frame = YuvFrame::black(32, 32);
1693        // mb-tree defaults ON: the streaming path buffers one GOP, so a single
1694        // frame's access unit arrives on `flush` (same shape the fuzz seeds use).
1695        let mut au = enc.encode(&frame);
1696        au.extend(enc.flush());
1697
1698        let nals = split_annex_b(&au);
1699        assert_eq!(nals.len(), 3);
1700        assert_eq!(NalUnitType::from_id(nals[0][0]), NalUnitType::Sps);
1701        assert_eq!(NalUnitType::from_id(nals[1][0]), NalUnitType::Pps);
1702        assert_eq!(NalUnitType::from_id(nals[2][0]), NalUnitType::IdrSlice);
1703    }
1704
1705    #[test]
1706    fn encode_all_matches_sequential_cqp() {
1707        // GOP-parallel batch encoding must be byte-identical to frame-by-frame
1708        // sequential encoding at constant QP (GOPs are independent).
1709        let (w, h) = (48usize, 32usize);
1710        let mut cfg = EncoderConfig::new(w, h);
1711        cfg.gop_size = 4; // 10 frames → 3 GOPs (4,4,2)
1712        let frames: Vec<YuvFrame> = (0..10u8)
1713            .map(|t| YuvFrame {
1714                width: w,
1715                height: h,
1716                y: (0..w * h).map(|i| (i as u8).wrapping_add(t.wrapping_mul(7))).collect(),
1717                u: vec![128u8.wrapping_add(t); (w / 2) * (h / 2)],
1718                v: vec![128u8.wrapping_sub(t); (w / 2) * (h / 2)],
1719            })
1720            .collect();
1721        let mut seq_enc = Encoder::new(cfg.clone()).unwrap();
1722        let mut seq: Vec<u8> = frames.iter().flat_map(|f| seq_enc.encode(f)).collect();
1723        seq.extend_from_slice(&seq_enc.flush()); // end of stream (lookahead tail)
1724        let par: Vec<u8> = Encoder::new(cfg).unwrap().encode_all(&frames).unwrap().concat();
1725        assert_eq!(seq, par, "GOP-parallel must equal sequential+flush at CQP");
1726    }
1727
1728    #[test]
1729    fn encode_all_matches_sequential_quality_preset() {
1730        // Same invariant on the QUALITY preset, whose per-frame dispatch decisions
1731        // (b2_mgain SAD/mv-cost routing) once lived in a process-global and RACED
1732        // across GOP workers — divergence only appears with >1 GOP in flight, which
1733        // the single-GOP hash harness never exercised. Content varies per frame so
1734        // the per-frame routing decisions actually differ between GOPs.
1735        let (w, h) = (48usize, 32usize);
1736        let mut cfg = EncoderConfig::new(w, h);
1737        cfg.gop_size = 3; // 12 frames → 4 GOPs, several workers in flight
1738        cfg.preset = crate::config::Preset::Quality;
1739        let frames: Vec<YuvFrame> = (0..12u8)
1740            .map(|t| YuvFrame {
1741                width: w,
1742                height: h,
1743                y: (0..w * h)
1744                    .map(|i| {
1745                        // alternate calm and busy frames so the mgain probe flips
1746                        let base = (i as u8).wrapping_add(t.wrapping_mul(3));
1747                        if t % 2 == 0 { base } else { base.wrapping_mul(37).wrapping_add(i as u8) }
1748                    })
1749                    .collect(),
1750                u: vec![128u8.wrapping_add(t); (w / 2) * (h / 2)],
1751                v: vec![128u8.wrapping_sub(t); (w / 2) * (h / 2)],
1752            })
1753            .collect();
1754        let mut seq_enc = Encoder::new(cfg.clone()).unwrap();
1755        let mut seq: Vec<u8> = frames.iter().flat_map(|f| seq_enc.encode(f)).collect();
1756        seq.extend_from_slice(&seq_enc.flush());
1757        let par: Vec<u8> = Encoder::new(cfg).unwrap().encode_all(&frames).unwrap().concat();
1758        assert_eq!(seq, par, "quality-preset GOP-parallel must equal sequential+flush");
1759    }
1760
1761    #[test]
1762    fn rejects_mismatched_frame() {
1763        let cfg = EncoderConfig::new(16, 16);
1764        let mut enc = Encoder::new(cfg).unwrap();
1765        let frame = YuvFrame::black(32, 16);
1766        assert_eq!(enc.try_encode(&frame), Err(EncodeError::FrameMismatch));
1767    }
1768}