Codecs: add clean-room APV (RFC 9924) decoder, encoder, container support, and export presets

This commit is contained in:
mweinbach 2026-10-06 17:24:34 -04:00
parent 771b614ad4
commit 15787f7fe8
34 changed files with 3230 additions and 21 deletions

12
Cargo.lock generated
View File

@ -1693,6 +1693,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-apv"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-audio-dsp"
version = "0.2.1"
@ -1774,6 +1784,7 @@ version = "0.2.1"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
"filmcraft-apv",
"filmcraft-av1",
"filmcraft-bitstream",
"filmcraft-color",
@ -1866,6 +1877,7 @@ name = "filmcraft-export"
version = "0.2.1"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
"filmcraft-audio-dsp",
"filmcraft-codecs",
"filmcraft-color",

View File

@ -29,6 +29,7 @@ filmcraft-hevc = { path = "crates/hevc" }
filmcraft-vp9 = { path = "crates/vp9" }
filmcraft-prores = { path = "crates/prores" }
filmcraft-dnx = { path = "crates/dnx" }
filmcraft-apv = { path = "crates/apv" }
filmcraft-av1 = { path = "crates/av1" }
filmcraft-aac = { path = "crates/aac" }
filmcraft-opus = { path = "crates/opus" }
@ -101,6 +102,9 @@ opt-level = 3
[profile.dev.package.filmcraft-dnx]
opt-level = 3
[profile.dev.package.filmcraft-apv]
opt-level = 3
[profile.dev.package.filmcraft-av1]
opt-level = 3

21
crates/apv/Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "filmcraft-apv"
description = "Clean-room Advanced Professional Video (APV, IETF RFC 9924) decoder and encoder"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["threads"]
threads = ["dep:rayon"]
[dependencies]
filmcraft-bitstream.workspace = true
rayon = { workspace = true, optional = true }
thiserror.workspace = true
[dev-dependencies]
filmcraft-testkit.workspace = true
[lints]
workspace = true

74
crates/apv/README.md Normal file
View File

@ -0,0 +1,74 @@
# filmcraft-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).
Layer L0: depends only on `filmcraft-bitstream`, `thiserror`, and (optional, feature `threads`,
default on) `rayon`. Builds for `wasm32-unknown-unknown`; `unsafe_code = "forbid"`.
## API
```rust
use filmcraft_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), threads: true })?;
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/fuzz.rs`
across thousands of mutated and random inputs.
### 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/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 `filmcraft_apv::decode_frame`.
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
(`max_diff = 0`) with FFmpeg's APV decoder across every profile and test case.**

217
crates/apv/src/dct.rs Normal file
View File

@ -0,0 +1,217 @@
//! RFC 9924 §6.3 scaling and 8x8 integer inverse transform, plus the matching forward transform.
use crate::tables::LEVEL_SCALE;
/// 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(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
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 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 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(d[x], d[8 + x], d[16 + x], d[24 + x], d[32 + x], d[40 + x], d[48 + x], d[56 + 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(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
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 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(
s[0] as i32 - mid,
s[1] as i32 - mid,
s[2] as i32 - mid,
s[3] as i32 - mid,
s[4] as i32 - mid,
s[5] as i32 - mid,
s[6] as i32 - mid,
s[7] 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(t[u], t[8 + u], t[16 + u], t[24 + u], t[32 + u], t[40 + u], t[48 + u], t[56 + 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 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::*;
#[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]);
}
}
}

484
crates/apv/src/decode.rs Normal file
View File

@ -0,0 +1,484 @@
//! RFC 9924 APV frame and tile decoder.
use filmcraft_bitstream::BitReader;
use crate::dct::{scale_and_idct8x8, scale_and_idct8x8_dc_only};
use crate::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::tables::ZIGZAG_8X8;
use crate::{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,
}
impl Default for DecodeOptions {
fn default() -> Self {
Self { bit_depth: None, threads: true }
}
}
/// 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);
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
&& ahdr.width == hdr.width
&& ahdr.height == hdr.height
{
let mut aframe = Frame::new(ahdr.width, ahdr.height, ahdr.chroma, 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], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, 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;
copy_tile_comp(&tile.comps[1], tw_c, th_c, tx_c, ty_c, &mut out.cb, cw, ch);
copy_tile_comp(&tile.comps[2], tw_c, th_c, tx_c, ty_c, &mut out.cr, cw, ch);
}
if hdr.chroma == ChromaFormat::Yuv4444
&& let Some(alpha) = out.alpha.as_mut()
{
copy_tile_comp(&tile.comps[3], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, alpha, w, h);
}
}
Ok(())
}
fn copy_tile_comp(src: &[u16], tw: usize, th: usize, tx: usize, ty: usize, dst: &mut [u16], fw: usize, fh: usize) {
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));
}
#[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], w_luma, h_luma, out_depth)).collect()
} else {
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).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], w_luma, h_luma, out_depth)).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 })
}
#[allow(clippy::too_many_arguments)]
fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[u8], qp: u8, w_luma: u32, h_luma: u32, out_depth: u8) -> Result<Vec<u16>> {
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;
let mut comp_buf = vec![0u16; tw_c * th_c];
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;
}
}
}

598
crates/apv/src/encode.rs Normal file
View File

@ -0,0 +1,598 @@
//! RFC 9924 APV intra frame encoder.
use filmcraft_bitstream::BitWriter;
use crate::dct::{fdct8x8, quantize8x8};
use crate::header::{FrameHeader, MAX_DIMENSION, PBU_PRIMARY_FRAME, write_frame_header, write_metadata_pbu};
use crate::tables::{DEFAULT_Q_MATRIX_VAL, ZIGZAG_8X8, select_level_and_band};
use crate::{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) * (height as u64) * 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
}
}

460
crates/apv/src/header.rs Normal file
View File

@ -0,0 +1,460 @@
//! RFC 9924 §5.3 and §8 syntax structures: Access Unit, PBU, Frame Header, Tile Info, and Metadata.
use filmcraft_bitstream::{BitReader, BitWriter};
use crate::tables::DEFAULT_Q_MATRIX_VAL;
use crate::{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)
}

329
crates/apv/src/lib.rs Normal file
View File

