mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
craft-video: APV decoder and encoder from FilmCraft (apv) (#865)
FilmCraft's crates/apv at 4bec22bd moves to craft_video::apv: the decoder (craft_video::apv::decode, feature `apv-decode`), the encoder (craft_video::apv::encode, feature `apv-encode`), both re-exported at the module root; `apv` turns on both. Sources, README and tests (ffmpeg oracle, alpha, mutation and hostile sweeps). Changes in the move: craft_media::bitstream and craft-media-testkit instead of FilmCraft's, one test target (tests/apv), crate paths. New: DecodeOptions::memory_budget (craft_video::budget) refuses a frame whose samples do not fit before anything is allocated (Error::MemoryBudget). Fixed, each with a test that fails without it (debug-build overflows): - EncoderConfig::new multiplied width, height and 30 in u64 without a check. - Frame::new shifted by the bit depth unchecked (32 and above overflow). - The public transform functions shifted by a bit depth outside 10..=16.
This commit is contained in:
@@ -17,6 +17,10 @@ workspace = true
|
||||
# Every codec feature turns on one codec module; none is on by default.
|
||||
# DNxHD / DNxHR (SMPTE ST 2019-1): decoder and encoder.
|
||||
dnx = []
|
||||
# APV (Advanced Professional Video, RFC 9924): decoder and encoder; `apv-decode` or `apv-encode` for one of them.
|
||||
apv = ["apv-decode", "apv-encode"]
|
||||
apv-decode = ["craft-media/bitstream"]
|
||||
apv-encode = ["craft-media/bitstream"]
|
||||
# VP8 (RFC 6386): decoder.
|
||||
vp8 = []
|
||||
# Apple ProRes (SMPTE RDD 36): decoder and encoder.
|
||||
@@ -50,6 +54,10 @@ craft-media-testkit = { workspace = true }
|
||||
name = "dnx"
|
||||
required-features = ["dnx"]
|
||||
|
||||
[[test]]
|
||||
name = "apv"
|
||||
required-features = ["apv"]
|
||||
|
||||
[[test]]
|
||||
name = "vp8"
|
||||
required-features = ["vp8"]
|
||||
|
||||
@@ -19,6 +19,7 @@ No codec feature is on by default.
|
||||
| Feature | Module | Codec |
|
||||
|---|---|---|
|
||||
| `dnx` | [`dnx`](src/dnx/README.md) | Avid DNxHD / DNxHR (SMPTE ST 2019-1 VC-3): decoder and encoder |
|
||||
| `apv` | [`apv`](src/apv/README.md) | APV (Advanced Professional Video, RFC 9924): decoder (`apv-decode` alone) and encoder (`apv-encode` alone) |
|
||||
| `vp8` | `vp8` | VP8 (RFC 6386): decoder |
|
||||
| `prores` | `prores` | Apple ProRes (SMPTE RDD 36): decoder and encoder |
|
||||
| `h264` | [`h264`](src/h264/decode/README.md) | ITU-T H.264 / AVC: decoder (`h264-decode` alone) and encoder (`h264-encode` alone) |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# APV decoder and encoder (`craft_video::apv`, feature `apv`)
|
||||
|
||||
Clean-room Advanced Professional Video (APV) decoder and encoder in pure Rust, implemented strictly
|
||||
from the public **IETF RFC 9924** specification (*Advanced Professional Video*, February 2026,
|
||||
ISSN 2070-1721).
|
||||
|
||||
Depends only on `craft_media::bitstream`, `thiserror`, and (optional, feature `threads`) `rayon`.
|
||||
Builds for `wasm32-unknown-unknown`; no `unsafe`. Feature `apv-decode` builds the decoder alone
|
||||
(`craft_video::apv::decode`), `apv-encode` the encoder alone (`craft_video::apv::encode`); both
|
||||
are re-exported at `craft_video::apv`.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
use craft_video::apv::*;
|
||||
|
||||
// Decode one APV Access Unit (or raw bitstream AU prefixed with a 32-bit au_size).
|
||||
let frame: Frame = decode_frame(&au)?;
|
||||
let f8 = decode_frame_with(&au, &DecodeOptions { bit_depth: Some(8), ..Default::default() })?;
|
||||
decode_frame_into(&au, &DecodeOptions::default(), &mut frame_buf)?;
|
||||
let hdr: FrameHeader = probe(&au)?;
|
||||
|
||||
// Split a multi-AU raw .apv bitstream (RFC 9924 Appendix A).
|
||||
let aus: Vec<&[u8]> = split_raw_bitstream(&raw_apv_bytes)?;
|
||||
|
||||
// Encode progressive intra frames.
|
||||
let mut enc = Encoder::new(Profile::P422_10, 1920, 1080)?;
|
||||
let au: Vec<u8> = enc.encode(&frame)?; // Access Unit starting with 'aPv1'
|
||||
let raw_au: Vec<u8> = enc.encode_raw_au(&frame)?; // Prefixed with 32-bit au_size (ISOBMFF apv1 / .apv)
|
||||
let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationRecord payload
|
||||
```
|
||||
|
||||
## Specification Coverage (IETF RFC 9924)
|
||||
|
||||
### Decoder
|
||||
- **Access Units & PBUs (§5.3.1–5.3.3, Appendix A)**: Accepts both unprefixed Access Units
|
||||
(`'aPv1'` signature) and length-prefixed `raw_bitstream_access_unit()` packets. Parses primary
|
||||
(`pbu_type == 1`), non-primary (`2`), preview (`25`), auxiliary alpha (`27`), AU info (`65`),
|
||||
metadata (`66`), and filler (`67`) PBUs. PBUs or frame headers with non-zero `reserved_zero_*`
|
||||
fields are ignored per §5.3.3 and §5.3.5.
|
||||
- **Profiles & Sampling (§9.3)**: All 7 standard profiles (`422-10` idc 33, `422-12` idc 44,
|
||||
`444-10` idc 55, `444-12` idc 66, `4444-10` idc 77, `4444-12` idc 88, `400-10` idc 99) at
|
||||
coded bit depths 10–16, with optional output depth rescaling (`8..=16`).
|
||||
- **Quantization Matrices & Tiles (§5.3.7–5.3.8, §5.3.12–5.3.14)**: Default flat (`16`) and
|
||||
custom per-component 8×8 quantization matrices (`use_q_matrix`), optional `tile_size_in_fh`
|
||||
verification, and independent per-tile, per-component byte-aligned entropy partitions. Tiles
|
||||
decode in parallel via `rayon` when `threads` is enabled.
|
||||
- **Entropy Decoding (§5.3.15–5.3.16, §7.1)**: Adaptive Golomb-Rice / Exp-Golomb VLC decoding for
|
||||
`abs_dc_coeff_diff`, `coeff_zero_run`, and `abs_ac_coeff_minus1` with state tracking (`PrevDC`,
|
||||
`PrevDcDiff`, `Prev1stAcLevel`, `PrevLevel`, `PrevRun`) across the 8×8 blocks of each macroblock.
|
||||
- **Scaling & Inverse Transform (§6.3)**: Exact integer dequantization (`levelScale = [40, 45, 51,
|
||||
57, 64, 71]`, `bdShift = BitDepth - 2`) and 2-D separable 8×8 integer inverse transform using the
|
||||
RFC 9924 Figure 25 `transMatrix`. Note: in RFC 9924 §6.3.2.2, `transMatrix[m][n]` has row `m` as
|
||||
the frequency index (row 0 is `[64; 8]`) and column `n` as the spatial sample index, so the 1-D
|
||||
inverse transform evaluates `sum_j transMatrix[j][i] * x[j]`.
|
||||
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
|
||||
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
|
||||
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
|
||||
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/apv/fuzz.rs`
|
||||
across thousands of mutated and random inputs.
|
||||
- **Memory budget**: the samples of one access unit (the decoded tiles and the frame assembled
|
||||
from them, plus the same for an auxiliary alpha frame) must fit `DecodeOptions::memory_budget`
|
||||
(`craft_video::budget::DECODE_MEMORY_BUDGET` by default: 4 GiB, 1 GiB on 32-bit targets), or the
|
||||
frame is refused with `Error::MemoryBudget` before anything is allocated. The largest frame the
|
||||
format allows here (16384×16384 4:4:4:4) needs exactly 4 GiB.
|
||||
|
||||
### Encoder
|
||||
- Progressive intra encoding for all 7 profiles (`422-10`, `422-12`, `444-10`, `444-12`,
|
||||
`4444-10`, `4444-12`, `400-10`).
|
||||
- Supports fixed `qp` or binary-search frame-budget rate control over forward-transformed tiles.
|
||||
- Emits conforming tile grids (`tile_width_in_mbs >= 16`, `tile_height_in_mbs >= 8`,
|
||||
`TileCols, TileRows <= 20`), optional custom quantization matrices, optional `MDCV`/`CLL`
|
||||
metadata PBUs, and `APVDecoderConfigurationRecord` (`apvC`) metadata.
|
||||
|
||||
## Accuracy (vs FFmpeg as an External Oracle)
|
||||
|
||||
`tests/apv/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
|
||||
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
|
||||
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `craft_video::apv::decode_frame`.
|
||||
Because APV specifies an exact integer inverse transform, **the decoder is 100% bit-exact
|
||||
(`max_diff = 0`) with FFmpeg's APV decoder across every profile and test case.**
|
||||
@@ -0,0 +1,242 @@
|
||||
//! RFC 9924 §6.3 scaling and 8x8 integer inverse transform, plus the matching forward transform.
|
||||
|
||||
use crate::apv::tables::LEVEL_SCALE;
|
||||
|
||||
/// `qp` and `bit_depth` held to the format's ranges (`BitDepth` 10..=16, QP `0..=51 + QpBdOffset`):
|
||||
/// the codec only passes values in range, but these functions are public and the shifts below
|
||||
/// overflow outside it.
|
||||
#[inline(always)]
|
||||
fn in_range(qp: u8, bit_depth: u8) -> (u8, u8) {
|
||||
let bit_depth = bit_depth.clamp(10, 16);
|
||||
(qp.min(51 + 6 * (bit_depth - 8)), bit_depth)
|
||||
}
|
||||
|
||||
/// Gathers column `x` of an 8x8 block stored row-major.
|
||||
#[inline(always)]
|
||||
fn column(block: &[i32; 64], x: usize) -> [i32; 8] {
|
||||
std::array::from_fn(|i| block[i * 8 + x])
|
||||
}
|
||||
|
||||
/// 1D 8-point inverse transform using the RFC 9924 §6.3.2.3 even/odd butterfly decomposition,
|
||||
/// shifting each output by `(sum + round) >> shift`.
|
||||
#[inline(always)]
|
||||
fn idct_1d(x: [i32; 8], round: i32, shift: u32) -> [i32; 8] {
|
||||
let [x0, x1, x2, x3, x4, x5, x6, x7] = x;
|
||||
if (x1 | x2 | x3 | x4 | x5 | x6 | x7) == 0 {
|
||||
let v = (x0 * 64 + round) >> shift;
|
||||
return [v; 8];
|
||||
}
|
||||
let e0 = 64 * (x0 + x4);
|
||||
let e1 = 64 * (x0 - x4);
|
||||
let e2 = 35 * x2 - 84 * x6;
|
||||
let e3 = 84 * x2 + 35 * x6;
|
||||
|
||||
let even0 = e0 + e3;
|
||||
let even1 = e1 + e2;
|
||||
let even2 = e1 - e2;
|
||||
let even3 = e0 - e3;
|
||||
|
||||
let odd0 = 89 * x1 + 75 * x3 + 50 * x5 + 18 * x7;
|
||||
let odd1 = 75 * x1 - 18 * x3 - 89 * x5 - 50 * x7;
|
||||
let odd2 = 50 * x1 - 89 * x3 + 18 * x5 + 75 * x7;
|
||||
let odd3 = 18 * x1 - 50 * x3 + 75 * x5 - 89 * x7;
|
||||
|
||||
[
|
||||
(even0 + odd0 + round) >> shift,
|
||||
(even1 + odd1 + round) >> shift,
|
||||
(even2 + odd2 + round) >> shift,
|
||||
(even3 + odd3 + round) >> shift,
|
||||
(even3 - odd3 + round) >> shift,
|
||||
(even2 - odd2 + round) >> shift,
|
||||
(even1 - odd1 + round) >> shift,
|
||||
(even0 - odd0 + round) >> shift,
|
||||
]
|
||||
}
|
||||
|
||||
/// Fast path when an 8x8 block has only a DC coefficient (`coeff[1..64]` are all zero).
|
||||
#[inline]
|
||||
pub fn scale_and_idct8x8_dc_only(dc_coeff: i16, q0: u8, qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
|
||||
let (qp, bit_depth) = in_range(qp, bit_depth);
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let scaled = ((dc_coeff as i64 * q0 as i64 * level_scale) << qp_shift) + scale_round;
|
||||
let d0 = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
|
||||
let g0 = (d0 * 64 + 64) >> 7;
|
||||
|
||||
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
|
||||
let round = 1i32 << (bd_shift - 1);
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let max_val = (1i32 << bit_depth) - 1;
|
||||
let sample = (((g0 * 64 + round) >> bd_shift) + mid).clamp(0, max_val) as u16;
|
||||
out.fill(sample);
|
||||
}
|
||||
|
||||
/// Scale and inverse-transform one 8x8 block of transform coefficients (indexed by `y * 8 + x`)
|
||||
/// into reconstructed samples at `bit_depth` (`10..=16`), per RFC 9924 §6.3.
|
||||
#[inline]
|
||||
pub fn scale_and_idct8x8(coeff: &[i16; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
|
||||
let (qp, bit_depth) = in_range(qp, bit_depth);
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let mut d = [0i32; 64];
|
||||
for idx in 0..64 {
|
||||
let c = coeff[idx] as i64;
|
||||
if c != 0 {
|
||||
let qm = q_matrix[idx] as i64;
|
||||
let scaled = ((c * qm * level_scale) << qp_shift) + scale_round;
|
||||
d[idx] = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
|
||||
}
|
||||
}
|
||||
|
||||
// 1D vertical transform on each column x = 0..8, followed by (e + 64) >> 7
|
||||
let mut g = [0i32; 64];
|
||||
for x in 0..8 {
|
||||
let col = idct_1d(column(&d, x), 64, 7);
|
||||
for i in 0..8 {
|
||||
g[i * 8 + x] = col[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 1D horizontal transform on each row y = 0..8, followed by final bit-depth shift and offset
|
||||
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
|
||||
let round = 1i32 << (bd_shift - 1);
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let max_val = (1i32 << bit_depth) - 1;
|
||||
|
||||
for y in 0..8 {
|
||||
let r = &g[y * 8..y * 8 + 8];
|
||||
let row = idct_1d([r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7]], round, bd_shift);
|
||||
for i in 0..8 {
|
||||
out[y * 8 + i] = (row[i] + mid).clamp(0, max_val) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1D 8-point forward butterfly transform matching `TRANS_MATRIX`.
|
||||
#[inline(always)]
|
||||
fn fdct_1d(x: [i32; 8], round: i32, shift: u32) -> [i32; 8] {
|
||||
let [x0, x1, x2, x3, x4, x5, x6, x7] = x;
|
||||
let s0 = x0 + x7;
|
||||
let d0 = x0 - x7;
|
||||
let s1 = x1 + x6;
|
||||
let d1 = x1 - x6;
|
||||
let s2 = x2 + x5;
|
||||
let d2 = x2 - x5;
|
||||
let s3 = x3 + x4;
|
||||
let d3 = x3 - x4;
|
||||
|
||||
let es0 = s0 + s3;
|
||||
let ed0 = s0 - s3;
|
||||
let es1 = s1 + s2;
|
||||
let ed1 = s1 - s2;
|
||||
|
||||
[
|
||||
(64 * (es0 + es1) + round) >> shift,
|
||||
(89 * d0 + 75 * d1 + 50 * d2 + 18 * d3 + round) >> shift,
|
||||
(84 * ed0 + 35 * ed1 + round) >> shift,
|
||||
(75 * d0 - 18 * d1 - 89 * d2 - 50 * d3 + round) >> shift,
|
||||
(64 * (es0 - es1) + round) >> shift,
|
||||
(50 * d0 - 89 * d1 + 18 * d2 + 75 * d3 + round) >> shift,
|
||||
(35 * ed0 - 84 * ed1 + round) >> shift,
|
||||
(18 * d0 - 50 * d1 + 75 * d2 - 89 * d3 + round) >> shift,
|
||||
]
|
||||
}
|
||||
|
||||
/// Forward 8x8 transform of level-shifted samples (`sample - (1 << (bit_depth - 1))`) into the
|
||||
/// scaled coefficient domain `d[y * 8 + x]` matching [`scale_and_idct8x8`].
|
||||
#[inline]
|
||||
pub fn fdct8x8(samples: &[u16; 64], bit_depth: u8) -> [i32; 64] {
|
||||
let (_, bit_depth) = in_range(0, bit_depth);
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let shift1 = (bit_depth as u32).saturating_sub(5);
|
||||
let add1 = if shift1 > 0 { 1i32 << (shift1 - 1) } else { 0 };
|
||||
|
||||
// Horizontal forward pass
|
||||
let mut t = [0i32; 64];
|
||||
for y in 0..8 {
|
||||
let s = &samples[y * 8..y * 8 + 8];
|
||||
let row = fdct_1d(std::array::from_fn(|i| s[i] as i32 - mid), add1, shift1);
|
||||
t[y * 8..y * 8 + 8].copy_from_slice(&row);
|
||||
}
|
||||
|
||||
// Vertical forward pass
|
||||
let mut f = [0i32; 64];
|
||||
for u in 0..8 {
|
||||
let col = fdct_1d(column(&t, u), 128, 8);
|
||||
for v in 0..8 {
|
||||
f[v * 8 + u] = col[v];
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Quantize forward-transformed coefficients `f` (in the `d` domain) at `qp` with `q_matrix`.
|
||||
#[inline]
|
||||
pub fn quantize8x8(f: &[i32; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8) -> [i16; 64] {
|
||||
let (qp, bit_depth) = in_range(qp, bit_depth);
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let mut out = [0i16; 64];
|
||||
for idx in 0..64 {
|
||||
let val = f[idx] as i64;
|
||||
if val == 0 {
|
||||
continue;
|
||||
}
|
||||
let sign = if val < 0 { -1i64 } else { 1i64 };
|
||||
let abs_val = val.unsigned_abs() as i64;
|
||||
let qm = q_matrix[idx].max(1) as i64;
|
||||
let den = (qm * level_scale) << qp_shift;
|
||||
let num = abs_val << bd_shift_scale;
|
||||
// Deadzone/rounding offset: 1/2 for DC, 3/8 for AC
|
||||
let offset = if idx == 0 { den / 2 } else { (den * 3) / 8 };
|
||||
let level = ((num + offset) / den).min(32767);
|
||||
out[idx] = (level * sign) as i16;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The functions are public: a bit depth or QP outside the format's range must not overflow.
|
||||
#[test]
|
||||
fn out_of_range_bit_depth_and_qp_do_not_overflow() {
|
||||
let (src, qm) = ([u16::MAX; 64], [255u8; 64]);
|
||||
for depth in [0, 1, 9, 17, 20, 21, 64, 255] {
|
||||
for qp in [0, 99, 255] {
|
||||
let q = quantize8x8(&fdct8x8(&src, depth), &qm, qp, depth);
|
||||
let mut rec = [0u16; 64];
|
||||
scale_and_idct8x8(&q, &qm, qp, depth, &mut rec);
|
||||
scale_and_idct8x8(&[i16::MIN; 64], &qm, qp, depth, &mut rec);
|
||||
scale_and_idct8x8_dc_only(i16::MIN, 255, qp, depth, &mut rec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdct_idct_roundtrip_10bit() {
|
||||
let mut src = [0u16; 64];
|
||||
for (i, s) in src.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 13) % 850) as u16;
|
||||
}
|
||||
let qm = [16u8; 64];
|
||||
// At qp = 0 (very fine quantization), roundtrip is within 1 LSB
|
||||
let f = fdct8x8(&src, 10);
|
||||
let q = quantize8x8(&f, &qm, 0, 10);
|
||||
let mut rec = [0u16; 64];
|
||||
scale_and_idct8x8(&q, &qm, 0, 10, &mut rec);
|
||||
for i in 0..64 {
|
||||
let err = (src[i] as i32 - rec[i] as i32).abs();
|
||||
assert!(err <= 1, "idx {i}: src={} rec={} err={err}", src[i], rec[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! RFC 9924 APV frame and tile decoder.
|
||||
|
||||
use craft_media::bitstream::BitReader;
|
||||
|
||||
use crate::apv::dct::{scale_and_idct8x8, scale_and_idct8x8_dc_only};
|
||||
use crate::apv::header::{
|
||||
FrameHeader, PBU_ALPHA_FRAME, PBU_METADATA, PBU_NON_PRIMARY_FRAME, PBU_PREVIEW_FRAME, PBU_PRIMARY_FRAME, parse_au_pbus, parse_frame_header,
|
||||
parse_metadata_pbu, read_byte_alignment,
|
||||
};
|
||||
use crate::apv::tables::ZIGZAG_8X8;
|
||||
use crate::apv::{ChromaFormat, ContentLightLevel, Error, Frame, MasteringDisplay, Result};
|
||||
|
||||
/// Options for [`decode_frame_with`] and [`decode_frame_into`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodeOptions {
|
||||
/// Output sample bit depth (`8..=16`). `None` keeps the stream's coded bit depth (`10..=16`).
|
||||
pub bit_depth: Option<u8>,
|
||||
/// Decode tiles in parallel when the `threads` feature is enabled.
|
||||
pub threads: bool,
|
||||
/// Bytes the decoded samples of one access unit may take ([`crate::budget`]): the frame and
|
||||
/// the tiles it is assembled from, and the same for an auxiliary alpha frame. A frame that
|
||||
/// needs more is refused with [`Error::MemoryBudget`] before anything is allocated.
|
||||
pub memory_budget: u64,
|
||||
}
|
||||
|
||||
impl Default for DecodeOptions {
|
||||
fn default() -> Self {
|
||||
Self { bit_depth: None, threads: true, memory_budget: crate::budget::DECODE_MEMORY_BUDGET }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes decoding a frame PBU takes: its samples twice (the decoded tiles, padded to whole
|
||||
/// macroblocks, and the frame they are copied into). `None` on overflow.
|
||||
fn pbu_bytes(hdr: &FrameHeader, luma_only: bool) -> Option<u64> {
|
||||
let comps = if luma_only { 1 } else { hdr.chroma.num_comps() as u64 };
|
||||
crate::budget::PictureShape {
|
||||
width: u64::from(hdr.frame_width_in_mbs()) * 16,
|
||||
height: u64::from(hdr.frame_height_in_mbs()) * 16,
|
||||
shift_x: hdr.chroma.sub_width_c() - 1,
|
||||
shift_y: 0,
|
||||
chroma_planes: comps - 1,
|
||||
bytes_per_sample: 2,
|
||||
}
|
||||
.store_bytes(2)
|
||||
}
|
||||
|
||||
/// Parse RFC 9924 §7.1.4 variable-length codeword (`h(v)`).
|
||||
#[inline]
|
||||
pub(crate) fn read_vlc(r: &mut BitReader, k_param: u32) -> Result<u32> {
|
||||
let mut symbol_value: u64 = 0;
|
||||
let mut k = k_param;
|
||||
let parse_exp_golomb = if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
false
|
||||
} else if !r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
symbol_value += 1u64 << k;
|
||||
false
|
||||
} else {
|
||||
symbol_value += 2u64 << k;
|
||||
true
|
||||
};
|
||||
if parse_exp_golomb {
|
||||
loop {
|
||||
if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
break;
|
||||
}
|
||||
symbol_value += 1u64 << k;
|
||||
k += 1;
|
||||
if k > 28 {
|
||||
return Err(Error::Invalid("VLC codeword exceeds maximum length"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if k > 0 {
|
||||
symbol_value += r.read_bits(k).map_err(|_| Error::Truncated)? as u64;
|
||||
}
|
||||
u32::try_from(symbol_value).map_err(|_| Error::Invalid("VLC symbol overflow"))
|
||||
}
|
||||
|
||||
/// Probe the primary frame header of an Access Unit without decoding its tiles.
|
||||
pub fn probe(data: &[u8]) -> Result<FrameHeader> {
|
||||
let pbus = parse_au_pbus(data)?;
|
||||
for pbu in &pbus {
|
||||
if matches!(pbu.pbu_type, PBU_PRIMARY_FRAME | PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME)
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
return Ok(hdr);
|
||||
}
|
||||
}
|
||||
Err(Error::Invalid("no decodable frame PBU in access unit"))
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit with default options.
|
||||
pub fn decode_frame(data: &[u8]) -> Result<Frame> {
|
||||
decode_frame_with(data, &DecodeOptions::default())
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit with custom [`DecodeOptions`].
|
||||
pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result<Frame> {
|
||||
let mut out = Frame::new(2, 2, ChromaFormat::Yuv422, 10, false);
|
||||
decode_frame_into(data, opts, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit into `out`, reusing its plane allocations when possible.
|
||||
pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<FrameHeader> {
|
||||
if let Some(d) = opts.bit_depth
|
||||
&& !(8..=16).contains(&d)
|
||||
{
|
||||
return Err(Error::Invalid("requested output bit_depth must be in 8..=16"));
|
||||
}
|
||||
|
||||
let pbus = parse_au_pbus(data)?;
|
||||
let mut primary: Option<(u16, FrameHeader, &[u8])> = None;
|
||||
let mut alpha_pbu: Option<(FrameHeader, &[u8])> = None;
|
||||
let mut mdcv: Option<MasteringDisplay> = None;
|
||||
let mut cll: Option<ContentLightLevel> = None;
|
||||
|
||||
for pbu in &pbus {
|
||||
match pbu.pbu_type {
|
||||
PBU_PRIMARY_FRAME => {
|
||||
if primary.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME => {
|
||||
if primary.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_ALPHA_FRAME => {
|
||||
if alpha_pbu.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
alpha_pbu = Some((hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_METADATA => {
|
||||
let _ = parse_metadata_pbu(pbu.payload, &mut mdcv, &mut cll);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (_group_id, hdr, payload) = primary.ok_or(Error::Invalid("no primary frame PBU in access unit"))?;
|
||||
let out_depth = opts.bit_depth.unwrap_or(hdr.bit_depth);
|
||||
|
||||
// The auxiliary alpha frame is decoded only when the primary has no alpha and the sizes match.
|
||||
let alpha_pbu = alpha_pbu.filter(|(ahdr, _)| !hdr.chroma.has_alpha() && ahdr.width == hdr.width && ahdr.height == hdr.height);
|
||||
let alpha_bytes = alpha_pbu.as_ref().map_or(Some(0), |(ahdr, _)| pbu_bytes(ahdr, true));
|
||||
let fits = pbu_bytes(&hdr, false).zip(alpha_bytes).and_then(|(a, b)| a.checked_add(b)).is_some_and(|b| b <= opts.memory_budget);
|
||||
if !fits {
|
||||
return Err(Error::MemoryBudget);
|
||||
}
|
||||
|
||||
decode_pbu_planes(&hdr, payload, out_depth, opts.threads, out)?;
|
||||
out.mastering_display = mdcv;
|
||||
out.content_light = cll;
|
||||
|
||||
// If the primary frame has no 4th component and an auxiliary alpha frame PBU (type 27) is present
|
||||
// with matching dimensions, decode its luma plane as the alpha plane.
|
||||
if out.alpha.is_none()
|
||||
&& let Some((ahdr, apayload)) = alpha_pbu
|
||||
{
|
||||
// Start from a minimal frame: `decode_pbu_planes` sizes the planes only after every tile
|
||||
// of the auxiliary payload is present and decoded, so a truncated or bogus auxiliary PBU
|
||||
// cannot make us allocate a full-size frame first.
|
||||
let mut aframe = Frame::new(2, 2, ChromaFormat::Yuv422, out_depth, false);
|
||||
if decode_pbu_planes(&ahdr, apayload, out_depth, opts.threads, &mut aframe).is_ok() {
|
||||
out.alpha = Some(aframe.y);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hdr)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DecodedTile {
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
w_luma: u32,
|
||||
h_luma: u32,
|
||||
/// Decoded pixels for each component `c` in `0..num_comps`, packed with stride `w_luma / sub_w`.
|
||||
comps: [Vec<u16>; 4],
|
||||
}
|
||||
|
||||
fn decode_pbu_planes(hdr: &FrameHeader, payload: &[u8], out_depth: u8, threads: bool, out: &mut Frame) -> Result<()> {
|
||||
let num_tiles = hdr.num_tiles();
|
||||
let mut pos = hdr.header_bytes;
|
||||
let mut tile_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_tiles);
|
||||
|
||||
for tile_idx in 0..num_tiles {
|
||||
let sz_bytes = payload.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||
let tile_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||
if tile_size == 0 {
|
||||
return Err(Error::Invalid("tile_size cannot be 0"));
|
||||
}
|
||||
if let Some(fh_sizes) = &hdr.tile_sizes_in_fh
|
||||
&& fh_sizes.get(tile_idx).copied() != Some(tile_size as u32)
|
||||
{
|
||||
return Err(Error::Invalid("tile_size does not match tile_size_in_fh"));
|
||||
}
|
||||
pos += 4;
|
||||
let tile_end = pos.checked_add(tile_size).ok_or(Error::Truncated)?;
|
||||
let tile_bytes = payload.get(pos..tile_end).ok_or(Error::Truncated)?;
|
||||
pos = tile_end;
|
||||
tile_slices.push((tile_idx, tile_bytes));
|
||||
}
|
||||
|
||||
// Verify any trailing filler bytes in the frame PBU are 0xFF (RFC 9924 §5.3.4 & §5.3.11)
|
||||
if payload.get(pos..).is_some_and(|rest| rest.iter().any(|&b| b != 0xFF)) {
|
||||
return Err(Error::Invalid("non-0xFF filler byte at end of frame PBU"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
let decoded_tiles: Result<Vec<DecodedTile>> = if threads && tile_slices.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
tile_slices.into_par_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||
} else {
|
||||
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, threads)).collect()
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let decoded_tiles: Result<Vec<DecodedTile>> = {
|
||||
let _ = threads;
|
||||
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||
};
|
||||
|
||||
let mut decoded_tiles = decoded_tiles?;
|
||||
|
||||
// Prepare output frame metadata
|
||||
let w = hdr.width as usize;
|
||||
let h = hdr.height as usize;
|
||||
let cw = hdr.chroma.chroma_width(hdr.width) as usize;
|
||||
let ch = hdr.chroma.chroma_height(hdr.height) as usize;
|
||||
let n_luma = w.checked_mul(h).ok_or(Error::Invalid("frame luma size overflow"))?;
|
||||
let n_chroma = cw.checked_mul(ch).ok_or(Error::Invalid("frame chroma size overflow"))?;
|
||||
|
||||
out.width = hdr.width;
|
||||
out.height = hdr.height;
|
||||
out.chroma = hdr.chroma;
|
||||
out.bit_depth = out_depth;
|
||||
out.color = hdr.color;
|
||||
out.profile_idc = hdr.profile_idc;
|
||||
out.level_idc = hdr.level_idc;
|
||||
out.band_idc = hdr.band_idc;
|
||||
|
||||
// Zero-copy fast path when a single tile covers the exact frame dimensions without macroblock padding.
|
||||
if decoded_tiles.len() == 1 && decoded_tiles[0].w_luma == hdr.width && decoded_tiles[0].h_luma == hdr.height {
|
||||
let [y, cb, cr, a] = std::mem::take(&mut decoded_tiles[0].comps);
|
||||
out.y = y;
|
||||
if hdr.chroma.num_comps() >= 3 {
|
||||
out.cb = cb;
|
||||
out.cr = cr;
|
||||
} else {
|
||||
out.cb.clear();
|
||||
out.cr.clear();
|
||||
}
|
||||
out.alpha = if hdr.chroma == ChromaFormat::Yuv4444 { Some(a) } else { None };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
out.y.resize(n_luma, 0);
|
||||
out.cb.resize(n_chroma, 0);
|
||||
out.cr.resize(n_chroma, 0);
|
||||
if hdr.chroma == ChromaFormat::Yuv4444 {
|
||||
let a = out.alpha.get_or_insert_with(Vec::new);
|
||||
a.resize(n_luma, 0);
|
||||
} else {
|
||||
out.alpha = None;
|
||||
}
|
||||
|
||||
// Copy each tile's reconstructed samples into the cropped output planes (RFC 9924 §6)
|
||||
let sub_w = hdr.chroma.sub_width_c() as usize;
|
||||
let sub_h = hdr.chroma.sub_height_c() as usize;
|
||||
|
||||
for tile in &decoded_tiles {
|
||||
copy_tile_comp(
|
||||
&tile.comps[0],
|
||||
&TilePlacement { w: tile.w_luma as usize, h: tile.h_luma as usize, x: tile.x0 as usize, y: tile.y0 as usize },
|
||||
&mut out.y,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
if hdr.chroma.num_comps() >= 3 {
|
||||
let tw_c = (tile.w_luma as usize) / sub_w;
|
||||
let th_c = (tile.h_luma as usize) / sub_h;
|
||||
let tx_c = (tile.x0 as usize) / sub_w;
|
||||
let ty_c = (tile.y0 as usize) / sub_h;
|
||||
let chroma = TilePlacement { w: tw_c, h: th_c, x: tx_c, y: ty_c };
|
||||
copy_tile_comp(&tile.comps[1], &chroma, &mut out.cb, cw, ch);
|
||||
copy_tile_comp(&tile.comps[2], &chroma, &mut out.cr, cw, ch);
|
||||
}
|
||||
if hdr.chroma == ChromaFormat::Yuv4444
|
||||
&& let Some(alpha) = out.alpha.as_mut()
|
||||
{
|
||||
copy_tile_comp(
|
||||
&tile.comps[3],
|
||||
&TilePlacement { w: tile.w_luma as usize, h: tile.h_luma as usize, x: tile.x0 as usize, y: tile.y0 as usize },
|
||||
alpha,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Placement of a tile plane (`w` x `h` samples at `x`, `y`) within a frame plane.
|
||||
struct TilePlacement {
|
||||
w: usize,
|
||||
h: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
}
|
||||
|
||||
fn copy_tile_comp(src: &[u16], tile: &TilePlacement, dst: &mut [u16], fw: usize, fh: usize) {
|
||||
let TilePlacement { w: tw, h: th, x: tx, y: ty } = *tile;
|
||||
if tx >= fw || ty >= fh {
|
||||
return;
|
||||
}
|
||||
let copy_w = tw.min(fw - tx);
|
||||
let copy_h = th.min(fh - ty);
|
||||
for r in 0..copy_h {
|
||||
let s_off = r * tw;
|
||||
let d_off = (ty + r) * fw + tx;
|
||||
dst[d_off..d_off + copy_w].copy_from_slice(&src[s_off..s_off + copy_w]);
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_single_tile(hdr: &FrameHeader, tile_idx: usize, tile_bytes: &[u8], out_depth: u8, par_comps: bool) -> Result<DecodedTile> {
|
||||
let num_comps = hdr.chroma.num_comps();
|
||||
let min_hdr_size = 5 + 5 * num_comps;
|
||||
if tile_bytes.len() < min_hdr_size {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
|
||||
// tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||
let tile_header_size = u16::from_be_bytes([tile_bytes[0], tile_bytes[1]]) as usize;
|
||||
let tile_index = u16::from_be_bytes([tile_bytes[2], tile_bytes[3]]) as usize;
|
||||
if tile_index != tile_idx {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
if tile_header_size < min_hdr_size || tile_header_size > tile_bytes.len() {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
|
||||
let mut tile_data_size = [0usize; 4];
|
||||
for c in 0..num_comps {
|
||||
let off = 4 + c * 4;
|
||||
let sz = u32::from_be_bytes([tile_bytes[off], tile_bytes[off + 1], tile_bytes[off + 2], tile_bytes[off + 3]]) as usize;
|
||||
if sz == 0 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
tile_data_size[c] = sz;
|
||||
}
|
||||
|
||||
let max_tile_qp = (51 + hdr.qp_bd_offset()) as u8;
|
||||
let mut tile_qp = [0u8; 4];
|
||||
for c in 0..num_comps {
|
||||
let qp = tile_bytes[4 + num_comps * 4 + c];
|
||||
if qp > max_tile_qp {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
tile_qp[c] = qp;
|
||||
}
|
||||
|
||||
let reserved_zero_8bits = tile_bytes[4 + num_comps * 5];
|
||||
if reserved_zero_8bits != 0 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
|
||||
let tx = tile_idx % (hdr.tile_cols as usize);
|
||||
let ty = tile_idx / (hdr.tile_cols as usize);
|
||||
let x0 = hdr.col_starts[tx];
|
||||
let x1 = hdr.col_starts[tx + 1];
|
||||
let y0 = hdr.row_starts[ty];
|
||||
let y1 = hdr.row_starts[ty + 1];
|
||||
let w_luma = x1.saturating_sub(x0);
|
||||
let h_luma = y1.saturating_sub(y0);
|
||||
|
||||
let mut comp_off = tile_header_size;
|
||||
let mut comp_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_comps);
|
||||
for c in 0..num_comps {
|
||||
let comp_end = comp_off.checked_add(tile_data_size[c]).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
let comp_bytes = tile_bytes.get(comp_off..comp_end).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
comp_off = comp_end;
|
||||
comp_slices.push((c, comp_bytes));
|
||||
}
|
||||
|
||||
let geom = TileGeom { w_luma, h_luma, out_depth };
|
||||
#[cfg(feature = "threads")]
|
||||
let decoded_comps: Result<Vec<Vec<u16>>> = if par_comps && comp_slices.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
comp_slices.into_par_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], geom)).collect()
|
||||
} else {
|
||||
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], geom)).collect()
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let decoded_comps: Result<Vec<Vec<u16>>> = {
|
||||
let _ = par_comps;
|
||||
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], geom)).collect()
|
||||
};
|
||||
|
||||
let mut comps: [Vec<u16>; 4] = Default::default();
|
||||
for (c, buf) in decoded_comps?.into_iter().enumerate() {
|
||||
comps[c] = buf;
|
||||
}
|
||||
|
||||
Ok(DecodedTile { x0, y0, w_luma, h_luma, comps })
|
||||
}
|
||||
|
||||
/// Luma size of a tile and the output sample bit depth.
|
||||
#[derive(Clone, Copy)]
|
||||
struct TileGeom {
|
||||
w_luma: u32,
|
||||
h_luma: u32,
|
||||
out_depth: u8,
|
||||
}
|
||||
|
||||
fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[u8], qp: u8, geom: TileGeom) -> Result<Vec<u16>> {
|
||||
let TileGeom { w_luma, h_luma, out_depth } = geom;
|
||||
let num_mb_cols = w_luma / 16;
|
||||
let num_mb_rows = h_luma / 16;
|
||||
let sub_w = if c == 0 { 1 } else { hdr.chroma.sub_width_c() };
|
||||
let sub_h = if c == 0 { 1 } else { hdr.chroma.sub_height_c() };
|
||||
let tw_c = (w_luma / sub_w) as usize;
|
||||
let th_c = (h_luma / sub_h) as usize;
|
||||
let blk_w = (16 / sub_w) as usize;
|
||||
let blk_h = (16 / sub_h) as usize;
|
||||
let blocks_x = blk_w / 8;
|
||||
let blocks_y = blk_h / 8;
|
||||
|
||||
// Every coded 8x8 block needs at least one bit for its DC code. Validate
|
||||
// that the component can describe every required block before allocating
|
||||
// its complete reconstructed plane.
|
||||
let block_count = (num_mb_cols as usize)
|
||||
.checked_mul(num_mb_rows as usize)
|
||||
.and_then(|n| n.checked_mul(blocks_x))
|
||||
.and_then(|n| n.checked_mul(blocks_y))
|
||||
.ok_or(Error::CorruptTile(tile_idx))?;
|
||||
if comp_bytes.len() < block_count.div_ceil(8) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
let samples = tw_c.checked_mul(th_c).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
|
||||
let mut comp_buf = vec![0u16; samples];
|
||||
let mut r = BitReader::new(comp_bytes);
|
||||
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
|
||||
let q_mat = &hdr.q_matrix[c];
|
||||
let coded_depth = hdr.bit_depth;
|
||||
|
||||
for mb_y in 0..(num_mb_rows as usize) {
|
||||
for mb_x in 0..(num_mb_cols as usize) {
|
||||
for by in 0..blocks_y {
|
||||
for bx in 0..blocks_x {
|
||||
// abs_dc_coeff_diff (RFC 9924 §5.3.15 & §7.1.1)
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
let abs_dc_diff = read_vlc(&mut r, k_dc).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let sign_dc = if abs_dc_diff > 0 { r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))? } else { false };
|
||||
let diff = if sign_dc { -(abs_dc_diff as i64) } else { abs_dc_diff as i64 };
|
||||
let dc = prev_dc as i64 + diff;
|
||||
if !(-32768..=32767).contains(&dc) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
prev_dc = dc as i32;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut coeff = [0i16; 64];
|
||||
coeff[0] = dc as i16;
|
||||
|
||||
// ac_coeff_coding (RFC 9924 §5.3.16 & §7.1.2–7.1.3)
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
let run = read_vlc(&mut r, k_run).map_err(|_| Error::CorruptTile(tile_idx))? as usize;
|
||||
scan_pos = scan_pos.checked_add(run).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
if scan_pos > 64 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
prev_run = run as u32;
|
||||
if scan_pos < 64 {
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
let abs_ac_minus1 = read_vlc(&mut r, k_lvl).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let sign_ac = r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let abs_level = (abs_ac_minus1 as u64) + 1;
|
||||
let level_i64 = if sign_ac { -(abs_level as i64) } else { abs_level as i64 };
|
||||
if !(-32768..=32767).contains(&level_i64) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
coeff[ZIGZAG_8X8[scan_pos] as usize] = level_i64 as i16;
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level as u32;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rec = [0u16; 64];
|
||||
if first_ac {
|
||||
scale_and_idct8x8_dc_only(coeff[0], q_mat[0], qp, coded_depth, &mut rec);
|
||||
} else {
|
||||
scale_and_idct8x8(&coeff, q_mat, qp, coded_depth, &mut rec);
|
||||
}
|
||||
if out_depth != coded_depth {
|
||||
rescale_block(&mut rec, coded_depth, out_depth);
|
||||
}
|
||||
|
||||
let px0 = mb_x * blk_w + bx * 8;
|
||||
let py0 = mb_y * blk_h + by * 8;
|
||||
for ry in 0..8 {
|
||||
let dst_row = (py0 + ry) * tw_c + px0;
|
||||
comp_buf[dst_row..dst_row + 8].copy_from_slice(&rec[ry * 8..ry * 8 + 8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
read_byte_alignment(&mut r).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
Ok(comp_buf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rescale_block(block: &mut [u16; 64], from_depth: u8, to_depth: u8) {
|
||||
if to_depth > from_depth {
|
||||
let shift = (to_depth - from_depth) as u32;
|
||||
let fill_shift = from_depth as u32;
|
||||
for s in block.iter_mut() {
|
||||
let v = *s as u32;
|
||||
*s = ((v << shift) | (v >> (fill_shift - shift))) as u16;
|
||||
}
|
||||
} else if to_depth < from_depth {
|
||||
let shift = (from_depth - to_depth) as u32;
|
||||
let round = 1u32 << (shift - 1);
|
||||
let max_v = (1u32 << to_depth) - 1;
|
||||
for s in block.iter_mut() {
|
||||
*s = (((*s as u32) + round) >> shift).min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
//! RFC 9924 APV intra frame encoder.
|
||||
|
||||
use craft_media::bitstream::BitWriter;
|
||||
|
||||
use crate::apv::dct::{fdct8x8, quantize8x8};
|
||||
use crate::apv::header::{FrameHeader, MAX_DIMENSION, PBU_PRIMARY_FRAME, write_frame_header, write_metadata_pbu};
|
||||
use crate::apv::tables::{DEFAULT_Q_MATRIX_VAL, ZIGZAG_8X8, select_level_and_band};
|
||||
use crate::apv::{ChromaFormat, ColorInfo, ContentLightLevel, Error, Frame, MasteringDisplay, Profile, Result};
|
||||
|
||||
/// Generate RFC 9924 §7.2.4 variable-length codeword (`h(v)`).
|
||||
#[inline]
|
||||
pub(crate) fn write_vlc(w: &mut BitWriter, symbol_val: u32, k_param: u32) {
|
||||
const PREFIX_VLC: [[u32; 2]; 3] = [[1, 0], [0, 0], [0, 1]];
|
||||
let mut symbol_value = symbol_val;
|
||||
let val_prefix = ((symbol_val >> k_param) as usize).min(2);
|
||||
let mut bit_count = 0usize;
|
||||
let mut k = k_param;
|
||||
|
||||
while symbol_value >= (1u32 << k) {
|
||||
symbol_value -= 1u32 << k;
|
||||
if bit_count < 2 {
|
||||
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||
} else {
|
||||
w.write_bits(0, 1);
|
||||
}
|
||||
if bit_count >= 2 {
|
||||
k += 1;
|
||||
}
|
||||
bit_count += 1;
|
||||
}
|
||||
if bit_count < 2 {
|
||||
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||
} else {
|
||||
w.write_bits(1, 1);
|
||||
}
|
||||
if k > 0 {
|
||||
w.write_bits(symbol_value, k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact bit length of a VLC codeword generated by [`write_vlc`].
|
||||
#[inline]
|
||||
pub(crate) fn vlc_bit_len(symbol_val: u32, k_param: u32) -> u32 {
|
||||
if symbol_val < (1u32 << k_param) {
|
||||
1 + k_param
|
||||
} else if symbol_val < (2u32 << k_param) {
|
||||
2 + k_param
|
||||
} else {
|
||||
let rem = (symbol_val >> k_param) - 1;
|
||||
let zeros = 31 - rem.leading_zeros();
|
||||
3 + k_param + 2 * zeros
|
||||
}
|
||||
}
|
||||
|
||||
/// APV encoder configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EncoderConfig {
|
||||
pub profile: Profile,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
pub bit_depth: u8,
|
||||
pub color: ColorInfo,
|
||||
pub color_description_present: bool,
|
||||
/// Fixed tile quantization parameter (`0..=51 + QpBdOffset`). When `None`, rate control
|
||||
/// chooses a uniform QP per frame to fit [`Self::target_frame_bytes`].
|
||||
pub qp: Option<u8>,
|
||||
/// Target encoded frame size in bytes when `qp` is `None`.
|
||||
pub target_frame_bytes: u32,
|
||||
/// Tile width in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 16`).
|
||||
pub tile_width_in_mbs: u32,
|
||||
/// Tile height in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 8`).
|
||||
pub tile_height_in_mbs: u32,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
/// Optional custom per-component 8x8 quantization matrices (`y * 8 + x`).
|
||||
pub q_matrix: Option<[[u8; 64]; 4]>,
|
||||
/// Write `tile_size_in_fh` array in `frame_header()` (`tile_size_present_in_fh_flag = 1`).
|
||||
pub write_tile_sizes_in_fh: bool,
|
||||
pub mastering_display: Option<MasteringDisplay>,
|
||||
pub content_light: Option<ContentLightLevel>,
|
||||
}
|
||||
|
||||
impl EncoderConfig {
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Self {
|
||||
let chroma = profile.default_chroma();
|
||||
let bit_depth = profile.default_bit_depth();
|
||||
let w_mbs = width.div_ceil(16).max(1);
|
||||
let h_mbs = height.div_ceil(16).max(1);
|
||||
// Choose conforming tile dimensions (>= 16x8 MBs, <= 20x20 tiles) with ~4-8 tiles on HD/4K.
|
||||
let tile_width_in_mbs = w_mbs.div_ceil(4).max(16).max(w_mbs.div_ceil(20));
|
||||
let tile_height_in_mbs = h_mbs.div_ceil(4).max(8).max(h_mbs.div_ceil(20));
|
||||
let target_frame_bytes = profile.nominal_frame_bytes(width, height);
|
||||
let kbps = ((target_frame_bytes as u64) * 8 * 30 / 1000).max(1000) as u32;
|
||||
let (level_idc, band_idc) = select_level_and_band((width as u64).saturating_mul(height as u64).saturating_mul(30), kbps);
|
||||
Self {
|
||||
profile,
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
color: ColorInfo::BT709,
|
||||
color_description_present: true,
|
||||
qp: None,
|
||||
target_frame_bytes,
|
||||
tile_width_in_mbs,
|
||||
tile_height_in_mbs,
|
||||
level_idc,
|
||||
band_idc,
|
||||
q_matrix: None,
|
||||
write_tile_sizes_in_fh: false,
|
||||
mastering_display: None,
|
||||
content_light: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// APV intra encoder.
|
||||
pub struct Encoder {
|
||||
cfg: EncoderConfig,
|
||||
hdr: FrameHeader,
|
||||
}
|
||||
|
||||
struct PreparedTileComp {
|
||||
/// Forward-transformed 8x8 blocks in MB-then-subblock scan order.
|
||||
blocks: Vec<[i32; 64]>,
|
||||
}
|
||||
|
||||
struct PreparedTile {
|
||||
tile_idx: usize,
|
||||
comps: Vec<PreparedTileComp>,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Result<Self> {
|
||||
Self::with_config(EncoderConfig::new(profile, width, height))
|
||||
}
|
||||
|
||||
pub fn with_config(cfg: EncoderConfig) -> Result<Self> {
|
||||
if cfg.width == 0 || cfg.height == 0 || cfg.width > MAX_DIMENSION || cfg.height > MAX_DIMENSION {
|
||||
return Err(Error::Input(format!("unsupported dimensions {}x{}", cfg.width, cfg.height)));
|
||||
}
|
||||
if cfg.chroma == ChromaFormat::Yuv422 && !cfg.width.is_multiple_of(2) {
|
||||
return Err(Error::Input("4:2:2 requires even frame width".into()));
|
||||
}
|
||||
if !(10..=16).contains(&cfg.bit_depth) {
|
||||
return Err(Error::Input(format!("unsupported coded bit depth {}", cfg.bit_depth)));
|
||||
}
|
||||
if !cfg.profile.supports(cfg.chroma, cfg.bit_depth) {
|
||||
return Err(Error::Input(format!("profile {} does not support {:?} {}-bit", cfg.profile.name(), cfg.chroma, cfg.bit_depth)));
|
||||
}
|
||||
if cfg.tile_width_in_mbs == 0 || cfg.tile_height_in_mbs == 0 {
|
||||
return Err(Error::Input("tile dimensions in MBs must be non-zero".into()));
|
||||
}
|
||||
|
||||
let w_mbs = cfg.width.div_ceil(16);
|
||||
let h_mbs = cfg.height.div_ceil(16);
|
||||
let tile_cols = w_mbs.div_ceil(cfg.tile_width_in_mbs);
|
||||
let tile_rows = h_mbs.div_ceil(cfg.tile_height_in_mbs);
|
||||
if tile_cols > 20 || tile_rows > 20 {
|
||||
return Err(Error::Input(format!("tile grid {tile_cols}x{tile_rows} exceeds RFC 9924 limit of 20x20")));
|
||||
}
|
||||
|
||||
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||
let mut s = 0u32;
|
||||
while s < w_mbs {
|
||||
col_starts.push(s * 16);
|
||||
s += cfg.tile_width_in_mbs;
|
||||
}
|
||||
col_starts.push(w_mbs * 16);
|
||||
|
||||
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||
let mut s = 0u32;
|
||||
while s < h_mbs {
|
||||
row_starts.push(s * 16);
|
||||
s += cfg.tile_height_in_mbs;
|
||||
}
|
||||
row_starts.push(h_mbs * 16);
|
||||
|
||||
if let Some(qm) = &cfg.q_matrix {
|
||||
for c in 0..cfg.chroma.num_comps() {
|
||||
if qm[c].contains(&0) {
|
||||
return Err(Error::Input("q_matrix entries must be non-zero (1..=255)".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hdr = FrameHeader {
|
||||
profile_idc: cfg.profile.idc(),
|
||||
level_idc: cfg.level_idc,
|
||||
band_idc: cfg.band_idc.min(3),
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
chroma: cfg.chroma,
|
||||
bit_depth: cfg.bit_depth,
|
||||
capture_time_distance: 0,
|
||||
color_description_present: cfg.color_description_present,
|
||||
color: cfg.color,
|
||||
use_q_matrix: cfg.q_matrix.is_some(),
|
||||
q_matrix: cfg.q_matrix.unwrap_or([[DEFAULT_Q_MATRIX_VAL; 64]; 4]),
|
||||
tile_width_in_mbs: cfg.tile_width_in_mbs,
|
||||
tile_height_in_mbs: cfg.tile_height_in_mbs,
|
||||
tile_cols,
|
||||
tile_rows,
|
||||
col_starts,
|
||||
row_starts,
|
||||
tile_sizes_in_fh: None,
|
||||
header_bytes: 0,
|
||||
};
|
||||
|
||||
Ok(Self { cfg, hdr })
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &EncoderConfig {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
pub fn target_frame_bytes(&self) -> u32 {
|
||||
self.cfg.target_frame_bytes
|
||||
}
|
||||
|
||||
/// Encode one frame into an APV Access Unit (`b"aPv1"...`), suitable for MP4/MOV (`apv1`) or MKV (`V_APV`).
|
||||
pub fn encode(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||
if frame.width != self.cfg.width || frame.height != self.cfg.height {
|
||||
return Err(Error::Input(format!("frame is {}x{}, encoder is {}x{}", frame.width, frame.height, self.cfg.width, self.cfg.height)));
|
||||
}
|
||||
if frame.chroma != self.cfg.chroma {
|
||||
return Err(Error::Input(format!("frame chroma {:?} does not match encoder {:?}", frame.chroma, self.cfg.chroma)));
|
||||
}
|
||||
if !(8..=16).contains(&frame.bit_depth) {
|
||||
return Err(Error::Input(format!("unsupported input bit depth {}", frame.bit_depth)));
|
||||
}
|
||||
let n_luma = (frame.width as usize) * (frame.height as usize);
|
||||
let n_chroma = (frame.chroma_width() as usize) * (frame.chroma_height() as usize);
|
||||
if frame.y.len() != n_luma || frame.cb.len() != n_chroma || frame.cr.len() != n_chroma {
|
||||
return Err(Error::Input("plane buffer lengths do not match frame geometry".into()));
|
||||
}
|
||||
if self.cfg.chroma == ChromaFormat::Yuv4444 && frame.alpha.as_ref().is_none_or(|a| a.len() != n_luma) {
|
||||
return Err(Error::Input("4:4:4:4 profile requires an alpha plane of width * height samples".into()));
|
||||
}
|
||||
|
||||
let prepared = self.prepare_tiles(frame);
|
||||
let max_qp = (51 + self.hdr.qp_bd_offset()) as u8;
|
||||
let qp = match self.cfg.qp {
|
||||
Some(q) => q.min(max_qp),
|
||||
None => self.select_qp(&prepared, max_qp),
|
||||
};
|
||||
|
||||
let encoded_tiles: Vec<Vec<u8>> = {
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
prepared.par_iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
prepared.iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||
}
|
||||
};
|
||||
|
||||
let mut hdr = self.hdr.clone();
|
||||
hdr.color = if !self.cfg.color_description_present {
|
||||
ColorInfo::default()
|
||||
} else if frame.color != ColorInfo::BT709 {
|
||||
frame.color
|
||||
} else {
|
||||
self.cfg.color
|
||||
};
|
||||
if self.cfg.write_tile_sizes_in_fh {
|
||||
hdr.tile_sizes_in_fh = Some(encoded_tiles.iter().map(|t| t.len() as u32).collect());
|
||||
}
|
||||
let fh_bytes = write_frame_header(&hdr);
|
||||
|
||||
let tiles_total_bytes: usize = encoded_tiles.iter().map(|t| 4 + t.len()).sum();
|
||||
let pbu_size = 4 + fh_bytes.len() + tiles_total_bytes;
|
||||
|
||||
let mdcv = frame.mastering_display.as_ref().or(self.cfg.mastering_display.as_ref());
|
||||
let cll = frame.content_light.as_ref().or(self.cfg.content_light.as_ref());
|
||||
let meta_pbu = write_metadata_pbu(1, mdcv, cll);
|
||||
let meta_total = meta_pbu.as_ref().map_or(0, |m| 4 + m.len());
|
||||
|
||||
let mut au = Vec::with_capacity(4 + 4 + pbu_size + meta_total);
|
||||
au.extend_from_slice(b"aPv1");
|
||||
// Primary frame PBU (pbu_type = 1, group_id = 1)
|
||||
au.extend_from_slice(&(pbu_size as u32).to_be_bytes());
|
||||
au.push(PBU_PRIMARY_FRAME);
|
||||
au.extend_from_slice(&1u16.to_be_bytes());
|
||||
au.push(0); // reserved_zero_8bits
|
||||
au.extend_from_slice(&fh_bytes);
|
||||
for t in &encoded_tiles {
|
||||
au.extend_from_slice(&(t.len() as u32).to_be_bytes());
|
||||
au.extend_from_slice(t);
|
||||
}
|
||||
if let Some(m) = meta_pbu {
|
||||
au.extend_from_slice(&(m.len() as u32).to_be_bytes());
|
||||
au.extend_from_slice(&m);
|
||||
}
|
||||
Ok(au)
|
||||
}
|
||||
|
||||
/// Encode one frame as a `raw_bitstream_access_unit()` (prefixed by `au_size: u32 BE`, RFC 9924 Appendix A).
|
||||
pub fn encode_raw_au(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||
let au = self.encode(frame)?;
|
||||
let mut raw = Vec::with_capacity(4 + au.len());
|
||||
raw.extend_from_slice(&(au.len() as u32).to_be_bytes());
|
||||
raw.extend_from_slice(&au);
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Build the `APVDecoderConfigurationRecord` payload (for ISOBMFF `apvC` or Matroska CodecPrivate).
|
||||
pub fn decoder_config_record(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(24);
|
||||
v.push(1); // configurationVersion = 1
|
||||
v.push(1); // number_of_configuration_entry = 1
|
||||
v.push(PBU_PRIMARY_FRAME); // pbu_type[0] = 1
|
||||
v.push(1); // number_of_frame_info[0] = 1
|
||||
let flags = if self.cfg.color_description_present { 0x02 } else { 0x00 } | 0x01; // capture_time_distance_ignored = 1
|
||||
v.push(flags);
|
||||
v.push(self.hdr.profile_idc);
|
||||
v.push(self.hdr.level_idc);
|
||||
v.push(self.hdr.band_idc);
|
||||
v.extend_from_slice(&self.hdr.width.to_be_bytes());
|
||||
v.extend_from_slice(&self.hdr.height.to_be_bytes());
|
||||
v.push((self.hdr.chroma.idc() << 4) | (self.hdr.bit_depth.saturating_sub(8) & 0x0F));
|
||||
v.push(0); // capture_time_distance = 0
|
||||
if self.cfg.color_description_present {
|
||||
v.push(self.cfg.color.primaries);
|
||||
v.push(self.cfg.color.transfer);
|
||||
v.push(self.cfg.color.matrix);
|
||||
v.push(if self.cfg.color.full_range { 0x80 } else { 0x00 });
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn prepare_tiles(&self, frame: &Frame) -> Vec<PreparedTile> {
|
||||
let num_tiles = self.hdr.num_tiles();
|
||||
let prepare_one = |tile_idx: usize| -> PreparedTile {
|
||||
let tx = tile_idx % (self.hdr.tile_cols as usize);
|
||||
let ty = tile_idx / (self.hdr.tile_cols as usize);
|
||||
let x0 = self.hdr.col_starts[tx];
|
||||
let x1 = self.hdr.col_starts[tx + 1];
|
||||
let y0 = self.hdr.row_starts[ty];
|
||||
let y1 = self.hdr.row_starts[ty + 1];
|
||||
let num_mb_cols = (x1 - x0) / 16;
|
||||
let num_mb_rows = (y1 - y0) / 16;
|
||||
let num_comps = self.hdr.chroma.num_comps();
|
||||
let mut comps = Vec::with_capacity(num_comps);
|
||||
|
||||
for c in 0..num_comps {
|
||||
let (plane, pw, ph, sub_w, sub_h) = match c {
|
||||
0 => (frame.y.as_slice(), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||
1 => (
|
||||
frame.cb.as_slice(),
|
||||
frame.chroma_width() as usize,
|
||||
frame.chroma_height() as usize,
|
||||
self.hdr.chroma.sub_width_c(),
|
||||
self.hdr.chroma.sub_height_c(),
|
||||
),
|
||||
2 => (
|
||||
frame.cr.as_slice(),
|
||||
frame.chroma_width() as usize,
|
||||
frame.chroma_height() as usize,
|
||||
self.hdr.chroma.sub_width_c(),
|
||||
self.hdr.chroma.sub_height_c(),
|
||||
),
|
||||
_ => (frame.alpha.as_deref().unwrap_or(&[]), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||
};
|
||||
let blk_w = (16 / sub_w) as usize;
|
||||
let blk_h = (16 / sub_h) as usize;
|
||||
let blocks_x = blk_w / 8;
|
||||
let blocks_y = blk_h / 8;
|
||||
let total_blocks = (num_mb_cols as usize) * (num_mb_rows as usize) * blocks_x * blocks_y;
|
||||
let mut blocks = Vec::with_capacity(total_blocks);
|
||||
|
||||
let coded_depth = self.hdr.bit_depth;
|
||||
let in_depth = frame.bit_depth;
|
||||
|
||||
for mb_y in 0..num_mb_rows {
|
||||
for mb_x in 0..num_mb_cols {
|
||||
let x_mb_c = ((x0 + mb_x * 16) / sub_w) as usize;
|
||||
let y_mb_c = ((y0 + mb_y * 16) / sub_h) as usize;
|
||||
for by in 0..blocks_y {
|
||||
for bx in 0..blocks_x {
|
||||
let bx0 = x_mb_c + bx * 8;
|
||||
let by0 = y_mb_c + by * 8;
|
||||
let mut samples = [0u16; 64];
|
||||
for ry in 0..8 {
|
||||
let sy = (by0 + ry).min(ph.saturating_sub(1));
|
||||
for rx in 0..8 {
|
||||
let sx = (bx0 + rx).min(pw.saturating_sub(1));
|
||||
let v = plane[sy * pw + sx];
|
||||
samples[ry * 8 + rx] = scale_sample(v, in_depth, coded_depth);
|
||||
}
|
||||
}
|
||||
blocks.push(fdct8x8(&samples, coded_depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comps.push(PreparedTileComp { blocks });
|
||||
}
|
||||
PreparedTile { tile_idx, comps }
|
||||
};
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
(0..num_tiles).into_par_iter().map(prepare_one).collect()
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
(0..num_tiles).map(prepare_one).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn select_qp(&self, tiles: &[PreparedTile], max_qp: u8) -> u8 {
|
||||
let target_bytes = (self.cfg.target_frame_bytes as usize).max(64);
|
||||
let frame_bytes_at = |qp: u8| -> usize {
|
||||
let tile_bytes = |t: &PreparedTile| -> usize {
|
||||
let num_comps = t.comps.len();
|
||||
let mut sz = 4 + (5 + 5 * num_comps);
|
||||
for (c, comp) in t.comps.iter().enumerate() {
|
||||
let bits = self.estimate_comp_bits(comp, c, qp);
|
||||
sz += (bits.div_ceil(8) as usize).max(1);
|
||||
}
|
||||
sz
|
||||
};
|
||||
#[cfg(feature = "threads")]
|
||||
let sum: usize = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().map(tile_bytes).sum()
|
||||
};
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let sum: usize = tiles.iter().map(tile_bytes).sum();
|
||||
24 + sum
|
||||
};
|
||||
|
||||
let mut lo = 0u8;
|
||||
let mut hi = max_qp;
|
||||
if frame_bytes_at(0) <= target_bytes {
|
||||
return 0;
|
||||
}
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if frame_bytes_at(mid) <= target_bytes {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
fn estimate_comp_bits(&self, comp: &PreparedTileComp, c: usize, qp: u8) -> u64 {
|
||||
let qm = &self.hdr.q_matrix[c];
|
||||
let depth = self.hdr.bit_depth;
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
let mut bits: u64 = 0;
|
||||
|
||||
for f_blk in &comp.blocks {
|
||||
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||
let dc = coeff[0] as i32;
|
||||
let diff = dc - prev_dc;
|
||||
let abs_dc_diff = diff.unsigned_abs();
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
bits += vlc_bit_len(abs_dc_diff, k_dc) as u64;
|
||||
if abs_dc_diff > 0 {
|
||||
bits += 1;
|
||||
}
|
||||
prev_dc = dc;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let mut run = 0u32;
|
||||
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||
run += 1;
|
||||
}
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
bits += vlc_bit_len(run, k_run) as u64;
|
||||
scan_pos += run as usize;
|
||||
prev_run = run;
|
||||
if scan_pos < 64 {
|
||||
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||
let abs_level = level.unsigned_abs() as u32;
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
bits += vlc_bit_len(abs_level - 1, k_lvl) as u64 + 1;
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn encode_tile(&self, tile: &PreparedTile, qp: u8) -> Vec<u8> {
|
||||
let num_comps = tile.comps.len();
|
||||
let mut comp_payloads: Vec<Vec<u8>> = Vec::with_capacity(num_comps);
|
||||
let depth = self.hdr.bit_depth;
|
||||
|
||||
for (c, comp) in tile.comps.iter().enumerate() {
|
||||
let qm = &self.hdr.q_matrix[c];
|
||||
let mut w = BitWriter::new();
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
|
||||
for f_blk in &comp.blocks {
|
||||
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||
let dc = coeff[0] as i32;
|
||||
let diff = dc - prev_dc;
|
||||
let abs_dc_diff = diff.unsigned_abs();
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
write_vlc(&mut w, abs_dc_diff, k_dc);
|
||||
if abs_dc_diff > 0 {
|
||||
w.write_bit(diff < 0);
|
||||
}
|
||||
prev_dc = dc;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let mut run = 0u32;
|
||||
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||
run += 1;
|
||||
}
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
write_vlc(&mut w, run, k_run);
|
||||
scan_pos += run as usize;
|
||||
prev_run = run;
|
||||
if scan_pos < 64 {
|
||||
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||
let abs_level = level.unsigned_abs() as u32;
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
write_vlc(&mut w, abs_level - 1, k_lvl);
|
||||
w.write_bit(level < 0);
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comp_payloads.push(w.finish());
|
||||
}
|
||||
|
||||
// Write tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||
let tile_header_size = (5 + 5 * num_comps) as u16;
|
||||
let total_size = tile_header_size as usize + comp_payloads.iter().map(Vec::len).sum::<usize>();
|
||||
let mut out = Vec::with_capacity(total_size);
|
||||
out.extend_from_slice(&tile_header_size.to_be_bytes());
|
||||
out.extend_from_slice(&(tile.tile_idx as u16).to_be_bytes());
|
||||
for cp in &comp_payloads {
|
||||
out.extend_from_slice(&(cp.len() as u32).to_be_bytes());
|
||||
}
|
||||
for _ in 0..num_comps {
|
||||
out.push(qp);
|
||||
}
|
||||
out.push(0); // reserved_zero_8bits
|
||||
for cp in comp_payloads {
|
||||
out.extend_from_slice(&cp);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn scale_sample(v: u16, from_depth: u8, to_depth: u8) -> u16 {
|
||||
let max_in = (1u32 << from_depth) - 1;
|
||||
let v = (v as u32).min(max_in);
|
||||
if to_depth == from_depth {
|
||||
v as u16
|
||||
} else if to_depth > from_depth {
|
||||
let shift = (to_depth - from_depth) as u32;
|
||||
((v << shift) | (v >> ((from_depth as u32) - shift))) as u16
|
||||
} else {
|
||||
let shift = (from_depth - to_depth) as u32;
|
||||
let round = 1u32 << (shift - 1);
|
||||
let max_out = (1u32 << to_depth) - 1;
|
||||
(((v + round) >> shift).min(max_out)) as u16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! RFC 9924 §5.3 and §8 syntax structures: Access Unit, PBU, Frame Header, Tile Info, and Metadata.
|
||||
|
||||
use craft_media::bitstream::{BitReader, BitWriter};
|
||||
|
||||
use crate::apv::tables::DEFAULT_Q_MATRIX_VAL;
|
||||
use crate::apv::{ChromaFormat, ColorInfo, ContentLightLevel, Error, MasteringDisplay, Result};
|
||||
|
||||
/// Four-character signature `'aPv1'` (`0x61507631`) at the start of every APV Access Unit (RFC 9924 §5.3.1).
|
||||
pub const AU_SIGNATURE: [u8; 4] = *b"aPv1";
|
||||
|
||||
/// Primitive Bitstream Unit (PBU) types (RFC 9924 §5.3.3 Table 3).
|
||||
pub const PBU_PRIMARY_FRAME: u8 = 1;
|
||||
pub const PBU_NON_PRIMARY_FRAME: u8 = 2;
|
||||
pub const PBU_PREVIEW_FRAME: u8 = 25;
|
||||
pub const PBU_DEPTH_FRAME: u8 = 26;
|
||||
pub const PBU_ALPHA_FRAME: u8 = 27;
|
||||
pub const PBU_AU_INFO: u8 = 65;
|
||||
pub const PBU_METADATA: u8 = 66;
|
||||
pub const PBU_FILLER: u8 = 67;
|
||||
|
||||
/// Maximum supported frame dimension in luma samples (guards against hostile allocations).
|
||||
pub const MAX_DIMENSION: u32 = 16384;
|
||||
|
||||
/// Maximum tiles per axis (RFC 9924 §9.4.1 specifies 20; we allow up to 64 for robustness).
|
||||
pub const MAX_TILE_DIM: u32 = 64;
|
||||
|
||||
/// Parsed `frame_header()` and derived tile geometry (RFC 9924 §5.3.5–5.3.8).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FrameHeader {
|
||||
pub profile_idc: u8,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
pub bit_depth: u8,
|
||||
pub capture_time_distance: u8,
|
||||
pub color_description_present: bool,
|
||||
pub color: ColorInfo,
|
||||
pub use_q_matrix: bool,
|
||||
/// Per-component 8x8 quantization matrix in raster order (`y * 8 + x`).
|
||||
pub q_matrix: [[u8; 64]; 4],
|
||||
pub tile_width_in_mbs: u32,
|
||||
pub tile_height_in_mbs: u32,
|
||||
pub tile_cols: u32,
|
||||
pub tile_rows: u32,
|
||||
/// Tile column start positions in luma samples (`len == tile_cols + 1`).
|
||||
pub col_starts: Vec<u32>,
|
||||
/// Tile row start positions in luma samples (`len == tile_rows + 1`).
|
||||
pub row_starts: Vec<u32>,
|
||||
pub tile_sizes_in_fh: Option<Vec<u32>>,
|
||||
/// Byte offset within the PBU payload where `frame_header()` ends (start of the tile loop).
|
||||
pub header_bytes: usize,
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
pub fn frame_width_in_mbs(&self) -> u32 {
|
||||
self.width.div_ceil(16)
|
||||
}
|
||||
|
||||
pub fn frame_height_in_mbs(&self) -> u32 {
|
||||
self.height.div_ceil(16)
|
||||
}
|
||||
|
||||
pub fn num_tiles(&self) -> usize {
|
||||
(self.tile_cols as usize) * (self.tile_rows as usize)
|
||||
}
|
||||
|
||||
pub fn qp_bd_offset(&self) -> i32 {
|
||||
(self.bit_depth.saturating_sub(8) as i32) * 6
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed PBU slice inside an Access Unit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PbuSlice<'a> {
|
||||
pub pbu_type: u8,
|
||||
pub group_id: u16,
|
||||
/// Payload after the 4-byte `pbu_header()`.
|
||||
pub payload: &'a [u8],
|
||||
}
|
||||
|
||||
/// Unwrap an Access Unit byte slice: accepts either a direct `access_unit` starting with `'aPv1'`
|
||||
/// or a `raw_bitstream_access_unit` prefixed with a 32-bit big-endian `au_size` (RFC 9924 Appendix A).
|
||||
pub fn unwrap_au(data: &[u8]) -> Result<&[u8]> {
|
||||
if data.len() < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
if data[..4] == AU_SIGNATURE {
|
||||
return Ok(data);
|
||||
}
|
||||
if data.len() >= 8 && data[4..8] == AU_SIGNATURE {
|
||||
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||
}
|
||||
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||
return data.get(4..end).ok_or(Error::Truncated);
|
||||
}
|
||||
Err(Error::Invalid("missing aPv1 access unit signature"))
|
||||
}
|
||||
|
||||
/// Split a raw `.apv` bitstream (`raw_bitstream_access_unit()` sequence per RFC 9924 Appendix A)
|
||||
/// into individual Access Unit slices (each starting with `'aPv1'`).
|
||||
pub fn split_raw_bitstream(mut data: &[u8]) -> Result<Vec<&[u8]>> {
|
||||
if data.is_empty() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
// If the buffer starts directly with 'aPv1', treat the entire buffer as a single AU.
|
||||
if data.len() >= 4 && data[..4] == AU_SIGNATURE {
|
||||
return Ok(vec![data]);
|
||||
}
|
||||
let mut aus = Vec::new();
|
||||
while !data.is_empty() {
|
||||
if data.len() < 8 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||
}
|
||||
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||
let au = data.get(4..end).ok_or(Error::Truncated)?;
|
||||
if au[..4] != AU_SIGNATURE {
|
||||
return Err(Error::Invalid("missing aPv1 signature in raw bitstream AU"));
|
||||
}
|
||||
aus.push(au);
|
||||
data = &data[end..];
|
||||
}
|
||||
if aus.is_empty() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
Ok(aus)
|
||||
}
|
||||
|
||||
/// Parse the PBU list of a single Access Unit (RFC 9924 §5.3.1–5.3.3).
|
||||
/// PBUs with `reserved_zero_8bits > 0` are ignored as required by RFC 9924 §5.3.3.
|
||||
pub fn parse_au_pbus(data: &[u8]) -> Result<Vec<PbuSlice<'_>>> {
|
||||
let au = unwrap_au(data)?;
|
||||
if au.len() < 8 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let mut pos = 4usize;
|
||||
let mut pbus = Vec::new();
|
||||
while pos < au.len() {
|
||||
let sz_bytes = au.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||
let pbu_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||
if pbu_size == 0 || pbu_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid pbu_size"));
|
||||
}
|
||||
if pbu_size < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
pos += 4;
|
||||
let pbu_end = pos.checked_add(pbu_size).ok_or(Error::Truncated)?;
|
||||
let pbu = au.get(pos..pbu_end).ok_or(Error::Truncated)?;
|
||||
pos = pbu_end;
|
||||
|
||||
let pbu_type = pbu[0];
|
||||
let group_id = u16::from_be_bytes([pbu[1], pbu[2]]);
|
||||
let reserved_zero_8bits = pbu[3];
|
||||
if reserved_zero_8bits > 0 {
|
||||
// RFC 9924 §5.3.3: Decoders MUST ignore PBU with reserved_zero_8bits > 0.
|
||||
continue;
|
||||
}
|
||||
if group_id == 0xFFFF || (group_id == 0 && pbu_type <= 64) {
|
||||
return Err(Error::Invalid("invalid PBU group_id"));
|
||||
}
|
||||
pbus.push(PbuSlice { pbu_type, group_id, payload: &pbu[4..] });
|
||||
}
|
||||
Ok(pbus)
|
||||
}
|
||||
|
||||
/// Read `byte_alignment()` and verify all alignment bits are zero (RFC 9924 §5.3.17).
|
||||
pub fn read_byte_alignment(r: &mut BitReader) -> Result<()> {
|
||||
while !r.is_byte_aligned() {
|
||||
let bit = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
if bit {
|
||||
return Err(Error::Invalid("non-zero alignment bit"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `frame_header()` from the payload of a frame PBU (after `pbu_header()`), per RFC 9924 §5.3.5–5.3.8.
|
||||
/// Returns `Ok(None)` if any `reserved_zero_*` field is non-zero (RFC 9924 §5.3.5 requires ignoring the PBU).
|
||||
pub fn parse_frame_header(payload: &[u8]) -> Result<Option<FrameHeader>> {
|
||||
let mut r = BitReader::new(payload);
|
||||
|
||||
// frame_info() (RFC 9924 §5.3.6)
|
||||
let profile_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let level_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let band_idc = r.read_bits(3).map_err(|_| Error::Truncated)? as u8;
|
||||
let reserved_zero_5bits = r.read_bits(5).map_err(|_| Error::Truncated)? as u8;
|
||||
let width = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||
let height = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||
let chroma_format_idc = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||
let bit_depth_minus8 = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||
let capture_time_distance = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let fi_reserved_8 = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
|
||||
// frame_header() (RFC 9924 §5.3.5)
|
||||
let fh_reserved_8_a = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if reserved_zero_5bits > 0 || fi_reserved_8 > 0 || fh_reserved_8_a > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
|
||||
return Err(Error::Invalid("unsupported frame dimensions"));
|
||||
}
|
||||
let chroma = ChromaFormat::from_idc(chroma_format_idc).ok_or(Error::Invalid("invalid chroma_format_idc"))?;
|
||||
if chroma == ChromaFormat::Yuv422 && !width.is_multiple_of(2) {
|
||||
return Err(Error::Invalid("4:2:2 frame_width must be even"));
|
||||
}
|
||||
if !(2..=8).contains(&bit_depth_minus8) {
|
||||
return Err(Error::Invalid("bit_depth_minus8 out of range 2..=8"));
|
||||
}
|
||||
let bit_depth = bit_depth_minus8 + 8;
|
||||
|
||||
let color_description_present = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let color = if color_description_present {
|
||||
let primaries = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let transfer = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let matrix = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let full_range = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
ColorInfo { primaries, transfer, matrix, full_range }
|
||||
} else {
|
||||
ColorInfo::default()
|
||||
};
|
||||
|
||||
let use_q_matrix = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let mut q_matrix = [[DEFAULT_Q_MATRIX_VAL; 64]; 4];
|
||||
if use_q_matrix {
|
||||
for c in 0..chroma.num_comps() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let q = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if q == 0 {
|
||||
return Err(Error::Invalid("q_matrix entry 0 is reserved"));
|
||||
}
|
||||
q_matrix[c][y * 8 + x] = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tile_info() (RFC 9924 §5.3.8)
|
||||
let tile_width_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||
let tile_height_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||
if tile_width_in_mbs == 0 || tile_height_in_mbs == 0 {
|
||||
return Err(Error::Invalid("tile dimensions in MBs must be non-zero"));
|
||||
}
|
||||
|
||||
let w_mbs = width.div_ceil(16);
|
||||
let h_mbs = height.div_ceil(16);
|
||||
let tile_cols = w_mbs.div_ceil(tile_width_in_mbs);
|
||||
let tile_rows = h_mbs.div_ceil(tile_height_in_mbs);
|
||||
if tile_cols == 0 || tile_rows == 0 || tile_cols > MAX_TILE_DIM || tile_rows > MAX_TILE_DIM {
|
||||
return Err(Error::Invalid("too many tiles in frame"));
|
||||
}
|
||||
|
||||
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||
let mut start_mb = 0u32;
|
||||
while start_mb < w_mbs {
|
||||
col_starts.push(start_mb * 16);
|
||||
start_mb = start_mb.saturating_add(tile_width_in_mbs);
|
||||
}
|
||||
col_starts.push(w_mbs * 16);
|
||||
|
||||
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||
let mut start_mb = 0u32;
|
||||
while start_mb < h_mbs {
|
||||
row_starts.push(start_mb * 16);
|
||||
start_mb = start_mb.saturating_add(tile_height_in_mbs);
|
||||
}
|
||||
row_starts.push(h_mbs * 16);
|
||||
|
||||
let num_tiles = (tile_cols as usize) * (tile_rows as usize);
|
||||
let tile_size_present_in_fh = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let tile_sizes_in_fh = if tile_size_present_in_fh {
|
||||
let mut sizes = Vec::with_capacity(num_tiles);
|
||||
for _ in 0..num_tiles {
|
||||
let sz = r.read_bits(32).map_err(|_| Error::Truncated)?;
|
||||
if sz == 0 {
|
||||
return Err(Error::Invalid("tile_size_in_fh cannot be 0"));
|
||||
}
|
||||
sizes.push(sz);
|
||||
}
|
||||
Some(sizes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let fh_reserved_8_b = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if fh_reserved_8_b > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
read_byte_alignment(&mut r)?;
|
||||
|
||||
Ok(Some(FrameHeader {
|
||||
profile_idc,
|
||||
level_idc,
|
||||
band_idc,
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
capture_time_distance,
|
||||
color_description_present,
|
||||
color,
|
||||
use_q_matrix,
|
||||
q_matrix,
|
||||
tile_width_in_mbs,
|
||||
tile_height_in_mbs,
|
||||
tile_cols,
|
||||
tile_rows,
|
||||
col_starts,
|
||||
row_starts,
|
||||
tile_sizes_in_fh,
|
||||
header_bytes: r.byte_pos(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Write `frame_header()` to a byte vector (RFC 9924 §5.3.5–5.3.8).
|
||||
pub fn write_frame_header(h: &FrameHeader) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
// frame_info()
|
||||
w.write_bits(h.profile_idc as u32, 8);
|
||||
w.write_bits(h.level_idc as u32, 8);
|
||||
w.write_bits((h.band_idc & 7) as u32, 3);
|
||||
w.write_bits(0, 5); // reserved_zero_5bits
|
||||
w.write_bits(h.width, 24);
|
||||
w.write_bits(h.height, 24);
|
||||
w.write_bits(h.chroma.idc() as u32, 4);
|
||||
w.write_bits(h.bit_depth.saturating_sub(8) as u32, 4);
|
||||
w.write_bits(h.capture_time_distance as u32, 8);
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
|
||||
// frame_header()
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
w.write_bit(h.color_description_present);
|
||||
if h.color_description_present {
|
||||
w.write_bits(h.color.primaries as u32, 8);
|
||||
w.write_bits(h.color.transfer as u32, 8);
|
||||
w.write_bits(h.color.matrix as u32, 8);
|
||||
w.write_bit(h.color.full_range);
|
||||
}
|
||||
w.write_bit(h.use_q_matrix);
|
||||
if h.use_q_matrix {
|
||||
for c in 0..h.chroma.num_comps() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
w.write_bits(h.q_matrix[c][y * 8 + x].max(1) as u32, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// tile_info()
|
||||
w.write_bits(h.tile_width_in_mbs, 20);
|
||||
w.write_bits(h.tile_height_in_mbs, 20);
|
||||
if let Some(sizes) = &h.tile_sizes_in_fh {
|
||||
w.write_bit(true);
|
||||
for &sz in sizes {
|
||||
w.write_bits(sz, 32);
|
||||
}
|
||||
} else {
|
||||
w.write_bit(false);
|
||||
}
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
w.finish()
|
||||
}
|
||||
|
||||
/// Parse a `metadata()` PBU payload (after `pbu_header()`), extracting MDCV (`payloadType == 5`)
|
||||
/// and CLL (`payloadType == 6`) if present (RFC 9924 §5.3.10 & §8).
|
||||
pub fn parse_metadata_pbu(payload: &[u8], mdcv: &mut Option<MasteringDisplay>, cll: &mut Option<ContentLightLevel>) -> Result<()> {
|
||||
if payload.len() < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let metadata_size = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
|
||||
let body = payload.get(4..4usize.checked_add(metadata_size).ok_or(Error::Truncated)?).ok_or(Error::Truncated)?;
|
||||
let mut pos = 0usize;
|
||||
while pos < body.len() {
|
||||
let mut payload_type = 0u32;
|
||||
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||
payload_type = payload_type.checked_add(255).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||
pos += 1;
|
||||
}
|
||||
payload_type =
|
||||
payload_type.checked_add(*body.get(pos).ok_or(Error::Truncated)? as u32).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||
pos += 1;
|
||||
|
||||
let mut payload_size = 0usize;
|
||||
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||
payload_size = payload_size.checked_add(255).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||
pos += 1;
|
||||
}
|
||||
payload_size =
|
||||
payload_size.checked_add(*body.get(pos).ok_or(Error::Truncated)? as usize).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||
pos += 1;
|
||||
|
||||
let end = pos.checked_add(payload_size).ok_or(Error::Truncated)?;
|
||||
let item = body.get(pos..end).ok_or(Error::Truncated)?;
|
||||
pos = end;
|
||||
|
||||
match payload_type {
|
||||
5 if item.len() >= 24 => {
|
||||
let u16_at = |i: usize| u16::from_be_bytes([item[i], item[i + 1]]);
|
||||
let u32_at = |i: usize| u32::from_be_bytes([item[i], item[i + 1], item[i + 2], item[i + 3]]);
|
||||
*mdcv = Some(MasteringDisplay {
|
||||
primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))],
|
||||
white_point: (u16_at(12), u16_at(14)),
|
||||
max_luminance: u32_at(16),
|
||||
min_luminance: u32_at(20),
|
||||
});
|
||||
}
|
||||
6 if item.len() >= 4 => {
|
||||
*cll = Some(ContentLightLevel { max_cll: u16::from_be_bytes([item[0], item[1]]), max_fall: u16::from_be_bytes([item[2], item[3]]) });
|
||||
}
|
||||
// Filler metadata (§8.2.1): must be 0xFF bytes
|
||||
10 if item.iter().any(|&b| b != 0xFF) => {
|
||||
return Err(Error::Invalid("non-0xFF byte in filler metadata"));
|
||||
}
|
||||
_ => {
|
||||
// RFC 9924 §10: A decoder MUST NOT try to process metadata whose type is not recognized.
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a `metadata()` PBU (including its 4-byte `pbu_header`) carrying MDCV and/or CLL metadata.
|
||||
pub fn write_metadata_pbu(group_id: u16, mdcv: Option<&MasteringDisplay>, cll: Option<&ContentLightLevel>) -> Option<Vec<u8>> {
|
||||
if mdcv.is_none() && cll.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
if let Some(m) = mdcv {
|
||||
body.push(5); // payloadType = 5 (MDCV)
|
||||
body.push(24); // payloadSize = 24
|
||||
for &(x, y) in &m.primaries {
|
||||
body.extend_from_slice(&x.to_be_bytes());
|
||||
body.extend_from_slice(&y.to_be_bytes());
|
||||
}
|
||||
body.extend_from_slice(&m.white_point.0.to_be_bytes());
|
||||
body.extend_from_slice(&m.white_point.1.to_be_bytes());
|
||||
body.extend_from_slice(&m.max_luminance.to_be_bytes());
|
||||
body.extend_from_slice(&m.min_luminance.to_be_bytes());
|
||||
}
|
||||
if let Some(c) = cll {
|
||||
body.push(6); // payloadType = 6 (CLL)
|
||||
body.push(4); // payloadSize = 4
|
||||
body.extend_from_slice(&c.max_cll.to_be_bytes());
|
||||
body.extend_from_slice(&c.max_fall.to_be_bytes());
|
||||
}
|
||||
let mut pbu = Vec::with_capacity(8 + body.len());
|
||||
pbu.push(PBU_METADATA);
|
||||
pbu.extend_from_slice(&group_id.to_be_bytes());
|
||||
pbu.push(0); // reserved_zero_8bits
|
||||
pbu.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pbu.extend_from_slice(&body);
|
||||
Some(pbu)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Clean-room Advanced Professional Video (APV) codec, implemented strictly from IETF RFC 9924
|
||||
//! (February 2026).
|
||||
//!
|
||||
//! - **Decoder** (feature `apv-decode`: [`decode_frame`], [`decode_frame_with`], [`decode_frame_into`], [`probe`]):
|
||||
//! every profile in RFC 9924 §9 (`422-10`, `422-12`, `444-10`, `444-12`, `4444-10`, `4444-12`,
|
||||
//! `400-10`) at 10–16 bits per sample, custom quantization matrices, multi-tile parallel decoding
|
||||
//! (behind the `threads` feature), 4:4:4:4 and auxiliary alpha PBU (`pbu_type == 27`) support,
|
||||
//! and HDR static metadata (`MDCV` and `CLL`, RFC 9924 §8).
|
||||
//! - **Encoder** (feature `apv-encode`: [`Encoder`]): progressive intra frame encoding across all 7 profiles with fixed-QP
|
||||
//! or frame-budget rate control, plus `APVDecoderConfigurationRecord` (`apvC`) generation.
|
||||
//!
|
||||
//! See this module's README for specification edition, accuracy against external oracles, and design notes.
|
||||
|
||||
#![allow(clippy::needless_range_loop, clippy::type_complexity)]
|
||||
|
||||
pub mod dct;
|
||||
#[cfg(feature = "apv-decode")]
|
||||
pub mod decode;
|
||||
#[cfg(feature = "apv-encode")]
|
||||
pub mod encode;
|
||||
pub mod header;
|
||||
pub mod tables;
|
||||
|
||||
#[cfg(feature = "apv-decode")]
|
||||
pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe};
|
||||
#[cfg(feature = "apv-encode")]
|
||||
pub use encode::{Encoder, EncoderConfig};
|
||||
pub use header::{AU_SIGNATURE, FrameHeader, split_raw_bitstream, unwrap_au};
|
||||
|
||||
/// APV codec errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("truncated APV bitstream")]
|
||||
Truncated,
|
||||
#[error("invalid APV bitstream: {0}")]
|
||||
Invalid(&'static str),
|
||||
#[error("corrupt APV tile {0}")]
|
||||
CorruptTile(usize),
|
||||
#[error("invalid APV encoder input: {0}")]
|
||||
Input(String),
|
||||
#[error("APV frame exceeds the decoder memory budget")]
|
||||
MemoryBudget,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Chroma format (`chroma_format_idc` in RFC 9924 §4.1 Table 2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChromaFormat {
|
||||
/// `chroma_format_idc == 0`: 4:0:0 monochrome (1 component).
|
||||
Monochrome,
|
||||
/// `chroma_format_idc == 2`: 4:2:2 YCbCr (3 components, `SubWidthC = 2, SubHeightC = 1`).
|
||||
Yuv422,
|
||||
/// `chroma_format_idc == 3`: 4:4:4 YCbCr (3 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||
Yuv444,
|
||||
/// `chroma_format_idc == 4`: 4:4:4:4 YCbCrA (4 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||
Yuv4444,
|
||||
}
|
||||
|
||||
impl ChromaFormat {
|
||||
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||
match idc {
|
||||
0 => Some(Self::Monochrome),
|
||||
2 => Some(Self::Yuv422),
|
||||
3 => Some(Self::Yuv444),
|
||||
4 => Some(Self::Yuv4444),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
Self::Yuv422 => 2,
|
||||
Self::Yuv444 => 3,
|
||||
Self::Yuv4444 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num_comps(self) -> usize {
|
||||
match self {
|
||||
Self::Monochrome => 1,
|
||||
Self::Yuv422 | Self::Yuv444 => 3,
|
||||
Self::Yuv4444 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sub_width_c(self) -> u32 {
|
||||
match self {
|
||||
Self::Yuv422 => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sub_height_c(self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn chroma_width(self, width: u32) -> u32 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
Self::Yuv422 => width.div_ceil(2),
|
||||
Self::Yuv444 | Self::Yuv4444 => width,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chroma_height(self, height: u32) -> u32 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
_ => height,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_alpha(self) -> bool {
|
||||
self == Self::Yuv4444
|
||||
}
|
||||
}
|
||||
|
||||
/// APV profiles defined in RFC 9924 §9.3.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Profile {
|
||||
/// `profile_idc == 33`: 422-10 profile (4:2:2, 10-bit).
|
||||
#[default]
|
||||
P422_10,
|
||||
/// `profile_idc == 44`: 422-12 profile (4:2:2, 10–12-bit).
|
||||
P422_12,
|
||||
/// `profile_idc == 55`: 444-10 profile (4:2:2..4:4:4, 10-bit).
|
||||
P444_10,
|
||||
/// `profile_idc == 66`: 444-12 profile (4:2:2..4:4:4, 10–12-bit).
|
||||
P444_12,
|
||||
/// `profile_idc == 77`: 4444-10 profile (4:2:2..4:4:4:4, 10-bit).
|
||||
P4444_10,
|
||||
/// `profile_idc == 88`: 4444-12 profile (4:2:2..4:4:4:4, 10–12-bit).
|
||||
P4444_12,
|
||||
/// `profile_idc == 99`: 400-10 profile (4:0:0, 10-bit).
|
||||
P400_10,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub const ALL: [Profile; 7] =
|
||||
[Profile::P422_10, Profile::P422_12, Profile::P444_10, Profile::P444_12, Profile::P4444_10, Profile::P4444_12, Profile::P400_10];
|
||||
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
Self::P422_10 => 33,
|
||||
Self::P422_12 => 44,
|
||||
Self::P444_10 => 55,
|
||||
Self::P444_12 => 66,
|
||||
Self::P4444_10 => 77,
|
||||
Self::P4444_12 => 88,
|
||||
Self::P400_10 => 99,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| p.idc() == idc)
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::P422_10 => "APV 422-10",
|
||||
Self::P422_12 => "APV 422-12",
|
||||
Self::P444_10 => "APV 444-10",
|
||||
Self::P444_12 => "APV 444-12",
|
||||
Self::P4444_10 => "APV 4444-10",
|
||||
Self::P4444_12 => "APV 4444-12",
|
||||
Self::P400_10 => "APV 400-10",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::P422_10 => "422-10",
|
||||
Self::P422_12 => "422-12",
|
||||
Self::P444_10 => "444-10",
|
||||
Self::P444_12 => "444-12",
|
||||
Self::P4444_10 => "4444-10",
|
||||
Self::P4444_12 => "4444-12",
|
||||
Self::P400_10 => "400-10",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_chroma(self) -> ChromaFormat {
|
||||
match self {
|
||||
Self::P422_10 | Self::P422_12 => ChromaFormat::Yuv422,
|
||||
Self::P444_10 | Self::P444_12 => ChromaFormat::Yuv444,
|
||||
Self::P4444_10 | Self::P4444_12 => ChromaFormat::Yuv4444,
|
||||
Self::P400_10 => ChromaFormat::Monochrome,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_bit_depth(self) -> u8 {
|
||||
match self {
|
||||
Self::P422_12 | Self::P444_12 | Self::P4444_12 => 12,
|
||||
_ => 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether `(chroma, bit_depth)` conforms to this profile per RFC 9924 §9.3.
|
||||
pub fn supports(self, chroma: ChromaFormat, bit_depth: u8) -> bool {
|
||||
let c = chroma.idc();
|
||||
let b = bit_depth.saturating_sub(8);
|
||||
match self {
|
||||
Self::P422_10 => c == 2 && b == 2,
|
||||
Self::P422_12 => c == 2 && (2..=4).contains(&b),
|
||||
Self::P444_10 => (2..=3).contains(&c) && b == 2,
|
||||
Self::P444_12 => (2..=3).contains(&c) && (2..=4).contains(&b),
|
||||
Self::P4444_10 => (2..=4).contains(&c) && b == 2,
|
||||
Self::P4444_12 => (2..=4).contains(&c) && (2..=4).contains(&b),
|
||||
Self::P400_10 => c == 0 && b == 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nominal target bytes per frame for `(width, height)`.
|
||||
pub fn nominal_frame_bytes(self, width: u32, height: u32) -> u32 {
|
||||
let bpp = match self {
|
||||
Self::P400_10 => 1.5,
|
||||
Self::P422_10 => 3.0,
|
||||
Self::P422_12 => 3.8,
|
||||
Self::P444_10 => 4.2,
|
||||
Self::P444_12 => 5.0,
|
||||
Self::P4444_10 => 5.2,
|
||||
Self::P4444_12 => 6.2,
|
||||
};
|
||||
let bytes = ((width as f64) * (height as f64) * bpp / 8.0).round() as u32;
|
||||
bytes.max(512)
|
||||
}
|
||||
|
||||
/// Nominal data rate in Mbit/s at 1920x1080 29.97 fps.
|
||||
pub fn nominal_mbps_1080p30(self) -> f64 {
|
||||
(self.nominal_frame_bytes(1920, 1080) as f64) * 8.0 * 29.97 / 1e6
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable profile name from `profile_idc` (e.g. `"APV 422-10"`).
|
||||
pub fn profile_name(profile_idc: u8) -> String {
|
||||
Profile::from_idc(profile_idc).map(|p| p.name().to_string()).unwrap_or_else(|| format!("APV (profile {profile_idc})"))
|
||||
}
|
||||
|
||||
/// Colour description from `frame_header()` (ITU-T H.273 code points, RFC 9924 §5.3.5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ColorInfo {
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorInfo {
|
||||
/// Inferred values when `color_description_present_flag == 0` (RFC 9924 §5.3.5: 2, 2, 2, 0).
|
||||
fn default() -> Self {
|
||||
Self { primaries: 2, transfer: 2, matrix: 2, full_range: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorInfo {
|
||||
pub const BT709: Self = Self { primaries: 1, transfer: 1, matrix: 1, full_range: false };
|
||||
pub const BT2020_PQ: Self = Self { primaries: 9, transfer: 16, matrix: 9, full_range: false };
|
||||
pub const BT2020_HLG: Self = Self { primaries: 9, transfer: 18, matrix: 9, full_range: false };
|
||||
}
|
||||
|
||||
/// Mastering Display Color Volume metadata (`payloadType == 5`, RFC 9924 §8.2.3).
|
||||
///
|
||||
/// Chromaticities are in 0.16 fixed-point (1/65536), order `[R, G, B]`;
|
||||
/// `max_luminance` is 24.8 fixed-point cd/m²; `min_luminance` is 18.14 fixed-point cd/m².
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct MasteringDisplay {
|
||||
pub primaries: [(u16, u16); 3],
|
||||
pub white_point: (u16, u16),
|
||||
pub max_luminance: u32,
|
||||
pub min_luminance: u32,
|
||||
}
|
||||
|
||||
/// Content Light-Level Information metadata (`payloadType == 6`, RFC 9924 §8.2.4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ContentLightLevel {
|
||||
pub max_cll: u16,
|
||||
pub max_fall: u16,
|
||||
}
|
||||
|
||||
/// A decoded (or to-be-encoded) APV frame: planar `u16` samples, 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 (`10..=16`, or `8..=16` when rescaled via the decoder's `DecodeOptions`).
|
||||
pub bit_depth: u8,
|
||||
pub y: Vec<u16>,
|
||||
pub cb: Vec<u16>,
|
||||
pub cr: Vec<u16>,
|
||||
pub alpha: Option<Vec<u16>>,
|
||||
pub color: ColorInfo,
|
||||
pub profile_idc: u8,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
pub mastering_display: Option<MasteringDisplay>,
|
||||
pub content_light: Option<ContentLightLevel>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Allocate a black video-range frame.
|
||||
pub fn new(width: u32, height: u32, chroma: ChromaFormat, bit_depth: u8, with_alpha: bool) -> Self {
|
||||
let n = (width as usize) * (height as usize);
|
||||
let cw = chroma.chroma_width(width) as usize;
|
||||
let ch = chroma.chroma_height(height) as usize;
|
||||
let cn = cw * ch;
|
||||
// The fill values are those of the nearest depth the format has (8..=16 bits).
|
||||
let depth = bit_depth.clamp(8, 16) as u32;
|
||||
let shift = depth - 8;
|
||||
let has_alpha = with_alpha || chroma == ChromaFormat::Yuv4444;
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
y: vec![(16u32 << shift) as u16; n],
|
||||
cb: vec![(128u32 << shift) as u16; cn],
|
||||
cr: vec![(128u32 << shift) as u16; cn],
|
||||
alpha: has_alpha.then(|| vec![((1u32 << bit_depth.min(16)) - 1) as u16; n]),
|
||||
color: ColorInfo::BT709,
|
||||
profile_idc: Profile::P422_10.idc(),
|
||||
level_idc: 90,
|
||||
band_idc: 2,
|
||||
mastering_display: None,
|
||||
content_light: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chroma_width(&self) -> u32 {
|
||||
self.chroma.chroma_width(self.width)
|
||||
}
|
||||
|
||||
pub fn chroma_height(&self) -> u32 {
|
||||
self.chroma.chroma_height(self.height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! RFC 9924 tables: zig-zag scan, 8x8 transform matrix, quantization level scales, and level limits.
|
||||
|
||||
/// 8x8 zig-zag scan order (`ScanOrder[sPos] = yC * 8 + xC`), derived from RFC 9924 §4.4.1 Figure 5.
|
||||
pub const ZIGZAG_8X8: [u8; 64] = [
|
||||
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43,
|
||||
36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
|
||||
];
|
||||
|
||||
/// 8x8 transform matrix (`transMatrix` in RFC 9924 §6.3.2.3 Figure 25).
|
||||
pub const TRANS_MATRIX: [[i32; 8]; 8] = [
|
||||
[64, 64, 64, 64, 64, 64, 64, 64],
|
||||
[89, 75, 50, 18, -18, -50, -75, -89],
|
||||
[84, 35, -35, -84, -84, -35, 35, 84],
|
||||
[75, -18, -89, -50, 50, 89, 18, -75],
|
||||
[64, -64, -64, 64, 64, -64, -64, 64],
|
||||
[50, -89, 18, 75, -75, -18, 89, -50],
|
||||
[35, -84, 84, -35, -35, 84, -84, 35],
|
||||
[18, -50, 75, -89, 89, -75, 50, -18],
|
||||
];
|
||||
|
||||
/// Quantization scaling list `levelScale[k]` for `k = qP % 6` (RFC 9924 §6.3.1).
|
||||
pub const LEVEL_SCALE: [i32; 6] = [40, 45, 51, 57, 64, 71];
|
||||
|
||||
/// Default flat quantization matrix value when `use_q_matrix == 0` (RFC 9924 §5.3.7).
|
||||
pub const DEFAULT_Q_MATRIX_VAL: u8 = 16;
|
||||
|
||||
/// General level limits (`level_idc`, max luma sample rate, and max coded data rate in Mbit/s per band)
|
||||
/// from RFC 9924 §9.4.2 Table 4.
|
||||
pub const LEVEL_LIMITS: [(u8, u64, [u32; 4]); 14] = [
|
||||
(30, 3_041_280, [8, 11, 15, 23]),
|
||||
(33, 6_082_560, [16, 21, 30, 45]),
|
||||
(60, 15_667_200, [39, 54, 76, 114]),
|
||||
(63, 31_334_400, [78, 108, 152, 227]),
|
||||
(90, 66_846_720, [114, 159, 222, 333]),
|
||||
(93, 133_693_440, [227, 317, 444, 666]),
|
||||
(120, 265_420_800, [455, 637, 892, 1_338]),
|
||||
(123, 530_841_600, [910, 1_274, 1_784, 2_675]),
|
||||
(150, 1_061_683_200, [1_820, 2_548, 3_567, 5_350]),
|
||||
(153, 2_123_366_400, [3_639, 5_095, 7_133, 10_699]),
|
||||
(180, 4_777_574_400, [7_278, 10_189, 14_265, 21_397]),
|
||||
(183, 8_493_465_600, [14_556, 20_378, 28_529, 42_793]),
|
||||
(210, 16_986_931_200, [29_111, 40_756, 57_058, 85_586]),
|
||||
(213, 33_973_862_400, [58_222, 81_511, 114_115, 171_172]),
|
||||
];
|
||||
|
||||
/// Choose the lowest conforming `(level_idc, band_idc)` for a luma sample rate and bitrate (in kbps).
|
||||
pub fn select_level_and_band(luma_samples_per_sec: u64, kbps: u32) -> (u8, u8) {
|
||||
let mbps = kbps.div_ceil(1000);
|
||||
for &(level_idc, max_samples, bands) in &LEVEL_LIMITS {
|
||||
if luma_samples_per_sec <= max_samples {
|
||||
for (band_idc, &max_mbps) in bands.iter().enumerate() {
|
||||
if mbps <= max_mbps {
|
||||
return (level_idc, band_idc as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(213, 3)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! | Feature | Module | Codec |
|
||||
//! |---|---|---|
|
||||
//! | `dnx` | [`dnx`] | Avid DNxHD / DNxHR (SMPTE ST 2019-1): decoder and encoder |
|
||||
//! | `apv` | [`apv`] | APV (RFC 9924): decoder (`apv-decode`) and encoder (`apv-encode`) |
|
||||
//! | `vp8` | [`vp8`] | VP8 (RFC 6386): decoder |
|
||||
//! | `prores` | [`prores`] | Apple ProRes (SMPTE RDD 36): decoder and encoder |
|
||||
//! | `h264` | [`h264`] | ITU-T H.264 / AVC: decoder (`h264-decode`) and encoder (`h264-encode`) |
|
||||
@@ -16,13 +17,16 @@
|
||||
//! No codec is on by default. Feature `threads` spreads the work of the codecs that support it
|
||||
//! over rayon worker threads.
|
||||
//!
|
||||
//! The decoders that keep reference pictures bound them with a memory budget ([`budget`]).
|
||||
//! The decoders bound the pictures they hold with a memory budget ([`budget`]).
|
||||
//!
|
||||
//! Containers and bit-level I/O are in `craft-media`; the codecs here share its `bitstream`
|
||||
//! module and describe their streams with `craft_media::CodecParams`.
|
||||
|
||||
pub mod budget;
|
||||
|
||||
#[cfg(any(feature = "apv-decode", feature = "apv-encode"))]
|
||||
pub mod apv;
|
||||
|
||||
#[cfg(feature = "dnx")]
|
||||
pub mod dnx;
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Auxiliary alpha frame PBUs (type 27): a valid one becomes the alpha plane, a damaged one is
|
||||
//! ignored without disturbing the primary picture.
|
||||
|
||||
use craft_video::apv::header::PBU_ALPHA_FRAME;
|
||||
use craft_video::apv::*;
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 32;
|
||||
|
||||
fn encode(profile: Profile, seed: usize) -> Vec<u8> {
|
||||
let chroma = profile.default_chroma();
|
||||
let mut f = Frame::new(W, H, chroma, profile.default_bit_depth(), false);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = (64 + (i * seed) % 800) as u16;
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(profile, W, H);
|
||||
cfg.qp = Some(16);
|
||||
Encoder::with_config(cfg).unwrap().encode(&f).unwrap()
|
||||
}
|
||||
|
||||
/// The first PBU of an access unit written by [`Encoder::encode`] (header and payload).
|
||||
fn first_pbu(au: &[u8]) -> &[u8] {
|
||||
let n = u32::from_be_bytes(au[4..8].try_into().unwrap()) as usize;
|
||||
&au[8..8 + n]
|
||||
}
|
||||
|
||||
/// `primary` with `alpha_pbu` appended as an auxiliary alpha frame PBU.
|
||||
fn with_alpha(primary: &[u8], alpha_pbu: &[u8]) -> Vec<u8> {
|
||||
let mut au = primary.to_vec();
|
||||
au.extend_from_slice(&(alpha_pbu.len() as u32).to_be_bytes());
|
||||
au.push(PBU_ALPHA_FRAME);
|
||||
au.extend_from_slice(&alpha_pbu[1..]);
|
||||
au
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auxiliary_alpha_frame_becomes_the_alpha_plane() {
|
||||
let primary = encode(Profile::P422_10, 37);
|
||||
let luma = encode(Profile::P400_10, 11);
|
||||
let expected = decode_frame(&luma).unwrap().y;
|
||||
let f = decode_frame(&with_alpha(&primary, first_pbu(&luma))).unwrap();
|
||||
assert_eq!(f.alpha.as_deref(), Some(&expected[..]));
|
||||
assert_eq!(f.y, decode_frame(&primary).unwrap().y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_auxiliary_alpha_frame_is_ignored() {
|
||||
let primary = encode(Profile::P422_10, 37);
|
||||
let luma = encode(Profile::P400_10, 11);
|
||||
let pbu = first_pbu(&luma);
|
||||
// keep the frame header and drop the tile data: the auxiliary frame cannot be decoded
|
||||
for keep in [pbu.len() / 2, pbu.len() - 1] {
|
||||
let f = decode_frame(&with_alpha(&primary, &pbu[..keep])).unwrap();
|
||||
assert!(f.alpha.is_none(), "kept {keep} of {} bytes", pbu.len());
|
||||
assert_eq!(f.y, decode_frame(&primary).unwrap().y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! The memory budget of `DecodeOptions`: a frame whose samples (decoded tiles plus the assembled
|
||||
//! frame, and the same for an auxiliary alpha frame) do not fit is refused before any allocation.
|
||||
|
||||
use craft_video::apv::header::{PBU_ALPHA_FRAME, PBU_PRIMARY_FRAME, write_frame_header};
|
||||
use craft_video::apv::*;
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 32;
|
||||
|
||||
fn encode(profile: Profile) -> Vec<u8> {
|
||||
let f = Frame::new(W, H, profile.default_chroma(), profile.default_bit_depth(), false);
|
||||
let mut cfg = EncoderConfig::new(profile, W, H);
|
||||
cfg.qp = Some(16);
|
||||
Encoder::with_config(cfg).unwrap().encode(&f).unwrap()
|
||||
}
|
||||
|
||||
fn decode(au: &[u8], memory_budget: u64) -> Result<Frame> {
|
||||
decode_frame_with(au, &DecodeOptions { memory_budget, ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_budget_boundary() {
|
||||
// 4:2:2 at 64x32: 2048 luma + 2 x 1024 chroma samples, two bytes each, held twice.
|
||||
let au = encode(Profile::P422_10);
|
||||
let need = (2048 + 2 * 1024) * 2 * 2;
|
||||
assert!(decode(&au, need).is_ok());
|
||||
assert_eq!(decode(&au, need - 1).unwrap_err(), Error::MemoryBudget);
|
||||
// 4:4:4:4 holds four full planes.
|
||||
let au = encode(Profile::P4444_10);
|
||||
let need = 4 * 2048 * 2 * 2;
|
||||
assert!(decode(&au, need).is_ok());
|
||||
assert_eq!(decode(&au, need - 1).unwrap_err(), Error::MemoryBudget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auxiliary_alpha_frame_counts() {
|
||||
let mut au = encode(Profile::P422_10);
|
||||
let luma = encode(Profile::P400_10);
|
||||
let n = u32::from_be_bytes(luma[4..8].try_into().unwrap()) as usize;
|
||||
let pbu = &luma[8..8 + n];
|
||||
au.extend_from_slice(&(pbu.len() as u32).to_be_bytes());
|
||||
au.push(PBU_ALPHA_FRAME);
|
||||
au.extend_from_slice(&pbu[1..]);
|
||||
let need = (2048 + 2 * 1024 + 2048) * 2 * 2;
|
||||
assert!(decode(&au, need).unwrap().alpha.is_some());
|
||||
assert_eq!(decode(&au, need - 1).unwrap_err(), Error::MemoryBudget);
|
||||
}
|
||||
|
||||
/// A 16384 x 16384 4:4:4:4 header in a few dozen bytes asks for exactly 4 GiB (the default budget
|
||||
/// of a 64-bit target); the 1 GiB default of a 32-bit target refuses it.
|
||||
#[test]
|
||||
fn the_largest_header_needs_four_gibibytes() {
|
||||
let mut hdr = probe(&encode(Profile::P4444_10)).unwrap();
|
||||
(hdr.width, hdr.height) = (16384, 16384);
|
||||
(hdr.tile_width_in_mbs, hdr.tile_height_in_mbs) = (1024, 1024);
|
||||
let fh = write_frame_header(&hdr);
|
||||
let mut au = b"aPv1".to_vec();
|
||||
au.extend_from_slice(&(fh.len() as u32 + 4).to_be_bytes());
|
||||
au.extend_from_slice(&[PBU_PRIMARY_FRAME, 0, 1, 0]);
|
||||
au.extend_from_slice(&fh);
|
||||
assert_eq!(probe(&au).unwrap().width, 16384);
|
||||
assert_eq!(decode(&au, (4 << 30) - 1).unwrap_err(), Error::MemoryBudget);
|
||||
assert_eq!(decode(&au, 1 << 30).unwrap_err(), Error::MemoryBudget);
|
||||
// Within the budget the decoder goes on to read the tiles, which are not there.
|
||||
assert_eq!(decode(&au, 4 << 30).unwrap_err(), Error::Truncated);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Robustness: mutated, truncated, spliced, and random bitstreams must never panic (errors are fine).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use craft_video::apv::*;
|
||||
|
||||
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::P422_10, 64u32, 48u32),
|
||||
(Profile::P422_12, 70, 38),
|
||||
(Profile::P444_10, 48, 32),
|
||||
(Profile::P4444_10, 64, 32),
|
||||
(Profile::P400_10, 32, 32),
|
||||
] {
|
||||
let chroma = p.default_chroma();
|
||||
let depth = p.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
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 = ((i * 19) % 1024) as u16;
|
||||
}
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(p, w, h);
|
||||
cfg.qp = Some(16);
|
||||
let mut enc = Encoder::with_config(cfg).expect("seed encoder");
|
||||
v.push(enc.encode(&f).expect("seed encode"));
|
||||
v.push(enc.encode_raw_au(&f).expect("seed encode_raw_au"));
|
||||
}
|
||||
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 = 0usize;
|
||||
let iters = 3000usize;
|
||||
|
||||
for i in 0..iters {
|
||||
let mut d = seeds[i % seeds.len()].clone();
|
||||
match rng.below(6) {
|
||||
0 => {
|
||||
// Bit flips
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Random byte overwrites
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
let p = rng.below(d.len());
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Truncation
|
||||
let n = rng.below(d.len());
|
||||
d.truncate(n);
|
||||
}
|
||||
3 => {
|
||||
// Corrupt header fields
|
||||
if d.len() > 8 {
|
||||
let p = 4 + rng.below(24.min(d.len() - 4));
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
// Corrupt tile payload region
|
||||
let start = 24.min(d.len().saturating_sub(1));
|
||||
let off = start + rng.below(d.len().saturating_sub(start).max(1));
|
||||
for b in d.iter_mut().skip(off).take(1 + rng.below(48)) {
|
||||
*b = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Splice random bytes
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for depth in [None, Some(8), Some(16)] {
|
||||
let opts = DecodeOptions { bit_depth: depth, threads: i % 2 == 0, ..Default::default() };
|
||||
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);
|
||||
let _ = split_raw_bitstream(&d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic on mutated stream iteration {i}");
|
||||
}
|
||||
println!("{decoded_ok} of {} mutated decodes succeeded", iters * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic() {
|
||||
let mut rng = Rng(123456789);
|
||||
for i 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(b"aPv1");
|
||||
} else if d.len() >= 12 && rng.below(2) == 0 {
|
||||
let au_sz = (n - 4) as u32;
|
||||
d[..4].copy_from_slice(&au_sz.to_be_bytes());
|
||||
d[4..8].copy_from_slice(b"aPv1");
|
||||
}
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
let _ = decode_frame(&d);
|
||||
let _ = probe(&d);
|
||||
let _ = split_raw_bitstream(&d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic on random bytes iteration {i}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
|
||||
//! truncation at (nearly) every length must never panic or run away (errors are fine).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use craft_video::apv::*;
|
||||
|
||||
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::P422_10, 64u32, 48u32), (Profile::P444_10, 48, 32), (Profile::P4444_10, 32, 32), (Profile::P400_10, 32, 32)] {
|
||||
let chroma = p.default_chroma();
|
||||
let depth = p.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 37) % 800) as u16;
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(p, w, h);
|
||||
cfg.qp = Some(16);
|
||||
let Ok(mut enc) = Encoder::with_config(cfg) else { continue };
|
||||
if let Ok(d) = enc.encode(&f) {
|
||||
v.push(d);
|
||||
}
|
||||
if let Ok(d) = enc.encode_raw_au(&f) {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn run(d: &[u8], label: &str) {
|
||||
craft_media_testkit::set_progress(label);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for depth in [None, Some(8), Some(16)] {
|
||||
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none(), ..Default::default() });
|
||||
}
|
||||
let _ = probe(d);
|
||||
let _ = split_raw_bitstream(d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: CRAFT_TEST_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_frames_never_panic() {
|
||||
craft_media_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let seeds = seeds();
|
||||
assert!(!seeds.is_empty());
|
||||
for (si, seed) in seeds.iter().enumerate() {
|
||||
let stride = (seed.len() / 400).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run(&seed[..n], &format!("seed {si} truncate {n}"));
|
||||
}
|
||||
for p in 0..seed.len().min(256) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[p] = v;
|
||||
run(&d, &format!("seed {si} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[p] ^= 1 << b;
|
||||
run(&d, &format!("seed {si} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
|
||||
for p in 0..seed.len().min(128) {
|
||||
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
|
||||
let mut d = seed.clone();
|
||||
if let Some(s) = d.get_mut(p..p + 4) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
|
||||
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
|
||||
for i in 0..iters {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run(&d, &format!("seed {si} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Out-of-range arguments to the public constructors are errors or clamped, never arithmetic
|
||||
//! overflow (a panic in a debug build).
|
||||
|
||||
use craft_video::apv::*;
|
||||
|
||||
#[test]
|
||||
fn encoder_config_for_absurd_dimensions_does_not_overflow() {
|
||||
for (w, h) in [(u32::MAX, u32::MAX), (1 << 31, 1 << 31), (u32::MAX, 1), (0, 0)] {
|
||||
let cfg = EncoderConfig::new(Profile::P4444_12, w, h);
|
||||
assert!(Encoder::with_config(cfg).is_err(), "{w}x{h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_with_an_absurd_bit_depth_does_not_overflow() {
|
||||
for depth in [0, 7, 17, 31, 32, 40, 255] {
|
||||
let f = Frame::new(2, 2, ChromaFormat::Yuv4444, depth, true);
|
||||
assert_eq!((f.bit_depth, f.y.len(), f.alpha.map(|a| a.len())), (depth, 4, Some(4)));
|
||||
}
|
||||
assert_eq!(Frame::new(1, 1, ChromaFormat::Yuv422, 10, true).alpha, Some(vec![1023]));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod alpha;
|
||||
mod budget;
|
||||
mod fuzz;
|
||||
mod fuzz_hostile;
|
||||
mod hostile_args;
|
||||
mod oracle;
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Conformance, bit-exactness, and quality tests against ffmpeg's APV decoder (external oracle).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use craft_video::apv::{
|
||||
ChromaFormat, ContentLightLevel, DecodeOptions, Encoder, EncoderConfig, Frame, MasteringDisplay, Profile, decode_frame, decode_frame_with, probe,
|
||||
};
|
||||
|
||||
fn ffmpeg() -> Option<PathBuf> {
|
||||
// The APV demuxer is new in ffmpeg 8.0; older toolchains are found but cannot read the
|
||||
// fixtures, so they skip like a missing tool (a failure when oracles are required).
|
||||
let ff = craft_media_testkit::ffmpeg_or_skip("apv oracle")?;
|
||||
let formats = Command::new(&ff).args(["-hide_banner", "-formats"]).output().ok()?;
|
||||
let has_apv = String::from_utf8_lossy(&formats.stdout).lines().any(|l| l.split_whitespace().nth(1) == Some("apv"));
|
||||
if has_apv { Some(ff) } else { craft_media_testkit::skip("apv oracle: this ffmpeg cannot read apv (needs 8.0+)") }
|
||||
}
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
craft_media_testkit::fixtures_dir("apv")
|
||||
}
|
||||
|
||||
fn native_pix_fmt(profile: Profile) -> &'static str {
|
||||
match profile {
|
||||
Profile::P422_10 => "yuv422p10le",
|
||||
Profile::P422_12 => "yuv422p12le",
|
||||
Profile::P444_10 => "yuv444p10le",
|
||||
Profile::P444_12 => "yuv444p12le",
|
||||
Profile::P4444_10 => "yuva444p10le",
|
||||
Profile::P4444_12 => "yuva444p12le",
|
||||
Profile::P400_10 => "gray10le",
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a raw `.apv` file with ffmpeg (`-v error -xerror`) to planar `u16` samples.
|
||||
fn ffmpeg_decode_raw(ff: &Path, apv_path: &Path, pix_fmt: &str) -> (Vec<u16>, String) {
|
||||
let out = Command::new(ff)
|
||||
.args(["-hide_banner", "-v", "error", "-xerror", "-f", "apv", "-i"])
|
||||
.arg(apv_path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
assert!(out.status.success(), "ffmpeg failed on {:?}: {}", apv_path, stderr);
|
||||
let samples = out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
|
||||
(samples, stderr)
|
||||
}
|
||||
|
||||
fn synthetic_frame(profile: Profile, w: u32, h: u32) -> Frame {
|
||||
let chroma = profile.default_chroma();
|
||||
let depth = profile.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
let max_v = (1u32 << depth) - 1;
|
||||
let lo = 16u32 << (depth - 8);
|
||||
let hi = 235u32 << (depth - 8);
|
||||
let span = hi - lo;
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Mix smooth gradients, diagonal edges, and high-frequency patterns
|
||||
let grad = (x * 7 + y * 13) % span;
|
||||
let checker = if ((x / 3) ^ (y / 5)) & 1 == 0 { 40 << (depth - 10) } else { 0 };
|
||||
f.y[(y * w + x) as usize] = (lo + (grad + checker) % span).min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
let cw = f.chroma_width();
|
||||
let ch = f.chroma_height();
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let u = lo + ((x * 11 + y * 5 + 100) % span);
|
||||
let v = lo + ((x * 3 + y * 17 + 300) % span);
|
||||
f.cb[(y * cw + x) as usize] = u.min(max_v) as u16;
|
||||
f.cr[(y * cw + x) as usize] = v.min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
if let Some(a) = f.alpha.as_mut() {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let dx = x as i32 - (w as i32) / 2;
|
||||
let dy = y as i32 - (h as i32) / 2;
|
||||
a[(y * w + x) as usize] =
|
||||
if dx * dx + dy * dy < ((w.min(h) as i32) / 3).pow(2) { max_v as u16 } else { ((x * 19 + y * 23) % (max_v + 1)) as u16 };
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn flatten_frame(f: &Frame) -> Vec<u16> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&f.y);
|
||||
v.extend_from_slice(&f.cb);
|
||||
v.extend_from_slice(&f.cr);
|
||||
if let Some(a) = &f.alpha {
|
||||
v.extend_from_slice(a);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn psnr(a: &[u16], b: &[u16], peak: f64) -> f64 {
|
||||
if a.is_empty() {
|
||||
return 99.0;
|
||||
}
|
||||
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() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_profiles_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
for profile in Profile::ALL {
|
||||
let (w, h) = (320u32, 192u32);
|
||||
let src = synthetic_frame(profile, w, h);
|
||||
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||
cfg.qp = Some(12);
|
||||
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode_raw_au");
|
||||
let apv_path = dir.join(format!("oracle_{}.apv", profile.short_name()));
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "{profile:?}: ffmpeg stderr: {stderr}");
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("our decode");
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples.len(), ff_samples.len(), "{profile:?}: sample count mismatch");
|
||||
|
||||
let max_diff = our_samples.iter().zip(&ff_samples).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).max().unwrap_or(0);
|
||||
assert_eq!(max_diff, 0, "{profile:?}: our decoder must be 100% bit-exact with ffmpeg (max_diff={max_diff})");
|
||||
|
||||
let peak = ((1u32 << profile.default_bit_depth()) - 1) as f64;
|
||||
let y_psnr = psnr(&ours.y, &src.y, peak);
|
||||
assert!(y_psnr > 45.0, "{profile:?}: expected high PSNR at qp=12, got {y_psnr:.2} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_dimensions_and_multi_tile_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
for (profile, w, h) in [
|
||||
(Profile::P422_10, 640u32, 360u32),
|
||||
(Profile::P422_10, 334, 202),
|
||||
(Profile::P444_10, 333, 201),
|
||||
(Profile::P4444_12, 512, 288),
|
||||
(Profile::P400_10, 70, 38),
|
||||
] {
|
||||
let mut src = synthetic_frame(profile, w, h);
|
||||
src.mastering_display = Some(MasteringDisplay {
|
||||
primaries: [(46400, 19136), (11141, 52232), (8585, 3015)],
|
||||
white_point: (20493, 21561),
|
||||
max_luminance: 1000 << 8,
|
||||
min_luminance: 1,
|
||||
});
|
||||
src.content_light = Some(ContentLightLevel { max_cll: 1000, max_fall: 400 });
|
||||
|
||||
let mut enc = Encoder::new(profile, w, h).expect("encoder init");
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||
let apv_path = dir.join(format!("oracle_geom_{}_{w}x{h}.apv", profile.short_name()));
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "{profile:?} {w}x{h}: {stderr}");
|
||||
|
||||
let hdr = probe(&raw_au).expect("probe");
|
||||
assert_eq!((hdr.width, hdr.height), (w, h));
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("decode");
|
||||
assert_eq!(ours.mastering_display, src.mastering_display);
|
||||
assert_eq!(ours.content_light, src.content_light);
|
||||
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples, ff_samples, "{profile:?} {w}x{h}: bit-exact match with ffmpeg failed");
|
||||
|
||||
// Also verify single-threaded decode produces identical output
|
||||
let ours_st =
|
||||
decode_frame_with(&raw_au, &DecodeOptions { bit_depth: None, threads: false, ..Default::default() }).expect("decode single-threaded");
|
||||
assert_eq!(ours, ours_st);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_q_matrix_and_fh_tile_sizes_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
let profile = Profile::P444_12;
|
||||
let (w, h) = (512u32, 256u32);
|
||||
let src = synthetic_frame(profile, w, h);
|
||||
|
||||
let mut q_matrix = [[16u8; 64]; 4];
|
||||
for (c, qm) in q_matrix.iter_mut().enumerate() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
// Intentionally asymmetric (x != y weight) to verify exact (x, y) matrix orientation
|
||||
qm[y * 8 + x] = (10 + c * 3 + y * 5 + x * 2) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||
cfg.qp = Some(18);
|
||||
cfg.q_matrix = Some(q_matrix);
|
||||
cfg.write_tile_sizes_in_fh = true;
|
||||
cfg.tile_width_in_mbs = 16;
|
||||
cfg.tile_height_in_mbs = 8;
|
||||
|
||||
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||
let apv_path = dir.join("oracle_custom_qmat_444-12.apv");
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "ffmpeg stderr: {stderr}");
|
||||
|
||||
let hdr = probe(&raw_au).expect("probe");
|
||||
assert!(hdr.use_q_matrix);
|
||||
assert!(hdr.tile_sizes_in_fh.is_some());
|
||||
assert_eq!(hdr.num_tiles(), 4);
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("decode");
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples, ff_samples, "asymmetric q_matrix decode must be bit-exact with ffmpeg");
|
||||
|
||||
// Verify 8-bit and 16-bit rescaled output
|
||||
let d8 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(8), threads: true, ..Default::default() }).expect("decode 8-bit");
|
||||
assert_eq!(d8.bit_depth, 8);
|
||||
assert!(d8.y.iter().all(|&s| s <= 255));
|
||||
|
||||
let d16 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(16), threads: true, ..Default::default() }).expect("decode 16-bit");
|
||||
assert_eq!(d16.bit_depth, 16);
|
||||
}
|
||||
Reference in New Issue
Block a user