diff --git a/Cargo.lock b/Cargo.lock index 551f6dcb..e3df9d69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1236,6 +1236,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-prores" +version = "0.1.0" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-render" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8534bc61..26eb98d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ filmcraft-geom = { path = "crates/geom" } filmcraft-color = { path = "crates/color" } filmcraft-bitstream = { path = "crates/bitstream" } filmcraft-isobmff = { path = "crates/isobmff" } +filmcraft-prores = { path = "crates/prores" } filmcraft-frame = { path = "crates/frame" } filmcraft-media = { path = "crates/media" } filmcraft-project = { path = "crates/project" } diff --git a/crates/prores/Cargo.toml b/crates/prores/Cargo.toml new file mode 100644 index 00000000..aa3f2f11 --- /dev/null +++ b/crates/prores/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "filmcraft-prores" +description = "Clean-room Apple ProRes (SMPTE RDD 36) decoder and encoder" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[features] +default = ["threads"] +threads = ["dep:rayon"] + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } +rayon = { workspace = true, optional = true } diff --git a/crates/prores/README.md b/crates/prores/README.md new file mode 100644 index 00000000..99336e2b --- /dev/null +++ b/crates/prores/README.md @@ -0,0 +1,114 @@ +# filmcraft-prores + +Clean-room Apple ProRes decoder and encoder in pure Rust, written from the public SMPTE RDD 36 +bitstream description. Layer L0: depends only on `filmcraft-bitstream`, `thiserror` and (optional, +feature `threads`, default on) `rayon`. Builds for `wasm32-unknown-unknown`; no `unsafe`. + +## API + +```rust +use filmcraft_prores::*; + +// Decode one QuickTime sample (one ProRes frame). +let frame: Frame = decode_frame(&sample)?; // 10-bit 4:2:2 / 12-bit 4:4:4 +let f10 = decode_frame_with(&sample, &DecodeOptions { bit_depth: Some(10), threads: true })?; +decode_frame_into(&sample, &DecodeOptions::default(), &mut frame_buf)?; // reuses planes +let hdr: FrameHeader = probe(&sample)?; // header only + +// Encode (progressive). +let mut enc = Encoder::new(Profile::Hq, 1920, 1080); // or Encoder::with_config(EncoderConfig { .. }) +let sample: Vec = enc.encode(&frame)?; // the QuickTime sample for fourcc Profile::fourcc() +``` + +`Frame { width, height, chroma: ChromaFormat::{Yuv422, Yuv444}, bit_depth, y, cb, cr: Vec, +alpha: Option>, interlace: Interlace, color: ColorInfo { primaries, transfer, matrix }, +aspect_ratio, frame_rate_code }` holds planar samples with no row padding (`chroma_width()` is +`ceil(width/2)` for 4:2:2). The colour codes are H.273 code points straight from the frame header. + +`Profile` = `Proxy` (`apco`), `Lt` (`apcs`), `Standard` (`apcn`), `Hq` (`apch`), `P4444` (`ap4h`), +`P4444Xq` (`ap4x`). The frame itself doesn't say which profile it is (only the container fourcc +does), so the decoder doesn't need to know. + +## Decoder + +- Frame header (dimensions, chroma format, interlace mode, aspect/frame-rate codes, colour + description, alpha type, optional luma/chroma quantisation matrices), picture header(s), slice + table, slices. +- Entropy decoding: RDD 36's adaptive Golomb-Rice/Exp-Golomb codebooks. DC is coded as the first + value plus sign-predicted differences. AC is run/level coded, interleaved across all blocks of a + slice component. +- Progressive and interlaced scan orders. Interlaced frames are two field pictures (top or bottom + field first) of `ceil(height/32)` macroblock rows each. +- Dequantisation `F = level × qmat[i] × qscale(qidx)` (qscale = qidx up to 128, then + `128 + 4·(qidx−128)`). +- IDCT: the exact 2-D orthonormal inverse DCT, evaluated separably in `f32`. It uses even/odd + symmetry, skips zero rows and fills DC-only blocks directly. Its error is below 2⁻⁸ in the + 12-bit domain. The IDCT output `f` is the 12-bit-domain sample minus 2048. The output at depth + `d` is `floor(f·2^(d−12) + 2^(d−1) + ½)`, clipped to `[4, 2^d − 5]` for d ≥ 10 (the four + lowest/highest codes are SDI reserved values; this matches common decoders at 10 and 12 bits) + and to `[1, 2^d − 2]` below 10 bits. +- 4:2:2 chroma blocks per macroblock are top/bottom. 4:4:4 chroma blocks run TL, BL, TR, BR, + while luma runs TL, TR, BL, BR. +- Alpha (8- or 16-bit coding): lossless difference/run coding in raster order over each slice's + 16 lines. The starting value is all-ones. Output is scaled to the output depth: 8-bit coding is + bit-replicated, 16-bit coding is truncated. +- Default output depth: 10 bits for 4:2:2 and 12 bits for 4:4:4. Any depth from 8 to 16 is + available on request. +- Slice-parallel: each macroblock row, covering both fields of interlaced frames, is decoded by a + rayon task directly into the output planes. +- Robustness: every size field is validated before allocation, codewords longer than 56 bits are + rejected, and arithmetic is overflow-safe. Mutated streams return `Err`, never panic (see + `tests/fuzz.rs`). + +## Encoder + +- Progressive ProRes 422 (Proxy/LT/Standard/HQ) and 4444/4444 XQ. For 4444 the frame's alpha + plane is coded with 16-bit alpha coding by default (`EncoderConfig::encode_alpha`). +- Input: planar `u16` at any depth from 8 to 16 (normally 10). The chroma format must match the + profile. Edge macroblocks are padded by replicating the edge. +- Flat quantisation matrices (all 4, not transmitted), 8 macroblocks per slice by default + (`log2_slice_mbs`), encoder id `fmcr`. +- Rate control targets Apple's published nominal rates (Mbit/s at 1920×1080, 29.97 fps: 45, 102, + 147, 220, 330, 500). These are converted to bits per macroblock (184, 417, 601, 900, 1349, + 2045), so frame targets scale with frame area (`Encoder::target_frame_bytes`). Alpha comes on + top of the target. Every slice is transformed once. Then a frame-level binary search finds the + smallest uniform quantiser index whose frame fits the budget, and as many slices as the rest of + the budget allows get one finer step. Detailed content lands within 0.1% of the target. Simple + content uses qidx 1 and comes in under the target. Every slice is kept within the 16-bit + size fields. + +## Accuracy (vs ffmpeg as an external oracle) + +`tests/oracle_decode.rs` decodes 29 fixtures made by ffmpeg's `prores_ks` and `prores_aw` +encoders. They cover every profile, testsrc2/mandelbrot/SMPTE bars at 1920×1080, 1918×1080, +1280×720, 640×360, 352×288, 70×38 and 333×201, interlaced 720×486 TFF/BFF and 1080i, 2-MB slices, +and 4444 with 8- and 16-bit alpha. The results are compared with ffmpeg's decode at the native +depth (10-bit 4:2:2, 12-bit 4:4:4). **Every fixture is within ±1 LSB.** Mean absolute error is +0.0001–0.03 LSB for 422 and up to 0.15 LSB for 12-bit XQ, and 97–99.99% of samples are +bit-identical (86% for 12-bit XQ mandelbrot). Alpha is always bit-exact. The remaining +differences are the reference decoder's integer-IDCT rounding. This decoder is closer to the +exact IDCT and does not reproduce that approximation. + +`tests/oracle_encode.rs` encodes 1920×1080 frames with every profile (testsrc2, mandelbrot and +noisy content; 4444 with an alpha disc) and writes them into a minimal test-only MOV. ffmpeg then +decodes them with `-xerror`, with no errors, and agrees with this decoder within ±1. HQ PSNR is +69.9/69.2 dB (Y/C) on mandelbrot at 100% of the target rate. Busy content lands at 99.9–100.4% +of the target. + +## Performance + +Measured with `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture` on +an M4 Pro (14 cores) that was heavily loaded by other jobs, so these are lower bounds. A 1080p HQ +frame at the full nominal rate (918 KB) decoded at up to 318 fps threaded and 53 fps on one +thread. A prores_ks HQ mandelbrot frame (657 KB) decoded at 390 fps threaded. Encoding a 1080p +HQ frame takes about 40 ms threaded. + +## Gaps + +- The encoder is progressive-only (the decoder handles interlaced), uses flat quantisation + matrices (no perceptual matrices), and has no trellis/RDO. +- Output is not bit-exact with ffmpeg's integer IDCT; it stays within ±1 LSB of it (see above). +- The 4:2:2 decoder output is fixed at the coded 10-bit precision unless another depth is + requested; there is no direct output of packed formats (v210, etc.). +- No container handling: frames are QuickTime samples, and demuxing/muxing lives in + `filmcraft-isobmff`. diff --git a/crates/prores/src/bits.rs b/crates/prores/src/bits.rs new file mode 100644 index 00000000..a1a16bd5 --- /dev/null +++ b/crates/prores/src/bits.rs @@ -0,0 +1,280 @@ +//! Fast MSB-first bit reader/writer for slice payloads (the hot path of the codec). +//! +//! Frame and picture headers use `filmcraft_bitstream`; the entropy-coded slice data uses these +//! specialised helpers, which read 64-bit windows and decode a whole codeword per call. + +use crate::tables::Codebook; + +/// Bit reader over one slice component with a 64-bit MSB-aligned cache. Reads past the end +/// yield zero bits; callers check [`Reader::overrun`] once a component has been consumed. +pub(crate) struct Reader<'a> { + data: &'a [u8], + /// Next byte of `data` to load into the cache (may run past the end: zeros are loaded). + next: usize, + /// Cached bits, MSB first; bits below `avail` are zero. + cache: u64, + avail: u32, +} + +impl<'a> Reader<'a> { + #[inline] + pub fn new(data: &'a [u8]) -> Self { + let mut r = Self { data, next: 0, cache: 0, avail: 0 }; + r.refill(); + r + } + + /// Ensure at least 56 valid bits are cached. + #[inline(always)] + fn refill(&mut self) { + if self.avail >= 56 { + return; + } + if let Some(s) = self.data.get(self.next..self.next + 8) { + let w = u64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]); + self.cache |= w >> self.avail; + self.next += ((63 - self.avail) >> 3) as usize; + self.avail |= 56; + } else { + while self.avail <= 56 { + let b = self.data.get(self.next).copied().unwrap_or(0) as u64; + self.cache |= b << (56 - self.avail); + self.avail += 8; + self.next += 1; + } + } + } + + #[inline(always)] + fn consume(&mut self, n: u32) { + // n <= 56 <= avail + self.cache = if n >= 64 { 0 } else { self.cache << n }; + self.avail -= n; + } + + #[inline] + fn consumed(&self) -> usize { + self.next * 8 - self.avail as usize + } + + #[inline] + pub fn bits_left(&self) -> isize { + (self.data.len() * 8) as isize - self.consumed() as isize + } + + #[inline] + pub fn overrun(&self) -> bool { + self.consumed() > self.data.len() * 8 + } + + /// True when every remaining bit is zero (the component's padding), or nothing is left. + #[inline(always)] + pub fn only_padding_left(&mut self) -> bool { + let left = self.bits_left(); + if left > 56 { + return false; + } + if left <= 0 { + return true; + } + self.refill(); + self.cache >> (64 - left as u32) == 0 + } + + #[inline] + pub fn read(&mut self, n: u32) -> u32 { + debug_assert!(n <= 32); + if n == 0 { + return 0; + } + self.refill(); + let v = (self.cache >> (64 - n)) as u32; + self.consume(n); + v + } + + #[inline(always)] + pub fn read_bit(&mut self) -> bool { + if self.avail == 0 { + self.refill(); + } + let b = self.cache >> 63 == 1; + self.consume(1); + b + } + + /// Decode one codeword; `None` for codewords longer than 56 bits (never produced by valid + /// streams). + #[inline(always)] + pub fn read_cw(&mut self, cb: Codebook) -> Option { + self.refill(); + let w = self.cache; + let q = w.leading_zeros(); + let rq = cb.rice_q_max as u32; + if q <= rq { + let rice = cb.rice as u32; + let len = q + 1 + rice; + let low = if rice == 0 { 0 } else { ((w << (q + 1)) >> (64 - rice)) as u32 }; + self.consume(len); + Some((q << rice) | low) + } else { + let exp = cb.exp as u32; + let len = 2 * q - rq + exp; + if len > 56 { + return None; + } + let code = w >> (64 - len); + self.consume(len); + let v = code - (1u64 << exp) + (((rq + 1) as u64) << cb.rice); + u32::try_from(v).ok() + } + } +} + +/// Number of bits of the codeword for `val` in codebook `cb`. +#[inline] +pub(crate) fn cw_len(cb: Codebook, val: u32) -> u32 { + let rq = cb.rice_q_max as u32; + let rice = cb.rice as u32; + let rice_span = (rq + 1) << rice; + if val < rice_span { + (val >> rice) + 1 + rice + } else { + let exp = cb.exp as u32; + let v = (val - rice_span) as u64 + (1u64 << exp); + let nbits = 64 - v.leading_zeros(); // n + exp + 1 + let n = nbits - exp - 1; + (rq + 1 + n) + nbits + } +} + +/// A bit sink: either a real writer or a bit counter (for rate control trials). +pub(crate) trait Sink { + fn put(&mut self, val: u32, bits: u32); + fn put_cw(&mut self, cb: Codebook, val: u32); +} + +/// Counts bits without storing them. +#[derive(Default)] +pub(crate) struct Counter(pub u64); + +impl Sink for Counter { + #[inline] + fn put(&mut self, _val: u32, bits: u32) { + self.0 += bits as u64; + } + #[inline] + fn put_cw(&mut self, cb: Codebook, val: u32) { + self.0 += cw_len(cb, val) as u64; + } +} + +/// MSB-first bit writer. +#[derive(Default)] +pub(crate) struct Writer { + pub buf: Vec, + acc: u64, + n: u32, +} + +impl Writer { + pub fn new() -> Self { + Self::default() + } + /// Pad with zero bits to a byte boundary and return the bytes. + pub fn finish(mut self) -> Vec { + if self.n > 0 { + let pad = 8 - self.n; + self.put(0, pad); + } + self.buf + } +} + +impl Sink for Writer { + #[inline] + fn put(&mut self, val: u32, bits: u32) { + debug_assert!(bits <= 32); + if bits == 0 { + return; + } + let v = (val as u64) & ((1u64 << bits) - 1); + self.acc = (self.acc << bits) | v; + self.n += bits; + while self.n >= 8 { + self.n -= 8; + self.buf.push((self.acc >> self.n) as u8); + } + self.acc &= (1u64 << self.n) - 1; + } + + fn put_cw(&mut self, cb: Codebook, val: u32) { + let rq = cb.rice_q_max as u32; + let rice = cb.rice as u32; + let rice_span = (rq + 1) << rice; + if val < rice_span { + let q = val >> rice; + // q zeros, a one, then `rice` low bits + self.put(0, q); + self.put(1, 1); + self.put(val & ((1 << rice) - 1), rice); + } else { + let exp = cb.exp as u32; + let v = (val - rice_span) as u64 + (1u64 << exp); + let nbits = 64 - v.leading_zeros(); + let n = nbits - exp - 1; + let zeros = rq + 1 + n; + self.put(0, zeros.min(32)); + if zeros > 32 { + self.put(0, zeros - 32); + } + if nbits > 32 { + self.put((v >> 32) as u32, nbits - 32); + self.put(v as u32, 32); + } else { + self.put(v as u32, nbits); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tables::*; + + #[test] + fn codeword_roundtrip_all_codebooks() { + let mut books = vec![FIRST_DC_CB]; + books.extend_from_slice(&DC_CB); + books.extend_from_slice(&RUN_CB); + books.extend_from_slice(&LEVEL_CB); + let vals: Vec = (0..300).chain([1000, 4095, 65535, 1 << 20]).collect(); + for cb in books { + let mut w = Writer::new(); + let mut total = 0; + for &v in &vals { + w.put_cw(cb, v); + total += cw_len(cb, v); + } + let bits = w.buf.len() as u32 * 8 + w.n; + assert_eq!(bits, total); + let data = w.finish(); + let mut r = Reader::new(&data); + for &v in &vals { + assert_eq!(r.read_cw(cb), Some(v), "{cb:?}"); + } + assert!(!r.overrun()); + } + } + + #[test] + fn exp_golomb5_equivalence() { + // FIRST_DC_CB is Exp-Golomb order 5: value 32 is `01` + 6 bits (000000) + assert_eq!(cw_len(FIRST_DC_CB, 0), 6); + assert_eq!(cw_len(FIRST_DC_CB, 31), 6); + assert_eq!(cw_len(FIRST_DC_CB, 32), 8); + assert_eq!(cw_len(FIRST_DC_CB, 95), 8); + assert_eq!(cw_len(FIRST_DC_CB, 96), 10); + } +} diff --git a/crates/prores/src/dct.rs b/crates/prores/src/dct.rs new file mode 100644 index 00000000..f4ab8583 --- /dev/null +++ b/crates/prores/src/dct.rs @@ -0,0 +1,161 @@ +//! 8×8 orthonormal DCT-II / DCT-III (IDCT) in single precision. +//! +//! The reconstruction IDCT is defined as the exact real-valued 2-D inverse DCT +//! `f(x,y) = Σ_u Σ_v C(u)C(v)/4 · F(v,u) · cos((2x+1)uπ/16) · cos((2y+1)vπ/16)` +//! evaluated separably (rows then columns) in `f32`. For ProRes coefficient ranges the `f32` +//! evaluation error is below 2⁻⁸ in the 12-bit sample domain, so the rounded output equals the +//! rounded exact IDCT except when the exact value lies within that distance of a rounding tie. + +use crate::tables::BASIS; + +/// In-place inverse DCT. `block` holds dequantised coefficients in raster order +/// (`block[v*8 + u]`, `v` = vertical frequency) and receives samples (`block[y*8 + x]`). +/// +/// Each 1-D pass uses the even/odd symmetry of the basis (`B[u][7-x] = ±B[u][x]` for even/odd +/// `u`) and skips all-zero rows; DC-only blocks are filled directly. +#[inline] +pub(crate) fn idct8x8(block: &mut [f32; 64]) { + let mut t = [[0f32; 8]; 8]; + let mut nz = [false; 8]; + let mut dc_only = true; + // Horizontal pass: t[r][x] = Σ_u F[r][u] · B[u][x] + for r in 0..8 { + let c: &[f32; 8] = block[r * 8..r * 8 + 8].try_into().unwrap(); + if c[1..].iter().all(|&v| v == 0.0) { + if c[0] != 0.0 { + t[r] = [c[0] * BASIS[0][0]; 8]; + nz[r] = true; + dc_only &= r == 0; + } + continue; + } + dc_only = false; + nz[r] = true; + let mut out = [0f32; 8]; + for x in 0..4 { + let e = c[0] * BASIS[0][x] + c[2] * BASIS[2][x] + c[4] * BASIS[4][x] + c[6] * BASIS[6][x]; + let o = c[1] * BASIS[1][x] + c[3] * BASIS[3][x] + c[5] * BASIS[5][x] + c[7] * BASIS[7][x]; + out[x] = e + o; + out[7 - x] = e - o; + } + t[r] = out; + } + if dc_only { + *block = [t[0][0] * BASIS[0][0]; 64]; + return; + } + // Vertical pass: f[y][x] = Σ_v t[v][x] · B[v][y] + for y in 0..4 { + let mut e = [0f32; 8]; + let mut o = [0f32; 8]; + for v in [0, 2, 4, 6] { + if nz[v] { + let b = BASIS[v][y]; + for x in 0..8 { + e[x] += t[v][x] * b; + } + } + } + for v in [1, 3, 5, 7] { + if nz[v] { + let b = BASIS[v][y]; + for x in 0..8 { + o[x] += t[v][x] * b; + } + } + } + for x in 0..8 { + block[y * 8 + x] = e[x] + o[x]; + block[(7 - y) * 8 + x] = e[x] - o[x]; + } + } +} + +/// In-place forward DCT (samples in, coefficients out; same layout as [`idct8x8`]). +pub(crate) fn fdct8x8(block: &mut [f32; 64]) { + let mut t = [[0f32; 8]; 8]; + // Horizontal: t[y][u] = Σ_x f[y][x] · B[u][x] + for y in 0..8 { + for u in 0..8 { + let mut s = 0f32; + for x in 0..8 { + s += block[y * 8 + x] * BASIS[u][x]; + } + t[y][u] = s; + } + } + // Vertical: F[v][u] = Σ_y t[y][u] · B[v][y] + for v in 0..8 { + let mut acc = [0f32; 8]; + for y in 0..8 { + let b = BASIS[v][y]; + for u in 0..8 { + acc[u] += t[y][u] * b; + } + } + block[v * 8..v * 8 + 8].copy_from_slice(&acc); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference_idct(c: &[f32; 64]) -> [f64; 64] { + let mut out = [0f64; 64]; + for y in 0..8 { + for x in 0..8 { + let mut s = 0f64; + for v in 0..8 { + for u in 0..8 { + let cu = if u == 0 { std::f64::consts::FRAC_1_SQRT_2 } else { 1.0 }; + let cv = if v == 0 { std::f64::consts::FRAC_1_SQRT_2 } else { 1.0 }; + s += cu * cv / 4.0 + * c[v * 8 + u] as f64 + * (((2 * x + 1) * u) as f64 * std::f64::consts::PI / 16.0).cos() + * (((2 * y + 1) * v) as f64 * std::f64::consts::PI / 16.0).cos(); + } + } + out[y * 8 + x] = s; + } + } + out + } + + #[test] + fn idct_matches_double_reference() { + let mut seed = 12345u32; + let mut rnd = || { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + seed >> 8 + }; + for _ in 0..200 { + let mut c = [0f32; 64]; + for v in c.iter_mut() { + if rnd() % 3 == 0 { + *v = ((rnd() % 8192) as i32 - 4096) as f32; + } + } + let r = reference_idct(&c); + let mut b = c; + idct8x8(&mut b); + for i in 0..64 { + assert!((b[i] as f64 - r[i]).abs() < 4e-3, "{} vs {}", b[i], r[i]); + } + } + } + + #[test] + fn fdct_inverts_idct() { + let mut b = [0f32; 64]; + for (i, v) in b.iter_mut().enumerate() { + *v = ((i * 37) % 200) as f32 - 100.0; + } + let orig = b; + fdct8x8(&mut b); + idct8x8(&mut b); + for i in 0..64 { + assert!((b[i] - orig[i]).abs() < 1e-3); + } + } +} diff --git a/crates/prores/src/decode.rs b/crates/prores/src/decode.rs new file mode 100644 index 00000000..71dd1a87 --- /dev/null +++ b/crates/prores/src/decode.rs @@ -0,0 +1,498 @@ +//! ProRes frame decoding. + +use crate::bits::Reader; +use crate::dct::idct8x8; +use crate::header::{FrameHeader, PictureHeader, row_slice_widths}; +use crate::tables::*; +use crate::{AlphaType, ChromaFormat, Error, Frame, Interlace, Result}; + +/// Decoder options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeOptions { + /// Output bit depth (8..=16). `None`: 10 for 4:2:2, 12 for 4:4:4 (the precision ProRes + /// codes each format at). + pub bit_depth: Option, + /// Decode slices in parallel (requires the `threads` feature). + pub threads: bool, +} + +impl Default for DecodeOptions { + fn default() -> Self { + DecodeOptions { bit_depth: None, threads: true } + } +} + +/// Parse only the frame header (cheap; for probing geometry and format). +pub fn probe(data: &[u8]) -> Result { + FrameHeader::parse(data).map(|(h, _, _)| h) +} + +/// Decode one ProRes frame (the contents of one QuickTime sample) with default options. +pub fn decode_frame(data: &[u8]) -> Result { + decode_frame_with(data, &DecodeOptions::default()) +} + +/// Maps IDCT output (12-bit-domain signed samples) to the output bit depth. +#[derive(Clone, Copy)] +struct OutConv { + scale: f32, + bias: f32, + min: i32, + max: i32, +} + +impl OutConv { + fn new(depth: u8) -> OutConv { + // The IDCT produces `f`, where the 12-bit sample is `f + 2048`. + let scale = (2f32).powi(depth as i32 - 12); + // Samples are clipped away from the extremes: the four lowest and highest codes are + // excluded at 10 bits and above (0..=3 and 1020..=1023 are the SDI timing-reference codes + // at 10 bits; the same margin is kept at 12 bits, matching common decoders), and the + // single lowest/highest code at 8 and 9 bits. + let reserved = if depth >= 10 { 4 } else { 1 }; + OutConv { scale, bias: (1u32 << (depth - 1)) as f32 + 0.5, min: reserved, max: (1i32 << depth) - 1 - reserved } + } + #[inline(always)] + fn conv(&self, v: f32) -> u16 { + // Truncation equals floor here: negative values clamp to `min` (> 0) either way. + ((v * self.scale + self.bias) as i32).clamp(self.min, self.max) as u16 + } +} + +struct Ctx { + width: usize, + cwidth: usize, + chroma444: bool, + qmat_luma: [u8; 64], + qmat_chroma: [u8; 64], + scan: &'static [u8; 64], + alpha: AlphaType, + conv: OutConv, + depth: u8, +} + +/// Output rows belonging to one macroblock row of every coded picture. +struct Band<'a> { + y: &'a mut [u16], + cb: &'a mut [u16], + cr: &'a mut [u16], + a: Option<&'a mut [u16]>, + rows: usize, +} + +struct Picture { + /// Line parity within the frame (0 = even lines) for field pictures. + parity: usize, + /// Byte ranges of each slice, row-major. + slices: Vec<(usize, usize)>, +} + +struct Scratch { + coeffs: Vec<[f32; 64]>, + alpha: Vec, +} + +impl Scratch { + fn new() -> Scratch { + Scratch { coeffs: vec![[0f32; 64]; 32], alpha: vec![0; 16 * 128] } + } +} + +/// Decode one ProRes frame. +pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result { + let mut f = Frame::new(0, 0, ChromaFormat::Yuv422, 10, false); + decode_frame_into(data, opts, &mut f)?; + Ok(f) +} + +/// Decode one ProRes frame into `out`, reusing its plane allocations (the fastest way to decode +/// a sequence). On error the contents of `out` are unspecified. +pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<()> { + let (hdr, mut off, frame_size) = FrameHeader::parse(data)?; + let data = &data[..frame_size]; + let depth = opts.bit_depth.unwrap_or(match hdr.chroma { + ChromaFormat::Yuv422 => 10, + ChromaFormat::Yuv444 => 12, + }); + if !(8..=16).contains(&depth) { + return Err(Error::Unsupported("output bit depth")); + } + let width = hdr.width as usize; + let height = hdr.height as usize; + let cwidth = hdr.chroma.chroma_width(hdr.width as u32) as usize; + let interlaced = hdr.interlace != Interlace::Progressive; + let mb_width = width.div_ceil(16); + let mb_height = if interlaced { height.div_ceil(32) } else { height.div_ceil(16) }; + + let npics = if interlaced { 2 } else { 1 }; + let mut pics = Vec::with_capacity(npics); + for p in 0..npics { + let ph = PictureHeader::parse(data.get(off..).ok_or(Error::Truncated)?)?; + let pic_end = off.checked_add(ph.picture_size).ok_or(Error::Truncated)?; + if pic_end > data.len() || ph.picture_size < ph.header_size { + return Err(Error::Truncated); + } + let widths = row_slice_widths(mb_width, ph.log2_slice_mbs); + let nslices = widths.len() * mb_height; + if ph.num_slices != 0 && ph.num_slices != nslices { + return Err(Error::Invalid("slice count does not match frame geometry")); + } + let table = off + ph.header_size; + let mut pos = table + 2 * nslices; + if pos > pic_end { + return Err(Error::Truncated); + } + let mut slices = Vec::with_capacity(nslices); + for i in 0..nslices { + let sz = u16::from_be_bytes([data[table + 2 * i], data[table + 2 * i + 1]]) as usize; + // Every slice carries at least its 6-byte header. + if sz < 6 || pos + sz > pic_end { + return Err(Error::Truncated); + } + slices.push((pos, sz)); + pos += sz; + } + let parity = match (hdr.interlace, p) { + (Interlace::BottomFieldFirst, 0) | (Interlace::TopFieldFirst, 1) => 1, + _ => 0, + }; + pics.push((Picture { parity, slices }, widths)); + off = pic_end; + } + + let ctx = Ctx { + width, + cwidth, + chroma444: hdr.chroma == ChromaFormat::Yuv444, + qmat_luma: hdr.qmat_luma, + qmat_chroma: hdr.qmat_chroma, + scan: if interlaced { &SCAN_INTERLACED } else { &SCAN_PROGRESSIVE }, + alpha: hdr.alpha, + conv: OutConv::new(depth), + depth, + }; + + let take = |v: &mut Vec, n: usize| { + let mut v = std::mem::take(v); + v.resize(n, 0); + v + }; + let mut y = take(&mut out.y, width * height); + let mut cb = take(&mut out.cb, cwidth * height); + let mut cr = take(&mut out.cr, cwidth * height); + let mut alpha = match (hdr.alpha, out.alpha.take()) { + (AlphaType::None, _) => None, + (_, a) => Some(take(&mut a.unwrap_or_default(), width * height)), + }; + + let band_h = if interlaced { 32 } else { 16 }; + let mut bands: Vec = Vec::with_capacity(mb_height); + { + let mut yc = y.chunks_mut(band_h * width); + let mut cbc = cb.chunks_mut(band_h * cwidth); + let mut crc = cr.chunks_mut(band_h * cwidth); + let mut ac = alpha.as_mut().map(|a| a.chunks_mut(band_h * width)); + while let (Some(y), Some(cb), Some(cr)) = (yc.next(), cbc.next(), crc.next()) { + let rows = y.len() / width; + let a = ac.as_mut().and_then(|it| it.next()); + bands.push(Band { y, cb, cr, a, rows }); + } + } + + let step = if interlaced { 2 } else { 1 }; + let run_band = |(row, band): (usize, &mut Band), scratch: &mut Scratch| -> Result<()> { + for (pi, (pic, widths)) in pics.iter().enumerate() { + let mut mb_x = 0; + for (k, &w) in widths.iter().enumerate() { + let si = row * widths.len() + k; + let (start, len) = pic.slices[si]; + decode_slice(&ctx, &data[start..start + len], mb_x, w, band, step, pic.parity, scratch).map_err(|_| Error::Slice { picture: pi, slice: si })?; + mb_x += w; + } + } + Ok(()) + }; + + #[cfg(feature = "threads")] + if opts.threads { + use rayon::prelude::*; + bands.par_iter_mut().enumerate().try_for_each_init(Scratch::new, |s, b| run_band(b, s))?; + } else { + let mut s = Scratch::new(); + bands.iter_mut().enumerate().try_for_each(|b| run_band(b, &mut s))?; + } + #[cfg(not(feature = "threads"))] + { + let mut s = Scratch::new(); + bands.iter_mut().enumerate().try_for_each(|b| run_band(b, &mut s))?; + } + drop(bands); + + *out = Frame { + width: hdr.width as u32, + height: hdr.height as u32, + chroma: hdr.chroma, + bit_depth: depth, + y, + cb, + cr, + alpha, + interlace: hdr.interlace, + color: hdr.color, + aspect_ratio: hdr.aspect_ratio, + frame_rate_code: hdr.frame_rate_code, + }; + Ok(()) +} + +#[inline] +fn be16(d: &[u8], i: usize) -> usize { + u16::from_be_bytes([d[i], d[i + 1]]) as usize +} + +/// Dequantisation factors (matrix × scale) for a slice. +fn qfactors(qmat: &[u8; 64], qs: u32) -> [f32; 64] { + let mut q = [0f32; 64]; + for i in 0..64 { + q[i] = (qmat[i] as u32 * qs) as f32; + } + q +} + +#[allow(clippy::too_many_arguments)] +fn decode_slice(ctx: &Ctx, d: &[u8], mb_x: usize, mbs: usize, band: &mut Band, step: usize, parity: usize, s: &mut Scratch) -> std::result::Result<(), ()> { + if d.len() < 6 { + return Err(()); + } + let hsize = (d[0] >> 3) as usize; + let has_alpha = ctx.alpha != AlphaType::None; + if hsize < if has_alpha { 8 } else { 6 } || hsize > d.len() { + return Err(()); + } + let qs = qscale(d[1]); + let ysize = be16(d, 2); + let cbsize = be16(d, 4); + let body = d.len() - hsize; + let crsize = if has_alpha { be16(d, 6) } else { body.checked_sub(ysize + cbsize).ok_or(())? }; + if ysize + cbsize + crsize > body { + return Err(()); + } + let mut p = hsize; + let ydata = &d[p..p + ysize]; + p += ysize; + let cbdata = &d[p..p + cbsize]; + p += cbsize; + let crdata = &d[p..p + crsize]; + p += crsize; + let adata = &d[p..]; + + let log2_mbs = mbs.trailing_zeros(); + let x0 = mb_x * 16; + + // Luma + let ql = qfactors(&ctx.qmat_luma, qs); + let nb = 4 * mbs; + decode_component(ydata, log2_mbs + 2, ctx.scan, &ql, &mut s.coeffs[..nb])?; + for (b, blk) in s.coeffs[..nb].iter_mut().enumerate() { + idct8x8(blk); + let (mb, i) = (b >> 2, b & 3); + put_block(blk, band.y, ctx.width, x0 + mb * 16 + (i & 1) * 8, (i >> 1) * 8, band.rows, step, parity, ctx.conv); + } + + // Chroma + let qc = qfactors(&ctx.qmat_chroma, qs); + let (cnb, clog2) = if ctx.chroma444 { (4 * mbs, log2_mbs + 2) } else { (2 * mbs, log2_mbs + 1) }; + for (data, plane) in [(cbdata, &mut *band.cb), (crdata, &mut *band.cr)] { + decode_component(data, clog2, ctx.scan, &qc, &mut s.coeffs[..cnb])?; + for (b, blk) in s.coeffs[..cnb].iter_mut().enumerate() { + idct8x8(blk); + let (bx, by) = if ctx.chroma444 { + // 4:4:4 chroma blocks run down each 8-wide column pair: TL, BL, TR, BR. + let (mb, i) = (b >> 2, b & 3); + (x0 + mb * 16 + (i >> 1) * 8, (i & 1) * 8) + } else { + let (mb, i) = (b >> 1, b & 1); + (mb_x * 8 + mb * 8, i * 8) + }; + put_block(blk, plane, ctx.cwidth, bx, by, band.rows, step, parity, ctx.conv); + } + } + + // Alpha + if let Some(a) = band.a.as_deref_mut() { + let w = mbs * 16; + let n = w * 16; + let buf = &mut s.alpha[..n]; + decode_alpha(adata, ctx.alpha, buf)?; + let depth = ctx.depth as u32; + let conv = |v: u16| -> u16 { + let v = v as u32; + (match ctx.alpha { + AlphaType::Bits8 => { + if depth >= 8 { + let r = (v << (depth - 8)) | (v >> (16 - depth).min(8)); + r & ((1 << depth) - 1) + } else { + v + } + } + _ => v >> (16 - depth), + }) as u16 + }; + for py in 0..16 { + let line = py * step + parity; + if line >= band.rows { + break; + } + let row = &mut a[line * ctx.width..(line + 1) * ctx.width]; + let xe = (x0 + w).min(ctx.width); + if x0 >= xe { + continue; + } + for (o, &v) in row[x0..xe].iter_mut().zip(&buf[py * w..py * w + (xe - x0)]) { + *o = conv(v); + } + } + } + Ok(()) +} + +#[inline] +fn to_signed(code: u32) -> i64 { + let c = code as i64; + (c >> 1) ^ -(c & 1) +} + +/// Entropy-decode and dequantise one slice component into `blocks` (raster-order coefficients). +fn decode_component(data: &[u8], log2_blocks: u32, scan: &[u8; 64], q: &[f32; 64], blocks: &mut [[f32; 64]]) -> std::result::Result<(), ()> { + for b in blocks.iter_mut() { + *b = [0f32; 64]; + } + let n = blocks.len(); + let mut r = Reader::new(data); + + // DC coefficients + let code = r.read_cw(FIRST_DC_CB).ok_or(())?; + let mut dc = to_signed(code); + blocks[0][0] = dc as f32 * q[0]; + let mut code = DC_CTX_INIT; + let mut sign = 0i64; + for blk in blocks.iter_mut().skip(1) { + code = r.read_cw(DC_CB[code.min(6) as usize]).ok_or(())?; + if code == 0 { + sign = 0; + } else { + sign ^= -((code & 1) as i64); + } + let mag = (code as i64 + 1) >> 1; + dc = (dc + ((mag ^ sign) - sign)).clamp(-(1 << 24), 1 << 24); + blk[0] = dc as f32 * q[0]; + } + + // AC coefficients, interleaved across blocks: position = scan_index * n + block + let mask = n - 1; + let total = 64 * n; + let mut pos = mask; + let mut run_ctx = RUN_CTX_INIT; + let mut lev_ctx = LEVEL_CTX_INIT; + while !r.only_padding_left() { + let run = r.read_cw(RUN_CB[run_ctx.min(15) as usize]).ok_or(())?; + run_ctx = run; + pos += run as usize + 1; + if pos >= total { + return Err(()); + } + let lev = r.read_cw(LEVEL_CB[lev_ctx.min(9) as usize]).ok_or(())?.saturating_add(1); + lev_ctx = lev; + let neg = r.read_bit(); + let i = scan[pos >> log2_blocks] as usize; + let v = lev as f32 * q[i]; + blocks[pos & mask][i] = if neg { -v } else { v }; + } + if r.overrun() { + return Err(()); + } + Ok(()) +} + +/// Decode a slice's alpha plane (raster order over the slice's 16 lines) into `out` +/// (native 8- or 16-bit values). +fn decode_alpha(data: &[u8], ty: AlphaType, out: &mut [u16]) -> std::result::Result<(), ()> { + let bits = if ty == AlphaType::Bits16 { 16 } else { 8 }; + let short_bits = if bits == 16 { 7 } else { 4 }; + let mask: u32 = (1 << bits) - 1; + let mut r = Reader::new(data); + let mut val: u32 = mask; + let n = out.len(); + let mut idx = 0; + 'outer: loop { + loop { + let d = if r.read_bit() { + r.read(bits) + } else { + let c = r.read(short_bits); + let m = (c + 2) >> 1; + if c & 1 == 1 { m.wrapping_neg() } else { m } + }; + val = val.wrapping_add(d) & mask; + out[idx] = val as u16; + idx += 1; + if idx >= n { + break 'outer; + } + if r.bits_left() <= 0 || !r.read_bit() { + break; + } + } + let mut run = r.read(4) as usize; + if run == 0 { + run = r.read(11) as usize; + } + let run = run.min(n - idx); + out[idx..idx + run].fill(val as u16); + idx += run; + if idx >= n { + break; + } + if r.bits_left() <= 0 { + // Streams may end early; the remainder repeats the last value. + out[idx..].fill(val as u16); + break; + } + } + if r.bits_left() < -64 { + return Err(()); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +#[inline] +fn put_block(f: &[f32; 64], plane: &mut [u16], stride: usize, x0: usize, y0: usize, rows: usize, step: usize, parity: usize, conv: OutConv) { + if x0 >= stride { + return; + } + let xn = (stride - x0).min(8); + if xn == 8 && (y0 + 7) * step + parity < rows { + for yy in 0..8 { + let o = ((y0 + yy) * step + parity) * stride + x0; + let row: &mut [u16; 8] = (&mut plane[o..o + 8]).try_into().unwrap(); + let src: &[f32; 8] = f[yy * 8..yy * 8 + 8].try_into().unwrap(); + for x in 0..8 { + row[x] = conv.conv(src[x]); + } + } + return; + } + for yy in 0..8 { + let line = (y0 + yy) * step + parity; + if line >= rows { + break; + } + let row = &mut plane[line * stride + x0..line * stride + x0 + xn]; + let src = &f[yy * 8..yy * 8 + xn]; + for (o, &v) in row.iter_mut().zip(src) { + *o = conv.conv(v); + } + } +} diff --git a/crates/prores/src/encode.rs b/crates/prores/src/encode.rs new file mode 100644 index 00000000..6530d568 --- /dev/null +++ b/crates/prores/src/encode.rs @@ -0,0 +1,495 @@ +//! ProRes frame encoding (progressive) with per-slice quantiser rate control. + +use crate::bits::{Counter, Sink, Writer}; +use crate::dct::fdct8x8; +use crate::header::{FRAME_ID, FrameHeader, PictureHeader, row_slice_widths}; +use crate::tables::*; +use crate::{AlphaType, ChromaFormat, ColorInfo, Error, Frame, Interlace, Profile, Result}; + +/// Encoder configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncoderConfig { + pub profile: Profile, + pub width: u32, + pub height: u32, + /// Coded-bits budget per macroblock; `None` uses [`Profile::nominal_bits_per_mb`]. + pub bits_per_mb: Option, + /// log2 of the macroblocks per slice (0..=3; 3 = 8 macroblocks, the common choice). + pub log2_slice_mbs: u8, + pub color: ColorInfo, + pub aspect_ratio: u8, + pub frame_rate_code: u8, + /// Encode the frame's alpha plane (4444 profiles only; 16-bit alpha coding). + pub encode_alpha: bool, + /// Encode slices in parallel (requires the `threads` feature). + pub threads: bool, + /// Four-character encoder identifier written into the frame header. + pub encoder_id: [u8; 4], +} + +impl EncoderConfig { + pub fn new(profile: Profile, width: u32, height: u32) -> EncoderConfig { + EncoderConfig { + profile, + width, + height, + bits_per_mb: None, + log2_slice_mbs: 3, + color: ColorInfo::default(), + aspect_ratio: 0, + frame_rate_code: 0, + encode_alpha: true, + threads: true, + encoder_id: *b"fmcr", + } + } +} + +/// ProRes encoder for one stream of same-sized progressive frames. +#[derive(Debug, Clone)] +pub struct Encoder { + cfg: EncoderConfig, +} + +/// Quantiser search range (RDD 36 allows 1..=224). +const Q_MIN: u8 = 1; +const Q_MAX: u8 = 224; + +impl Encoder { + pub fn new(profile: Profile, width: u32, height: u32) -> Encoder { + Encoder { cfg: EncoderConfig::new(profile, width, height) } + } + + pub fn with_config(cfg: EncoderConfig) -> Encoder { + Encoder { cfg } + } + + pub fn config(&self) -> &EncoderConfig { + &self.cfg + } + + pub fn bits_per_mb(&self) -> u32 { + self.cfg.bits_per_mb.unwrap_or_else(|| self.cfg.profile.nominal_bits_per_mb()) + } + + /// The rate-control target for one frame in bytes (excluding alpha). + pub fn target_frame_bytes(&self) -> usize { + let mbs = (self.cfg.width as usize).div_ceil(16) * (self.cfg.height as usize).div_ceil(16); + mbs * self.bits_per_mb() as usize / 8 + } + + /// Encode one frame. Input planes may be at any bit depth 8..=16 (10 is typical); the frame's + /// chroma format must match the profile. + pub fn encode(&mut self, frame: &Frame) -> Result> { + let cfg = &self.cfg; + if frame.width != cfg.width || frame.height != cfg.height { + return Err(Error::Input(format!("frame is {}x{}, encoder is {}x{}", frame.width, frame.height, cfg.width, cfg.height))); + } + if cfg.width == 0 || cfg.height == 0 || cfg.width > 65535 || cfg.height > 65535 { + return Err(Error::Input("unsupported frame size".into())); + } + let chroma = cfg.profile.chroma(); + if frame.chroma != chroma { + return Err(Error::Input(format!("{:?} input for a {:?} profile", frame.chroma, chroma))); + } + if !(8..=16).contains(&frame.bit_depth) { + return Err(Error::Input("bit depth must be 8..=16".into())); + } + let (w, h) = (cfg.width as usize, cfg.height as usize); + let cw = chroma.chroma_width(cfg.width) as usize; + if frame.y.len() < w * h || frame.cb.len() < cw * h || frame.cr.len() < cw * h { + return Err(Error::Input("plane too small".into())); + } + let with_alpha = cfg.encode_alpha && chroma == ChromaFormat::Yuv444 && frame.alpha.is_some(); + if let Some(a) = &frame.alpha + && with_alpha + && a.len() < w * h + { + return Err(Error::Input("alpha plane too small".into())); + } + if cfg.log2_slice_mbs > 3 { + return Err(Error::Input("log2_slice_mbs must be 0..=3".into())); + } + + let mb_width = w.div_ceil(16); + let mb_height = h.div_ceil(16); + let widths = row_slice_widths(mb_width, cfg.log2_slice_mbs); + let mut jobs = Vec::with_capacity(widths.len() * mb_height); + for row in 0..mb_height { + let mut x = 0; + for &sw in &widths { + jobs.push((x, row, sw)); + x += sw; + } + } + let src = Source { + frame, + w, + h, + cw, + chroma444: chroma == ChromaFormat::Yuv444, + scale: (2f32).powi(12 - frame.bit_depth as i32), + alpha: with_alpha, + bits_per_mb: self.bits_per_mb(), + }; + let threads = cfg.threads; + let prepared: Vec = par_map(threads, &jobs, |&(x, row, sw)| prepare_slice(&src, x, row, sw)); + let qs = choose_quantisers(&prepared, &src, mb_width * mb_height, threads); + let items: Vec<(&SliceData, u8)> = prepared.iter().zip(qs).collect(); + let slices: Vec> = par_map(threads, &items, |&(sd, q)| write_slice(sd, q)); + + let hdr = FrameHeader { + header_size: 0, + bitstream_version: if chroma == ChromaFormat::Yuv444 { 1 } else { 0 }, + encoder_id: cfg.encoder_id, + width: cfg.width as u16, + height: cfg.height as u16, + chroma, + interlace: Interlace::Progressive, + aspect_ratio: cfg.aspect_ratio & 15, + frame_rate_code: cfg.frame_rate_code & 15, + color: cfg.color, + alpha: if with_alpha { AlphaType::Bits16 } else { AlphaType::None }, + qmat_luma: [4; 64], + qmat_chroma: [4; 64], + luma_matrix_present: false, + chroma_matrix_present: false, + }; + let fh = hdr.write(); + let slice_bytes: usize = slices.iter().map(Vec::len).sum(); + let pic_size = 8 + 2 * slices.len() + slice_bytes; + let total = 8 + fh.len() + pic_size; + let mut out = Vec::with_capacity(total); + out.extend_from_slice(&(total as u32).to_be_bytes()); + out.extend_from_slice(&FRAME_ID); + out.extend_from_slice(&fh); + PictureHeader { header_size: 8, picture_size: pic_size, num_slices: slices.len(), log2_slice_mbs: cfg.log2_slice_mbs }.write(&mut out); + for s in &slices { + out.extend_from_slice(&(s.len() as u16).to_be_bytes()); + } + for s in &slices { + out.extend_from_slice(s); + } + debug_assert_eq!(out.len(), total); + Ok(out) + } +} + +struct Source<'a> { + frame: &'a Frame, + w: usize, + h: usize, + cw: usize, + chroma444: bool, + /// Multiplier from input sample values to the 12-bit domain. + scale: f32, + alpha: bool, + bits_per_mb: u32, +} + +/// Load an 8×8 block (edge-replicated) into the 12-bit signed domain and transform it. +fn load_block(plane: &[u16], stride: usize, height: usize, x0: usize, y0: usize, scale: f32) -> [f32; 64] { + let mut b = [0f32; 64]; + for yy in 0..8 { + let y = (y0 + yy).min(height - 1); + let row = &plane[y * stride..(y + 1) * stride]; + for xx in 0..8 { + let x = (x0 + xx).min(stride - 1); + b[yy * 8 + xx] = row[x] as f32 * scale - 2048.0; + } + } + fdct8x8(&mut b); + b +} + +/// Rounding offsets for quantisation: nearest for DC, a slight dead zone for AC. +const AC_ROUND: f32 = 0.45; + +fn quantise(coeffs: &[[f32; 64]], qmat_inv: &[f32; 64], out: &mut Vec<[i32; 64]>) { + out.clear(); + for c in coeffs { + let mut l = [0i32; 64]; + let dc = c[0] * qmat_inv[0]; + l[0] = dc.round() as i32; + for i in 1..64 { + let v = c[i] * qmat_inv[i]; + let a = (v.abs() + AC_ROUND) as i32; + l[i] = if v < 0.0 { -a } else { a }; + } + out.push(l); + } +} + +fn encode_component(s: &mut S, levels: &[[i32; 64]], scan: &[u8; 64]) { + let n = levels.len(); + // DC + let first = levels[0][0]; + let zz = if first >= 0 { (first as u32) << 1 } else { ((-(first as i64)) as u32) * 2 - 1 }; + s.put_cw(FIRST_DC_CB, zz); + let mut prev = first; + let mut ctx = DC_CTX_INIT; + let mut sign = false; + for blk in &levels[1..] { + let dc = blk[0]; + let delta = dc - prev; + prev = dc; + let code = if delta == 0 { + sign = false; + 0 + } else { + let neg = delta < 0; + let mag = delta.unsigned_abs(); + let c = if neg != sign { 2 * mag - 1 } else { 2 * mag }; + sign = neg; + c + }; + s.put_cw(DC_CB[ctx.min(6) as usize], code); + ctx = code; + } + // AC, interleaved across blocks + let mut run = 0u32; + let mut run_ctx = RUN_CTX_INIT; + let mut lev_ctx = LEVEL_CTX_INIT; + for &nat in &scan[1..] { + let nat = nat as usize; + for blk in levels.iter().take(n) { + let l = blk[nat]; + if l == 0 { + run += 1; + } else { + s.put_cw(RUN_CB[run_ctx.min(15) as usize], run); + run_ctx = run; + let a = l.unsigned_abs(); + s.put_cw(LEVEL_CB[lev_ctx.min(9) as usize], a - 1); + lev_ctx = a; + s.put((l < 0) as u32, 1); + run = 0; + } + } + } +} + +fn component_bits(levels: &[[i32; 64]]) -> u64 { + let mut c = Counter::default(); + encode_component(&mut c, levels, &SCAN_PROGRESSIVE); + c.0.div_ceil(8) * 8 +} + +/// A slice's transformed blocks (Y, Cb, Cr) and its coded alpha. +struct SliceData { + comps: [Vec<[f32; 64]>; 3], + alpha: Option>, +} + +impl SliceData { + fn header_size(&self) -> usize { + if self.alpha.is_some() { 8 } else { 6 } + } + /// Largest DCT payload (bits) that keeps every 16-bit size field valid. + fn limit_bits(&self) -> u64 { + (65535 - self.header_size() - self.alpha.as_ref().map_or(0, Vec::len)) as u64 * 8 + } +} + +fn par_map(threads: bool, items: &[T], f: impl Fn(&T) -> R + Sync + Send) -> Vec { + #[cfg(feature = "threads")] + if threads { + use rayon::prelude::*; + return items.par_iter().map(f).collect(); + } + let _ = threads; + items.iter().map(f).collect() +} + +fn prepare_slice(src: &Source, mb_x: usize, mb_y: usize, mbs: usize) -> SliceData { + let f = src.frame; + let x0 = mb_x * 16; + let y0 = mb_y * 16; + let mut luma = Vec::with_capacity(4 * mbs); + for mb in 0..mbs { + for i in 0..4 { + luma.push(load_block(&f.y, src.w, src.h, x0 + mb * 16 + (i & 1) * 8, y0 + (i >> 1) * 8, src.scale)); + } + } + let mut chroma = [Vec::with_capacity(4 * mbs), Vec::with_capacity(4 * mbs)]; + for (k, plane) in [&f.cb, &f.cr].into_iter().enumerate() { + for mb in 0..mbs { + if src.chroma444 { + // 4:4:4 chroma block order within a macroblock: TL, BL, TR, BR. + for i in 0..4 { + chroma[k].push(load_block(plane, src.cw, src.h, x0 + mb * 16 + (i >> 1) * 8, y0 + (i & 1) * 8, src.scale)); + } + } else { + for i in 0..2 { + chroma[k].push(load_block(plane, src.cw, src.h, mb_x * 8 + mb * 8, y0 + i * 8, src.scale)); + } + } + } + } + let [cb, cr] = chroma; + let alpha = src.alpha.then(|| encode_alpha_slice(src, x0, y0, mbs)); + SliceData { comps: [luma, cb, cr], alpha } +} + +fn quantise_all(sd: &SliceData, q: u8, lv: &mut [Vec<[i32; 64]>; 3]) { + let inv = [1.0 / (4.0 * qscale(q) as f32); 64]; + for (c, l) in sd.comps.iter().zip(lv.iter_mut()) { + quantise(c, &inv, l); + } +} + +/// Coded DCT payload bits of a slice at quantiser `q` (components padded to bytes). +fn slice_bits(sd: &SliceData, q: u8) -> u64 { + let mut lv = [Vec::new(), Vec::new(), Vec::new()]; + quantise_all(sd, q, &mut lv); + lv.iter().map(|l| component_bits(l)).sum() +} + +/// Frame-level rate control: the smallest uniform quantiser whose frame fits the budget, then +/// one step finer on as many slices as the remaining budget allows. Uniform quantisation +/// approximately minimises frame MSE for a given size. +fn choose_quantisers(slices: &[SliceData], src: &Source, total_mbs: usize, threads: bool) -> Vec { + let n = slices.len(); + let hsize = slices.first().map_or(6, SliceData::header_size); + let fixed = 8 * (8 + 20 + 8 + n * (2 + hsize)) as u64; + let budget = (src.bits_per_mb as u64 * total_mbs as u64).saturating_sub(fixed); + let bits_at = |q: u8| -> Vec { par_map(threads, slices, |sd| slice_bits(sd, q)) }; + let sum = |v: &[u64]| v.iter().sum::(); + + let mut best = bits_at(Q_MIN); + let mut qg = Q_MIN; + if sum(&best) > budget { + let (mut lo, mut hi) = (Q_MIN + 1, Q_MAX); + let mut hi_bits = None; + while lo < hi { + let mid = lo + (hi - lo) / 2; + let v = bits_at(mid); + if sum(&v) <= budget { + hi = mid; + hi_bits = Some(v); + } else { + lo = mid + 1; + } + } + qg = lo; + best = hi_bits.unwrap_or_else(|| bits_at(qg)); + } + let mut qs = vec![qg; n]; + let mut cur = best; + let total = sum(&cur); + if qg > Q_MIN && total < budget { + let finer = bits_at(qg - 1); + let mut left = budget - total; + for i in 0..n { + let d = finer[i].saturating_sub(cur[i]); + if d <= left && finer[i] <= slices[i].limit_bits() { + left -= d; + qs[i] = qg - 1; + cur[i] = finer[i]; + } + } + } + // Keep every slice within its 16-bit size fields. + for i in 0..n { + while cur[i] > slices[i].limit_bits() && qs[i] < Q_MAX { + qs[i] += 1; + cur[i] = slice_bits(&slices[i], qs[i]); + } + } + qs +} + +fn write_slice(sd: &SliceData, q: u8) -> Vec { + let mut lv = [Vec::new(), Vec::new(), Vec::new()]; + quantise_all(sd, q, &mut lv); + let comps: Vec> = lv + .iter() + .map(|l| { + let mut w = Writer::new(); + encode_component(&mut w, l, &SCAN_PROGRESSIVE); + w.finish() + }) + .collect(); + let hsize = sd.header_size(); + let alpha = sd.alpha.as_deref().unwrap_or(&[]); + let mut out = Vec::with_capacity(hsize + comps.iter().map(Vec::len).sum::() + alpha.len()); + out.push((hsize as u8) << 3); + out.push(q); + out.extend_from_slice(&(comps[0].len() as u16).to_be_bytes()); + out.extend_from_slice(&(comps[1].len() as u16).to_be_bytes()); + if sd.alpha.is_some() { + out.extend_from_slice(&(comps[2].len() as u16).to_be_bytes()); + } + for c in &comps { + out.extend_from_slice(c); + } + out.extend_from_slice(alpha); + out +} + +/// Encode the slice's alpha samples (16-bit coding) in raster order over its 16 lines. +fn encode_alpha_slice(src: &Source, x0: usize, y0: usize, mbs: usize) -> Vec { + let a = src.frame.alpha.as_ref().expect("alpha checked"); + let d = src.frame.bit_depth as u32; + let to16 = |v: u16| -> u32 { + let v = v as u32 & ((1 << d) - 1); + if d >= 16 { v } else { ((v << (16 - d)) | (v >> (2 * d).saturating_sub(16).min(d))) & 0xffff } + }; + let sw = mbs * 16; + let mut vals = Vec::with_capacity(sw * 16); + for yy in 0..16 { + let y = (y0 + yy).min(src.h - 1); + for xx in 0..sw { + let x = (x0 + xx).min(src.w - 1); + vals.push(to16(a[y * src.w + x])); + } + } + let mut w = Writer::new(); + encode_alpha(&mut w, &vals, 16); + w.finish() +} + +/// Alpha plane coding: a value (difference from the previous value, short or full width), then +/// either a one bit (another value follows) or a zero bit and a run of repeats of the value. +pub(crate) fn encode_alpha(w: &mut Writer, vals: &[u32], bits: u32) { + let mask = (1u32 << bits) - 1; + let (short_bits, short_max) = if bits == 16 { (7, 64i32) } else { (4, 8i32) }; + let n = vals.len(); + let mut prev = mask; + let mut idx = 0; + while idx < n { + let v = vals[idx]; + let diff = v.wrapping_sub(prev) & mask; + // signed difference in (-2^(bits-1), 2^(bits-1)] + let sd = if diff > mask >> 1 { diff as i32 - (mask as i32 + 1) } else { diff as i32 }; + if sd != 0 && sd.abs() <= short_max { + w.put(0, 1); + let c = (2 * sd.unsigned_abs() - 2) | (sd < 0) as u32; + w.put(c, short_bits); + } else { + w.put(1, 1); + w.put(diff, bits); + } + prev = v; + idx += 1; + if idx >= n { + break; + } + let mut run = 0; + while idx + run < n && vals[idx + run] == prev && run < 2047 { + run += 1; + } + if run == 0 { + w.put(1, 1); + continue; + } + w.put(0, 1); + if run < 16 { + w.put(run as u32, 4); + } else { + w.put(0, 4); + w.put(run as u32, 11); + } + idx += run; + } +} diff --git a/crates/prores/src/header.rs b/crates/prores/src/header.rs new file mode 100644 index 00000000..8666c6d3 --- /dev/null +++ b/crates/prores/src/header.rs @@ -0,0 +1,234 @@ +//! Frame and picture headers (RDD 36 §5). + +use crate::{AlphaType, ChromaFormat, ColorInfo, Error, Interlace, Result}; +use filmcraft_bitstream::BitReader; + +/// `'icpf'`: the frame identifier following the 32-bit frame size. +pub const FRAME_ID: [u8; 4] = *b"icpf"; + +/// A parsed ProRes frame header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrameHeader { + /// Size of the frame header in bytes (from its own first field). + pub header_size: u16, + pub bitstream_version: u8, + /// Four-character encoder identifier (e.g. `apl0`, `Lavc`, `fmcr`). + pub encoder_id: [u8; 4], + pub width: u16, + pub height: u16, + pub chroma: ChromaFormat, + pub interlace: Interlace, + /// `aspect_ratio_information` (0 = unknown/square, 1 = 1:1, 2 = 4:3, 3 = 16:9). + pub aspect_ratio: u8, + /// `frame_rate_code` (0 = unknown). + pub frame_rate_code: u8, + pub color: ColorInfo, + pub alpha: AlphaType, + /// Luma quantisation matrix in raster order (all 4 when not transmitted). + pub qmat_luma: [u8; 64], + /// Chroma quantisation matrix in raster order (the luma matrix when not transmitted). + pub qmat_chroma: [u8; 64], + pub luma_matrix_present: bool, + pub chroma_matrix_present: bool, +} + +impl FrameHeader { + /// Parse the 8-byte frame prefix and frame header. Returns the header, the byte offset of the + /// first picture, and the frame size (bytes of `data` that belong to this frame). + pub fn parse(data: &[u8]) -> Result<(FrameHeader, usize, usize)> { + if data.len() < 8 + 20 { + return Err(Error::Truncated); + } + let frame_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; + if data[4..8] != FRAME_ID { + return Err(Error::Invalid("missing 'icpf' frame identifier")); + } + if frame_size < 28 || frame_size > data.len() { + return Err(Error::Truncated); + } + let data = &data[..frame_size]; + let h = &data[8..]; + let mut r = BitReader::new(h); + let rd = |r: &mut BitReader, n: u32| r.read_bits(n).map_err(|_| Error::Truncated); + let header_size = rd(&mut r, 16)? as u16; + if (header_size as usize) < 20 || 8 + header_size as usize > data.len() { + return Err(Error::Invalid("bad frame header size")); + } + let _reserved = rd(&mut r, 8)?; + let bitstream_version = rd(&mut r, 8)? as u8; + let encoder_id = [h[4], h[5], h[6], h[7]]; + r.skip(32).map_err(|_| Error::Truncated)?; + let width = rd(&mut r, 16)? as u16; + let height = rd(&mut r, 16)? as u16; + let chroma = match rd(&mut r, 2)? { + 2 => ChromaFormat::Yuv422, + 3 => ChromaFormat::Yuv444, + _ => return Err(Error::Unsupported("chroma format")), + }; + let _ = rd(&mut r, 2)?; + let interlace = match rd(&mut r, 2)? { + 0 => Interlace::Progressive, + 1 => Interlace::TopFieldFirst, + 2 => Interlace::BottomFieldFirst, + _ => return Err(Error::Invalid("interlace mode")), + }; + let _ = rd(&mut r, 2)?; + let aspect_ratio = rd(&mut r, 4)? as u8; + let frame_rate_code = rd(&mut r, 4)? as u8; + let color = ColorInfo { primaries: rd(&mut r, 8)? as u8, transfer: rd(&mut r, 8)? as u8, matrix: rd(&mut r, 8)? as u8 }; + let _ = rd(&mut r, 4)?; + let alpha = match rd(&mut r, 4)? { + 0 => AlphaType::None, + 1 => AlphaType::Bits8, + 2 => AlphaType::Bits16, + _ => return Err(Error::Unsupported("alpha channel type")), + }; + let _ = rd(&mut r, 14)?; + let luma_matrix_present = rd(&mut r, 1)? == 1; + let chroma_matrix_present = rd(&mut r, 1)? == 1; + let mut pos = 20usize; + let mut qmat_luma = [4u8; 64]; + if luma_matrix_present { + let m = h.get(pos..pos + 64).ok_or(Error::Truncated)?; + qmat_luma.copy_from_slice(m); + pos += 64; + } + let mut qmat_chroma = qmat_luma; + if chroma_matrix_present { + let m = h.get(pos..pos + 64).ok_or(Error::Truncated)?; + qmat_chroma.copy_from_slice(m); + pos += 64; + } + if pos > header_size as usize { + return Err(Error::Invalid("quantisation matrices exceed frame header")); + } + if qmat_luma.contains(&0) || qmat_chroma.contains(&0) { + return Err(Error::Invalid("zero quantisation matrix entry")); + } + if width == 0 || height == 0 { + return Err(Error::Invalid("zero frame dimension")); + } + let hdr = FrameHeader { + header_size, + bitstream_version, + encoder_id, + width, + height, + chroma, + interlace, + aspect_ratio, + frame_rate_code, + color, + alpha, + qmat_luma, + qmat_chroma, + luma_matrix_present, + chroma_matrix_present, + }; + Ok((hdr, 8 + header_size as usize, frame_size)) + } + + /// Serialise the frame header (without the 8-byte size/identifier prefix). + pub fn write(&self) -> Vec { + let mut v = Vec::with_capacity(148); + let size = 20 + 64 * (self.luma_matrix_present as usize + self.chroma_matrix_present as usize); + v.extend_from_slice(&(size as u16).to_be_bytes()); + v.push(0); + v.push(self.bitstream_version); + v.extend_from_slice(&self.encoder_id); + v.extend_from_slice(&self.width.to_be_bytes()); + v.extend_from_slice(&self.height.to_be_bytes()); + let cf = match self.chroma { + ChromaFormat::Yuv422 => 2u8, + ChromaFormat::Yuv444 => 3, + }; + let il = match self.interlace { + Interlace::Progressive => 0u8, + Interlace::TopFieldFirst => 1, + Interlace::BottomFieldFirst => 2, + }; + v.push((cf << 6) | (il << 2)); + v.push((self.aspect_ratio << 4) | (self.frame_rate_code & 15)); + v.push(self.color.primaries); + v.push(self.color.transfer); + v.push(self.color.matrix); + v.push(match self.alpha { + AlphaType::None => 0, + AlphaType::Bits8 => 1, + AlphaType::Bits16 => 2, + }); + v.push(0); + v.push(((self.luma_matrix_present as u8) << 1) | self.chroma_matrix_present as u8); + if self.luma_matrix_present { + v.extend_from_slice(&self.qmat_luma); + } + if self.chroma_matrix_present { + v.extend_from_slice(&self.qmat_chroma); + } + v + } +} + +/// A parsed picture header (one per progressive frame, two per interlaced frame). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PictureHeader { + pub header_size: usize, + /// Total picture size in bytes including this header, the slice table and the slices. + pub picture_size: usize, + pub num_slices: usize, + pub log2_slice_mbs: u8, +} + +impl PictureHeader { + pub fn parse(data: &[u8]) -> Result { + if data.len() < 8 { + return Err(Error::Truncated); + } + let header_size = (data[0] >> 3) as usize; + if header_size < 8 || header_size > data.len() { + return Err(Error::Invalid("bad picture header size")); + } + let picture_size = u32::from_be_bytes([data[1], data[2], data[3], data[4]]) as usize; + let num_slices = u16::from_be_bytes([data[5], data[6]]) as usize; + let log2_slice_mbs = (data[7] >> 4) & 3; + Ok(PictureHeader { header_size, picture_size, num_slices, log2_slice_mbs }) + } + + pub fn write(&self, out: &mut Vec) { + out.push(8 << 3); + out.extend_from_slice(&(self.picture_size as u32).to_be_bytes()); + out.extend_from_slice(&(self.num_slices as u16).to_be_bytes()); + out.push((self.log2_slice_mbs & 3) << 4); + } +} + +/// Slice widths (in macroblocks) of one macroblock row: as many `1 << log2` slices as fit, then +/// the remainder split into decreasing powers of two. +pub fn row_slice_widths(mb_width: usize, log2_slice_mbs: u8) -> Vec { + let size = 1usize << log2_slice_mbs; + let mut v = vec![size; mb_width / size]; + let mut rem = mb_width % size; + let mut s = size >> 1; + while rem > 0 { + if rem >= s { + v.push(s); + rem -= s; + } + s >>= 1; + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slice_widths() { + assert_eq!(row_slice_widths(120, 3), vec![8; 15]); + assert_eq!(row_slice_widths(45, 3), vec![8, 8, 8, 8, 8, 4, 1]); + assert_eq!(row_slice_widths(7, 3), vec![4, 2, 1]); + assert_eq!(row_slice_widths(2, 3), vec![2]); + assert_eq!(row_slice_widths(5, 0), vec![1; 5]); + } +} diff --git a/crates/prores/src/lib.rs b/crates/prores/src/lib.rs new file mode 100644 index 00000000..1e52fd97 --- /dev/null +++ b/crates/prores/src/lib.rs @@ -0,0 +1,211 @@ +//! Clean-room Apple ProRes codec (implemented from SMPTE RDD 36). +//! +//! - **Decoder** ([`decode_frame`], [`decode_frame_with`]): ProRes 422 Proxy/LT/Standard/HQ and +//! 4444/4444 XQ including the alpha channel; progressive and interlaced frames; slice-parallel +//! with the `threads` feature (rayon). Output is planar `u16` at 10 bits for 4:2:2 and 12 bits +//! for 4:4:4 by default (any depth 8..=16 on request). +//! - **Encoder** ([`Encoder`]): ProRes 422 (all four flavours) and 4444/4444 XQ (optionally with +//! alpha), progressive, with per-slice quantiser rate control targeting Apple's nominal rates. +//! +//! See the crate README for bitstream details, accuracy and limitations. + +mod bits; +mod dct; +mod decode; +mod encode; +pub mod header; +mod tables; + +pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe}; +pub use encode::{Encoder, EncoderConfig}; +pub use header::{FrameHeader, PictureHeader}; + +/// Codec errors. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("truncated ProRes data")] + Truncated, + #[error("invalid ProRes data: {0}")] + Invalid(&'static str), + #[error("unsupported ProRes feature: {0}")] + Unsupported(&'static str), + #[error("corrupt slice {slice} in picture {picture}")] + Slice { picture: usize, slice: usize }, + #[error("invalid encoder input: {0}")] + Input(String), +} + +pub type Result = std::result::Result; + +/// Chroma subsampling of a frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChromaFormat { + /// Chroma planes are half width, full height. + Yuv422, + /// Chroma planes are full size. + Yuv444, +} + +impl ChromaFormat { + /// Width of a chroma plane for a luma width. + pub fn chroma_width(self, width: u32) -> u32 { + match self { + ChromaFormat::Yuv422 => width.div_ceil(2), + ChromaFormat::Yuv444 => width, + } + } +} + +/// Frame scan structure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum Interlace { + #[default] + Progressive, + /// Two fields; the first coded picture is the top field (even lines). + TopFieldFirst, + /// Two fields; the first coded picture is the bottom field (odd lines). + BottomFieldFirst, +} + +/// Alpha channel coding in the bitstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum AlphaType { + #[default] + None, + Bits8, + Bits16, +} + +/// Colour description codes from the frame header (ITU-T H.273 / ISO 23091-2 code points; +/// 0 = unspecified in ProRes, 2 = unspecified in H.273). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ColorInfo { + pub primaries: u8, + pub transfer: u8, + pub matrix: u8, +} + +impl Default for ColorInfo { + /// BT.709 primaries, transfer and matrix. + fn default() -> Self { + ColorInfo { primaries: 1, transfer: 1, matrix: 1 } + } +} + +/// ProRes profiles (flavours). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Profile { + /// `apco` + Proxy, + /// `apcs` + Lt, + /// `apcn` + Standard, + /// `apch` + Hq, + /// `ap4h` + P4444, + /// `ap4x` + P4444Xq, +} + +impl Profile { + pub const ALL: [Profile; 6] = [Profile::Proxy, Profile::Lt, Profile::Standard, Profile::Hq, Profile::P4444, Profile::P4444Xq]; + + /// The QuickTime sample-entry four-character code. + pub fn fourcc(self) -> [u8; 4] { + *match self { + Profile::Proxy => b"apco", + Profile::Lt => b"apcs", + Profile::Standard => b"apcn", + Profile::Hq => b"apch", + Profile::P4444 => b"ap4h", + Profile::P4444Xq => b"ap4x", + } + } + + pub fn from_fourcc(f: &[u8; 4]) -> Option { + Profile::ALL.into_iter().find(|p| &p.fourcc() == f) + } + + pub fn chroma(self) -> ChromaFormat { + match self { + Profile::P4444 | Profile::P4444Xq => ChromaFormat::Yuv444, + _ => ChromaFormat::Yuv422, + } + } + + /// Apple's published nominal data rate at 1920×1080, 29.97 fps, in Mbit/s. + pub fn nominal_mbps_1080p30(self) -> f64 { + match self { + Profile::Proxy => 45.0, + Profile::Lt => 102.0, + Profile::Standard => 147.0, + Profile::Hq => 220.0, + Profile::P4444 => 330.0, + Profile::P4444Xq => 500.0, + } + } + + /// Nominal coded bits per 16×16 macroblock (the nominal rate spread over the 8160 + /// macroblocks of a 1920×1080 frame at 30000/1001 fps). Frame targets scale with area. + pub fn nominal_bits_per_mb(self) -> u32 { + (self.nominal_mbps_1080p30() * 1e6 * 1001.0 / 30000.0 / 8160.0).round() as u32 + } +} + +/// A decoded (or to-be-encoded) picture: planar `u16` samples, one sample per element, +/// rows packed without padding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub width: u32, + pub height: u32, + pub chroma: ChromaFormat, + /// Significant bits per sample of every plane (including alpha). + pub bit_depth: u8, + /// `width × height` + pub y: Vec, + /// `chroma_width × height` + pub cb: Vec, + /// `chroma_width × height` + pub cr: Vec, + /// `width × height` (full range: 0 = transparent, max = opaque) + pub alpha: Option>, + pub interlace: Interlace, + pub color: ColorInfo, + /// `aspect_ratio_information` from the frame header. + pub aspect_ratio: u8, + /// `frame_rate_code` from the frame header. + pub frame_rate_code: u8, +} + +impl Frame { + /// A black (video-range) frame with the given geometry. + pub fn new(width: u32, height: u32, chroma: ChromaFormat, bit_depth: u8, with_alpha: bool) -> Frame { + let n = width as usize * height as usize; + let cn = chroma.chroma_width(width) as usize * height as usize; + let s = bit_depth.saturating_sub(8) as u32; + Frame { + width, + height, + chroma, + bit_depth, + y: vec![(16u32 << s) as u16; n], + cb: vec![(128u32 << s) as u16; cn], + cr: vec![(128u32 << s) as u16; cn], + alpha: with_alpha.then(|| vec![((1u32 << bit_depth) - 1) as u16; n]), + interlace: Interlace::Progressive, + color: ColorInfo::default(), + aspect_ratio: 0, + frame_rate_code: 0, + } + } + + pub fn chroma_width(&self) -> u32 { + self.chroma.chroma_width(self.width) + } + + pub fn is_interlaced(&self) -> bool { + self.interlace != Interlace::Progressive + } +} diff --git a/crates/prores/src/tables.rs b/crates/prores/src/tables.rs new file mode 100644 index 00000000..a395e906 --- /dev/null +++ b/crates/prores/src/tables.rs @@ -0,0 +1,133 @@ +//! Constant tables defined by the ProRes bitstream format (SMPTE RDD 36). + +/// A ProRes variable-length codebook: a Golomb-Rice code for small values that switches to an +/// Exp-Golomb code for large ones. +/// +/// A codeword starts with `q` zero bits and a one bit. +/// - If `q <= rice_q_max` it is a Rice codeword: `value = (q << rice) | next(rice bits)`. +/// - Otherwise it is an Exp-Golomb codeword of order `exp` whose prefix is `q - rice_q_max - 1` +/// zeros, offset by the number of values the Rice part covers: `(rice_q_max + 1) << rice`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Codebook { + pub rice: u8, + pub exp: u8, + pub rice_q_max: u8, +} + +const fn cb(rice: u8, exp: u8, rice_q_max: u8) -> Codebook { + Codebook { rice, exp, rice_q_max } +} + +/// Codebook of the first DC coefficient in a slice component (equivalent to Exp-Golomb order 5). +pub(crate) const FIRST_DC_CB: Codebook = cb(5, 6, 0); + +/// DC difference codebooks, selected by the previous DC codeword value (clamped to 6). +pub(crate) const DC_CB: [Codebook; 7] = [cb(0, 1, 0), cb(1, 2, 0), cb(1, 2, 0), cb(2, 3, 1), cb(2, 3, 1), cb(3, 4, 0), cb(3, 4, 0)]; + +/// AC run codebooks, selected by the previous run (clamped to 15). +pub(crate) const RUN_CB: [Codebook; 16] = [ + cb(0, 1, 2), + cb(0, 1, 2), + cb(0, 1, 1), + cb(0, 1, 1), + cb(0, 1, 0), + cb(1, 2, 1), + cb(1, 2, 1), + cb(1, 2, 1), + cb(1, 2, 1), + cb(1, 2, 0), + cb(1, 2, 0), + cb(1, 2, 0), + cb(1, 2, 0), + cb(1, 2, 0), + cb(1, 2, 0), + cb(2, 3, 0), +]; + +/// AC level codebooks (level magnitude minus one), selected by the previous level magnitude (clamped to 9). +pub(crate) const LEVEL_CB: [Codebook; 10] = + [cb(0, 1, 0), cb(0, 2, 2), cb(0, 1, 1), cb(0, 1, 2), cb(0, 1, 0), cb(1, 2, 0), cb(1, 2, 0), cb(1, 2, 0), cb(1, 2, 0), cb(2, 3, 0)]; + +/// Initial DC codebook context for the second DC of a component. +pub(crate) const DC_CTX_INIT: u32 = 5; +/// Initial AC run / level codebook contexts. +pub(crate) const RUN_CTX_INIT: u32 = 4; +pub(crate) const LEVEL_CTX_INIT: u32 = 2; + +/// Progressive-frame coefficient scan: scan position → raster index within the 8×8 block. +pub(crate) const SCAN_PROGRESSIVE: [u8; 64] = [ + 0, 1, 8, 9, 2, 3, 10, 11, // + 16, 17, 24, 25, 18, 19, 26, 27, // + 4, 5, 12, 20, 13, 6, 7, 14, // + 21, 28, 29, 22, 15, 23, 30, 31, // + 32, 33, 40, 48, 41, 34, 35, 42, // + 49, 56, 57, 50, 43, 36, 37, 44, // + 51, 58, 59, 52, 45, 38, 39, 46, // + 53, 60, 61, 54, 47, 55, 62, 63, +]; + +/// Interlaced-field coefficient scan: scan position → raster index within the 8×8 block. +pub(crate) const SCAN_INTERLACED: [u8; 64] = [ + 0, 8, 1, 9, 16, 24, 17, 25, // + 2, 10, 3, 11, 18, 26, 19, 27, // + 32, 40, 33, 34, 41, 48, 56, 49, // + 42, 35, 43, 50, 57, 58, 51, 59, // + 4, 12, 5, 6, 13, 20, 28, 21, // + 14, 7, 15, 22, 29, 36, 44, 37, // + 30, 23, 31, 38, 45, 52, 60, 53, // + 46, 39, 47, 54, 61, 62, 55, 63, +]; + +/// Quantisation scale for a slice `quantization_index` (1..=224). +#[inline] +pub(crate) fn qscale(qidx: u8) -> u32 { + let q = qidx.max(1) as u32; + if q <= 128 { q } else { 128 + ((q - 128) << 2) } +} + +/// Orthonormal 1-D DCT-II basis: `BASIS[u][x] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`. +pub(crate) const BASIS: [[f32; 8]; 8] = [ + [0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39], + [0.490_392_64, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.490_392_64], + [0.461_939_77, 0.191_341_72, -0.191_341_72, -0.461_939_77, -0.461_939_77, -0.191_341_72, 0.191_341_72, 0.461_939_77], + [0.415_734_8, -0.097_545_16, -0.490_392_64, -0.277_785_12, 0.277_785_12, 0.490_392_64, 0.097_545_16, -0.415_734_8], + [0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39, 0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39], + [0.277_785_12, -0.490_392_64, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.490_392_64, -0.277_785_12], + [0.191_341_72, -0.461_939_77, 0.461_939_77, -0.191_341_72, -0.191_341_72, 0.461_939_77, -0.461_939_77, 0.191_341_72], + [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.490_392_64, 0.490_392_64, -0.415_734_8, 0.277_785_12, -0.097_545_16], +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scans_are_permutations() { + for scan in [&SCAN_PROGRESSIVE, &SCAN_INTERLACED] { + let mut seen = [false; 64]; + for &i in scan.iter() { + assert!(!seen[i as usize]); + seen[i as usize] = true; + } + } + } + + #[test] + fn basis_matches_cosines() { + for u in 0..8 { + for x in 0..8 { + let c = if u == 0 { (0.125f64).sqrt() } else { 0.5 }; + let v = c * (((2 * x + 1) * u) as f64 * std::f64::consts::PI / 16.0).cos(); + assert!((v - BASIS[u][x] as f64).abs() < 1e-7); + } + } + } + + #[test] + fn qscale_mapping() { + assert_eq!(qscale(1), 1); + assert_eq!(qscale(128), 128); + assert_eq!(qscale(129), 132); + assert_eq!(qscale(224), 512); + } +} diff --git a/crates/prores/tests/common/mod.rs b/crates/prores/tests/common/mod.rs new file mode 100644 index 00000000..d567dac6 --- /dev/null +++ b/crates/prores/tests/common/mod.rs @@ -0,0 +1,297 @@ +//! Shared helpers for oracle tests. ffmpeg is used only as an external fixture generator and +//! reference decoder (never linked). +#![allow(dead_code)] + +use filmcraft_prores::{ChromaFormat, Frame}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub fn ffmpeg() -> Option { + for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { + let p = Path::new(dir).join("ffmpeg"); + if p.exists() { + return Some(p); + } + } + let ok = Command::new("ffmpeg").arg("-version").output().map(|o| o.status.success()).unwrap_or(false); + if !ok { + eprintln!("ffmpeg not found; skipping oracle test"); + } + ok.then(|| PathBuf::from("ffmpeg")) +} + +pub fn fixture_dir() -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/prores"); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn run(ff: &Path, args: &[&str]) { + let out = Command::new(ff).args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).output().expect("run ffmpeg"); + assert!(out.status.success(), "ffmpeg {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr)); +} + +/// A ProRes fixture: a lavfi source encoded by an ffmpeg ProRes encoder. +pub struct Spec { + pub name: &'static str, + /// lavfi graph (source plus filters, including the input pixel format). + pub lavfi: String, + pub frames: u32, + /// Encoder arguments (codec, profile, flags...). + pub enc: Vec<&'static str>, +} + +impl Spec { + pub fn new(name: &'static str, lavfi: impl Into, frames: u32, enc: &[&'static str]) -> Spec { + Spec { name, lavfi: lavfi.into(), frames, enc: enc.to_vec() } + } +} + +/// Generate (or reuse) the fixture MOV. +pub fn make(ff: &Path, spec: &Spec) -> PathBuf { + let path = fixture_dir().join(format!("{}.mov", spec.name)); + if !path.exists() { + let tmp = fixture_dir().join(format!("{}.tmp.mov", spec.name)); + let frames = spec.frames.to_string(); + let mut args = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str()]; + args.extend(spec.enc.iter().copied()); + let t = tmp.to_str().unwrap().to_string(); + args.push(&t); + run(ff, &args); + std::fs::rename(&tmp, &path).unwrap(); + } + path +} + +/// Extract the coded ProRes frames of a MOV (ffmpeg's `data` muxer concatenates packets; ProRes +/// frames are self-delimiting through their leading 32-bit size). +pub fn packets(ff: &Path, mov: &Path) -> Vec> { + let out = Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-i"]) + .arg(mov) + .args(["-map", "0:v", "-c", "copy", "-f", "data", "-"]) + .output() + .expect("run ffmpeg"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + split_frames(&out.stdout) +} + +pub fn split_frames(mut d: &[u8]) -> Vec> { + let mut v = Vec::new(); + while d.len() >= 8 { + let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) as usize; + assert!(n >= 8 && n <= d.len(), "bad frame size"); + v.push(d[..n].to_vec()); + d = &d[n..]; + } + v +} + +/// Reference-decode a MOV with ffmpeg to raw planar little-endian 16-bit samples. +pub fn reference(ff: &Path, mov: &Path, pix_fmt: &str) -> Vec { + let out = Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-i"]) + .arg(mov) + .args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]) + .output() + .expect("run ffmpeg"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() +} + +/// Number of u16 samples per frame of a raw planar image. +pub fn frame_samples(w: usize, h: usize, chroma: ChromaFormat, alpha: bool) -> usize { + let cw = if chroma == ChromaFormat::Yuv422 { w.div_ceil(2) } else { w }; + w * h * (1 + alpha as usize) + 2 * cw * h +} + +#[derive(Default, Clone, Copy, Debug)] +pub struct Stats { + pub max: u32, + pub sum: u64, + pub n: u64, + pub exact: u64, +} + +impl Stats { + pub fn add(&mut self, a: &[u16], b: &[u16]) { + assert_eq!(a.len(), b.len()); + for (&x, &y) in a.iter().zip(b) { + let d = (x as i32 - y as i32).unsigned_abs(); + self.max = self.max.max(d); + self.sum += d as u64; + self.n += 1; + self.exact += (d == 0) as u64; + } + } + pub fn merge(&mut self, o: &Stats) { + self.max = self.max.max(o.max); + self.sum += o.sum; + self.n += o.n; + self.exact += o.exact; + } + pub fn mean(&self) -> f64 { + self.sum as f64 / self.n.max(1) as f64 + } + pub fn exact_pct(&self) -> f64 { + 100.0 * self.exact as f64 / self.n.max(1) as f64 + } +} + +/// Compare a decoded frame against a raw reference frame (planes in Y, Cb, Cr, A order). +pub fn compare(f: &Frame, raw: &[u16]) -> (Stats, Stats) { + let n = f.y.len(); + let c = f.cb.len(); + let mut luma = Stats::default(); + let mut rest = Stats::default(); + luma.add(&f.y, &raw[..n]); + rest.add(&f.cb, &raw[n..n + c]); + rest.add(&f.cr, &raw[n + c..n + 2 * c]); + if let Some(a) = &f.alpha { + rest.add(a, &raw[n + 2 * c..n + 2 * c + n]); + } + (luma, rest) +} + +/// PSNR over given peak. +pub fn psnr(a: &[u16], b: &[u16], peak: f64) -> f64 { + let mse: f64 = a.iter().zip(b).map(|(&x, &y)| (x as f64 - y as f64).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { 99.0 } else { 10.0 * (peak * peak / mse).log10() } +} + +fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = Vec::with_capacity(payload.len() + 8); + v.extend_from_slice(&((payload.len() + 8) as u32).to_be_bytes()); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v +} + +const MATRIX: [u32; 9] = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000]; + +/// Minimal QuickTime writer (test-only): one ProRes video track, all samples in one chunk. +pub fn write_mov(path: &Path, fourcc: &[u8; 4], width: u16, height: u16, depth: u16, frames: &[Vec]) { + let n = frames.len() as u32; + let be32 = |v: u32| v.to_be_bytes(); + let ftyp = bx(b"ftyp", &[b"qt ".as_slice(), &be32(0x200), b"qt "].concat()); + let mdat_payload: Vec = frames.concat(); + let data_off = ftyp.len() as u32 + 8; + let mdat = bx(b"mdat", &mdat_payload); + let matrix: Vec = MATRIX.iter().flat_map(|v| v.to_be_bytes()).collect(); + + let mut mvhd = vec![0u8; 4 + 8]; + mvhd.extend_from_slice(&be32(25)); + mvhd.extend_from_slice(&be32(n)); + mvhd.extend_from_slice(&be32(0x10000)); + mvhd.extend_from_slice(&[1, 0]); + mvhd.extend_from_slice(&[0; 10]); + mvhd.extend_from_slice(&matrix); + mvhd.extend_from_slice(&[0; 24]); + mvhd.extend_from_slice(&be32(2)); + + let mut tkhd = vec![0, 0, 0, 0xf]; + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&be32(1)); + tkhd.extend_from_slice(&[0; 4]); + tkhd.extend_from_slice(&be32(n)); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&[0; 8]); // layer, alternate group, volume, reserved + tkhd.extend_from_slice(&matrix); + tkhd.extend_from_slice(&be32((width as u32) << 16)); + tkhd.extend_from_slice(&be32((height as u32) << 16)); + + let mut mdhd = vec![0u8; 4 + 8]; + mdhd.extend_from_slice(&be32(25)); + mdhd.extend_from_slice(&be32(n)); + mdhd.extend_from_slice(&[0; 4]); + let hdlr = [&[0u8; 4][..], b"mhlr", b"vide", &[0; 12], &[0]].concat(); + let vmhd = [0u8, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]; + let dref = bx(b"dref", &[&[0u8; 4][..], &be32(1), &bx(b"alis", &[0, 0, 0, 1])].concat()); + let dinf = bx(b"dinf", &dref); + + let mut entry = vec![0u8; 6]; + entry.extend_from_slice(&[0, 1]); + entry.extend_from_slice(&[0; 4]); // version, revision + entry.extend_from_slice(b"fmcr"); + entry.extend_from_slice(&be32(0)); + entry.extend_from_slice(&be32(0x400)); + entry.extend_from_slice(&width.to_be_bytes()); + entry.extend_from_slice(&height.to_be_bytes()); + entry.extend_from_slice(&be32(72 << 16)); + entry.extend_from_slice(&be32(72 << 16)); + entry.extend_from_slice(&be32(0)); + entry.extend_from_slice(&[0, 1]); + entry.extend_from_slice(&[0; 32]); + entry.extend_from_slice(&depth.to_be_bytes()); + entry.extend_from_slice(&[0xff, 0xff]); + let stsd = bx(b"stsd", &[&[0u8; 4][..], &be32(1), &bx(fourcc, &entry)].concat()); + let stts = bx(b"stts", &[&[0u8; 4][..], &be32(1), &be32(n), &be32(1)].concat()); + let stsc = bx(b"stsc", &[&[0u8; 4][..], &be32(1), &be32(1), &be32(n), &be32(1)].concat()); + let mut stsz = vec![0u8; 4]; + stsz.extend_from_slice(&be32(0)); + stsz.extend_from_slice(&be32(n)); + for f in frames { + stsz.extend_from_slice(&be32(f.len() as u32)); + } + let stsz = bx(b"stsz", &stsz); + let stco = bx(b"stco", &[&[0u8; 4][..], &be32(1), &be32(data_off)].concat()); + let stbl = bx(b"stbl", &[stsd, stts, stsc, stsz, stco].concat()); + let minf = bx(b"minf", &[bx(b"vmhd", &vmhd), dinf, stbl].concat()); + let mdia = bx(b"mdia", &[bx(b"mdhd", &mdhd), bx(b"hdlr", &hdlr), minf].concat()); + let trak = bx(b"trak", &[bx(b"tkhd", &tkhd), mdia].concat()); + let moov = bx(b"moov", &[bx(b"mvhd", &mvhd), trak].concat()); + std::fs::write(path, [ftyp, mdat, moov].concat()).unwrap(); +} + +/// Decode a MOV with ffmpeg (failing on any decode error); returns (raw samples, stderr text). +pub fn ffmpeg_decode(ff: &Path, mov: &Path, pix_fmt: &str) -> (Vec, String) { + let out = Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-xerror", "-i"]) + .arg(mov) + .args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]) + .output() + .expect("run ffmpeg"); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(out.status.success(), "ffmpeg failed: {err}"); + (out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(), err) +} + +/// Render a lavfi source to raw 10-bit planar frames with ffmpeg and load them as [`Frame`]s. +pub fn source_frames(ff: &Path, name: &str, lavfi: &str, frames: u32, w: u32, h: u32, chroma: ChromaFormat, alpha: bool) -> Vec { + let path = fixture_dir().join(format!("{name}.raw")); + if !path.exists() { + let pix = match (chroma, alpha) { + (ChromaFormat::Yuv422, _) => "yuv422p10le", + (ChromaFormat::Yuv444, false) => "yuv444p10le", + (ChromaFormat::Yuv444, true) => "yuva444p10le", + }; + let tmp = fixture_dir().join(format!("{name}.tmp.raw")); + let f = frames.to_string(); + run(ff, &["-f", "lavfi", "-i", lavfi, "-frames:v", &f, "-f", "rawvideo", "-pix_fmt", pix, tmp.to_str().unwrap()]); + std::fs::rename(&tmp, &path).unwrap(); + } + let bytes = std::fs::read(&path).unwrap(); + let s: Vec = bytes.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + let (w, h) = (w as usize, h as usize); + let per = frame_samples(w, h, chroma, alpha); + assert_eq!(s.len(), per * frames as usize); + s.chunks_exact(per).map(|p| frame_from_raw(p, w as u32, h as u32, chroma, 10, alpha)).collect() +} + +pub fn frame_from_raw(p: &[u16], w: u32, h: u32, chroma: ChromaFormat, depth: u8, alpha: bool) -> Frame { + let mut f = Frame::new(w, h, chroma, depth, alpha); + let n = f.y.len(); + let c = f.cb.len(); + f.y.copy_from_slice(&p[..n]); + f.cb.copy_from_slice(&p[n..n + c]); + f.cr.copy_from_slice(&p[n + c..n + 2 * c]); + if let Some(a) = f.alpha.as_mut() { + a.copy_from_slice(&p[n + 2 * c..n + 2 * c + n]); + } + f +} + +/// Frame planes concatenated (Y, Cb, Cr, A). +pub fn planes(f: &Frame) -> Vec { + [&f.y, &f.cb, &f.cr].into_iter().chain(f.alpha.as_ref()).flat_map(|p| p.iter().copied()).collect() +} diff --git a/crates/prores/tests/fuzz.rs b/crates/prores/tests/fuzz.rs new file mode 100644 index 00000000..cd012a04 --- /dev/null +++ b/crates/prores/tests/fuzz.rs @@ -0,0 +1,125 @@ +//! Robustness: mutated and random inputs must never panic (errors are fine). + +mod common; +use filmcraft_prores::*; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +fn seeds() -> Vec> { + let mut v = Vec::new(); + for (p, w, h) in [(Profile::Proxy, 96u32, 48u32), (Profile::Hq, 70, 38), (Profile::P4444, 64, 32), (Profile::P4444Xq, 48, 20)] { + let chroma = p.chroma(); + let mut f = Frame::new(w, h, chroma, 10, chroma == ChromaFormat::Yuv444); + for (i, s) in f.y.iter_mut().enumerate() { + *s = (64 + (i * 37) % 800) as u16; + } + if let Some(a) = f.alpha.as_mut() { + for (i, s) in a.iter_mut().enumerate() { + *s = if i % 5 == 0 { 1023 } else { (i % 1024) as u16 }; + } + } + let mut cfg = EncoderConfig::new(p, w, h); + cfg.log2_slice_mbs = (w % 4) as u8; + v.push(Encoder::with_config(cfg).encode(&f).unwrap()); + } + // An interlaced ffmpeg stream, when fixtures from the oracle tests are present. + if let Some(ff) = common::ffmpeg() { + let spec = common::Spec::new( + "fuzz_seed_interlaced", + "testsrc2=s=64x48:r=25,setfield=tff,format=yuv422p10le", + 1, + &["-c:v", "prores_ks", "-profile:v", "2", "-flags", "+ildct+ilme"], + ); + let mov = common::make(&ff, &spec); + v.extend(common::packets(&ff, &mov)); + } + v +} + +#[test] +fn mutated_streams_never_panic() { + let seeds = seeds(); + for s in &seeds { + decode_frame(s).expect("seed decodes"); + } + let mut rng = Rng(0x9e3779b97f4a7c15); + let mut decoded_ok = 0; + let iters = 3000; + for i in 0..iters { + let mut d = seeds[i % seeds.len()].clone(); + match rng.below(6) { + 0 => { + for _ in 0..1 + rng.below(8) { + let p = rng.below(d.len()); + d[p] ^= 1 << rng.below(8); + } + } + 1 => { + for _ in 0..1 + rng.below(4) { + let p = rng.below(d.len()); + d[p] = rng.next() as u8; + } + } + 2 => { + let n = rng.below(d.len()); + d.truncate(n); + if d.len() >= 4 && rng.below(2) == 0 { + // keep the frame size field consistent with the truncation + let n = d.len() as u32; + d[..4].copy_from_slice(&n.to_be_bytes()); + } + } + 3 => { + // header fields + let p = 8 + rng.below(20.min(d.len() - 8)); + d[p] = rng.next() as u8; + } + 4 => { + // slice region garbage + let start = 150.min(d.len() - 1) + rng.below(d.len().saturating_sub(150).max(1)); + for b in d.iter_mut().skip(start).take(1 + rng.below(64)) { + *b = rng.next() as u8; + } + } + _ => { + let p = rng.below(d.len()); + let extra: Vec = (0..1 + rng.below(16)).map(|_| rng.next() as u8).collect(); + d.splice(p..p, extra); + } + } + for depth in [None, Some(8), Some(16)] { + let opts = DecodeOptions { bit_depth: depth, threads: i % 2 == 0 }; + if let Ok(f) = decode_frame_with(&d, &opts) { + decoded_ok += 1; + assert_eq!(f.y.len(), f.width as usize * f.height as usize); + } + } + let _ = probe(&d); + } + println!("{decoded_ok} of {} mutated decodes succeeded", iters * 3); +} + +#[test] +fn random_bytes_never_panic() { + let mut rng = Rng(12345); + for _ in 0..2000 { + let n = rng.below(400); + let mut d: Vec = (0..n).map(|_| rng.next() as u8).collect(); + if d.len() >= 8 && rng.below(2) == 0 { + d[..4].copy_from_slice(&(n as u32).to_be_bytes()); + d[4..8].copy_from_slice(b"icpf"); + } + let _ = decode_frame(&d); + } +} diff --git a/crates/prores/tests/oracle_decode.rs b/crates/prores/tests/oracle_decode.rs new file mode 100644 index 00000000..23387807 --- /dev/null +++ b/crates/prores/tests/oracle_decode.rs @@ -0,0 +1,142 @@ +//! Decoder accuracy against ffmpeg's ProRes decoder (oracle only; skipped without ffmpeg). + +mod common; +use common::*; +use filmcraft_prores::{AlphaType, ChromaFormat, DecodeOptions, Interlace, decode_frame, decode_frame_with, probe}; + +fn specs() -> Vec { + let p422 = "format=yuv422p10le"; + let p444 = "format=yuv444p10le"; + let alpha = "format=yuva444p10le,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='mod(X*7+Y*3,1024)'"; + let ks = |p: &'static str| -> Vec<&'static str> { vec!["-c:v", "prores_ks", "-profile:v", p] }; + let aw = |p: &'static str| -> Vec<&'static str> { vec!["-c:v", "prores_aw", "-profile:v", p] }; + let mut v = Vec::new(); + for (i, p) in ["0", "1", "2", "3"].into_iter().enumerate() { + let names = [ + ["ks_proxy_testsrc2_1080", "ks_lt_testsrc2_1080", "ks_std_testsrc2_1080", "ks_hq_testsrc2_1080"], + ["ks_proxy_mandel_1918", "ks_lt_mandel_1918", "ks_std_mandel_1918", "ks_hq_mandel_1918"], + ["ks_proxy_bars_720", "ks_lt_bars_720", "ks_std_bars_720", "ks_hq_bars_720"], + ["aw_proxy_testsrc2_640", "aw_lt_testsrc2_640", "aw_std_testsrc2_640", "aw_hq_testsrc2_640"], + ]; + v.push(Spec::new(names[0][i], format!("testsrc2=s=1920x1080:r=25,{p422}"), 2, &ks(p))); + v.push(Spec::new(names[1][i], format!("mandelbrot=s=1918x1080:r=25,{p422}"), 2, &ks(p))); + v.push(Spec::new(names[2][i], format!("smptehdbars=s=1280x720:r=25,{p422}"), 1, &ks(p))); + v.push(Spec::new(names[3][i], format!("testsrc2=s=640x360:r=25,{p422}"), 2, &aw(p))); + } + v.push(Spec::new("ks_hq_odd_70x38", format!("testsrc2=s=70x38:r=25,{p422}"), 2, &ks("3"))); + v.push(Spec::new("ks_hq_small_slices", format!("mandelbrot=s=352x288:r=25,{p422}"), 1, &[&ks("3")[..], &["-mbs_per_slice", "2"]].concat())); + let il = ["-flags", "+ildct+ilme"]; + v.push(Spec::new("ks_hq_ntsc_tff", format!("testsrc2=s=720x486:r=30000/1001,setfield=tff,{p422}"), 2, &[&ks("3")[..], &il].concat())); + v.push(Spec::new("ks_std_ntsc_bff", format!("mandelbrot=s=720x486:r=30000/1001,setfield=bff,{p422}"), 2, &[&ks("2")[..], &il].concat())); + v.push(Spec::new("aw_hq_1080i", format!("testsrc2=s=1920x1080:r=30000/1001,setfield=tff,{p422}"), 1, &[&aw("3")[..], &il].concat())); + v.push(Spec::new("ks_4444_testsrc2_1080", format!("testsrc2=s=1920x1080:r=25,{p444}"), 2, &ks("4"))); + v.push(Spec::new("ks_4444xq_mandel_1918", format!("mandelbrot=s=1918x1080:r=25,{p444}"), 1, &ks("5"))); + v.push(Spec::new("ks_4444_alpha16_640", format!("testsrc2=s=640x360:r=25,{alpha}"), 2, &[&ks("4")[..], &["-alpha_bits", "16"]].concat())); + v.push(Spec::new("ks_4444_alpha8_640", format!("testsrc2=s=640x360:r=25,{alpha}"), 1, &[&ks("4")[..], &["-alpha_bits", "8"]].concat())); + v.push(Spec::new("ks_4444xq_alpha_odd", format!("mandelbrot=s=333x201:r=25,{alpha}"), 1, &ks("5"))); + v.push(Spec::new("aw_4444_640", format!("testsrc2=s=640x360:r=25,{p444}"), 1, &aw("4"))); + v.push(Spec::new("aw_4444_alpha_640", format!("testsrc2=s=640x360:r=25,{alpha}"), 1, &aw("4"))); + v.push(Spec::new("ks_4444_ntsc_tff", format!("testsrc2=s=720x486:r=30000/1001,setfield=tff,{p444}"), 1, &[&ks("4")[..], &il].concat())); + v +} + +fn native_pix_fmt(chroma: ChromaFormat, alpha: bool) -> &'static str { + match (chroma, alpha) { + (ChromaFormat::Yuv422, _) => "yuv422p10le", + (ChromaFormat::Yuv444, false) => "yuv444p12le", + (ChromaFormat::Yuv444, true) => "yuva444p12le", + } +} + +#[test] +fn decode_matches_ffmpeg() { + let Some(ff) = ffmpeg() else { return }; + let filter = std::env::var("PRORES_FIXTURE").ok(); + let mut report = vec![format!("{:<26} {:>9} {:>5} {:>8} {:>8} {:>8} {:>8}", "fixture", "format", "maxY", "meanY", "maxC/A", "meanC/A", "exact%")]; + let mut worst = 0; + for spec in specs() { + if let Some(f) = &filter + && !spec.name.contains(f.as_str()) + { + continue; + } + let mov = make(&ff, &spec); + let pkts = packets(&ff, &mov); + assert_eq!(pkts.len(), spec.frames as usize, "{}", spec.name); + let hdr = probe(&pkts[0]).unwrap(); + let has_alpha = hdr.alpha != AlphaType::None; + let fmt = native_pix_fmt(hdr.chroma, has_alpha); + let raw = reference(&ff, &mov, fmt); + let per = frame_samples(hdr.width as usize, hdr.height as usize, hdr.chroma, has_alpha); + assert_eq!(raw.len(), per * pkts.len()); + let (mut sl, mut sc) = (Stats::default(), Stats::default()); + for (i, p) in pkts.iter().enumerate() { + let f = decode_frame(p).unwrap_or_else(|e| panic!("{}: frame {i}: {e}", spec.name)); + if spec.enc.contains(&"+ildct+ilme") { + assert_ne!(f.interlace, Interlace::Progressive); + } + let (l, c) = compare(&f, &raw[i * per..(i + 1) * per]); + sl.merge(&l); + sc.merge(&c); + } + let mut all = sl; + all.merge(&sc); + worst = worst.max(all.max); + report.push(format!( + "{:<26} {:>9} {:>5} {:>8.5} {:>8} {:>8.5} {:>8.3}", + spec.name, + &fmt[..fmt.len() - 2], + sl.max, + sl.mean(), + sc.max, + sc.mean(), + all.exact_pct() + )); + } + let text = report.join("\n"); + println!("{text}"); + std::fs::write(fixture_dir().join("decode_report.txt"), &text).unwrap(); + assert!(worst <= 1, "decoder deviates from reference by up to {worst} LSB\n{text}"); +} + +/// 10-bit output of 4444 streams stays within ±1 of ffmpeg's 12-bit output rounded to 10 bits. +#[test] +fn decode_4444_at_10_bits() { + let Some(ff) = ffmpeg() else { return }; + let spec = Spec::new("ks_4444_alpha16_640", "", 2, &[]); + let spec = specs().into_iter().find(|s| s.name == spec.name).unwrap(); + let mov = make(&ff, &spec); + let pkts = packets(&ff, &mov); + let raw12 = reference(&ff, &mov, "yuva444p12le"); + let per = frame_samples(640, 360, ChromaFormat::Yuv444, true); + let f = decode_frame_with(&pkts[0], &DecodeOptions { bit_depth: Some(10), threads: true }).unwrap(); + assert_eq!(f.bit_depth, 10); + let r10: Vec = raw12[..per].iter().map(|&v| ((v as u32 + 2) >> 2).min(1023) as u16).collect(); + let (l, c) = compare(&f, &r10); + assert!(l.max <= 1 && c.max <= 1, "{l:?} {c:?}"); +} + +#[test] +fn single_threaded_matches_threaded() { + let Some(ff) = ffmpeg() else { return }; + let spec = specs().into_iter().find(|s| s.name == "ks_hq_ntsc_tff").unwrap(); + let mov = make(&ff, &spec); + let pkts = packets(&ff, &mov); + let a = decode_frame_with(&pkts[0], &DecodeOptions { bit_depth: None, threads: false }).unwrap(); + let b = decode_frame(&pkts[0]).unwrap(); + assert_eq!(a, b); +} + +#[test] +fn color_metadata_is_reported() { + let Some(ff) = ffmpeg() else { return }; + let spec = Spec::new( + "ks_hq_bt2020_pq", + "testsrc2=s=128x64:r=25,format=yuv422p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc", + 1, + &["-c:v", "prores_ks", "-profile:v", "3"], + ); + let mov = make(&ff, &spec); + let f = decode_frame(&packets(&ff, &mov)[0]).unwrap(); + assert_eq!((f.color.primaries, f.color.transfer, f.color.matrix), (9, 16, 9)); +} diff --git a/crates/prores/tests/oracle_encode.rs b/crates/prores/tests/oracle_encode.rs new file mode 100644 index 00000000..ed269a6d --- /dev/null +++ b/crates/prores/tests/oracle_encode.rs @@ -0,0 +1,138 @@ +//! Encoder conformance and quality against ffmpeg's ProRes decoder (oracle only). + +mod common; +use common::*; +use filmcraft_prores::{ChromaFormat, DecodeOptions, Encoder, Frame, Profile, decode_frame, decode_frame_with}; + +struct Content { + name: &'static str, + lavfi: &'static str, + /// Detailed enough that every profile's rate control is budget-bound. + busy: bool, +} + +const W: u32 = 1920; +const H: u32 = 1080; + +fn contents(chroma: ChromaFormat) -> Vec { + match chroma { + ChromaFormat::Yuv422 => vec![ + Content { name: "src422_testsrc2", lavfi: "testsrc2=s=1920x1080:r=25", busy: false }, + Content { name: "src422_mandel", lavfi: "mandelbrot=s=1920x1080:r=25", busy: false }, + Content { name: "src422_noise", lavfi: "testsrc2=s=1920x1080:r=25,noise=alls=40:allf=t", busy: true }, + ], + ChromaFormat::Yuv444 => vec![ + Content { + name: "src444a_testsrc2_disc", + lavfi: "testsrc2=s=1920x1080:r=25,format=yuva444p10le,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='1023*lt(hypot(X-W/2,Y-H/2),400)'", + busy: false, + }, + Content { name: "src444a_noise", lavfi: "testsrc2=s=1920x1080:r=25,noise=alls=40:allf=t,format=yuva444p10le", busy: true }, + ], + } +} + +fn native_fmt(chroma: ChromaFormat, alpha: bool) -> &'static str { + match (chroma, alpha) { + (ChromaFormat::Yuv422, _) => "yuv422p10le", + (ChromaFormat::Yuv444, false) => "yuv444p12le", + (ChromaFormat::Yuv444, true) => "yuva444p12le", + } +} + +#[test] +fn encoder_output_decodes_in_ffmpeg() { + let Some(ff) = ffmpeg() else { return }; + let mut report = vec![format!( + "{:<9} {:<18} {:>9} {:>9} {:>6} {:>8} {:>8} {:>7} {:>7}", + "profile", "content", "target", "avg", "ratio", "psnrY", "psnrC", "ffΔmax", "ffΔ=0%" + )]; + let mut failures = Vec::new(); + for profile in Profile::ALL { + let chroma = profile.chroma(); + for c in contents(chroma) { + let alpha = chroma == ChromaFormat::Yuv444; + let src = source_frames(&ff, c.name, c.lavfi, 2, W, H, chroma, alpha); + let mut enc = Encoder::new(profile, W, H); + let coded: Vec> = src.iter().map(|f| enc.encode(f).unwrap()).collect(); + let target = enc.target_frame_bytes() as f64; + let avg = coded.iter().map(|c| c.len()).sum::() as f64 / coded.len() as f64; + let mov = fixture_dir().join(format!("enc_{}_{}.mov", String::from_utf8_lossy(&profile.fourcc()), c.name)); + write_mov(&mov, &profile.fourcc(), W as u16, H as u16, if alpha { 32 } else { 24 }, &coded); + let fmt = native_fmt(chroma, alpha); + let (ffraw, err) = ffmpeg_decode(&ff, &mov, fmt); + if !err.trim().is_empty() { + failures.push(format!("{profile:?}/{}: ffmpeg reported: {err}", c.name)); + } + let per = frame_samples(W as usize, H as usize, chroma, alpha); + assert_eq!(ffraw.len(), per * coded.len(), "{profile:?}/{}", c.name); + let (mut ffd, mut py, mut pc) = (Stats::default(), 0.0, 0.0); + for (i, (cf, s)) in coded.iter().zip(&src).enumerate() { + // Our decode of our stream agrees with ffmpeg's decode of it. + let ours = decode_frame(cf).unwrap(); + let (l, cc) = compare(&ours, &ffraw[i * per..(i + 1) * per]); + ffd.merge(&l); + ffd.merge(&cc); + // Quality against the 10-bit source. + let d10 = decode_frame_with(cf, &DecodeOptions { bit_depth: Some(10), threads: true }).unwrap(); + py += psnr(&d10.y, &s.y, 1023.0) / coded.len() as f64; + pc += psnr(&[d10.cb.clone(), d10.cr.clone()].concat(), &[s.cb.clone(), s.cr.clone()].concat(), 1023.0) / coded.len() as f64; + if let (Some(a), Some(b)) = (&d10.alpha, &s.alpha) { + assert_eq!(a, b, "alpha is lossless"); + } + } + let ratio = avg / target; + report.push(format!( + "{:<9} {:<18} {:>9.0} {:>9.0} {:>6.3} {:>8.2} {:>8.2} {:>7} {:>7.2}", + format!("{profile:?}"), + c.name, + target, + avg, + ratio, + py, + pc, + ffd.max, + ffd.exact_pct() + )); + if ffd.max > 1 { + failures.push(format!("{profile:?}/{}: ffmpeg decode differs from ours by {}", c.name, ffd.max)); + } + if ratio > 1.15 || (c.busy && ratio < 0.85) { + failures.push(format!("{profile:?}/{}: frame size {avg:.0} vs target {target:.0}", c.name)); + } + if profile == Profile::Hq && !c.busy && py.min(pc) < 45.0 { + failures.push(format!("HQ/{}: PSNR {py:.2}/{pc:.2} dB below 45", c.name)); + } + } + } + let text = report.join("\n"); + println!("{text}"); + std::fs::write(fixture_dir().join("encode_report.txt"), &text).unwrap(); + assert!(failures.is_empty(), "{}\n{text}", failures.join("\n")); +} + +/// Odd sizes and small slices round-trip through ffmpeg too. +#[test] +fn odd_geometry_encodes() { + let Some(ff) = ffmpeg() else { return }; + for (profile, w, h) in [(Profile::Standard, 1918u32, 1080u32), (Profile::Proxy, 70, 38), (Profile::P4444, 333, 201)] { + let chroma = profile.chroma(); + let alpha = chroma == ChromaFormat::Yuv444; + let lav = if alpha { + format!("mandelbrot=s={w}x{h}:r=25,format=yuva444p10le,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='mod(3*X+Y,1024)'") + } else { + format!("mandelbrot=s={w}x{h}:r=25") + }; + let name = format!("src_odd_{w}x{h}"); + let src = source_frames(&ff, Box::leak(name.into_boxed_str()), &lav, 1, w, h, chroma, alpha); + let mut enc = Encoder::new(profile, w, h); + let coded = vec![enc.encode(&src[0]).unwrap()]; + let mov = fixture_dir().join(format!("enc_odd_{w}x{h}.mov")); + write_mov(&mov, &profile.fourcc(), w as u16, h as u16, 24, &coded); + let (ffraw, err) = ffmpeg_decode(&ff, &mov, native_fmt(chroma, alpha)); + assert!(err.trim().is_empty(), "{err}"); + let ours: Frame = decode_frame(&coded[0]).unwrap(); + let (l, c) = compare(&ours, &ffraw); + assert!(l.max <= 1 && c.max <= 1, "{profile:?} {w}x{h}: {l:?} {c:?}"); + } +} diff --git a/crates/prores/tests/perf.rs b/crates/prores/tests/perf.rs new file mode 100644 index 00000000..a35e1282 --- /dev/null +++ b/crates/prores/tests/perf.rs @@ -0,0 +1,68 @@ +//! Decode/encode throughput. Run with +//! `cargo test --release -p filmcraft-prores --test perf -- --ignored --nocapture`. + +mod common; +use filmcraft_prores::*; +use std::time::Instant; + +/// A 1080p frame whose HQ encoding uses the full nominal rate (detailed, noisy content). +fn busy_1080p(chroma: ChromaFormat) -> Frame { + let mut f = Frame::new(1920, 1080, chroma, 10, false); + let mut s = 0x1234_5678u32; + let mut rnd = move || { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + s + }; + let cw = f.chroma_width() as usize; + for y in 0..1080usize { + for x in 0..1920usize { + let base = 200 + ((x * 3 + y * 2) % 600) as u32; + f.y[y * 1920 + x] = (base + rnd() % 48) as u16; + } + for x in 0..cw { + f.cb[y * cw + x] = (480 + (x + y) as u32 % 64 + rnd() % 16) as u16; + f.cr[y * cw + x] = (500 + (x * 7) as u32 % 50 + rnd() % 16) as u16; + } + } + f +} + +fn fps(frame: &[u8], threads: bool, secs: f64) -> f64 { + let opts = DecodeOptions { bit_depth: None, threads }; + // Reuse the output planes across frames, as a player would. + let mut out = decode_frame_with(frame, &opts).unwrap(); + let t = Instant::now(); + let mut n = 0; + while t.elapsed().as_secs_f64() < secs { + decode_frame_into(frame, &opts, &mut out).unwrap(); + std::hint::black_box(&out); + n += 1; + } + n as f64 / t.elapsed().as_secs_f64() +} + +#[test] +#[ignore] +fn decode_1080p_fps() { + let mut streams: Vec<(String, Vec)> = Vec::new(); + for p in [Profile::Hq, Profile::P4444] { + let src = busy_1080p(p.chroma()); + let mut enc = Encoder::new(p, 1920, 1080); + let t = Instant::now(); + let coded = enc.encode(&src).unwrap(); + println!("encode {p:?} 1080p: {:.1} ms, {} bytes", t.elapsed().as_secs_f64() * 1e3, coded.len()); + streams.push((format!("ours {p:?} (nominal rate)"), coded)); + } + if let Some(ff) = common::ffmpeg() { + let spec = common::Spec::new("perf_ks_hq_mandel_1080", "mandelbrot=s=1920x1080:r=25,format=yuv422p10le", 1, &["-c:v", "prores_ks", "-profile:v", "3"]); + let mov = common::make(&ff, &spec); + streams.push(("prores_ks HQ mandelbrot".into(), common::packets(&ff, &mov).remove(0))); + } + for (name, s) in &streams { + let mt = fps(s, true, 2.0); + let st = fps(s, false, 2.0); + println!("{name:<32} {:>8} bytes: {mt:7.1} fps threaded, {st:6.1} fps single-threaded", s.len()); + } +} diff --git a/crates/prores/tests/roundtrip.rs b/crates/prores/tests/roundtrip.rs new file mode 100644 index 00000000..e3b058e0 --- /dev/null +++ b/crates/prores/tests/roundtrip.rs @@ -0,0 +1,136 @@ +//! Encoder → decoder round trips on synthetic content (no external tools needed). + +mod common; +use common::psnr; +use filmcraft_prores::*; + +/// Deterministic test picture: gradients, a sharp-edged grid, a disc and mild noise. +pub fn synth(w: u32, h: u32, chroma: ChromaFormat, depth: u8, alpha: bool, seed: u32) -> Frame { + let mut f = Frame::new(w, h, chroma, depth, alpha); + let max = ((1u32 << depth) - 1) as f32; + let mut s = seed.wrapping_mul(2654435761).wrapping_add(1); + let mut rnd = move || { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s % 1000) as f32 / 1000.0 - 0.5 + }; + let cw = f.chroma_width() as usize; + let (wu, hu) = (w as usize, h as usize); + for y in 0..hu { + for x in 0..wu { + let fx = x as f32 / w as f32; + let fy = y as f32 / h as f32; + let grid = if (x / 24 + y / 24) % 2 == 0 { 0.15 } else { 0.0 }; + let disc = if (fx - 0.6).powi(2) + (fy - 0.4).powi(2) < 0.04 { 0.2 } else { 0.0 }; + let v = 0.1 + 0.5 * fx * (1.0 - fy) + grid + disc + 0.01 * rnd(); + f.y[y * wu + x] = (v.clamp(0.0, 1.0) * max) as u16; + if let Some(a) = f.alpha.as_mut() { + a[y * wu + x] = if disc > 0.0 { max as u16 } else { ((fx * max) as u16) & !7 }; + } + } + for x in 0..cw { + let fx = x as f32 / cw as f32; + f.cb[y * cw + x] = ((0.5 + 0.2 * (fx * 6.0).sin()) * max) as u16; + f.cr[y * cw + x] = ((0.5 - 0.15 * (y as f32 / 40.0).cos()) * max) as u16; + } + } + f +} + +fn quality(profile: Profile, w: u32, h: u32) -> (f64, f64, usize, usize) { + let chroma = profile.chroma(); + let src = synth(w, h, chroma, 10, chroma == ChromaFormat::Yuv444, 7); + let mut enc = Encoder::new(profile, w, h); + let coded = enc.encode(&src).unwrap(); + let hdr = probe(&coded).unwrap(); + assert_eq!((hdr.width as u32, hdr.height as u32, hdr.chroma), (w, h, chroma)); + let dec = decode_frame_with(&coded, &DecodeOptions { bit_depth: Some(10), threads: true }).unwrap(); + assert_eq!(dec.alpha, src.alpha, "alpha is lossless"); + let py = psnr(&dec.y, &src.y, 1023.0); + let pc = psnr(&[dec.cb, dec.cr].concat(), &[src.cb, src.cr].concat(), 1023.0); + (py, pc, coded.len(), enc.target_frame_bytes()) +} + +#[test] +fn every_profile_round_trips() { + let mut last = 0.0; + for p in [Profile::Proxy, Profile::Lt, Profile::Standard, Profile::Hq] { + let (py, pc, size, target) = quality(p, 640, 360); + println!("{p:?}: Y {py:.2} dB, C {pc:.2} dB, {size} / {target} bytes"); + assert!(size as f64 <= target as f64 * 1.15); + assert!(py >= last - 0.5, "quality should not drop with a higher profile"); + assert!(py > 38.0 && pc > 38.0); + last = py; + } + let (py, pc, _, _) = quality(Profile::Hq, 640, 360); + assert!(py >= 45.0 && pc >= 45.0); + for p in [Profile::P4444, Profile::P4444Xq] { + let (py, pc, size, target) = quality(p, 320, 240); + println!("{p:?}: Y {py:.2} dB, C {pc:.2} dB, {size} / {target} bytes"); + assert!(py >= 45.0 && pc >= 45.0); + } +} + +#[test] +fn odd_sizes_and_slice_widths() { + for (w, h) in [(1u32, 1u32), (17, 9), (70, 38), (1918, 34), (720, 486)] { + for log2 in [0u8, 1, 3] { + let src = synth(w, h, ChromaFormat::Yuv422, 10, false, w); + let mut cfg = EncoderConfig::new(Profile::Hq, w, h); + cfg.log2_slice_mbs = log2; + let coded = Encoder::with_config(cfg).encode(&src).unwrap(); + let dec = decode_frame(&coded).unwrap(); + assert_eq!((dec.width, dec.height, dec.bit_depth), (w, h, 10)); + assert_eq!(dec.cb.len(), w.div_ceil(2) as usize * h as usize); + assert!(psnr(&dec.y, &src.y, 1023.0) > 40.0, "{w}x{h} log2 {log2}"); + } + } +} + +#[test] +fn twelve_bit_input_and_output() { + let src = synth(256, 128, ChromaFormat::Yuv444, 12, true, 3); + let coded = Encoder::new(Profile::P4444Xq, 256, 128).encode(&src).unwrap(); + let dec = decode_frame(&coded).unwrap(); + assert_eq!(dec.bit_depth, 12); + assert!(psnr(&dec.y, &src.y, 4095.0) > 50.0); + assert_eq!(dec.alpha, src.alpha); +} + +#[test] +fn metadata_round_trips() { + let src = synth(64, 32, ChromaFormat::Yuv422, 10, false, 1); + let mut cfg = EncoderConfig::new(Profile::Standard, 64, 32); + cfg.color = ColorInfo { primaries: 9, transfer: 16, matrix: 9 }; + cfg.aspect_ratio = 1; + cfg.frame_rate_code = 3; + let coded = Encoder::with_config(cfg).encode(&src).unwrap(); + let hdr = probe(&coded).unwrap(); + assert_eq!(&hdr.encoder_id, b"fmcr"); + let dec = decode_frame(&coded).unwrap(); + assert_eq!(dec.color, ColorInfo { primaries: 9, transfer: 16, matrix: 9 }); + assert_eq!((dec.aspect_ratio, dec.frame_rate_code, dec.interlace), (1, 3, Interlace::Progressive)); +} + +#[test] +fn rejects_bad_input() { + let mut enc = Encoder::new(Profile::Hq, 64, 32); + assert!(enc.encode(&Frame::new(32, 32, ChromaFormat::Yuv422, 10, false)).is_err()); + assert!(enc.encode(&Frame::new(64, 32, ChromaFormat::Yuv444, 10, false)).is_err()); + let mut short = Frame::new(64, 32, ChromaFormat::Yuv422, 10, false); + short.cb.truncate(10); + assert!(enc.encode(&short).is_err()); + assert!(decode_frame(&[]).is_err()); + assert!(decode_frame(b"\0\0\0\x20icpfxxxxxxxxxxxxxxxxxxxxxxxx").is_err()); +} + +#[test] +fn profile_identifiers() { + for p in Profile::ALL { + assert_eq!(Profile::from_fourcc(&p.fourcc()), Some(p)); + } + assert_eq!(Profile::Hq.nominal_bits_per_mb(), 900); + assert_eq!(Profile::Proxy.chroma(), ChromaFormat::Yuv422); + assert_eq!(Profile::P4444Xq.chroma(), ChromaFormat::Yuv444); +}