@ -0,0 +1,329 @@
//! Clean-room Advanced Professional Video (APV) codec, implemented strictly from IETF RFC 9924
//! (February 2026).
//!
//! - **Decoder** ([`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** ([`Encoder`]): progressive intra frame encoding across all 7 profiles with fixed-QP
//! or frame-budget rate control, plus `APVDecoderConfigurationRecord` (`apvC`) generation.
//!
//! See the crate README for specification edition, accuracy against external oracles, and design notes.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod dct;
mod decode;
mod encode;
pub mod header;
pub mod tables;
pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe};
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),
}
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 [`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;
let shift = bit_depth.saturating_sub(8) as u32;
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) - 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)
}
}

59
crates/apv/src/tables.rs Normal file
View File

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

136
crates/apv/tests/fuzz.rs Normal file
View File

@ -0,0 +1,136 @@
//! Robustness: mutated, truncated, spliced, and random bitstreams must never panic (errors are fine).
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_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 };
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}");
}
}

230
crates/apv/tests/oracle.rs Normal file
View File

@ -0,0 +1,230 @@
//! Conformance, bit-exactness, and quality tests against ffmpeg's APV decoder (external oracle).
use std::path::{Path, PathBuf};
use std::process::Command;
use filmcraft_apv::{
ChromaFormat, ContentLightLevel, DecodeOptions, Encoder, EncoderConfig, Frame, MasteringDisplay, Profile, decode_frame, decode_frame_with, probe,
};
fn ffmpeg() -> Option<PathBuf> {
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
}
fn fixture_dir() -> PathBuf {
filmcraft_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 }).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 }).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 }).expect("decode 16-bit");
assert_eq!(d16.bit_depth, 16);
}

View File

@ -28,6 +28,7 @@ filmcraft-vp9 = { workspace = true }
filmcraft-prores = { workspace = true }
filmcraft-dnx = { workspace = true }
filmcraft-av1 = { workspace = true }
filmcraft-apv = { workspace = true }
filmcraft-aac = { workspace = true }
filmcraft-opus = { workspace = true }
thiserror = { workspace = true }

177
crates/codecs/src/apv.rs Normal file
View File

@ -0,0 +1,177 @@
//! Standalone APV raw bitstream source (RFC 9924 Appendix A: `.apv` elementary streams).
//!
//! Each access unit in a raw bitstream is prefixed by a 4-byte big-endian `au_size` followed by
//! the `'aPv1'` signature (unprefixed single-AU streams starting directly with `'aPv1'` are also
//! accepted). Every frame is intra-coded, so random access is direct.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Matrix, Range, Transfer};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
use filmcraft_time::{FrameRate, Tick};
use crate::CodecError;
use crate::gop::{GopCache, VideoSamples};
use crate::video::{ApvDecoder, VideoDecoder, primaries_from_code};
/// Sniff a raw APV bitstream: either a `raw_bitstream_access_unit()` (`[au_size: u32]['aPv1']`) or
/// an unprefixed `access_unit()` starting with `'aPv1'`.
pub fn sniff(head: &[u8]) -> bool {
(head.len() >= 8 && &head[4..8] == b"aPv1" && u32::from_be_bytes([head[0], head[1], head[2], head[3]]) >= 8) || head.starts_with(b"aPv1")
}
pub struct ApvSource {
info: MediaInfo,
bytes: crate::Src,
samples: Vec<(u64, u32)>,
rate: FrameRate,
video: GopCache,
}
pub(crate) fn apv_pixfmt_label(chroma: filmcraft_apv::ChromaFormat, bit_depth: u8) -> String {
let sub = match chroma {
filmcraft_apv::ChromaFormat::Monochrome => "Monochrome",
filmcraft_apv::ChromaFormat::Yuv422 => "YUV 4:2:2",
filmcraft_apv::ChromaFormat::Yuv444 => "YUV 4:4:4",
filmcraft_apv::ChromaFormat::Yuv4444 => "YUVA 4:4:4:4",
};
format!("{sub} {bit_depth}-bit")
}
impl ApvSource {
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
}
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
let len = reader.len();
let mut samples = Vec::new();
let mut pos = 0u64;
let mut hdr = [0u8; 8];
while pos + 8 <= len {
if reader.read_at(pos, &mut hdr).is_err() {
break;
}
if &hdr[4..8] == b"aPv1" {
let sz = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
if sz < 8 || pos + 4 + sz as u64 > len {
break;
}
samples.push((pos + 4, sz));
pos += 4 + sz as u64;
} else if pos == 0 && &hdr[0..4] == b"aPv1" && len <= u32::MAX as u64 {
samples.push((0, len as u32));
break;
} else {
break;
}
}
let Some(&(off0, sz0)) = samples.first() else {
return Err(CodecError::Container("APV: no valid access units".into()));
};
let first = filmcraft_media::reader::read_range(&*reader, off0, sz0 as usize).map_err(|e| CodecError::Container(e.to_string()))?;
let fh = filmcraft_apv::probe(&first).map_err(|e| CodecError::Decode(e.to_string()))?;
let mut color = ColorInfo { matrix: filmcraft_frame::default_matrix(fh.width, fh.height), ..ColorInfo::REC709 };
let mut explicit_color = None;
if fh.color_description_present {
if let Some(m) = Matrix::from_code(fh.color.matrix) {
color.matrix = m;
}
if let Some(t) = Transfer::from_code(fh.color.transfer) {
color.transfer = t;
}
if let Some(p) = primaries_from_code(fh.color.primaries) {
color.primaries = p;
}
if fh.color.full_range {
color.range = Range::Full;
}
explicit_color = Some(color);
}
let rate = FrameRate::FPS_24;
let duration = Tick::from_rational(samples.len() as i64 * rate.den, 1, rate.num);
let total_bits: u64 = samples.iter().map(|s| s.1 as u64 * 8).sum();
let secs = duration.seconds();
let bitrate = (secs > 0.0).then(|| (total_bits as f64 / secs) as u64);
let codec = match filmcraft_apv::Profile::from_idc(fh.profile_idc) {
Some(p) => p.name().into(),
None => "APV".into(),
};
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Movie,
duration,
video: Some(VideoStreamInfo {
width: fh.width,
height: fh.height,
frame_rate: rate,
par: (1, 1),
codec,
pixel_format: apv_pixfmt_label(fh.chroma, fh.bit_depth),
color,
has_alpha: fh.chroma == filmcraft_apv::ChromaFormat::Yuv4444,
bitrate,
hdr: None,
}),
audio: None,
container: "APV".into(),
start_timecode: None,
file_size: Some(len),
};
Ok(Self { info, bytes: crate::Src(reader), samples, rate, video: GopCache::new(explicit_color) })
}
}
struct ApvVideo<'a>(&'a ApvSource);
impl VideoSamples for ApvVideo<'_> {
fn count(&self) -> usize {
self.0.samples.len()
}
fn pts(&self, i: usize) -> i64 {
i as i64
}
fn sync_before(&self, i: usize) -> usize {
i
}
fn sample_at(&self, t: i64) -> Option<usize> {
(!self.0.samples.is_empty()).then(|| t.clamp(0, self.0.samples.len() as i64 - 1) as usize)
}
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
let (off, sz) = self.0.samples.get(i).copied().ok_or_else(|| CodecError::Container("sample index out of range".into()))?;
filmcraft_media::reader::read_range(&*self.0.bytes.0, off, sz as usize).map_err(|e| CodecError::Container(e.to_string()))
}
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
Ok(Box::new(ApvDecoder))
}
}
impl MediaSource for ApvSource {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
let frame_idx = self.rate.frame_at(req.time.max(Tick::ZERO));
Ok(self.video.frame(&ApvVideo(self), frame_idx)?)
}
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer, MediaError> {
Err(MediaError::NoStream("audio"))
}
}
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
if !sniff(&bytes) {
return None;
}
Some(ApvSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
if !sniff(head) {
return None;
}
Some(ApvSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}

View File

@ -18,6 +18,7 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod apv;
pub mod audio;
pub mod gop;
pub mod hw;
@ -30,6 +31,7 @@ pub mod video;
use std::sync::{Arc, RwLock};
pub use apv::ApvSource;
pub use audio::AudioFileSource;
pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders};
pub use mkv::MkvSource;
@ -65,11 +67,12 @@ impl From<CodecError> for filmcraft_media::MediaError {
}
/// Our own (pure-Rust) decoders, in the order they are tried.
const BUILTIN_FACTORIES: [VideoDecoderFactory; 8] = [
const BUILTIN_FACTORIES: [VideoDecoderFactory; 9] = [
video::h264_factory,
video::hevc_factory,
video::vp9_factory,
video::av1_factory,
video::apv_factory,
video::prores_factory,
video::dnx_factory,
video::mjpeg_factory,
@ -117,14 +120,14 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<
}
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
/// MPEG-1/2 video elementary streams, standalone audio).
/// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio).
pub fn openers() -> Vec<filmcraft_media::Opener> {
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, audio::opener]
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, audio::opener]
}
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
static R: std::sync::OnceLock<RwLock<Vec<filmcraft_media::ReaderOpener>>> = std::sync::OnceLock::new();
R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener]))
R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener, apv::reader_opener]))
}
/// Openers that read containers through a [`filmcraft_media::ByteReader`] (index now, samples on

View File

@ -142,6 +142,7 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI
Some(match c {
Codec::Vp9 { private } => SampleEntry::video(FourCc(*b"vp09"), CodecConfig::Vp9(vp9_config(private, v)), w, h),
Codec::Av1 { av1c } => SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(filmcraft_isobmff::Av1Config::parse(av1c).unwrap_or_default()), w, h),
Codec::Apv { apvc } => SampleEntry::apv(filmcraft_isobmff::ApvConfig::parse(apvc).unwrap_or_default(), w, h),
Codec::Avc { avcc } => SampleEntry::avc(AvcConfig::parse(avcc).ok()?, w, h),
Codec::Hevc { hvcc } => SampleEntry::hevc(HevcConfig::parse(hvcc).ok()?, w, h),
Codec::ProRes { fourcc } => SampleEntry::prores(FourCc(fourcc.unwrap_or(*b"apcn")), w, h),
@ -203,6 +204,10 @@ fn codec_label(c: &Codec) -> String {
Codec::Vp8 => "VP8".into(),
Codec::Vp9 { .. } => "VP9".into(),
Codec::Av1 { .. } => "AV1".into(),
Codec::Apv { apvc } => match filmcraft_isobmff::ApvConfig::parse(apvc).ok().and_then(|a| filmcraft_apv::Profile::from_idc(a.profile_idc)) {
Some(p) => p.name().into(),
None => "APV".into(),
},
Codec::ProRes { .. } => "Apple ProRes".into(),
Codec::Mjpeg => "Motion JPEG".into(),
Codec::Aac { .. } => "AAC".into(),

View File

@ -62,7 +62,7 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u
fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo {
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
// VP9 carries its colour description in vpcC (used when there is no colr box).
// VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box).
let vpc = match &entry.codec {
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
primaries: v.colour_primaries as u16,
@ -70,6 +70,12 @@ fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorIn
matrix: v.matrix_coefficients as u16,
full_range: v.full_range,
}),
CodecConfig::Apv(a) if a.color_description_present => Some(filmcraft_isobmff::ColorInfo::Nclx {
primaries: a.color_primaries as u16,
transfer: a.transfer_characteristics as u16,
matrix: a.matrix_coefficients as u16,
full_range: a.full_range,
}),
_ => None,
};
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
@ -148,7 +154,8 @@ impl Mp4Source {
codec: codec_label(&entry.codec),
pixel_format: pixfmt_label(&entry.codec),
color,
has_alpha: matches!(&entry.codec, CodecConfig::ProRes { fourcc } if fourcc.0 == *b"ap4h" || fourcc.0 == *b"ap4x"),
has_alpha: matches!(&entry.codec, CodecConfig::ProRes { fourcc } if fourcc.0 == *b"ap4h" || fourcc.0 == *b"ap4x")
|| matches!(&entry.codec, CodecConfig::Apv(a) if a.chroma_format_idc == 4),
bitrate,
hdr: entry.video.as_ref().and_then(|v| hdr_metadata(v.mastering_display.as_ref(), v.content_light)),
};
@ -429,6 +436,10 @@ fn codec_label(c: &CodecConfig) -> String {
CodecConfig::Hevc(_) => "HEVC".into(),
CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile),
CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")),
CodecConfig::Apv(a) => match filmcraft_apv::Profile::from_idc(a.profile_idc) {
Some(p) => p.name().into(),
None => "APV".into(),
},
CodecConfig::ProRes { fourcc } => match &fourcc.0 {
b"apco" => "Apple ProRes 422 Proxy".into(),
b"apcs" => "Apple ProRes 422 LT".into(),
@ -469,6 +480,10 @@ fn pixfmt_label(c: &CodecConfig) -> String {
};
format!("YUV {sub} {}-bit", c.bit_depth.max(8))
}
CodecConfig::Apv(a) => {
let chroma = filmcraft_apv::ChromaFormat::from_idc(a.chroma_format_idc).unwrap_or(filmcraft_apv::ChromaFormat::Yuv422);
crate::apv::apv_pixfmt_label(chroma, a.bit_depth_minus8.saturating_add(8))
}
CodecConfig::ProRes { fourcc } if fourcc.0[2] == b'4' => "YUVA 4:4:4 12-bit".into(),
CodecConfig::ProRes { .. } => "YUV 4:2:2 10-bit".into(),
CodecConfig::Jpeg { .. } => "YUV 4:2:x 8-bit".into(),

View File

@ -578,3 +578,40 @@ fn opus_mp4_stereo() {
"MPEG-4",
);
}
#[test]
fn apv_mp4_and_raw_bitstream_decode() {
use filmcraft_isobmff::{ApvConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
let (w, h) = (64u32, 32u32);
let mut fr = filmcraft_apv::Frame::new(w, h, filmcraft_apv::ChromaFormat::Yuv422, 10, false);
for (i, y) in fr.y.iter_mut().enumerate() {
*y = if (i as u32 % w) < w / 2 { 850 } else { 150 };
}
let mut enc = filmcraft_apv::Encoder::new(filmcraft_apv::Profile::P422_10, w, h).expect("encoder");
let au = enc.encode_raw_au(&fr).expect("encode_raw_au");
let apvc = ApvConfig::parse(&enc.decoder_config_record()).expect("parse apvC");
// 1. Raw .apv elementary stream
let raw_src = crate::open_bytes("clip.apv", au.clone().into()).expect("open raw .apv");
let raw_v = raw_src.info().video.as_ref().expect("video info");
assert_eq!((raw_v.width, raw_v.height), (w, h));
assert_eq!(raw_v.codec, "APV 422-10");
assert_eq!(raw_v.pixel_format, "YUV 4:2:2 10-bit");
let decoded_raw = raw_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode raw frame");
assert_eq!((decoded_raw.width, decoded_raw.height), (w, h));
// 2. MP4 with apv1 + apvC
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer");
let vt = mux.add_track(TrackConfig::new(SampleEntry::apv(apvc, w as u16, h as u16), 24)).expect("track");
mux.write_sample(vt, WriteSample { data: &au, duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
let mp4_bytes: Arc<[u8]> = mux.finish().expect("finish").into_inner().into();
let mp4_src = crate::open_bytes("clip.mp4", mp4_bytes).expect("open mp4");
let mp4_v = mp4_src.info().video.as_ref().expect("mp4 video info");
assert_eq!((mp4_v.width, mp4_v.height), (w, h));
assert_eq!(mp4_v.codec, "APV 422-10");
assert_eq!(mp4_v.pixel_format, "YUV 4:2:2 10-bit");
let decoded_mp4 = mp4_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode mp4 frame");
assert_eq!(decoded_mp4.to_rgba8(), decoded_raw.to_rgba8());
}

View File

@ -676,6 +676,72 @@ pub fn prores_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>>
matches!(e.codec, CodecConfig::ProRes { .. }).then(|| Ok(Box::new(ProResDecoder) as Box<dyn VideoDecoder>))
}
/// Our APV (Advanced Professional Video, RFC 9924) decoder (every frame is intra; tiles decode in
/// parallel).
pub struct ApvDecoder;
/// Convert a decoded APV frame to a [`VideoFrame`].
pub fn apv_to_video_frame(f: filmcraft_apv::Frame) -> VideoFrame {
use std::sync::Arc;
let chroma = match f.chroma {
filmcraft_apv::ChromaFormat::Yuv422 => filmcraft_frame::Chroma::C422,
filmcraft_apv::ChromaFormat::Monochrome | filmcraft_apv::ChromaFormat::Yuv444 | filmcraft_apv::ChromaFormat::Yuv4444 => filmcraft_frame::Chroma::C444,
};
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(f.width, f.height), ..filmcraft_color::ColorInfo::REC709 };
if let Some(m) = filmcraft_color::Matrix::from_code(f.color.matrix) {
color.matrix = m;
}
if let Some(t) = filmcraft_color::Transfer::from_code(f.color.transfer) {
color.transfer = t;
}
if let Some(p) = primaries_from_code(f.color.primaries) {
color.primaries = p;
}
if f.color.full_range {
color.range = filmcraft_color::Range::Full;
}
let neutral = 1u16 << f.bit_depth.saturating_sub(1);
let (cb, cr) = if f.chroma == filmcraft_apv::ChromaFormat::Monochrome {
let u = vec![neutral; (f.width as usize) * (f.height as usize)];
(u.clone(), u)
} else {
(f.cb, f.cr)
};
let data = if f.bit_depth == 8 {
let to8 = |p: Vec<u16>| Arc::new(p.into_iter().map(|v| v as u8).collect::<Vec<u8>>());
filmcraft_frame::PixelData::Yuv8 { planes: [to8(f.y), to8(cb), to8(cr)], chroma, alpha: f.alpha.map(to8) }
} else {
filmcraft_frame::PixelData::Yuv16 {
planes: [Arc::new(f.y), Arc::new(cb), Arc::new(cr)],
chroma,
bits: f.bit_depth as u32,
alpha: f.alpha.map(Arc::new),
}
};
VideoFrame { width: f.width, height: f.height, data, color, par: (1, 1), pts: filmcraft_time::Tick::ZERO }
}
impl VideoDecoder for ApvDecoder {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
let f = filmcraft_apv::decode_frame(sample).map_err(|e| CodecError::Decode(e.to_string()))?;
Ok(vec![DecodedFrame { pts, frame: apv_to_video_frame(f), draft: false }])
}
fn flush(&mut self) -> Vec<DecodedFrame> {
Vec::new()
}
fn reset(&mut self) {}
fn name(&self) -> &str {
"FilmCraft APV"
}
fn intra_only(&self) -> bool {
true
}
}
pub fn apv_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
matches!(e.codec, CodecConfig::Apv(_)).then(|| Ok(Box::new(ApvDecoder) as Box<dyn VideoDecoder>))
}
/// Our DNxHD / DNxHR (VC-3) decoder (every frame is intra; macroblock rows decode in parallel).
pub struct DnxDecoder;

View File

@ -987,7 +987,7 @@ fn build() -> Vec<CommandSpec> {
"Media…",
["File", "Export"],
None,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
has_seq,
crate::export_tools::export_media
),

View File

@ -179,6 +179,24 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
assert!((vbr / (mbps * px) - 1.0).abs() < 0.25, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px);
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
}
"apv" => {
assert!(fmt_name.contains("mov"), "{name}: {fmt_name}");
assert_eq!(v["codec_name"], "apv", "{name}");
let p = settings["apvProfile"].as_str().unwrap();
let idc = match p {
"422-10" => "33",
"422-12" => "44",
"444-10" => "55",
"444-12" => "66",
"4444-10" => "77",
"4444-12" => "88",
"400-10" => "99",
other => other,
};
let got_profile = v["profile"].as_str().unwrap_or("");
assert!(got_profile == p || got_profile == idc, "{name}: profile {got_profile} != {p} ({idc})");
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
}
"mxf-op1a" => {
assert_eq!(fmt_name, "mxf", "{name}");
let codec = match settings["mxfVideoCodec"].as_str().unwrap() {

View File

@ -209,6 +209,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
if let Some(v) = str_p(p, "dnxProfile") {
settings.dnx_profile = v.to_string();
}
if let Some(v) = str_p(p, "apvProfile") {
settings.apv_profile = v.to_string();
}
if let Some(v) = str_p(p, "mxfVideoCodec") {
settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?;
}

View File

@ -22,6 +22,7 @@ filmcraft-text = { workspace = true }
filmcraft-isobmff = { workspace = true }
filmcraft-prores = { workspace = true }
filmcraft-dnx = { workspace = true }
filmcraft-apv = { workspace = true }
filmcraft-mxf = { workspace = true }
filmcraft-aac = { workspace = true }
filmcraft-h264enc = { workspace = true }

View File

@ -82,7 +82,7 @@ pub struct Exporter {
/// Whether [`Exporter`] handles a format.
pub fn stepped(format: Format) -> bool {
matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
}
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> {
@ -102,9 +102,9 @@ impl Exporter {
}
settings.validate()?;
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
// HDR sequences export HDR (H.264 / ProRes / DNxHR) unless SDR is asked for
// HDR sequences export HDR (H.264 / ProRes / DNxHR / APV) unless SDR is asked for
let pipe = q.settings.color;
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr);
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv);
let mut settings = settings.clone();
settings.signal = match (hdr_out, pipe.working) {
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,

View File

@ -67,6 +67,10 @@ pub enum Format {
/// QuickTime, Avid DNxHR (HQ unless the settings pick another profile) + PCM.
#[serde(rename = "dnxhr", alias = "DnxHr")]
DnxHr,
/// QuickTime, APV (Advanced Professional Video, RFC 9924; 422-10 unless the settings pick
/// another profile) + PCM.
#[serde(rename = "apv", alias = "Apv")]
Apv,
/// QuickTime, Motion-JPEG + PCM.
#[serde(rename = "mjpeg", alias = "Mjpeg")]
Mjpeg,
@ -100,6 +104,7 @@ impl Format {
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
"prores" | "mov" | "appleprores" => Format::ProRes,
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
"apv" | "apv1" => Format::Apv,
"mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a,
"mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom,
"mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg,
@ -118,6 +123,7 @@ impl Format {
Format::H264 => "h264",
Format::ProRes => "prores",
Format::DnxHr => "dnxhr",
Format::Apv => "apv",
Format::Mjpeg => "mjpeg",
Format::PngSequence => "png",
Format::TiffSequence => "tiff",
@ -132,7 +138,7 @@ impl Format {
pub fn extension(self) -> &'static str {
match self {
Format::H264 => "mp4",
Format::ProRes | Format::DnxHr | Format::Mjpeg => "mov",
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov",
Format::PngSequence => "png",
Format::TiffSequence => "tif",
Format::BmpSequence => "bmp",
@ -147,6 +153,7 @@ impl Format {
Format::H264 => "H.264",
Format::ProRes => "Apple ProRes",
Format::DnxHr => "Avid DNxHR",
Format::Apv => "APV",
Format::Mjpeg => "QuickTime (Motion JPEG)",
Format::PngSequence => "PNG",
Format::TiffSequence => "TIFF",
@ -162,10 +169,11 @@ impl Format {
pub fn is_mxf(self) -> bool {
matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
}
pub const ALL: [Format; 12] = [
pub const ALL: [Format; 13] = [
Format::H264,
Format::ProRes,
Format::DnxHr,
Format::Apv,
Format::Mjpeg,
Format::PngSequence,
Format::TiffSequence,
@ -215,6 +223,9 @@ pub struct ExportSettings {
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
#[serde(default)]
pub dnx_profile: String,
/// APV profile: `422-10`, `422-12`, `444-10` or `444-12` (empty = `422-10`).
#[serde(default)]
pub apv_profile: String,
/// Video codec of the MXF formats (DNxHR unless set; the ProRes / DNxHR profile fields apply).
#[serde(default)]
pub mxf_video_codec: MxfVideoCodec,
@ -407,6 +418,7 @@ impl Default for ExportSettings {
part_of_batch: false,
prores_profile: String::new(),
dnx_profile: String::new(),
apv_profile: String::new(),
mxf_video_codec: MxfVideoCodec::default(),
sdr: false,
frame_size: None,
@ -534,7 +546,7 @@ pub type AudioEncoderFactory = fn(format: Format, sample_rate: u32, channels: u3
fn video_factories() -> &'static RwLock<Vec<EncoderFactory>> {
static F: OnceLock<RwLock<Vec<EncoderFactory>>> = OnceLock::new();
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, mjpeg_factory]))
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, apv_factory, mjpeg_factory]))
}
fn audio_factories() -> &'static RwLock<Vec<AudioEncoderFactory>> {
static F: OnceLock<RwLock<Vec<AudioEncoderFactory>>> = OnceLock::new();
@ -794,6 +806,99 @@ fn dnx_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
})
}
/// The APV profile named by [`ExportSettings::apv_profile`].
pub fn apv_profile(name: &str) -> filmcraft_apv::Profile {
use filmcraft_apv::Profile;
match name.to_ascii_lowercase().replace([' ', '_'], "-").trim_start_matches("apv-").trim_start_matches("apv") {
"422-12" | "42212" => Profile::P422_12,
"444-10" | "44410" => Profile::P444_10,
"444-12" | "44412" => Profile::P444_12,
"4444-10" | "444410" => Profile::P4444_10,
"4444-12" | "444412" => Profile::P4444_12,
"400-10" | "40010" => Profile::P400_10,
_ => Profile::P422_10,
}
}
/// APV encoder (RFC 9924: 10/12-bit 4:2:2 or 4:4:4 intra).
struct ApvEncoder {
enc: filmcraft_apv::Encoder,
chroma: filmcraft_apv::ChromaFormat,
w: u32,
h: u32,
rate: FrameRate,
signal: ColorSignal,
}
impl VideoEncoder for ApvEncoder {
fn sample_entry(&self) -> SampleEntry {
let apvc = filmcraft_isobmff::ApvConfig::parse(&self.enc.decoder_config_record()).unwrap_or_default();
let mut e = SampleEntry::apv(apvc, self.w as u16, self.h as u16);
self.signal.apply_to(&mut e, true);
e
}
fn timescale(&self) -> u32 {
self.rate.num as u32
}
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
let (w, h) = (f.width as usize, f.height as usize);
let mut fr = filmcraft_apv::Frame::new(f.width, f.height, self.chroma, 10, self.chroma == filmcraft_apv::ChromaFormat::Yuv4444);
let mut y422 = vec![0u16; w * h];
let cw = w.div_ceil(2);
let mut cb422 = vec![0u16; cw * h];
let mut cr422 = vec![0u16; cw * h];
match f.hdr {
Some(rgb) => {
let (kr, kb) = self.signal.kr_kb();
rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut y422, &mut cb422, &mut cr422);
}
None => rgba_to_yuv422_10(f.rgba, w, h, &mut y422, &mut cb422, &mut cr422),
}
fr.y = y422;
match self.chroma {
filmcraft_apv::ChromaFormat::Monochrome => {
fr.cb.clear();
fr.cr.clear();
}
filmcraft_apv::ChromaFormat::Yuv422 => {
fr.cb = cb422;
fr.cr = cr422;
}
filmcraft_apv::ChromaFormat::Yuv444 | filmcraft_apv::ChromaFormat::Yuv4444 => {
for row in 0..h {
for x in 0..w {
fr.cb[row * w + x] = cb422[row * cw + x / 2];
fr.cr[row * w + x] = cr422[row * cw + x / 2];
}
}
if let Some(a) = fr.alpha.as_mut() {
for (i, dst) in a.iter_mut().enumerate() {
*dst = f.rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16);
}
}
}
}
let data = self.enc.encode_raw_au(&fr).map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }])
}
fn flush(&mut self) -> Result<Vec<EncodedPacket>> {
Ok(Vec::new())
}
}
fn apv_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
(format == Format::Apv).then(|| {
let profile = apv_profile(&s.apv_profile);
let mut cfg = filmcraft_apv::EncoderConfig::new(profile, w, h);
if s.signal.is_hdr() {
cfg.color = filmcraft_apv::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false };
}
let chroma = cfg.chroma;
let enc = filmcraft_apv::Encoder::with_config(cfg).map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(Box::new(ApvEncoder { enc, chroma, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
})
}
/// H.264 High (our encoder): sRGB/709 RGBA8 → 8-bit limited-range BT.709 4:2:0, VBR at the
/// requested bitrate, length-prefixed samples with the `avcC` in the sample entry.
struct H264Encoder {
@ -1117,7 +1222,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
(total, count)
}
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
// Handled by the stepped exporter above; reaching here would be a dispatch bug.
return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format)));
}

View File

@ -59,6 +59,7 @@ fn mov(format: Format, profile: &str) -> ExportSettings {
};
match format {
Format::ProRes => s.prores_profile = profile.into(),
Format::Apv => s.apv_profile = profile.into(),
_ => s.dnx_profile = profile.into(),
}
s
@ -124,6 +125,8 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")),
p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")),
p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")),
p("APV 422-10", bc, "QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::Apv, "422-10")),
p("APV 422-12", bc, "QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::Apv, "422-12")),
p("MXF OP1a DNxHR HQ", bc, "MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::Dnxhr, "hq")),
p("MXF OP1a ProRes 422 HQ", bc, "MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::ProRes, "hq")),
p("MXF OP1a H.264", bc, "MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM", mxf(Format::MxfOp1a, MxfVideoCodec::H264, "")),

View File

@ -433,6 +433,15 @@ impl ExportSettings {
};
mbps * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97
}
Format::Apv => {
let bpp = match crate::apv_profile(&self.apv_profile) {
filmcraft_apv::Profile::P422_12 => 3.6,
filmcraft_apv::Profile::P444_10 => 4.2,
filmcraft_apv::Profile::P444_12 | filmcraft_apv::Profile::P4444_10 | filmcraft_apv::Profile::P4444_12 => 5.0,
_ => 3.0,
};
px * fps * bpp
}
Format::Mjpeg => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0,
Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps,
Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps,
@ -495,6 +504,7 @@ impl ExportSettings {
}
}
Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }),
Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()),
Format::Mjpeg => v += &format!(", quality {}", self.quality),
_ => {}
}
@ -516,7 +526,7 @@ impl ExportSettings {
let container = match self.format {
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
Format::H264 => "MP4",
Format::ProRes | Format::DnxHr | Format::Mjpeg => "QuickTime",
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime",
Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(),
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
_ => "",

View File

@ -178,6 +178,8 @@ pub enum CodecConfig {
Dnx {
fourcc: FourCc,
},
/// Advanced Professional Video (`apv1` + `apvC`).
Apv(ApvConfig),
Aac(AacConfig),
/// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`).
Mp3,
@ -215,6 +217,7 @@ impl CodecConfig {
CodecConfig::ProRes { .. } => "prores",
CodecConfig::Jpeg { .. } => "mjpeg",
CodecConfig::Dnx { .. } => "dnxhd",
CodecConfig::Apv(_) => "apv",
CodecConfig::Aac(_) => "aac",
CodecConfig::Mp3 => "mp3",
CodecConfig::Pcm(_) => "pcm",
@ -722,6 +725,112 @@ impl TimecodeConfig {
}
}
/// `apvC` — APVDecoderConfigurationBox (`FullBox('apvC', version = 0, flags = 0)` containing
/// `APVDecoderConfigurationRecord`).
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ApvConfig {
pub profile_idc: u8,
pub level_idc: u8,
pub band_idc: u8,
pub frame_width: u32,
pub frame_height: u32,
pub chroma_format_idc: u8,
pub bit_depth_minus8: u8,
pub capture_time_distance: u8,
pub capture_time_distance_ignored: bool,
pub color_description_present: bool,
pub color_primaries: u8,
pub transfer_characteristics: u8,
pub matrix_coefficients: u8,
pub full_range: bool,
/// Raw `APVDecoderConfigurationRecord` bytes (after the 4-byte FullBox header).
pub raw_record: Vec<u8>,
}
impl ApvConfig {
/// Build from a raw `APVDecoderConfigurationRecord` (starting with `configurationVersion = 1`).
pub fn from_record(record: Vec<u8>) -> Result<Self> {
let mut c = Cur::new(&record, "apvC");
let version = c.u8()?;
if version != 1 {
return Err(Error::Invalid(format!("unsupported APVDecoderConfigurationRecord version {version}")));
}
let num_entries = c.u8()?;
let mut cfg = ApvConfig { raw_record: record.clone(), ..Default::default() };
if num_entries >= 1 && c.remaining() >= 2 {
let _pbu_type = c.u8()?;
let num_fi = c.u8()?;
if num_fi >= 1 && c.remaining() >= 14 {
let flags = c.u8()?;
cfg.color_description_present = (flags & 0x02) != 0;
cfg.capture_time_distance_ignored = (flags & 0x01) != 0;
cfg.profile_idc = c.u8()?;
cfg.level_idc = c.u8()?;
cfg.band_idc = c.u8()?;
cfg.frame_width = c.u32()?;
cfg.frame_height = c.u32()?;
let cb = c.u8()?;
cfg.chroma_format_idc = cb >> 4;
cfg.bit_depth_minus8 = cb & 0x0F;
cfg.capture_time_distance = c.u8()?;
if cfg.color_description_present && c.remaining() >= 4 {
cfg.color_primaries = c.u8()?;
cfg.transfer_characteristics = c.u8()?;
cfg.matrix_coefficients = c.u8()?;
cfg.full_range = (c.u8()? & 0x80) != 0;
}
}
}
Ok(cfg)
}
/// Parse the payload of an `apvC` box (with or without the 4-byte `FullBox` header).
pub fn parse(data: &[u8]) -> Result<Self> {
if data.len() >= 5 && data[..4] == [0, 0, 0, 0] && data[4] == 1 {
Self::from_record(data[4..].to_vec())
} else if !data.is_empty() && data[0] == 1 {
Self::from_record(data.to_vec())
} else {
let mut c = Cur::new(data, "apvC");
let (v, _) = c.full_header()?;
if v != 0 {
return Err(Error::Invalid(format!("unsupported apvC FullBox version {v}")));
}
Self::from_record(c.rest().to_vec())
}
}
/// Serialize the full `apvC` box payload (4-byte `FullBox` header + `APVDecoderConfigurationRecord`).
pub fn to_bytes(&self) -> Vec<u8> {
let mut b = BoxBuf::new();
b.u32(0); // FullBox version = 0, flags = 0
if !self.raw_record.is_empty() {
b.bytes(&self.raw_record);
return b.buf;
}
b.u8(1); // configurationVersion = 1
b.u8(1); // number_of_configuration_entry = 1
b.u8(1); // pbu_type = 1 (primary frame)
b.u8(1); // number_of_frame_info = 1
let flags = (if self.color_description_present { 0x02 } else { 0 }) | (if self.capture_time_distance_ignored { 0x01 } else { 0 });
b.u8(flags);
b.u8(self.profile_idc);
b.u8(self.level_idc);
b.u8(self.band_idc);
b.u32(self.frame_width);
b.u32(self.frame_height);
b.u8((self.chroma_format_idc << 4) | (self.bit_depth_minus8 & 0x0F));
b.u8(self.capture_time_distance);
if self.color_description_present {
b.u8(self.color_primaries);
b.u8(self.transfer_characteristics);
b.u8(self.matrix_coefficients);
b.u8(if self.full_range { 0x80 } else { 0 });
}
b.buf
}
}
// ---------------------------------------------------------------------------------------------
// Parsing
@ -741,6 +850,7 @@ fn is_video_fourcc(f: &[u8; 4]) -> bool {
| b"apco"
| b"ap4h"
| b"ap4x"
| b"apv1"
| b"jpeg"
| b"mjpa"
| b"mjpb"
@ -834,6 +944,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc
b"hvcC" if matches!(f, b"hvc1" | b"hev1") => codec = Some(CodecConfig::Hevc(HevcConfig::parse(p)?)),
b"av1C" if f == b"av01" => codec = Some(CodecConfig::Av1(Av1Config::parse(p)?)),
b"vpcC" if f == b"vp09" => codec = Some(CodecConfig::Vp9(VpcConfig::parse(p)?)),
b"apvC" if f == b"apv1" => codec = Some(CodecConfig::Apv(ApvConfig::parse(p)?)),
b"colr" => v.color = parse_colr(p).ok(),
b"mdcv" => v.mastering_display = MasteringDisplay::parse(p),
b"clli" if p.len() >= 4 => v.content_light = Some((u16::from_be_bytes([p[0], p[1]]), u16::from_be_bytes([p[2], p[3]]))),
@ -865,6 +976,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc
b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => CodecConfig::ProRes { fourcc: format },
b"jpeg" | b"mjpa" | b"mjpb" => CodecConfig::Jpeg { fourcc: format },
b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format },
b"apv1" => CodecConfig::Apv(ApvConfig::default()),
_ => unknown(),
},
};
@ -1181,6 +1293,14 @@ impl SampleEntry {
}
e
}
/// Advanced Professional Video entry (`apv1` + `apvC`).
pub fn apv(cfg: ApvConfig, width: u16, height: u16) -> Self {
let mut e = Self::video(FourCc(*b"apv1"), CodecConfig::Apv(cfg), width, height);
if let Some(v) = e.video.as_mut() {
v.compressor_name = "APV".into();
}
e
}
/// Motion-JPEG entry (`jpeg`).
pub fn jpeg(width: u16, height: u16) -> Self {
let f = FourCc(*b"jpeg");
@ -1251,6 +1371,7 @@ impl SampleEntry {
CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()),
CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()),
CodecConfig::Vp9(c) => b.leaf(b"vpcC", &c.to_bytes()),
CodecConfig::Apv(c) => b.leaf(b"apvC", &c.to_bytes()),
_ => {}
}
if let Some(g) = v.gamma {

View File

@ -19,7 +19,7 @@ mod source;
pub use bytes::FourCc;
pub use codec::{
AacConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray,
AacConfig, ApvConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray,
MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc,
};
pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open};

