diff --git a/crates/h264/examples/h264dec.rs b/crates/h264/examples/h264dec.rs index 58f8dfa6..1fe10ba5 100644 --- a/crates/h264/examples/h264dec.rs +++ b/crates/h264/examples/h264dec.rs @@ -1,5 +1,6 @@ //! Decode an Annex-B H.264 file to raw yuv420p: `h264dec in.h264 [out.yuv]`. -//! Prints timing information to stderr. +//! Prints timing information to stderr. `H264_BENCH_ITERS=n` (with `H264_THREADS=t`, and +//! `H264_DRAFT=1` for draft mode) turns it into a benchmark. use filmcraft_h264::Decoder; use std::io::Write; @@ -18,6 +19,7 @@ fn main() { let threads = std::env::var("H264_THREADS").ok().and_then(|v| v.parse::().ok()); for _ in 0..n { let mut dec = threads.map(Decoder::with_threads).unwrap_or_default(); + dec.set_draft(std::env::var_os("H264_DRAFT").is_some()); let t0 = std::time::Instant::now(); frames = dec.decode(&data, 0).expect("decode").len() + dec.flush().len(); best = best.min(t0.elapsed().as_secs_f64()); diff --git a/crates/h264/src/cabac.rs b/crates/h264/src/cabac.rs index b6b9a44a..184406ae 100644 --- a/crates/h264/src/cabac.rs +++ b/crates/h264/src/cabac.rs @@ -159,7 +159,10 @@ impl<'a> Cabac<'a> { /// at high bit rates, so it selects values instead of jumping. #[inline(always)] pub fn decode_decision(&mut self, ctx_idx: usize) -> u32 { - let s = self.ctx[ctx_idx] as usize; + // Every ctxIdx is < 1024 and every state < 128: the masks only let the compiler drop the + // bounds checks from the per-bin path. + let ctx_idx = ctx_idx & (NUM_CTX - 1); + let s = (self.ctx[ctx_idx] & 127) as usize; let q = ((self.range >> 6) & 3) as usize; let lps = RANGE_TAB_LPS[s >> 1][q] as u32; let mps_range = self.range - lps; diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 35bd2957..97826b20 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -63,6 +63,8 @@ pub struct DecodeStats { pub frame_num_gaps: u64, pub weighted_slices: u64, pub temporal_direct_slices: u64, + /// Non-reference pictures decoded in draft mode (deblocking skipped). + pub draft_pictures: u64, } /// State shared with decoding jobs. @@ -93,6 +95,9 @@ pub struct Decoder { pool: Option, in_flight: VecDeque, max_in_flight: usize, + threads: usize, + /// Draft mode: skip deblocking of non-reference pictures ([`Decoder::set_draft`]). + draft: bool, } impl Default for Decoder { @@ -122,7 +127,9 @@ fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool { false } -fn default_threads() -> usize { +/// The worker threads [`Decoder::new`] uses: the available cores (at most 16) with the `threads` +/// feature on native targets, 1 otherwise. +pub fn default_threads() -> usize { #[cfg(all(feature = "threads", not(target_arch = "wasm32")))] { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(16) @@ -148,6 +155,10 @@ impl Decoder { } else { None }; + #[cfg(feature = "threads")] + let workers = if pool.is_some() { threads } else { 1 }; + #[cfg(not(feature = "threads"))] + let workers = 1; Decoder { spss: vec![None; 32], ppss: vec![None; 256], @@ -165,9 +176,30 @@ impl Decoder { pool, in_flight: VecDeque::new(), max_in_flight: threads.max(1) + 2, + threads: workers, + draft: false, } } + /// Worker threads decoding pictures (1: on the calling thread). + pub fn threads(&self) -> usize { + self.threads + } + + /// Draft mode (off by default) for reduced-resolution playback: pictures submitted from now + /// on that are non-reference pictures (`nal_ref_idc` 0) skip the deblocking filter (8.7). + /// Nothing predicts from a non-reference picture (and deblocking never changes the motion + /// data used for direct prediction), so every other picture stays bit-exact; the draft + /// pictures themselves are approximate and flagged [`Picture::draft`]. Never use it for + /// export or for a frame shown while paused. + pub fn set_draft(&mut self, on: bool) { + self.draft = on; + } + + pub fn draft(&self) -> bool { + self.draft + } + /// Configure from an `avcC` (AVCDecoderConfigurationRecord) box payload. pub fn from_avcc(avcc: &[u8]) -> Result { let mut d = Decoder::new(); @@ -450,19 +482,20 @@ impl Decoder { fn submit_pending(&mut self) { let Some(p) = self.pending.take() else { return }; let PendingPic { frame, sps, first, poc, pts, key, slices } = p; - self.dispatch(frame.clone(), slices); + let draft = self.draft && first.nal_ref_idc == 0; + self.dispatch(frame.clone(), slices, draft); self.poc_state.update(&first, &poc); if first.nal_ref_idc != 0 { self.prev_ref_frame_num = if first.has_mmco5() { 0 } else { first.frame_num }; } - let meta = Arc::new(output_meta(&sps, pts, key)); + let meta = Arc::new(OutputMeta { draft, ..output_meta(&sps, pts, key) }); let mut outs = Vec::new(); let fpoc = frame.poc; self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs); self.emit(outs); } - fn dispatch(&mut self, frame: FrameRef, slices: Vec) { + fn dispatch(&mut self, frame: FrameRef, slices: Vec, draft: bool) { let shared = self.shared.clone(); #[cfg(feature = "threads")] if let Some(pool) = &self.pool { @@ -473,10 +506,10 @@ impl Decoder { } } self.in_flight.push_back(frame.clone()); - pool.spawn_fifo(move || run_job(frame, slices, &shared)); + pool.spawn_fifo(move || run_job(frame, slices, &shared, draft)); return; } - run_job(frame, slices, &shared); + run_job(frame, slices, &shared, draft); } fn emit(&mut self, outs: Vec) { @@ -485,7 +518,7 @@ impl Decoder { } /// Decode all slices of one picture and publish its rows. -fn run_job(frame: FrameRef, slices: Vec, shared: &Shared) { +fn run_job(frame: FrameRef, slices: Vec, shared: &Shared, draft: bool) { let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop()); let mut pic = match reuse { Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => { @@ -494,6 +527,7 @@ fn run_job(frame: FrameRef, slices: Vec, shared: &Shared) { } _ => PicState::new(frame.clone()), }; + pic.skip_deblock = draft; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut first_err = None; for s in &slices { @@ -521,6 +555,7 @@ fn run_job(frame: FrameRef, slices: Vec, shared: &Shared) { }; if let Ok(mut s) = shared.stats.lock() { s.pictures += 1; + s.draft_pictures += draft as u64; for st in &pic.mbs { match st.kind { MbKind::I4x4 => s.mb_i4x4 += 1, @@ -570,6 +605,7 @@ fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta { transfer_characteristics: vui.transfer_characteristics, matrix_coefficients: vui.matrix_coefficients, sar: vui.sar, + draft: false, } } @@ -598,5 +634,6 @@ fn make_picture(o: &Output) -> Picture { matrix: o.meta.matrix_coefficients, }, sar: o.meta.sar, + draft: o.meta.draft, } } diff --git a/crates/h264/src/dpb.rs b/crates/h264/src/dpb.rs index 2603c77b..691c62b1 100644 --- a/crates/h264/src/dpb.rs +++ b/crates/h264/src/dpb.rs @@ -24,6 +24,8 @@ pub struct OutputMeta { pub transfer_characteristics: u8, pub matrix_coefficients: u8, pub sar: (u16, u16), + /// Decoded without deblocking (draft mode, non-reference picture). + pub draft: bool, } pub struct DpbEntry { diff --git a/crates/h264/src/lib.rs b/crates/h264/src/lib.rs index 8094d234..9ad3a123 100644 --- a/crates/h264/src/lib.rs +++ b/crates/h264/src/lib.rs @@ -41,7 +41,7 @@ mod synth_tests; mod tables; mod transform; -pub use decoder::{DecodeStats, Decoder}; +pub use decoder::{DecodeStats, Decoder, default_threads}; pub use error::{Error, Result}; /// Colour description from the VUI (ITU-T H.273 code points). @@ -77,4 +77,8 @@ pub struct Picture { pub color: ColorInfo, /// Sample aspect ratio (0, 0 when unspecified). pub sar: (u16, u16), + /// Decoded in draft mode ([`Decoder::set_draft`]): a non-reference picture whose deblocking + /// filter was skipped. Its samples are approximate (not the conforming output); no other + /// picture is affected. + pub draft: bool, } diff --git a/crates/h264/src/slicedec.rs b/crates/h264/src/slicedec.rs index 75f192b4..ac82b54d 100644 --- a/crates/h264/src/slicedec.rs +++ b/crates/h264/src/slicedec.rs @@ -40,6 +40,8 @@ pub struct PicState { rows_recon: usize, rows_deblocked: usize, rows_published: usize, + /// Draft mode for a non-reference picture: rows are published without deblocking. + pub skip_deblock: bool, } impl PicState { @@ -58,6 +60,7 @@ impl PicState { rows_recon: 0, rows_deblocked: 0, rows_published: 0, + skip_deblock: false, } } @@ -76,6 +79,7 @@ impl PicState { self.rows_recon = 0; self.rows_deblocked = 0; self.rows_published = 0; + self.skip_deblock = false; } /// Record a decoded macroblock and deblock / publish rows that became final. @@ -100,8 +104,10 @@ impl PicState { let deblock_limit = if finished { self.mb_h } else { self.rows_recon.saturating_sub(1) }; while self.rows_deblocked < deblock_limit { let r = self.rows_deblocked; - for addr in r * self.mb_w..(r + 1) * self.mb_w { - deblock::deblock_mb(self, addr, self.mb_w); + if !self.skip_deblock { + for addr in r * self.mb_w..(r + 1) * self.mb_w { + deblock::deblock_mb(self, addr, self.mb_w); + } } self.rows_deblocked += 1; } diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/common/mod.rs index 78466ff5..ab75ea00 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/common/mod.rs @@ -422,8 +422,15 @@ pub fn decode_file(path: &Path) -> Result, DecodeFailure> { /// Decode with `threads` worker threads (0 = decoder default). pub fn decode_file_threads(path: &Path, threads: usize) -> Result, DecodeFailure> { + decode_file_opts(path, threads, false) +} + +/// [`decode_file_threads`] with draft mode ([`Decoder::set_draft`]) on or off. +pub fn decode_file_opts(path: &Path, threads: usize, draft: bool) -> Result, DecodeFailure> { let data = std::fs::read(path).unwrap(); let mut dec = if threads == 0 { Decoder::new() } else { Decoder::with_threads(threads) }; + assert!(!dec.draft(), "draft mode is off by default"); + dec.set_draft(draft); let mut out = Vec::new(); for (i, au) in split_access_units(&data).into_iter().enumerate() { match dec.decode(au, i as i64) { @@ -494,8 +501,9 @@ pub fn check_fixture(name: &str) -> Result { let f = fixture(name); let Some((h264, yuv)) = ensure(f) else { return Ok(false) }; let reference = std::fs::read(&yuv).unwrap(); - // single-threaded and frame-threaded decoding must both be bit-exact - for threads in [1, 0] { + // single-threaded and frame-threaded decoding (an odd thread count and every core) must all + // be bit-exact + for threads in [1, 3, 0] { let pics = match decode_file_threads(&h264, threads) { Ok(p) => p, Err((au, e, partial)) => { @@ -505,6 +513,9 @@ pub fn check_fixture(name: &str) -> Result { } }; compare(&pics, &reference, f.width as usize, f.height as usize).map_err(|e| format!("{name} (threads {threads}): {e}"))?; + if pics.iter().any(|p| p.draft) { + return Err(format!("{name} (threads {threads}): draft picture without draft mode")); + } check_pts(&pics).map_err(|e| format!("{name} (threads {threads}): {e}"))?; } Ok(true) diff --git a/crates/h264/tests/conformance.rs b/crates/h264/tests/conformance.rs index a2654c9c..154bb8e9 100644 --- a/crates/h264/tests/conformance.rs +++ b/crates/h264/tests/conformance.rs @@ -56,6 +56,48 @@ fixture_tests!( vt_high_1080p, ); +/// Draft mode (reduced-resolution playback) skips deblocking of non-reference pictures only: +/// every other picture stays bit-exact with ffmpeg, single- and frame-threaded, the skipped ones +/// are flagged, and they are the only ones that differ. +#[test] +fn draft_mode_changes_only_flagged_non_reference_pictures() { + let mut checked = 0; + for name in ["main_cabac_b", "bpyramid", "cavlc_b", "direct_temporal", "weightb"] { + let f = common::fixture(name); + let Some((h264, yuv)) = common::ensure(f) else { + eprintln!("skipped {name}"); + continue; + }; + let reference = std::fs::read(&yuv).unwrap(); + let (w, h) = (f.width as usize, f.height as usize); + let fsize = w * h + 2 * w.div_ceil(2) * h.div_ceil(2); + for threads in [1, 0] { + let pics = common::decode_file_opts(&h264, threads, true).unwrap_or_else(|(au, e, _)| panic!("{name}: error at {au}: {e}")); + common::check_pts(&pics).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(pics.len() * fsize, reference.len(), "{name}: picture count"); + let (mut draft, mut changed) = (0, 0); + for (i, p) in pics.iter().enumerate() { + let r = &reference[i * fsize..(i + 1) * fsize]; + let same = p.y == r[..w * h] && [&p.u[..], &p.v[..]].concat() == r[w * h..]; + if p.draft { + draft += 1; + changed += !same as usize; + } else { + assert!(same, "{name} (threads {threads}): picture {i} (not draft) differs from the reference"); + } + } + assert!(draft > 0, "{name}: has non-reference pictures"); + if name != "no_deblock" { + assert!(changed > 0, "{name}: skipping deblocking changes some draft picture"); + } + checked += 1; + } + } + if checked == 0 { + eprintln!("skipped: no fixtures"); + } +} + /// Print which coding tools each fixture exercises (run with `-- --ignored --nocapture`). #[test] #[ignore]