From 15787f7fe8632fe8e0fecd5fd6036407be430bb3 Mon Sep 17 00:00:00 2001 From: mweinbach Date: Tue, 6 Oct 2026 17:24:34 -0400 Subject: [PATCH] Codecs: add clean-room APV (RFC 9924) decoder, encoder, container support, and export presets --- Cargo.lock | 12 + Cargo.toml | 4 + crates/apv/Cargo.toml | 21 + crates/apv/README.md | 74 +++ crates/apv/src/dct.rs | 217 ++++++++ crates/apv/src/decode.rs | 484 ++++++++++++++++++ crates/apv/src/encode.rs | 598 +++++++++++++++++++++++ crates/apv/src/header.rs | 460 +++++++++++++++++ crates/apv/src/lib.rs | 329 +++++++++++++ crates/apv/src/tables.rs | 59 +++ crates/apv/tests/fuzz.rs | 136 ++++++ crates/apv/tests/oracle.rs | 230 +++++++++ crates/codecs/Cargo.toml | 1 + crates/codecs/src/apv.rs | 177 +++++++ crates/codecs/src/lib.rs | 11 +- crates/codecs/src/mkv.rs | 5 + crates/codecs/src/mp4.rs | 19 +- crates/codecs/src/tests.rs | 37 ++ crates/codecs/src/video.rs | 66 +++ crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 18 + crates/engine/src/export_tools.rs | 3 + crates/export/Cargo.toml | 1 + crates/export/src/job.rs | 6 +- crates/export/src/lib.rs | 113 ++++- crates/export/src/presets.rs | 3 + crates/export/src/settings.rs | 12 +- crates/isobmff/src/codec.rs | 121 +++++ crates/isobmff/src/lib.rs | 2 +- crates/matroska/src/codec.rs | 7 + crates/media/src/lib.rs | 5 +- crates/ui-egui/src/panels/export_mode.rs | 14 +- docs/architecture.md | 3 +- xtask/src/main.rs | 1 + 34 files changed, 3230 insertions(+), 21 deletions(-) create mode 100644 crates/apv/Cargo.toml create mode 100644 crates/apv/README.md create mode 100644 crates/apv/src/dct.rs create mode 100644 crates/apv/src/decode.rs create mode 100644 crates/apv/src/encode.rs create mode 100644 crates/apv/src/header.rs create mode 100644 crates/apv/src/lib.rs create mode 100644 crates/apv/src/tables.rs create mode 100644 crates/apv/tests/fuzz.rs create mode 100644 crates/apv/tests/oracle.rs create mode 100644 crates/codecs/src/apv.rs diff --git a/Cargo.lock b/Cargo.lock index c10c047d..63e28933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index dd95c6e3..7e515514 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/apv/Cargo.toml b/crates/apv/Cargo.toml new file mode 100644 index 00000000..edbfaf82 --- /dev/null +++ b/crates/apv/Cargo.toml @@ -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 diff --git a/crates/apv/README.md b/crates/apv/README.md new file mode 100644 index 00000000..e84c9f5f --- /dev/null +++ b/crates/apv/README.md @@ -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 = enc.encode(&frame)?; // Access Unit starting with 'aPv1' +let raw_au: Vec = enc.encode_raw_au(&frame)?; // Prefixed with 32-bit au_size (ISOBMFF apv1 / .apv) +let apvc: Vec = 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.** diff --git a/crates/apv/src/dct.rs b/crates/apv/src/dct.rs new file mode 100644 index 00000000..742e7510 --- /dev/null +++ b/crates/apv/src/dct.rs @@ -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]); + } + } +} diff --git a/crates/apv/src/decode.rs b/crates/apv/src/decode.rs new file mode 100644 index 00000000..997aaa0b --- /dev/null +++ b/crates/apv/src/decode.rs @@ -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, + /// 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 { + 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 { + 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 { + decode_frame_with(data, &DecodeOptions::default()) +} + +/// Decode one APV Access Unit with custom [`DecodeOptions`]. +pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result { + 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 { + 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 = None; + let mut cll: Option = 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; 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> = 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> = { + 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 { + 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>> = 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>> = { + 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; 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> { + 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; + } + } +} diff --git a/crates/apv/src/encode.rs b/crates/apv/src/encode.rs new file mode 100644 index 00000000..2c6e4cd9 --- /dev/null +++ b/crates/apv/src/encode.rs @@ -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, + /// 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, + pub content_light: Option, +} + +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, +} + +impl Encoder { + pub fn new(profile: Profile, width: u32, height: u32) -> Result { + Self::with_config(EncoderConfig::new(profile, width, height)) + } + + pub fn with_config(cfg: EncoderConfig) -> Result { + 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> { + 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> = { + #[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> { + 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 { + 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 { + 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 { + let num_comps = tile.comps.len(); + let mut comp_payloads: Vec> = 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::(); + 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 + } +} diff --git a/crates/apv/src/header.rs b/crates/apv/src/header.rs new file mode 100644 index 00000000..8f14c108 --- /dev/null +++ b/crates/apv/src/header.rs @@ -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, + /// Tile row start positions in luma samples (`len == tile_rows + 1`). + pub row_starts: Vec, + pub tile_sizes_in_fh: Option>, + /// 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> { + 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>> { + 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> { + 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 { + 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, cll: &mut Option) -> 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> { + 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) +} diff --git a/crates/apv/src/lib.rs b/crates/apv/src/lib.rs new file mode 100644 index 00000000..7285e96c --- /dev/null +++ b/crates/apv/src/lib.rs @@ -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 = std::result::Result; + +/// 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 { + 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::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, + pub cb: Vec, + pub cr: Vec, + pub alpha: Option>, + pub color: ColorInfo, + pub profile_idc: u8, + pub level_idc: u8, + pub band_idc: u8, + pub mastering_display: Option, + pub content_light: Option, +} + +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) + } +} diff --git a/crates/apv/src/tables.rs b/crates/apv/src/tables.rs new file mode 100644 index 00000000..b596c931 --- /dev/null +++ b/crates/apv/src/tables.rs @@ -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) +} diff --git a/crates/apv/tests/fuzz.rs b/crates/apv/tests/fuzz.rs new file mode 100644 index 00000000..e902270d --- /dev/null +++ b/crates/apv/tests/fuzz.rs @@ -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> { + 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 = (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 = (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}"); + } +} diff --git a/crates/apv/tests/oracle.rs b/crates/apv/tests/oracle.rs new file mode 100644 index 00000000..f23c32b5 --- /dev/null +++ b/crates/apv/tests/oracle.rs @@ -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 { + 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, 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 { + 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::() / 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); +} diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3acffb6d..8765f3eb 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -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 } diff --git a/crates/codecs/src/apv.rs b/crates/codecs/src/apv.rs new file mode 100644 index 00000000..0001e2a3 --- /dev/null +++ b/crates/codecs/src/apv.rs @@ -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::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes))) + } + + pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { + 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 { + (!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> { + 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> { + Ok(Box::new(ApvDecoder)) + } +} + +impl MediaSource for ApvSource { + fn info(&self) -> &MediaInfo { + &self.info + } + + fn video_frame(&self, req: FrameRequest) -> Result, 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 { + Err(MediaError::NoStream("audio")) + } +} + +pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option> { + 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> { + if !sniff(head) { + return None; + } + Some(ApvSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index d1598de7..24978d53 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -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 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 { - 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> { static R: std::sync::OnceLock>> = 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 diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 1a6ed36c..fae5b5c9 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -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(), diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 9820fbc8..b36aa904 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -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(), diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index e984856d..819a41f9 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -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()); +} diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 1fd52b4b..bb068a0d 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -676,6 +676,72 @@ pub fn prores_factory(e: &SampleEntry) -> Option>> matches!(e.codec, CodecConfig::ProRes { .. }).then(|| Ok(Box::new(ProResDecoder) as Box)) } +/// 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| Arc::new(p.into_iter().map(|v| v as u8).collect::>()); + 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> { + 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 { + 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>> { + matches!(e.codec, CodecConfig::Apv(_)).then(|| Ok(Box::new(ApvDecoder) as Box)) +} + /// Our DNxHD / DNxHR (VC-3) decoder (every frame is intra; macroblock rows decode in parallel). pub struct DnxDecoder; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 01575465..163652d4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -987,7 +987,7 @@ fn build() -> Vec { "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 ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index ae02fa76..62f22093 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -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() { diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 6d319ab1..a6cee128 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -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"))?; } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 22c2de4c..d058e4e6 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -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 } diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 70508164..f127747e 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -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> { @@ -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, diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 30a6d02c..d07bd349 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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> { static F: OnceLock>> = 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> { static F: OnceLock>> = 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> { + 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> { + Ok(Vec::new()) + } +} + +fn apv_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { + (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) + }) +} + /// 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, 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))); } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index e1545fda..3aa37d05 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -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 { 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, "")), diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 90b2b163..76a4443e 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -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", _ => "", diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index f9105613..117b6725 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -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, +} + +impl ApvConfig { + /// Build from a raw `APVDecoderConfigurationRecord` (starting with `configurationVersion = 1`). + pub fn from_record(record: Vec) -> Result { + 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 { + 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 { + 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 { diff --git a/crates/isobmff/src/lib.rs b/crates/isobmff/src/lib.rs index b34a158b..6c0b657d 100644 --- a/crates/isobmff/src/lib.rs +++ b/crates/isobmff/src/lib.rs @@ -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}; diff --git a/crates/matroska/src/codec.rs b/crates/matroska/src/codec.rs index e44f47ea..21de3715 100644 --- a/crates/matroska/src/codec.rs +++ b/crates/matroska/src/codec.rs @@ -17,6 +17,8 @@ pub enum Codec { Vp9 { private: Vec }, /// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C). Av1 { av1c: Vec }, + /// `V_APV`; payload is an `APVDecoderConfigurationRecord` (apvC), if present. + Apv { apvc: Vec }, /// `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"); diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 946d2aeb..3dc3cdd5 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -139,8 +139,9 @@ pub type SharedSource = Arc; /// 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(); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b9bdc706..c9446a7f 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -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; diff --git a/docs/architecture.md b/docs/architecture.md index 3c06cc2e..d45bcad6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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-` (`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 | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index efcbc15b..fb541946 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -44,6 +44,7 @@ const LAYERS: &[(&str, u8)] = &[ ("prores", 0), ("mjpeg", 0), ("dnx", 0), + ("apv", 0), ("av1", 0), ("aac", 0), ("opus", 0),