View File

@ -17,6 +17,8 @@ pub enum Codec {
Vp9 { private: Vec<u8> },
/// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C).
Av1 { av1c: Vec<u8> },
/// `V_APV`; payload is an `APVDecoderConfigurationRecord` (apvC), if present.
Apv { apvc: Vec<u8> },
/// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present.
ProRes { fourcc: Option<[u8; 4]> },
/// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC.
@ -62,6 +64,7 @@ impl Codec {
Codec::Vp8 => "vp8".into(),
Codec::Vp9 { .. } => "vp9".into(),
Codec::Av1 { .. } => "av1".into(),
Codec::Apv { .. } => "apv".into(),
Codec::ProRes { .. } => "prores".into(),
Codec::Mjpeg => "mjpeg".into(),
Codec::VfwFourcc { fourcc, .. } => String::from_utf8_lossy(fourcc).trim().to_ascii_lowercase(),
@ -145,6 +148,7 @@ pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInf
"V_VP8" => Codec::Vp8,
"V_VP9" => Codec::Vp9 { private: p },
"V_AV1" => Codec::Av1 { av1c: p },
"V_APV" => Codec::Apv { apvc: p },
"V_PRORES" => Codec::ProRes { fourcc: private.get(..4).and_then(|f| f.try_into().ok()) },
"V_MJPEG" => Codec::Mjpeg,
"V_MS/VFW/FOURCC" => {
@ -153,6 +157,7 @@ pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInf
b"MJPG" | b"mjpg" | b"AVRn" | b"AVDJ" => Codec::Mjpeg,
b"H264" | b"h264" | b"avc1" | b"X264" | b"x264" => Codec::Avc { avcc: Vec::new() },
b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => Codec::ProRes { fourcc: Some(fourcc) },
b"apv1" | b"APV1" => Codec::Apv { apvc: Vec::new() },
_ => Codec::VfwFourcc { fourcc, bitmap_info_header: p },
}
}
@ -200,6 +205,8 @@ mod tests {
#[test]
fn mapping() {
assert_eq!(map_codec("V_MPEG4/ISO/AVC", &[1, 2], None), Codec::Avc { avcc: vec![1, 2] });
assert_eq!(map_codec("V_APV", &[1, 2, 3], None), Codec::Apv { apvc: vec![1, 2, 3] });
assert_eq!(map_codec("V_APV", &[], None).name(), "apv");
assert_eq!(map_codec("V_PRORES", b"apcn", None), Codec::ProRes { fourcc: Some(*b"apcn") });
let mut bih = vec![0u8; 40];
bih[16..20].copy_from_slice(b"MJPG");

View File

@ -139,8 +139,9 @@ pub type SharedSource = Arc<dyn MediaSource>;
/// File extensions we recognise at import (the media browser filters on these).
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"];
pub const VIDEO_EXTENSIONS: &[&str] =
&["mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m"];
pub const VIDEO_EXTENSIONS: &[&str] = &[
"mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv",
];
pub fn is_importable(path: &Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();

View File

@ -462,7 +462,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if (s.has_audio()
|| !s.has_video()
|| s.format == Format::H264
|| matches!(s.format, Format::ProRes | Format::DnxHr | Format::Mjpeg)
|| matches!(s.format, Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg)
|| s.format.is_mxf())
&& !s.is_image_sequence()
&& s.format != Format::Gif
@ -507,7 +507,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if section(ui, &mut reg, &mut ex.open_sections, "effects", "Effects", &t) {
pick_overlay = effects_section(ui, &mut reg, s, &t);
}
if matches!(s.format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg)
if matches!(s.format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg)
&& section(ui, &mut reg, &mut ex.open_sections, "metadata", "Metadata", &t)
{
let m = &mut s.metadata;
@ -674,6 +674,16 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
}
});
}
Format::Apv => {
row(ui, t, "Profile", |ui| {
let keys = ["422-10", "422-12", "444-10", "444-12"];
let labels = ["APV 422-10", "APV 422-12", "APV 444-10", "APV 444-12"];
let cur = keys.iter().position(|k| *k == s.apv_profile).unwrap_or(0);
if let Some(i) = combo(ui, reg, "export.video.apvProfile", labels[cur], &opts(&labels), 220.0) {
s.apv_profile = keys[i].into();
}
});
}
Format::Mjpeg => {
row(ui, t, "Quality", |ui| {
let mut q = s.quality as f64;

View File

@ -27,7 +27,7 @@ Design principles:
L2 edit · codecs · interchange · captions · speech
L1 frame · media · project · audio-dsp · text
L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only)
codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · aac · ac3 · opus
codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · aac · ac3 · opus
```
Crates are named `filmcraft-<dir>` (`crates/time` is `filmcraft-time`). The apps are `filmcraft`
@ -49,6 +49,7 @@ and `filmcraft-cli`.
| `hevc` | L0 | H.265 Main/Main 10 decoder |
| `prores` | L0 | ProRes decoder and encoder |
| `dnx` | L0 | DNxHD / DNxHR (SMPTE ST 2019-1 VC-3) decoder and DNxHR encoder |
| `apv` | L0 | Advanced Professional Video (IETF RFC 9924) decoder and encoder ([README](../crates/apv/README.md)) |
| `av1` | L0 | AV1 decoder (Main profile; bit-exact with libdav1d; see its README for the stage table) |
| `mpeg2v` | L0 | MPEG-2 video (Main / 4:2:2 profile; frame and field pictures, dual prime) and MPEG-1 video decoder ([README](../crates/mpeg2v/README.md)) |
| `aac` | L0 | AAC-LC decoder and encoder |

View File

@ -44,6 +44,7 @@ const LAYERS: &[(&str, u8)] = &[
("prores", 0),
("mjpeg", 0),
("dnx", 0),
("apv", 0),
("av1", 0),
("aac", 0),
("opus", 0),