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//! let bitstream = enc.encode(&frame); // Annex-B bytes for one access unit
30//! assert!(!bitstream.is_empty());
31//! ```
32
33mod cabac;
34mod config;
35mod lookahead;
36mod mb16;
37mod mbtree;
38mod params;
39mod rc;
40mod slice;
41
42pub use crate::mb16::{EXT_MV, ME_PROBE, MVCMP, MVCMP_FRAME};
43pub use config::{EncoderConfig, LookaheadMode, Preset};
44pub use params::{Pps, Sps};
45pub use rc::RateControl;
46
47use rusty_h264_common::{BitWriter, ChromaFormat, NalUnit, NalUnitType, Profile, YuvFrame};
48
49/// Errors that can arise constructing or driving the encoder.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum EncodeError {
52 /// A feature outside the implemented Constrained Baseline subset was asked for.
53 Unsupported(&'static str),
54 /// The supplied frame's dimensions or plane sizes don't match the config.
55 FrameMismatch,
56}
57
58impl core::fmt::Display for EncodeError {
59 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
60 match self {
61 EncodeError::Unsupported(s) => write!(f, "unsupported: {s}"),
62 EncodeError::FrameMismatch => write!(f, "frame dimensions do not match encoder config"),
63 }
64 }
65}
66
67impl std::error::Error for EncodeError {}
68
69/// A Constrained Baseline H.264 encoder.
70#[derive(Debug)]
71pub struct Encoder {
72 cfg: EncoderConfig,
73 sps: Sps,
74 pps: Pps,
75 /// Count of frames fed so far; drives IDR placement via `gop_size`.
76 frame_index: u32,
77 /// `frame_num` of the next picture (resets to 0 at each IDR).
78 next_frame_num: u32,
79 /// Index of the current picture within its GOP (0 at IDR), for POC.
80 gop_index: u32,
81 /// Decoded-picture buffer: recent **deblocked** reconstructions (coded size),
82 /// most-recent first, used as inter references (`ref_idx` 0 = front).
83 refs: Vec<RefFrame>,
84 /// Average-bitrate controller; `None` for constant-QP encoding.
85 rc: Option<RateControl>,
86 /// Per-MB QP offset for the NEXT `encode()` (mb-tree temporal AQ). Set by the
87 /// batch path before each frame; consumed (and cleared) by `try_encode`. Empty /
88 /// `None` → no offset (byte-identical).
89 pending_qpo: Option<Vec<i32>>,
90}
91
92/// A reference picture: deblocked reconstruction at coded (MB-grid) resolution.
93/// Stored now (4a); read by motion compensation in 4b.
94#[derive(Clone, Debug)]
95#[allow(dead_code)]
96pub(crate) struct RefFrame {
97 // 16-byte aligned (moved from the encoder's aligned rec planes) so the openh264
98 // MC asm can load aligned reference row chunks.
99 pub y: rusty_h264_common::aligned::AlignedBytes,
100 pub u: rusty_h264_common::aligned::AlignedBytes,
101 pub v: rusty_h264_common::aligned::AlignedBytes,
102 /// Picture Order Count — the DISPLAY position. B ref-lists order L0/L1 by POC
103 /// relative to the current picture; P ignores it.
104 pub poc: i32,
105 /// The picture's `frame_num` (reference frames only advance it).
106 pub frame_num: u32,
107 /// Per-4×4-block List-0 motion (raster, `mb_w*4` wide). Populated for anchors;
108 /// read as the co-located picture (`RefPicList1[0]`) when deriving a B-frame's
109 /// spatial-direct `colZeroFlag`. `ref_idx == -1` marks intra/uncoded blocks.
110 pub mv: Vec<(i32, i32)>,
111 pub ref_idx: Vec<i32>,
112 /// Blocks-wide (`mb_w*4`), so the co-located index is `by*w4 + bx`.
113 pub w4: usize,
114 /// Cached half-pel luma planes, built on first sub-pel motion-search use.
115 ///
116 /// ENCODER-SIDE ONLY, and lazily: the motion search makes ~300 `mc_luma` calls
117 /// per macroblock while final reconstruction makes ~1, so this pays enormously
118 /// in the search and would be pure tax anywhere else. `Arc` so cloning a
119 /// `RefFrame` (the DPB does) does not copy three frame-sized planes.
120 pub hpel: std::sync::OnceLock<std::sync::Arc<rusty_h264_common::inter::HpelPlanes>>,
121}
122
123impl RefFrame {
124 /// The half-pel planes for this picture, filtering them once on first use.
125 pub(crate) fn hpel(&self, cw: usize, ch: usize) -> &rusty_h264_common::inter::HpelPlanes {
126 self.hpel.get_or_init(|| {
127 std::sync::Arc::new(rusty_h264_common::inter::build_hpel_planes(&self.y, cw, ch))
128 })
129 }
130}
131
132/// Sets the sub-pel refinement pattern (U1) for subsequent encodes in this process.
133/// 0 = 8-point ring + iterate, 1 = 4-point diamond + iterate, 2 = 8-point single
134/// pass, 3 = 4-point single pass. Exposed so the pattern can be A/B'd inside ONE
135/// binary, which is the only comparison this machine can resolve.
136/// Enables/disables the U1 online sub-pel dispatcher for subsequent encodes.
137/// Sets the λ-normalised partition-split search threshold (U2). 0 = off.
138/// Enables the U5-struct deferred sub-pel refinement (search all partition shapes at
139/// full-pel, refine only the winner). Bitstream-changing → BD-gated.
140/// Descent B: ME cost-path census [interior-fullpel, edge-fullpel, sub-pel].
141#[cfg(feature = "profile")]
142pub fn satdpath_snapshot() -> Vec<u64> { crate::mb16::satdpath::snapshot() }
143#[cfg(not(feature = "profile"))]
144pub fn satdpath_snapshot() -> Vec<u64> { Vec::new() }
145#[cfg(feature = "profile")]
146pub fn satdpath_reset() { crate::mb16::satdpath::reset() }
147#[cfg(not(feature = "profile"))]
148pub fn satdpath_reset() {}
149
150/// Descent D-2: sub-pel evaluations that re-price an already-priced MV.
151#[cfg(feature = "profile")]
152pub fn spstats_redundant() -> u64 { crate::mb16::spstats::redundant_count() }
153#[cfg(not(feature = "profile"))]
154pub fn spstats_redundant() -> u64 { 0 }
155
156/// Descent D: sub-pel ring census (profile builds only).
157#[cfg(feature = "profile")]
158pub fn spstats_snapshot() -> (Vec<u64>, Vec<u64>) { crate::mb16::spstats::snapshot() }
159#[cfg(not(feature = "profile"))]
160pub fn spstats_snapshot() -> (Vec<u64>, Vec<u64>) { (Vec::new(), Vec::new()) }
161#[cfg(feature = "profile")]
162pub fn spstats_reset() { crate::mb16::spstats::reset() }
163#[cfg(not(feature = "profile"))]
164pub fn spstats_reset() {}
165
166/// Default diamond rung mask (`[16,8,4]`).
167pub const DIA_DEFAULT_MASK: u32 = crate::mb16::DIA_DEFAULT;
168
169/// Descent A: select which rungs of the [64,32,16,8,4] diamond ladder to walk.
170pub fn set_dia_mask(m: u32) { crate::mb16::set_dia_mask(m) }
171/// Track-B B2: SAD-domain full-pel search phase (SATD from sub-pel on) — x264's
172/// cost split. Bitstream-changing; BD-gated; off = byte-identical to pre-B2.
173pub fn set_me_sadfp(on: bool) { crate::mb16::set_me_sadfp(on) }
174/// B2 mode: 0 off, 1 dispatched per frame by the `b2_mgain` probe, 2 force-on.
175pub fn set_me_sadfp_mode(m: u32) { crate::mb16::set_me_sadfp_mode(m) }
176/// Fixed-centre batched diamond passes (both cost domains). Off = cascade.
177pub fn set_me_fc(on: bool) { crate::mb16::set_me_fc(on) }
178/// Fixed-centre batched HALF-PEL sub-pel ring (satd_x4p). Off = cascade.
179pub fn set_sp_fc(on: bool) { crate::mb16::set_sp_fc(on) }
180/// Track-B B3: sub-pel iteration budget (0 = unlimited = byte-identical) — the
181/// bounded walk x264's subme levels have; pairs with B2. BD-gated.
182pub fn set_sp_maxit(n: u32) { crate::mb16::set_sp_maxit(n) }
183
184/// Descent A: diamond per-step evaluation census (profile builds only).
185#[cfg(feature = "profile")]
186pub fn diastats_snapshot() -> Vec<(u64, u64)> { crate::mb16::diastats::snapshot() }
187#[cfg(not(feature = "profile"))]
188pub fn diastats_snapshot() -> Vec<(u64, u64)> { Vec::new() }
189#[cfg(feature = "profile")]
190pub fn diastats_reset() { crate::mb16::diastats::reset() }
191#[cfg(not(feature = "profile"))]
192pub fn diastats_reset() {}
193
194pub fn set_defer_subpel(on: bool) {
195 crate::mb16::DEFER_SUBPEL.store(if on { 1 } else { 0 }, std::sync::atomic::Ordering::Relaxed);
196}
197
198pub fn set_split_t(t: u32) {
199 crate::mb16::SPLIT_T.store(t, std::sync::atomic::Ordering::Relaxed);
200}
201
202pub fn set_subpel_dispatch(on: bool) {
203 crate::mb16::SP_DISPATCH.store(if on { 1 } else { 0 }, std::sync::atomic::Ordering::Relaxed);
204}
205
206pub fn set_subpel_pattern(p: u32) {
207 crate::mb16::SUBPEL_PAT.store(p, std::sync::atomic::Ordering::Relaxed);
208}
209
210impl Encoder {
211 /// Creates an encoder, validating that the configuration is within the
212 /// implemented subset.
213 pub fn new(cfg: EncoderConfig) -> Result<Self, EncodeError> {
214 if !matches!(
215 cfg.profile,
216 Profile::ConstrainedBaseline | Profile::Baseline | Profile::Main | Profile::High
217 ) {
218 return Err(EncodeError::Unsupported("unsupported profile"));
219 }
220 // The 8×8 transform is a High-profile CAVLC feature (our decoder has no CABAC 8×8).
221 if cfg.transform_8x8 && (!matches!(cfg.profile, Profile::High) || cfg.cabac) {
222 return Err(EncodeError::Unsupported("8x8 transform requires High profile + CAVLC"));
223 }
224 // B-frames are illegal in Baseline (the decoder enforces this too): Main only.
225 if cfg.bframes > 0 && !matches!(cfg.profile, Profile::Main) {
226 return Err(EncodeError::Unsupported("B-frames require Main profile"));
227 }
228 if cfg.chroma != ChromaFormat::Yuv420 {
229 return Err(EncodeError::Unsupported("only 4:2:0 chroma"));
230 }
231 if cfg.width == 0 || cfg.height == 0 || cfg.width % 2 != 0 || cfg.height % 2 != 0 {
232 return Err(EncodeError::Unsupported("dimensions must be positive and even"));
233 }
234 let sps = Sps::from_config(&cfg);
235 let pps = Pps::from_config(&cfg);
236 let rc = (cfg.bitrate > 0).then(|| RateControl::new(cfg.bitrate, cfg.framerate, cfg.qp));
237 Ok(Self {
238 cfg,
239 sps,
240 pps,
241 frame_index: 0,
242 next_frame_num: 0,
243 gop_index: 0,
244 refs: Vec::new(),
245 rc,
246 pending_qpo: None,
247 })
248 }
249
250 /// Sets the per-MB QP offset applied to the NEXT [`encode`](Self::encode) call
251 /// (mb-tree temporal AQ). One entry per macroblock (raster). Consumed once.
252 pub(crate) fn set_pending_qpo(&mut self, qpo: Vec<i32>) {
253 self.pending_qpo = Some(qpo);
254 }
255
256 /// The active configuration.
257 pub fn config(&self) -> &EncoderConfig {
258 &self.cfg
259 }
260
261 /// Encodes one frame, returning the Annex-B access unit. Every `gop_size`
262 /// frames (and always the first) is coded as an IDR, prefixed with SPS/PPS.
263 ///
264 /// Generation 1 codes *every* picture as an IDR (all-intra); inter frames
265 /// arrive with motion compensation later.
266 pub fn encode(&mut self, frame: &YuvFrame) -> Vec<u8> {
267 self.try_encode(frame).expect("frame matched config")
268 }
269
270 /// Fallible [`encode`](Self::encode): validates the frame against the config.
271 pub fn try_encode(&mut self, frame: &YuvFrame) -> Result<Vec<u8>, EncodeError> {
272 let _g = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::Total);
273 if frame.width != self.cfg.width || frame.height != self.cfg.height || !frame.is_valid() {
274 return Err(EncodeError::FrameMismatch);
275 }
276
277 // B-frames need lookahead (a future anchor coded before the B), which the
278 // one-frame-in streaming API can't provide — use `encode_all` for B.
279 if self.cfg.bframes > 0 {
280 return Err(EncodeError::Unsupported("B-frames need encode_all (lookahead)"));
281 }
282 // GOP placement: an IDR at each `gop_size` boundary, P-frames between.
283 let is_idr = self.cfg.gop_size <= 1 || self.frame_index % self.cfg.gop_size == 0;
284 if is_idr {
285 self.gop_index = 0;
286 self.next_frame_num = 0;
287 self.refs.clear();
288 }
289 let frame_num = self.next_frame_num;
290 let poc_lsb = (2 * self.gop_index) % 16;
291 // mb-tree per-MB QP offset for this frame (empty = none / byte-identical).
292 let qpo = self.pending_qpo.take().unwrap_or_default();
293
294 // Rate control (if enabled) chooses this frame's QP from a cheap
295 // look-ahead complexity estimate; otherwise the QP is fixed.
296 let complexity = if self.rc.is_some() {
297 lookahead::complexity(&self.cfg, frame, if is_idr { None } else { self.refs.first() })
298 } else {
299 0.0
300 };
301 let qp = match &self.rc {
302 Some(rc) => rc.pick_qp(is_idr, complexity),
303 // Constant-QP: apply the per-GOP I-frame cascade offset (0 by default →
304 // byte-identical). Keeps the P-only path consistent with `code_picture`.
305 None if is_idr => (self.cfg.qp as i32 + self.cfg.i_qp_offset).clamp(0, 51) as u8,
306 None => self.cfg.qp,
307 };
308
309 let mut out = Vec::new();
310 // Pre-size the slice writer to a generous fraction of the raw frame so the
311 // CAVLC hot loop never reallocs mid-frame (byte-identical; just capacity).
312 let mut w = BitWriter::with_capacity(self.cfg.width * self.cfg.height / 2 + 4096);
313 let (nal_type, mut reference) = if is_idr {
314 // SPS/PPS precede every IDR so the stream is independently decodable.
315 self.sps.to_nal().write_annex_b(&mut out);
316 self.pps.to_nal().write_annex_b(&mut out);
317 slice::write_idr_slice_header(&mut w, &self.cfg, qp);
318 let r = if self.cfg.cabac {
319 mb16::encode_slice_data_cabac_intra(&mut w, &self.cfg, frame, qp, &qpo)
320 } else {
321 mb16::encode_slice_data(&mut w, &self.cfg, frame, qp, false, &[], &qpo)
322 };
323 (NalUnitType::IdrSlice, r)
324 } else {
325 slice::write_p_slice_header(&mut w, &self.cfg, qp, frame_num, poc_lsb, self.refs.len());
326 let r = if self.cfg.cabac {
327 mb16::encode_slice_data_cabac_p(&mut w, &self.cfg, frame, qp, &self.refs, &qpo)
328 } else {
329 mb16::encode_slice_data(&mut w, &self.cfg, frame, qp, true, &self.refs, &qpo)
330 };
331 (NalUnitType::NonIdrSlice, r)
332 };
333 // POC/frame_num carried on the reference so B-frame ref-lists (when enabled)
334 // can order L0/L1 by display position. Unused on the P-only path.
335 reference.poc = 2 * self.gop_index as i32;
336 reference.frame_num = frame_num;
337 let slice_bytes = w.into_bytes();
338 // Feed the coded slice size (the picture's own bits) back to the controller.
339 if let Some(rc) = &mut self.rc {
340 rc.update(is_idr, slice_bytes.len() * 8, qp, complexity);
341 }
342 {
343 let _g = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::EncNal);
344 NalUnit::new(3, nal_type, slice_bytes).write_annex_b(&mut out);
345 }
346
347 // The deblocked reconstruction enters the DPB (most-recent first), which
348 // is kept to `max_num_ref_frames` by a sliding window.
349 self.refs.insert(0, reference);
350 self.refs.truncate(self.cfg.num_ref_frames.max(1) as usize);
351 self.frame_index += 1;
352 self.gop_index += 1;
353 self.next_frame_num = (self.next_frame_num + 1) % 16;
354 Ok(out)
355 }
356
357 /// Batch-encodes every frame, returning one Annex-B access unit per frame.
358 ///
359 /// At constant QP the GOPs are independent — each begins with an IDR that
360 /// resets the DPB, `frame_num` and POC, and SPS/PPS precede every IDR — so they
361 /// are encoded **in parallel across CPU cores** and the result is
362 /// **byte-identical** to calling [`encode`](Self::encode) frame-by-frame. With
363 /// rate control enabled the per-frame QP depends on history, so this falls back
364 /// to sequential encoding. Within a GOP, P-frames are inherently sequential
365 /// (each predicts from the previous reconstruction); the parallelism is across
366 /// GOPs, so it scales with the number of GOPs in the clip.
367 pub fn encode_all(&self, frames: &[YuvFrame]) -> Result<Vec<Vec<u8>>, EncodeError> {
368 for f in frames {
369 if f.width != self.cfg.width || f.height != self.cfg.height || !f.is_valid() {
370 return Err(EncodeError::FrameMismatch);
371 }
372 }
373 // B-frames need a reorder pipeline (code the future anchor before the B's
374 // that reference it) — a separate sequential path.
375 if self.cfg.bframes > 0 {
376 // Content-adaptive dispatch, PER GOP (codec-content-adaptive-dispatch):
377 // code B-frames only in GOPs whose motion is predictable enough to pay,
378 // so a mixed clip gets B on its smooth segments and P on its busy ones.
379 let gop = self.cfg.gop_size.max(1) as usize;
380 let n_gops = frames.len().div_ceil(gop);
381 let (w, h) = (self.cfg.width, self.cfg.height);
382 // One cheap per-GOP signal drives BOTH content-adaptive knobs: the B/P
383 // structure dispatch AND the I-frame QP-cascade depth.
384 let gop_sig: Vec<f64> = (0..n_gops)
385 .map(|g| gop_bi_residual(&frames[g * gop..((g + 1) * gop).min(frames.len())], w, h, 1))
386 .collect();
387 let gop_fav: Vec<bool> = if self.cfg.bframes_adaptive {
388 gop_sig.iter().map(|&s| bframes_favorable(s)).collect()
389 } else {
390 vec![true; n_gops]
391 };
392 let gop_iqp: Vec<i32> = gop_sig.iter().map(|&s| gop_iqp_offset(s, self.cfg.i_qp_offset)).collect();
393 let gop_bqp: Vec<i32> = gop_sig.iter().map(|&s| gop_bframe_qp_offset(s, self.cfg.bframe_qp_offset)).collect();
394 // Adaptive B-COUNT: how many B's per anchor gap. Fixed `bframes` unless
395 // `auto`, where the 2-gap/1-gap bi-residual RATIO picks it — content that
396 // survives wider anchor spacing (low ratio) carries more cheap B's; simple
397 // translation (high ratio) wants a single equidistant B.
398 let bcount = if self.cfg.bframes_adaptive {
399 adaptive_bcount(frames, w, h, self.cfg.bframes as usize)
400 } else {
401 self.cfg.bframes as usize
402 };
403 if gop_fav.iter().any(|&f| f) {
404 return Ok(self.encode_all_bframes(frames, bcount, &gop_fav, &gop_iqp, &gop_bqp));
405 }
406 // No GOP is B-favorable → pure P-only (byte-identical to bframes=0).
407 let mut pcfg = self.cfg.clone();
408 pcfg.bframes = 0;
409 return Encoder::new(pcfg)?.encode_all(frames);
410 }
411 // Rate control threads state across frames → it must stay sequential. mb-tree
412 // runs in RC mode too: per-GOP lookahead → per-MB offsets (per-GOP centered, so
413 // rate-neutral per GOP), and the controller supplies each frame's base QP.
414 // (MEASURED: routing the cross-frame allocation through the RC's complexity
415 // instead of centering was worse — the centered offsets carry it correctly.)
416 if self.cfg.bitrate > 0 {
417 let mut enc = Encoder::new(self.cfg.clone())?;
418 let offs: Vec<Vec<i32>> = if self.cfg.mbtree {
419 let gop = self.cfg.gop_size.max(1) as usize;
420 frames
421 .chunks(gop)
422 .flat_map(|g| mbtree::gop_qp_offsets(&self.cfg, g, self.cfg.mbtree_strength))
423 .collect()
424 } else {
425 Vec::new()
426 };
427 return frames
428 .iter()
429 .enumerate()
430 .map(|(i, f)| {
431 if let Some(qpo) = offs.get(i) {
432 enc.pending_qpo = Some(qpo.clone());
433 }
434 enc.try_encode(f)
435 })
436 .collect();
437 }
438 let gop = self.cfg.gop_size.max(1) as usize;
439 let gops: Vec<&[YuvFrame]> = frames.chunks(gop).collect();
440 if gops.is_empty() {
441 return Ok(Vec::new());
442 }
443 let n = std::env::var("RUSTY_THREADS")
444 .ok()
445 .and_then(|v| v.parse().ok())
446 .or_else(|| std::thread::available_parallelism().map(|n| n.get()).ok())
447 .unwrap_or(1)
448 .min(gops.len());
449 // Each GOP is encoded with a fresh encoder (an IDR resets all state), so
450 // GOPs distribute across `n` worker threads with no shared mutable state.
451 let mut out: Vec<Option<Vec<Vec<u8>>>> = (0..gops.len()).map(|_| None).collect();
452 let cfg = &self.cfg;
453 let gops_ref = &gops;
454 std::thread::scope(|s| {
455 let handles: Vec<_> = (0..n)
456 .map(|t| {
457 s.spawn(move || {
458 let mut local = Vec::new();
459 let mut i = t;
460 while i < gops_ref.len() {
461 let mut enc = Encoder::new(cfg.clone()).expect("config");
462 // mb-tree temporal AQ: a per-GOP lookahead over the GOP's
463 // source frames yields per-frame per-MB QP offsets (the GOP
464 // is the natural window — the IDR resets references). Off →
465 // empty → byte-identical.
466 let offs = if cfg.mbtree {
467 mbtree::gop_qp_offsets(cfg, gops_ref[i], cfg.mbtree_strength)
468 } else {
469 Vec::new()
470 };
471 let aus: Vec<Vec<u8>> = gops_ref[i]
472 .iter()
473 .enumerate()
474 .map(|(fi, f)| {
475 if let Some(o) = offs.get(fi) {
476 enc.set_pending_qpo(o.clone());
477 }
478 enc.encode(f)
479 })
480 .collect();
481 local.push((i, aus));
482 i += n;
483 }
484 local
485 })
486 })
487 .collect();
488 for h in handles {
489 for (i, aus) in h.join().expect("encode worker panicked") {
490 out[i] = Some(aus);
491 }
492 }
493 });
494 Ok(out.into_iter().flatten().flatten().collect())
495 }
496
497 /// B-frame reorder pipeline (sequential). Produces access units in **coding
498 /// order** (the decoder reorders to display order by POC). Structure: an IDR
499 /// at each `gop_size` boundary, a P anchor every `bframes+1` frames within a
500 /// GOP, `bframes` non-reference B-frames between consecutive anchors, and the
501 /// last frame forced to an anchor so trailing B's always have a future
502 /// reference. Each anchor is coded before the B's that reference it.
503 /// `gop_favorable[g]` (content-adaptive): GOP `g` codes B-frames only when
504 /// `true`; a `false` GOP is coded all-P (every frame an anchor) so busy segments
505 /// of a mixed clip don't regress. Non-adaptive callers pass all-`true`.
506 fn encode_all_bframes(&self, frames: &[YuvFrame], bcount: usize, gop_favorable: &[bool], gop_iqp: &[i32], gop_bqp: &[i32]) -> Vec<Vec<u8>> {
507 let n = frames.len();
508 if n == 0 {
509 return Vec::new();
510 }
511 let step = bcount.max(1) + 1; // B's per anchor gap + 1 (adaptive in `auto`)
512 let gop = self.cfg.gop_size.max(1) as usize;
513 // A B-capable config: Main profile + ≥2 refs so the DPB holds both anchors.
514 let mut cfg = self.cfg.clone();
515 cfg.num_ref_frames = cfg.num_ref_frames.max(2);
516 let sps = Sps::from_config(&cfg);
517 let pps = Pps::from_config(&cfg);
518
519 // Anchor display-indices: IDR at GOP starts, P anchors every `step`, plus
520 // the frame right before each IDR boundary and the clip's last frame — a
521 // trailing B with no future reference IN ITS OWN GOP would otherwise be
522 // coded after the next GOP's IDR (which clears the DPB), losing its anchors.
523 let mut is_anchor = vec![false; n];
524 for (d, a) in is_anchor.iter_mut().enumerate() {
525 // A non-favorable GOP is coded all-P (every frame an anchor); a favorable
526 // one uses the B structure.
527 *a = if gop_favorable.get(d / gop).copied().unwrap_or(true) {
528 d % gop == 0 || (d % gop) % step == 0 || (d + 1) % gop == 0
529 } else {
530 true
531 };
532 }
533 is_anchor[n - 1] = true;
534
535 // mb-tree temporal AQ over the ANCHOR reference chain: B-frames are
536 // non-reference leaves (mb-tree offsets them at ~0 anyway), so the lookahead
537 // runs over each GOP's anchor sub-sequence — the frames that actually form the
538 // reference chain — and only anchors receive an offset. `mbtree_off[d]` is that
539 // anchor's per-MB offset (empty for B's / when off → byte-identical).
540 let mbtree_off: Vec<Vec<i32>> = if cfg.mbtree {
541 let mut off = vec![Vec::new(); n];
542 let mut g = 0;
543 while g < n {
544 let gop_end = (g + gop).min(n);
545 let anchors: Vec<usize> = (g..gop_end).filter(|&d| is_anchor[d]).collect();
546 let aframes: Vec<YuvFrame> = anchors.iter().map(|&d| frames[d].clone()).collect();
547 let offs = mbtree::gop_qp_offsets(&cfg, &aframes, cfg.mbtree_strength);
548 for (i, &d) in anchors.iter().enumerate() {
549 off[d] = offs[i].clone();
550 }
551 g = gop_end;
552 }
553 off
554 } else {
555 Vec::new()
556 };
557
558 // Coding order: each anchor (display order), then the B's before it.
559 let mut order: Vec<usize> = Vec::with_capacity(n);
560 let mut prev: Option<usize> = None;
561 for d in 0..n {
562 if !is_anchor[d] {
563 continue;
564 }
565 order.push(d);
566 if let Some(p) = prev {
567 order.extend((p + 1)..d);
568 }
569 prev = Some(d);
570 }
571
572 let mut dpb: Vec<RefFrame> = Vec::new();
573 let mut aus: Vec<Vec<u8>> = Vec::with_capacity(n);
574 let mut frame_num: u32 = 0;
575 for &d in &order {
576 let is_idr = d % gop == 0;
577 if is_idr {
578 dpb.clear();
579 frame_num = 0;
580 }
581 let is_b = !is_anchor[d];
582 let gop_start = (d / gop) * gop;
583 let poc = ((d - gop_start) as i32) * 2; // POC = display position within the GOP
584 let iqp = gop_iqp.get(d / gop).copied().unwrap_or(cfg.i_qp_offset);
585 let bqp = gop_bqp.get(d / gop).copied().unwrap_or(cfg.bframe_qp_offset);
586 let qpo: &[i32] = mbtree_off.get(d).map(|v| v.as_slice()).unwrap_or(&[]);
587 let (au, recon) =
588 code_picture(&cfg, &sps, &pps, &frames[d], is_idr, is_b, poc, frame_num, &dpb, iqp, bqp, qpo);
589 aus.push(au);
590 if !is_b {
591 if let Some(r) = recon {
592 dpb.insert(0, r);
593 dpb.truncate(cfg.num_ref_frames as usize);
594 }
595 frame_num = (frame_num + 1) % 16;
596 }
597 }
598 aus
599 }
600}
601
602/// Codes ONE picture (IDR / P anchor / B) with explicit POC + frame_num + DPB.
603/// Returns the access unit and, for reference pictures, the reconstruction to add
604/// to the DPB (B-frames are non-reference → `None`). `dpb` is most-recent-first.
605#[allow(clippy::too_many_arguments)]
606fn code_picture(
607 cfg: &EncoderConfig,
608 sps: &Sps,
609 pps: &Pps,
610 frame: &YuvFrame,
611 is_idr: bool,
612 is_b: bool,
613 poc: i32,
614 frame_num: u32,
615 dpb: &[RefFrame],
616 i_qp_offset: i32,
617 b_qp_offset: i32,
618 qpo: &[i32],
619) -> (Vec<u8>, Option<RefFrame>) {
620 let mut out = Vec::new();
621 let mut w = BitWriter::with_capacity(cfg.width * cfg.height / 2 + 4096);
622 let poc_lsb = (poc as u32) & 0xF; // log2_max_pic_order_cnt_lsb = 4
623 // Per-GOP QP cascade, both offsets content-adaptive: B-frames are non-reference →
624 // quantize HARDER (`b_qp_offset`, deeper on very predictable GOPs); the GOP's
625 // I-frame is the root reference → quantize FINER (`i_qp_offset`, deeper on
626 // predictable GOPs where the I dominates the bits).
627 let qp = if is_b {
628 (cfg.qp as i32 + b_qp_offset).clamp(0, 51) as u8
629 } else if is_idr {
630 (cfg.qp as i32 + i_qp_offset).clamp(0, 51) as u8
631 } else {
632 cfg.qp
633 };
634 let (nal_type, nal_ref_idc, recon) = if is_idr {
635 sps.to_nal().write_annex_b(&mut out);
636 pps.to_nal().write_annex_b(&mut out);
637 slice::write_idr_slice_header(&mut w, cfg, qp);
638 let mut r = if cfg.cabac {
639 mb16::encode_slice_data_cabac_intra(&mut w, cfg, frame, qp, qpo)
640 } else {
641 mb16::encode_slice_data(&mut w, cfg, frame, qp, false, &[], qpo)
642 };
643 r.poc = poc;
644 r.frame_num = frame_num;
645 (NalUnitType::IdrSlice, 3u8, Some(r))
646 } else if is_b {
647 // B is non-reference. We signal one active reference per list: L0[0] =
648 // nearest PAST anchor (highest poc < current), L1[0] = nearest FUTURE anchor
649 // (lowest poc > current) — the heads of the decoder's POC-ordered B lists.
650 let l0 = dpb.iter().filter(|r| r.poc < poc).max_by_key(|r| r.poc);
651 let l1 = dpb.iter().filter(|r| r.poc > poc).min_by_key(|r| r.poc);
652 slice::write_b_slice_header(&mut w, cfg, qp, frame_num, poc_lsb, 1, 1);
653 match (l0, l1) {
654 // B-frames are non-reference leaves — mb-tree offsets them at 0 anyway, so
655 // `qpo` is `&[]` here (the anchor reference chain carries the temporal AQ).
656 (Some(l0), Some(l1)) if cfg.cabac => {
657 mb16::encode_slice_data_cabac_b(&mut w, cfg, frame, qp, poc, l0, l1, &[])
658 }
659 (Some(l0), Some(l1)) => mb16::encode_slice_data_b(&mut w, cfg, frame, qp, poc, l0, l1, &[]),
660 // A B with no bracketing anchor pair can't be List-0/1 coded; fall back
661 // to an all-B_Skip slice (spatial-direct) so the stream stays legal.
662 _ => {
663 let n = cfg.mb_width() * cfg.mb_height();
664 if cfg.cabac {
665 mb16::encode_all_skip_b_cabac(&mut w, cfg, qp, n);
666 } else {
667 w.write_ue(n as u32);
668 w.rbsp_trailing_bits();
669 }
670 }
671 }
672 (NalUnitType::NonIdrSlice, 0u8, None)
673 } else {
674 // P anchor: L0 = the DPB (past anchors), ordered most-recent-first. Both CAVLC
675 // and CABAC now code ref_idx_l0 (cb_ref_idx / parse_ref_idx_cabac), so a P slice
676 // searches + signals the full DPB (`--refs N`) under either entropy coder.
677 let p_dpb: &[RefFrame] = dpb;
678 slice::write_p_slice_header(&mut w, cfg, qp, frame_num, poc_lsb, p_dpb.len());
679 let mut r = if cfg.cabac {
680 mb16::encode_slice_data_cabac_p(&mut w, cfg, frame, qp, p_dpb, qpo)
681 } else {
682 mb16::encode_slice_data(&mut w, cfg, frame, qp, true, dpb, qpo)
683 };
684 r.poc = poc;
685 r.frame_num = frame_num;
686 (NalUnitType::NonIdrSlice, 3u8, Some(r))
687 };
688 let slice_bytes = w.into_bytes();
689 NalUnit::new(nal_ref_idc, nal_type, slice_bytes).write_annex_b(&mut out);
690 (out, recon)
691}
692
693/// The B-favorability threshold on the per-GOP signal (`gop_bi_residual`): below it,
694/// motion is predictable enough that B-frames pay AND the I-frame dominates the GOP's
695/// bits (so it wants a deeper QP cascade); above it the GOP is busy.
696const BI_THRESH: f64 = 4.0;
697
698/// Whether a GOP's temporal residual makes B-frames pay (predictable motion).
699fn bframes_favorable(residual: f64) -> bool {
700 residual < BI_THRESH
701}
702
703/// Content-adaptive per-GOP I-frame QP offset (the ip_ratio cascade, DISPATCHED by
704/// content). `base` is the busy-GOP offset (`cfg.i_qp_offset`, default −3); a
705/// predictable GOP — where the I-frame is a large fraction of the GOP's bits, so
706/// investing in it pays outsized — gets up to 2 QP steps FINER, ramping from `base`
707/// at the threshold to `base−2` at residual 0. Calibrated: busy ≈ −3, compressible
708/// ≈ −5 (−11.6% vs −7.3% at −3). `base == 0` (the opt-out) disables it entirely so
709/// the byte-identical escape hatch survives.
710fn gop_iqp_offset(residual: f64, base: i32) -> i32 {
711 if base == 0 {
712 return 0;
713 }
714 let bonus = (2.0 * ((BI_THRESH - residual) / BI_THRESH).clamp(0.0, 1.0)).round() as i32;
715 base - bonus
716}
717
718/// Content-adaptive per-GOP B-frame QP offset. B-frames are non-reference, so on a
719/// VERY predictable GOP (bi-pred + spatial-direct nail them → tiny residual) they can
720/// be quantized much HARDER for near-free bits. But the optimum is KNIFE-EDGE in the
721/// signal — measured ~+8 at residual 0.10 yet ~+2 by residual 0.29 (and a heavy LOSS
722/// at +12 there) — so unlike the I-cascade this ramp is STEEP and confined to the
723/// near-perfect-motion regime: `base` (default +2) everywhere, boosted up to +4 only
724/// as residual → 0 (decaying to `base` by ~0.3/px). Deliberately conservative — it
725/// helps near-static / clean-pan content and must never touch the common range.
726fn gop_bframe_qp_offset(residual: f64, base: i32) -> i32 {
727 const RAMP: f64 = 0.3; // residual above this gets no boost (steep — see calibration)
728 let boost = (4.0 * ((RAMP - residual) / RAMP).clamp(0.0, 1.0)).round() as i32;
729 base + boost
730}
731
732/// Adaptive B-COUNT (B-frames per anchor gap) for `auto` mode. The RATIO of the
733/// 2-gap to 1-gap bi-prediction residual measures how fast bi-pred degrades as the
734/// anchor spacing widens: LOW ratio (content survives wider gaps) carries MORE cheap
735/// non-reference B's; HIGH ratio (simple translation — degrades fast, so wider anchors
736/// cost more than the extra B's save) wants a single equidistant B. Calibrated on
737/// pans/zoom: ratio ≥ 1.8 → 1, ≥ 1.4 → 2, else 3. Capped at `max_b` (the `auto` cap).
738fn adaptive_bcount(frames: &[YuvFrame], w: usize, h: usize, max_b: usize) -> usize {
739 let cap = max_b.clamp(1, 3);
740 let g1 = gop_bi_residual(frames, w, h, 1);
741 let g2 = gop_bi_residual(frames, w, h, 2);
742 if !g1.is_finite() || !g2.is_finite() {
743 return 1;
744 }
745 let ratio = g2 / g1.max(1e-3);
746 // Calibrated on this encoder's (subsampled global-ME) ratios: a simple
747 // translation degrades to ~1.5 (→ 1 B), predictable-under-wide-gaps content sits
748 // ~1.3 or below (→ 3 B).
749 let c = if ratio >= 1.4 { 1 } else if ratio >= 1.3 { 2 } else { 3 };
750 c.clamp(1, cap)
751}
752
753/// Cheap content signal for the content-adaptive dispatch: the mean per-pixel
754/// residual of a coarse GLOBAL-motion BI-prediction, over a subsample of interior
755/// frames. Low = temporally predictable (bi-pred + spatial-direct cheap → B-frames
756/// WIN, and the I-frame dominates → deeper QP cascade); high = busy motion.
757/// `f64::INFINITY` when the GOP is too short to measure (treated as busy).
758///
759/// Global (not block) ME keeps it O(pixels)-cheap and biases toward "coherent
760/// motion", which is what spatial-direct/skip exploit. Thresholds calibrated on
761/// extremes (pan ~0.03/px, high-motion ~12.3/px); refine on a corpus.
762fn gop_bi_residual(frames: &[YuvFrame], w: usize, h: usize, gap: usize) -> f64 {
763 let n = frames.len();
764 if n < 2 * gap + 1 || w < 48 || h < 48 {
765 return f64::INFINITY;
766 }
767 // Subsampled SAD of `cur` vs `rf` shifted by (dx,dy): interior pixels only
768 // (|shift| ≤ 15 stays in-bounds, no clamping), every 4th pixel for speed.
769 let sad = |cur: &[u8], rf: &[u8], dx: isize, dy: isize| -> u64 {
770 let mut s = 0u64;
771 let mut y = 16;
772 while y < h - 16 {
773 let cbase = (y * w) as isize;
774 let rbase = ((y as isize + dy) * w as isize) + dx;
775 let mut x = 16isize;
776 while x < (w - 16) as isize {
777 let c = cur[(cbase + x) as usize] as i32;
778 let r = rf[(rbase + x) as usize] as i32;
779 s += (c - r).unsigned_abs() as u64;
780 x += 8;
781 }
782 y += 8;
783 }
784 s
785 };
786 // Coarse global ME: ±12 step 4, then refine ±3 step 1.
787 let global_me = |cur: &[u8], rf: &[u8]| -> (isize, isize) {
788 let (mut best, mut bc) = ((0isize, 0isize), u64::MAX);
789 let mut dy = -12;
790 while dy <= 12 {
791 let mut dx = -12;
792 while dx <= 12 {
793 let c = sad(cur, rf, dx, dy);
794 if c < bc {
795 bc = c;
796 best = (dx, dy);
797 }
798 dx += 4;
799 }
800 dy += 4;
801 }
802 for dy in best.1 - 3..=best.1 + 3 {
803 for dx in best.0 - 3..=best.0 + 3 {
804 let c = sad(cur, rf, dx, dy);
805 if c < bc {
806 bc = c;
807 best = (dx, dy);
808 }
809 }
810 }
811 best
812 };
813 let mut n_samp = 0usize;
814 {
815 let mut y = 16;
816 while y < h - 16 {
817 let mut x = 16;
818 while x < w - 16 {
819 n_samp += 1;
820 x += 8;
821 }
822 y += 8;
823 }
824 }
825 let step = (n / 5).max(1);
826 let (mut total, mut cnt) = (0f64, 0usize);
827 // `gap` frames each side (1 = adjacent, for the B/P dispatch; 2 probes how well
828 // bi-prediction survives WIDER anchor spacing, for the adaptive B-count).
829 let mut d = gap;
830 while d < n - gap {
831 let (cur, past, fut) = (&frames[d].y, &frames[d - gap].y, &frames[d + gap].y);
832 let (mpx, mpy) = global_me(cur, past);
833 let (mfx, mfy) = global_me(cur, fut);
834 let mut bi = 0u64;
835 let mut y = 16;
836 while y < h - 16 {
837 let mut x = 16isize;
838 while x < (w - 16) as isize {
839 let c = cur[y * w + x as usize] as i32;
840 let p = past[((y as isize + mpy) * w as isize + x + mpx) as usize] as i32;
841 let f = fut[((y as isize + mfy) * w as isize + x + mfx) as usize] as i32;
842 bi += (c - ((p + f + 1) >> 1)).unsigned_abs() as u64;
843 x += 8;
844 }
845 y += 8;
846 }
847 total += bi as f64 / n_samp as f64;
848 cnt += 1;
849 d += step;
850 }
851 if cnt > 0 {
852 total / cnt as f64
853 } else {
854 f64::INFINITY
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861
862 #[test]
863 fn rejects_unsupported_config() {
864 // High profile is supported (8x8 transform); a High-profile 8x8 stream must be
865 // CAVLC (our decoder has no CABAC 8x8) — that combination is rejected.
866 let mut cfg = EncoderConfig::new(16, 16);
867 cfg.profile = Profile::High;
868 cfg.transform_8x8 = true;
869 cfg.cabac = true;
870 assert!(matches!(Encoder::new(cfg), Err(EncodeError::Unsupported(_))));
871 }
872
873 #[test]
874 fn encodes_access_unit_with_sps_pps_idr() {
875 use rusty_h264_common::nal::split_annex_b;
876 let cfg = EncoderConfig::new(32, 32);
877 let mut enc = Encoder::new(cfg).unwrap();
878 let frame = YuvFrame::black(32, 32);
879 let au = enc.encode(&frame);
880
881 let nals = split_annex_b(&au);
882 assert_eq!(nals.len(), 3);
883 assert_eq!(NalUnitType::from_id(nals[0][0]), NalUnitType::Sps);
884 assert_eq!(NalUnitType::from_id(nals[1][0]), NalUnitType::Pps);
885 assert_eq!(NalUnitType::from_id(nals[2][0]), NalUnitType::IdrSlice);
886 }
887
888 #[test]
889 fn encode_all_matches_sequential_cqp() {
890 // GOP-parallel batch encoding must be byte-identical to frame-by-frame
891 // sequential encoding at constant QP (GOPs are independent).
892 let (w, h) = (48usize, 32usize);
893 let mut cfg = EncoderConfig::new(w, h);
894 cfg.gop_size = 4; // 10 frames → 3 GOPs (4,4,2)
895 let frames: Vec<YuvFrame> = (0..10u8)
896 .map(|t| YuvFrame {
897 width: w,
898 height: h,
899 y: (0..w * h).map(|i| (i as u8).wrapping_add(t.wrapping_mul(7))).collect(),
900 u: vec![128u8.wrapping_add(t); (w / 2) * (h / 2)],
901 v: vec![128u8.wrapping_sub(t); (w / 2) * (h / 2)],
902 })
903 .collect();
904 let mut seq_enc = Encoder::new(cfg.clone()).unwrap();
905 let seq: Vec<Vec<u8>> = frames.iter().map(|f| seq_enc.encode(f)).collect();
906 let par = Encoder::new(cfg).unwrap().encode_all(&frames).unwrap();
907 assert_eq!(seq, par, "GOP-parallel must equal sequential at CQP");
908 }
909
910 #[test]
911 fn rejects_mismatched_frame() {
912 let cfg = EncoderConfig::new(16, 16);
913 let mut enc = Encoder::new(cfg).unwrap();
914 let frame = YuvFrame::black(32, 16);
915 assert_eq!(enc.try_encode(&frame), Err(EncodeError::FrameMismatch));
916 }
917}