M9.5: filmcraft-prores decoder + encoder

Clean-room ProRes (SMPTE RDD 36) codec: 422 Proxy/LT/Standard/HQ and
4444/4444 XQ decode incl. alpha and interlaced fields, slice-parallel;
progressive encoder with frame-level quantiser rate control targeting
Apple nominal rates. Oracle tests vs ffmpeg (within 1 LSB on all 29
fixtures), round-trip, fuzz and ignored perf tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 07:02:58 -04:00
parent 691864e9e7
commit eae1c8ad92
17 changed files with 3061 additions and 0 deletions

9
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

19
crates/prores/Cargo.toml Normal file
View File

@ -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 }

114
crates/prores/README.md Normal file
View File

@ -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<u8> = enc.encode(&frame)?; // the QuickTime sample for fourcc Profile::fourcc()
```
`Frame { width, height, chroma: ChromaFormat::{Yuv422, Yuv444}, bit_depth, y, cb, cr: Vec<u16>,
alpha: Option<Vec<u16>>, 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`.

280
crates/prores/src/bits.rs Normal file
View File

@ -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<u32> {
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<u8>,
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<u8> {
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<u32> = (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);
}
}

161
crates/prores/src/dct.rs Normal file
View File

@ -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);
}
}
}

498
crates/prores/src/decode.rs Normal file
View File

@ -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<u8>,
/// 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> {
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<Frame> {
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<u16>,
}
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<Frame> {
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<u16>, 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<Band> = 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);
}
}
}

495
crates/prores/src/encode.rs Normal file
View File

@ -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<u32>,
/// 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<Vec<u8>> {
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<SliceData> = 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<Vec<u8>> = 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: Sink>(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<Vec<u8>>,
}
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<T: Sync, R: Send>(threads: bool, items: &[T], f: impl Fn(&T) -> R + Sync + Send) -> Vec<R> {
#[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<u8> {
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<u64> { par_map(threads, slices, |sd| slice_bits(sd, q)) };
let sum = |v: &[u64]| v.iter().sum::<u64>();
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<u8> {
let mut lv = [Vec::new(), Vec::new(), Vec::new()];
quantise_all(sd, q, &mut lv);
let comps: Vec<Vec<u8>> = 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::<usize>() + 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<u8> {
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;
}
}

234
crates/prores/src/header.rs Normal file
View File

@ -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<u8> {
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<PictureHeader> {
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<u8>) {
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<usize> {
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]);
}
}

211
crates/prores/src/lib.rs Normal file
View File

@ -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<T> = std::result::Result<T, Error>;
/// 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> {
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<u16>,
/// `chroma_width × height`
pub cb: Vec<u16>,
/// `chroma_width × height`
pub cr: Vec<u16>,
/// `width × height` (full range: 0 = transparent, max = opaque)
pub alpha: Option<Vec<u16>>,
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
}
}

133
crates/prores/src/tables.rs Normal file
View File

@ -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);
}
}

View File

@ -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<PathBuf> {
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<String>, 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<Vec<u8>> {
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<Vec<u8>> {
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<u16> {
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::<f64>() / 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<u8> {
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<u8>]) {
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<u8> = frames.concat();
let data_off = ftyp.len() as u32 + 8;
let mdat = bx(b"mdat", &mdat_payload);
let matrix: Vec<u8> = 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<u16>, 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<Frame> {
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<u16> = 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<u16> {
[&f.y, &f.cb, &f.cr].into_iter().chain(f.alpha.as_ref()).flat_map(|p| p.iter().copied()).collect()
}

125
crates/prores/tests/fuzz.rs Normal file
View File

@ -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<Vec<u8>> {
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<u8> = (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<u8> = (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);
}
}

View File

@ -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<Spec> {
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<u16> = 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));
}

View File

@ -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<Content> {
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<Vec<u8>> = 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::<usize>() 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:?}");
}
}

View File

@ -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<u8>)> = 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());
}
}

View File

@ -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);
}