Codecs: add clean-room APV (RFC 9924) decoder, encoder, container support, and export presets
This commit is contained in:
parent
771b614ad4
commit
15787f7fe8
|
|
@ -1693,6 +1693,16 @@ dependencies = [
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filmcraft-apv"
|
||||||
|
version = "0.2.1"
|
||||||
|
dependencies = [
|
||||||
|
"filmcraft-bitstream",
|
||||||
|
"filmcraft-testkit",
|
||||||
|
"rayon",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filmcraft-audio-dsp"
|
name = "filmcraft-audio-dsp"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -1774,6 +1784,7 @@ version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filmcraft-aac",
|
"filmcraft-aac",
|
||||||
"filmcraft-ac3",
|
"filmcraft-ac3",
|
||||||
|
"filmcraft-apv",
|
||||||
"filmcraft-av1",
|
"filmcraft-av1",
|
||||||
"filmcraft-bitstream",
|
"filmcraft-bitstream",
|
||||||
"filmcraft-color",
|
"filmcraft-color",
|
||||||
|
|
@ -1866,6 +1877,7 @@ name = "filmcraft-export"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filmcraft-aac",
|
"filmcraft-aac",
|
||||||
|
"filmcraft-apv",
|
||||||
"filmcraft-audio-dsp",
|
"filmcraft-audio-dsp",
|
||||||
"filmcraft-codecs",
|
"filmcraft-codecs",
|
||||||
"filmcraft-color",
|
"filmcraft-color",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ filmcraft-hevc = { path = "crates/hevc" }
|
||||||
filmcraft-vp9 = { path = "crates/vp9" }
|
filmcraft-vp9 = { path = "crates/vp9" }
|
||||||
filmcraft-prores = { path = "crates/prores" }
|
filmcraft-prores = { path = "crates/prores" }
|
||||||
filmcraft-dnx = { path = "crates/dnx" }
|
filmcraft-dnx = { path = "crates/dnx" }
|
||||||
|
filmcraft-apv = { path = "crates/apv" }
|
||||||
filmcraft-av1 = { path = "crates/av1" }
|
filmcraft-av1 = { path = "crates/av1" }
|
||||||
filmcraft-aac = { path = "crates/aac" }
|
filmcraft-aac = { path = "crates/aac" }
|
||||||
filmcraft-opus = { path = "crates/opus" }
|
filmcraft-opus = { path = "crates/opus" }
|
||||||
|
|
@ -101,6 +102,9 @@ opt-level = 3
|
||||||
[profile.dev.package.filmcraft-dnx]
|
[profile.dev.package.filmcraft-dnx]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.dev.package.filmcraft-apv]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
[profile.dev.package.filmcraft-av1]
|
[profile.dev.package.filmcraft-av1]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# filmcraft-apv
|
||||||
|
|
||||||
|
Clean-room Advanced Professional Video (APV) decoder and encoder in pure Rust, implemented strictly
|
||||||
|
from the public **IETF RFC 9924** specification (*Advanced Professional Video*, February 2026,
|
||||||
|
ISSN 2070-1721).
|
||||||
|
|
||||||
|
Layer L0: depends only on `filmcraft-bitstream`, `thiserror`, and (optional, feature `threads`,
|
||||||
|
default on) `rayon`. Builds for `wasm32-unknown-unknown`; `unsafe_code = "forbid"`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use filmcraft_apv::*;
|
||||||
|
|
||||||
|
// Decode one APV Access Unit (or raw bitstream AU prefixed with a 32-bit au_size).
|
||||||
|
let frame: Frame = decode_frame(&au)?;
|
||||||
|
let f8 = decode_frame_with(&au, &DecodeOptions { bit_depth: Some(8), threads: true })?;
|
||||||
|
decode_frame_into(&au, &DecodeOptions::default(), &mut frame_buf)?;
|
||||||
|
let hdr: FrameHeader = probe(&au)?;
|
||||||
|
|
||||||
|
// Split a multi-AU raw .apv bitstream (RFC 9924 Appendix A).
|
||||||
|
let aus: Vec<&[u8]> = split_raw_bitstream(&raw_apv_bytes)?;
|
||||||
|
|
||||||
|
// Encode progressive intra frames.
|
||||||
|
let mut enc = Encoder::new(Profile::P422_10, 1920, 1080)?;
|
||||||
|
let au: Vec<u8> = enc.encode(&frame)?; // Access Unit starting with 'aPv1'
|
||||||
|
let raw_au: Vec<u8> = enc.encode_raw_au(&frame)?; // Prefixed with 32-bit au_size (ISOBMFF apv1 / .apv)
|
||||||
|
let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationRecord payload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Specification Coverage (IETF RFC 9924)
|
||||||
|
|
||||||
|
### Decoder
|
||||||
|
- **Access Units & PBUs (§5.3.1–5.3.3, Appendix A)**: Accepts both unprefixed Access Units
|
||||||
|
(`'aPv1'` signature) and length-prefixed `raw_bitstream_access_unit()` packets. Parses primary
|
||||||
|
(`pbu_type == 1`), non-primary (`2`), preview (`25`), auxiliary alpha (`27`), AU info (`65`),
|
||||||
|
metadata (`66`), and filler (`67`) PBUs. PBUs or frame headers with non-zero `reserved_zero_*`
|
||||||
|
fields are ignored per §5.3.3 and §5.3.5.
|
||||||
|
- **Profiles & Sampling (§9.3)**: All 7 standard profiles (`422-10` idc 33, `422-12` idc 44,
|
||||||
|
`444-10` idc 55, `444-12` idc 66, `4444-10` idc 77, `4444-12` idc 88, `400-10` idc 99) at
|
||||||
|
coded bit depths 10–16, with optional output depth rescaling (`8..=16`).
|
||||||
|
- **Quantization Matrices & Tiles (§5.3.7–5.3.8, §5.3.12–5.3.14)**: Default flat (`16`) and
|
||||||
|
custom per-component 8×8 quantization matrices (`use_q_matrix`), optional `tile_size_in_fh`
|
||||||
|
verification, and independent per-tile, per-component byte-aligned entropy partitions. Tiles
|
||||||
|
decode in parallel via `rayon` when `threads` is enabled.
|
||||||
|
- **Entropy Decoding (§5.3.15–5.3.16, §7.1)**: Adaptive Golomb-Rice / Exp-Golomb VLC decoding for
|
||||||
|
`abs_dc_coeff_diff`, `coeff_zero_run`, and `abs_ac_coeff_minus1` with state tracking (`PrevDC`,
|
||||||
|
`PrevDcDiff`, `Prev1stAcLevel`, `PrevLevel`, `PrevRun`) across the 8×8 blocks of each macroblock.
|
||||||
|
- **Scaling & Inverse Transform (§6.3)**: Exact integer dequantization (`levelScale = [40, 45, 51,
|
||||||
|
57, 64, 71]`, `bdShift = BitDepth - 2`) and 2-D separable 8×8 integer inverse transform using the
|
||||||
|
RFC 9924 Figure 25 `transMatrix`. Note: in RFC 9924 §6.3.2.2, `transMatrix[m][n]` has row `m` as
|
||||||
|
the frequency index (row 0 is `[64; 8]`) and column `n` as the spatial sample index, so the 1-D
|
||||||
|
inverse transform evaluates `sum_j transMatrix[j][i] * x[j]`.
|
||||||
|
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
|
||||||
|
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
|
||||||
|
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
|
||||||
|
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs`
|
||||||
|
across thousands of mutated and random inputs.
|
||||||
|
|
||||||
|
### Encoder
|
||||||
|
- Progressive intra encoding for all 7 profiles (`422-10`, `422-12`, `444-10`, `444-12`,
|
||||||
|
`4444-10`, `4444-12`, `400-10`).
|
||||||
|
- Supports fixed `qp` or binary-search frame-budget rate control over forward-transformed tiles.
|
||||||
|
- Emits conforming tile grids (`tile_width_in_mbs >= 16`, `tile_height_in_mbs >= 8`,
|
||||||
|
`TileCols, TileRows <= 20`), optional custom quantization matrices, optional `MDCV`/`CLL`
|
||||||
|
metadata PBUs, and `APVDecoderConfigurationRecord` (`apvC`) metadata.
|
||||||
|
|
||||||
|
## Accuracy (vs FFmpeg as an External Oracle)
|
||||||
|
|
||||||
|
`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
|
||||||
|
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
|
||||||
|
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`.
|
||||||
|
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
|
||||||
|
(`max_diff = 0`) with FFmpeg's APV decoder across every profile and test case.**
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,484 @@
|
||||||
|
//! RFC 9924 APV frame and tile decoder.
|
||||||
|
|
||||||
|
use filmcraft_bitstream::BitReader;
|
||||||
|
|
||||||
|
use crate::dct::{scale_and_idct8x8, scale_and_idct8x8_dc_only};
|
||||||
|
use crate::header::{
|
||||||
|
FrameHeader, PBU_ALPHA_FRAME, PBU_METADATA, PBU_NON_PRIMARY_FRAME, PBU_PREVIEW_FRAME, PBU_PRIMARY_FRAME, parse_au_pbus, parse_frame_header,
|
||||||
|
parse_metadata_pbu, read_byte_alignment,
|
||||||
|
};
|
||||||
|
use crate::tables::ZIGZAG_8X8;
|
||||||
|
use crate::{ChromaFormat, ContentLightLevel, Error, Frame, MasteringDisplay, Result};
|
||||||
|
|
||||||
|
/// Options for [`decode_frame_with`] and [`decode_frame_into`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DecodeOptions {
|
||||||
|
/// Output sample bit depth (`8..=16`). `None` keeps the stream's coded bit depth (`10..=16`).
|
||||||
|
pub bit_depth: Option<u8>,
|
||||||
|
/// Decode tiles in parallel when the `threads` feature is enabled.
|
||||||
|
pub threads: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DecodeOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { bit_depth: None, threads: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse RFC 9924 §7.1.4 variable-length codeword (`h(v)`).
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn read_vlc(r: &mut BitReader, k_param: u32) -> Result<u32> {
|
||||||
|
let mut symbol_value: u64 = 0;
|
||||||
|
let mut k = k_param;
|
||||||
|
let parse_exp_golomb = if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||||
|
false
|
||||||
|
} else if !r.read_bit().map_err(|_| Error::Truncated)? {
|
||||||
|
symbol_value += 1u64 << k;
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
symbol_value += 2u64 << k;
|
||||||
|
true
|
||||||
|
};
|
||||||
|
if parse_exp_golomb {
|
||||||
|
loop {
|
||||||
|
if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
symbol_value += 1u64 << k;
|
||||||
|
k += 1;
|
||||||
|
if k > 28 {
|
||||||
|
return Err(Error::Invalid("VLC codeword exceeds maximum length"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k > 0 {
|
||||||
|
symbol_value += r.read_bits(k).map_err(|_| Error::Truncated)? as u64;
|
||||||
|
}
|
||||||
|
u32::try_from(symbol_value).map_err(|_| Error::Invalid("VLC symbol overflow"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe the primary frame header of an Access Unit without decoding its tiles.
|
||||||
|
pub fn probe(data: &[u8]) -> Result<FrameHeader> {
|
||||||
|
let pbus = parse_au_pbus(data)?;
|
||||||
|
for pbu in &pbus {
|
||||||
|
if matches!(pbu.pbu_type, PBU_PRIMARY_FRAME | PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME)
|
||||||
|
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||||
|
{
|
||||||
|
return Ok(hdr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::Invalid("no decodable frame PBU in access unit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one APV Access Unit with default options.
|
||||||
|
pub fn decode_frame(data: &[u8]) -> Result<Frame> {
|
||||||
|
decode_frame_with(data, &DecodeOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one APV Access Unit with custom [`DecodeOptions`].
|
||||||
|
pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result<Frame> {
|
||||||
|
let mut out = Frame::new(2, 2, ChromaFormat::Yuv422, 10, false);
|
||||||
|
decode_frame_into(data, opts, &mut out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one APV Access Unit into `out`, reusing its plane allocations when possible.
|
||||||
|
pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<FrameHeader> {
|
||||||
|
if let Some(d) = opts.bit_depth
|
||||||
|
&& !(8..=16).contains(&d)
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid("requested output bit_depth must be in 8..=16"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pbus = parse_au_pbus(data)?;
|
||||||
|
let mut primary: Option<(u16, FrameHeader, &[u8])> = None;
|
||||||
|
let mut alpha_pbu: Option<(FrameHeader, &[u8])> = None;
|
||||||
|
let mut mdcv: Option<MasteringDisplay> = None;
|
||||||
|
let mut cll: Option<ContentLightLevel> = None;
|
||||||
|
|
||||||
|
for pbu in &pbus {
|
||||||
|
match pbu.pbu_type {
|
||||||
|
PBU_PRIMARY_FRAME => {
|
||||||
|
if primary.is_none()
|
||||||
|
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||||
|
{
|
||||||
|
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME => {
|
||||||
|
if primary.is_none()
|
||||||
|
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||||
|
{
|
||||||
|
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PBU_ALPHA_FRAME => {
|
||||||
|
if alpha_pbu.is_none()
|
||||||
|
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||||
|
{
|
||||||
|
alpha_pbu = Some((hdr, pbu.payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PBU_METADATA => {
|
||||||
|
let _ = parse_metadata_pbu(pbu.payload, &mut mdcv, &mut cll);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_group_id, hdr, payload) = primary.ok_or(Error::Invalid("no primary frame PBU in access unit"))?;
|
||||||
|
let out_depth = opts.bit_depth.unwrap_or(hdr.bit_depth);
|
||||||
|
|
||||||
|
decode_pbu_planes(&hdr, payload, out_depth, opts.threads, out)?;
|
||||||
|
out.mastering_display = mdcv;
|
||||||
|
out.content_light = cll;
|
||||||
|
|
||||||
|
// If the primary frame has no 4th component and an auxiliary alpha frame PBU (type 27) is present
|
||||||
|
// with matching dimensions, decode its luma plane as the alpha plane.
|
||||||
|
if out.alpha.is_none()
|
||||||
|
&& let Some((ahdr, apayload)) = alpha_pbu
|
||||||
|
&& ahdr.width == hdr.width
|
||||||
|
&& ahdr.height == hdr.height
|
||||||
|
{
|
||||||
|
let mut aframe = Frame::new(ahdr.width, ahdr.height, ahdr.chroma, out_depth, false);
|
||||||
|
if decode_pbu_planes(&ahdr, apayload, out_depth, opts.threads, &mut aframe).is_ok() {
|
||||||
|
out.alpha = Some(aframe.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(hdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DecodedTile {
|
||||||
|
x0: u32,
|
||||||
|
y0: u32,
|
||||||
|
w_luma: u32,
|
||||||
|
h_luma: u32,
|
||||||
|
/// Decoded pixels for each component `c` in `0..num_comps`, packed with stride `w_luma / sub_w`.
|
||||||
|
comps: [Vec<u16>; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_pbu_planes(hdr: &FrameHeader, payload: &[u8], out_depth: u8, threads: bool, out: &mut Frame) -> Result<()> {
|
||||||
|
let num_tiles = hdr.num_tiles();
|
||||||
|
let mut pos = hdr.header_bytes;
|
||||||
|
let mut tile_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_tiles);
|
||||||
|
|
||||||
|
for tile_idx in 0..num_tiles {
|
||||||
|
let sz_bytes = payload.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||||
|
let tile_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||||
|
if tile_size == 0 {
|
||||||
|
return Err(Error::Invalid("tile_size cannot be 0"));
|
||||||
|
}
|
||||||
|
if let Some(fh_sizes) = &hdr.tile_sizes_in_fh
|
||||||
|
&& fh_sizes.get(tile_idx).copied() != Some(tile_size as u32)
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid("tile_size does not match tile_size_in_fh"));
|
||||||
|
}
|
||||||
|
pos += 4;
|
||||||
|
let tile_end = pos.checked_add(tile_size).ok_or(Error::Truncated)?;
|
||||||
|
let tile_bytes = payload.get(pos..tile_end).ok_or(Error::Truncated)?;
|
||||||
|
pos = tile_end;
|
||||||
|
tile_slices.push((tile_idx, tile_bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify any trailing filler bytes in the frame PBU are 0xFF (RFC 9924 §5.3.4 & §5.3.11)
|
||||||
|
if payload.get(pos..).is_some_and(|rest| rest.iter().any(|&b| b != 0xFF)) {
|
||||||
|
return Err(Error::Invalid("non-0xFF filler byte at end of frame PBU"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "threads")]
|
||||||
|
let decoded_tiles: Result<Vec<DecodedTile>> = if threads && tile_slices.len() > 1 {
|
||||||
|
use rayon::prelude::*;
|
||||||
|
tile_slices.into_par_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||||
|
} else {
|
||||||
|
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, threads)).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(feature = "threads"))]
|
||||||
|
let decoded_tiles: Result<Vec<DecodedTile>> = {
|
||||||
|
let _ = threads;
|
||||||
|
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decoded_tiles = decoded_tiles?;
|
||||||
|
|
||||||
|
// Prepare output frame metadata
|
||||||
|
let w = hdr.width as usize;
|
||||||
|
let h = hdr.height as usize;
|
||||||
|
let cw = hdr.chroma.chroma_width(hdr.width) as usize;
|
||||||
|
let ch = hdr.chroma.chroma_height(hdr.height) as usize;
|
||||||
|
let n_luma = w.checked_mul(h).ok_or(Error::Invalid("frame luma size overflow"))?;
|
||||||
|
let n_chroma = cw.checked_mul(ch).ok_or(Error::Invalid("frame chroma size overflow"))?;
|
||||||
|
|
||||||
|
out.width = hdr.width;
|
||||||
|
out.height = hdr.height;
|
||||||
|
out.chroma = hdr.chroma;
|
||||||
|
out.bit_depth = out_depth;
|
||||||
|
out.color = hdr.color;
|
||||||
|
out.profile_idc = hdr.profile_idc;
|
||||||
|
out.level_idc = hdr.level_idc;
|
||||||
|
out.band_idc = hdr.band_idc;
|
||||||
|
|
||||||
|
// Zero-copy fast path when a single tile covers the exact frame dimensions without macroblock padding.
|
||||||
|
if decoded_tiles.len() == 1 && decoded_tiles[0].w_luma == hdr.width && decoded_tiles[0].h_luma == hdr.height {
|
||||||
|
let [y, cb, cr, a] = std::mem::take(&mut decoded_tiles[0].comps);
|
||||||
|
out.y = y;
|
||||||
|
if hdr.chroma.num_comps() >= 3 {
|
||||||
|
out.cb = cb;
|
||||||
|
out.cr = cr;
|
||||||
|
} else {
|
||||||
|
out.cb.clear();
|
||||||
|
out.cr.clear();
|
||||||
|
}
|
||||||
|
out.alpha = if hdr.chroma == ChromaFormat::Yuv4444 { Some(a) } else { None };
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
out.y.resize(n_luma, 0);
|
||||||
|
out.cb.resize(n_chroma, 0);
|
||||||
|
out.cr.resize(n_chroma, 0);
|
||||||
|
if hdr.chroma == ChromaFormat::Yuv4444 {
|
||||||
|
let a = out.alpha.get_or_insert_with(Vec::new);
|
||||||
|
a.resize(n_luma, 0);
|
||||||
|
} else {
|
||||||
|
out.alpha = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy each tile's reconstructed samples into the cropped output planes (RFC 9924 §6)
|
||||||
|
let sub_w = hdr.chroma.sub_width_c() as usize;
|
||||||
|
let sub_h = hdr.chroma.sub_height_c() as usize;
|
||||||
|
|
||||||
|
for tile in &decoded_tiles {
|
||||||
|
copy_tile_comp(&tile.comps[0], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, &mut out.y, w, h);
|
||||||
|
if hdr.chroma.num_comps() >= 3 {
|
||||||
|
let tw_c = (tile.w_luma as usize) / sub_w;
|
||||||
|
let th_c = (tile.h_luma as usize) / sub_h;
|
||||||
|
let tx_c = (tile.x0 as usize) / sub_w;
|
||||||
|
let ty_c = (tile.y0 as usize) / sub_h;
|
||||||
|
copy_tile_comp(&tile.comps[1], tw_c, th_c, tx_c, ty_c, &mut out.cb, cw, ch);
|
||||||
|
copy_tile_comp(&tile.comps[2], tw_c, th_c, tx_c, ty_c, &mut out.cr, cw, ch);
|
||||||
|
}
|
||||||
|
if hdr.chroma == ChromaFormat::Yuv4444
|
||||||
|
&& let Some(alpha) = out.alpha.as_mut()
|
||||||
|
{
|
||||||
|
copy_tile_comp(&tile.comps[3], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, alpha, w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_tile_comp(src: &[u16], tw: usize, th: usize, tx: usize, ty: usize, dst: &mut [u16], fw: usize, fh: usize) {
|
||||||
|
if tx >= fw || ty >= fh {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let copy_w = tw.min(fw - tx);
|
||||||
|
let copy_h = th.min(fh - ty);
|
||||||
|
for r in 0..copy_h {
|
||||||
|
let s_off = r * tw;
|
||||||
|
let d_off = (ty + r) * fw + tx;
|
||||||
|
dst[d_off..d_off + copy_w].copy_from_slice(&src[s_off..s_off + copy_w]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_single_tile(hdr: &FrameHeader, tile_idx: usize, tile_bytes: &[u8], out_depth: u8, par_comps: bool) -> Result<DecodedTile> {
|
||||||
|
let num_comps = hdr.chroma.num_comps();
|
||||||
|
let min_hdr_size = 5 + 5 * num_comps;
|
||||||
|
if tile_bytes.len() < min_hdr_size {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||||
|
let tile_header_size = u16::from_be_bytes([tile_bytes[0], tile_bytes[1]]) as usize;
|
||||||
|
let tile_index = u16::from_be_bytes([tile_bytes[2], tile_bytes[3]]) as usize;
|
||||||
|
if tile_index != tile_idx {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
if tile_header_size < min_hdr_size || tile_header_size > tile_bytes.len() {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tile_data_size = [0usize; 4];
|
||||||
|
for c in 0..num_comps {
|
||||||
|
let off = 4 + c * 4;
|
||||||
|
let sz = u32::from_be_bytes([tile_bytes[off], tile_bytes[off + 1], tile_bytes[off + 2], tile_bytes[off + 3]]) as usize;
|
||||||
|
if sz == 0 {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
tile_data_size[c] = sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_tile_qp = (51 + hdr.qp_bd_offset()) as u8;
|
||||||
|
let mut tile_qp = [0u8; 4];
|
||||||
|
for c in 0..num_comps {
|
||||||
|
let qp = tile_bytes[4 + num_comps * 4 + c];
|
||||||
|
if qp > max_tile_qp {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
tile_qp[c] = qp;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reserved_zero_8bits = tile_bytes[4 + num_comps * 5];
|
||||||
|
if reserved_zero_8bits != 0 {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = tile_idx % (hdr.tile_cols as usize);
|
||||||
|
let ty = tile_idx / (hdr.tile_cols as usize);
|
||||||
|
let x0 = hdr.col_starts[tx];
|
||||||
|
let x1 = hdr.col_starts[tx + 1];
|
||||||
|
let y0 = hdr.row_starts[ty];
|
||||||
|
let y1 = hdr.row_starts[ty + 1];
|
||||||
|
let w_luma = x1.saturating_sub(x0);
|
||||||
|
let h_luma = y1.saturating_sub(y0);
|
||||||
|
|
||||||
|
let mut comp_off = tile_header_size;
|
||||||
|
let mut comp_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_comps);
|
||||||
|
for c in 0..num_comps {
|
||||||
|
let comp_end = comp_off.checked_add(tile_data_size[c]).ok_or(Error::CorruptTile(tile_idx))?;
|
||||||
|
let comp_bytes = tile_bytes.get(comp_off..comp_end).ok_or(Error::CorruptTile(tile_idx))?;
|
||||||
|
comp_off = comp_end;
|
||||||
|
comp_slices.push((c, comp_bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "threads")]
|
||||||
|
let decoded_comps: Result<Vec<Vec<u16>>> = if par_comps && comp_slices.len() > 1 {
|
||||||
|
use rayon::prelude::*;
|
||||||
|
comp_slices.into_par_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||||
|
} else {
|
||||||
|
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(feature = "threads"))]
|
||||||
|
let decoded_comps: Result<Vec<Vec<u16>>> = {
|
||||||
|
let _ = par_comps;
|
||||||
|
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut comps: [Vec<u16>; 4] = Default::default();
|
||||||
|
for (c, buf) in decoded_comps?.into_iter().enumerate() {
|
||||||
|
comps[c] = buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DecodedTile { x0, y0, w_luma, h_luma, comps })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[u8], qp: u8, w_luma: u32, h_luma: u32, out_depth: u8) -> Result<Vec<u16>> {
|
||||||
|
let num_mb_cols = w_luma / 16;
|
||||||
|
let num_mb_rows = h_luma / 16;
|
||||||
|
let sub_w = if c == 0 { 1 } else { hdr.chroma.sub_width_c() };
|
||||||
|
let sub_h = if c == 0 { 1 } else { hdr.chroma.sub_height_c() };
|
||||||
|
let tw_c = (w_luma / sub_w) as usize;
|
||||||
|
let th_c = (h_luma / sub_h) as usize;
|
||||||
|
let blk_w = (16 / sub_w) as usize;
|
||||||
|
let blk_h = (16 / sub_h) as usize;
|
||||||
|
let blocks_x = blk_w / 8;
|
||||||
|
let blocks_y = blk_h / 8;
|
||||||
|
|
||||||
|
let mut comp_buf = vec![0u16; tw_c * th_c];
|
||||||
|
let mut r = BitReader::new(comp_bytes);
|
||||||
|
|
||||||
|
let mut prev_dc: i32 = 0;
|
||||||
|
let mut prev_dc_diff: u32 = 20;
|
||||||
|
let mut prev_1st_ac_level: u32 = 0;
|
||||||
|
|
||||||
|
let q_mat = &hdr.q_matrix[c];
|
||||||
|
let coded_depth = hdr.bit_depth;
|
||||||
|
|
||||||
|
for mb_y in 0..(num_mb_rows as usize) {
|
||||||
|
for mb_x in 0..(num_mb_cols as usize) {
|
||||||
|
for by in 0..blocks_y {
|
||||||
|
for bx in 0..blocks_x {
|
||||||
|
// abs_dc_coeff_diff (RFC 9924 §5.3.15 & §7.1.1)
|
||||||
|
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||||
|
let abs_dc_diff = read_vlc(&mut r, k_dc).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||||
|
let sign_dc = if abs_dc_diff > 0 { r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))? } else { false };
|
||||||
|
let diff = if sign_dc { -(abs_dc_diff as i64) } else { abs_dc_diff as i64 };
|
||||||
|
let dc = prev_dc as i64 + diff;
|
||||||
|
if !(-32768..=32767).contains(&dc) {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
prev_dc = dc as i32;
|
||||||
|
prev_dc_diff = abs_dc_diff;
|
||||||
|
|
||||||
|
let mut coeff = [0i16; 64];
|
||||||
|
coeff[0] = dc as i16;
|
||||||
|
|
||||||
|
// ac_coeff_coding (RFC 9924 §5.3.16 & §7.1.2–7.1.3)
|
||||||
|
let mut scan_pos = 1usize;
|
||||||
|
let mut first_ac = true;
|
||||||
|
let mut prev_level = prev_1st_ac_level;
|
||||||
|
let mut prev_run = 0u32;
|
||||||
|
|
||||||
|
while scan_pos < 64 {
|
||||||
|
let k_run = (prev_run >> 2).min(2);
|
||||||
|
let run = read_vlc(&mut r, k_run).map_err(|_| Error::CorruptTile(tile_idx))? as usize;
|
||||||
|
scan_pos = scan_pos.checked_add(run).ok_or(Error::CorruptTile(tile_idx))?;
|
||||||
|
if scan_pos > 64 {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
prev_run = run as u32;
|
||||||
|
if scan_pos < 64 {
|
||||||
|
let k_lvl = (prev_level >> 2).min(4);
|
||||||
|
let abs_ac_minus1 = read_vlc(&mut r, k_lvl).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||||
|
let sign_ac = r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||||
|
let abs_level = (abs_ac_minus1 as u64) + 1;
|
||||||
|
let level_i64 = if sign_ac { -(abs_level as i64) } else { abs_level as i64 };
|
||||||
|
if !(-32768..=32767).contains(&level_i64) {
|
||||||
|
return Err(Error::CorruptTile(tile_idx));
|
||||||
|
}
|
||||||
|
coeff[ZIGZAG_8X8[scan_pos] as usize] = level_i64 as i16;
|
||||||
|
scan_pos += 1;
|
||||||
|
prev_level = abs_level as u32;
|
||||||
|
if first_ac {
|
||||||
|
first_ac = false;
|
||||||
|
prev_1st_ac_level = prev_level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rec = [0u16; 64];
|
||||||
|
if first_ac {
|
||||||
|
scale_and_idct8x8_dc_only(coeff[0], q_mat[0], qp, coded_depth, &mut rec);
|
||||||
|
} else {
|
||||||
|
scale_and_idct8x8(&coeff, q_mat, qp, coded_depth, &mut rec);
|
||||||
|
}
|
||||||
|
if out_depth != coded_depth {
|
||||||
|
rescale_block(&mut rec, coded_depth, out_depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
let px0 = mb_x * blk_w + bx * 8;
|
||||||
|
let py0 = mb_y * blk_h + by * 8;
|
||||||
|
for ry in 0..8 {
|
||||||
|
let dst_row = (py0 + ry) * tw_c + px0;
|
||||||
|
comp_buf[dst_row..dst_row + 8].copy_from_slice(&rec[ry * 8..ry * 8 + 8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
read_byte_alignment(&mut r).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||||
|
Ok(comp_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn rescale_block(block: &mut [u16; 64], from_depth: u8, to_depth: u8) {
|
||||||
|
if to_depth > from_depth {
|
||||||
|
let shift = (to_depth - from_depth) as u32;
|
||||||
|
let fill_shift = from_depth as u32;
|
||||||
|
for s in block.iter_mut() {
|
||||||
|
let v = *s as u32;
|
||||||
|
*s = ((v << shift) | (v >> (fill_shift - shift))) as u16;
|
||||||
|
}
|
||||||
|
} else if to_depth < from_depth {
|
||||||
|
let shift = (from_depth - to_depth) as u32;
|
||||||
|
let round = 1u32 << (shift - 1);
|
||||||
|
let max_v = (1u32 << to_depth) - 1;
|
||||||
|
for s in block.iter_mut() {
|
||||||
|
*s = (((*s as u32) + round) >> shift).min(max_v) as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,598 @@
|
||||||
|
//! RFC 9924 APV intra frame encoder.
|
||||||
|
|
||||||
|
use filmcraft_bitstream::BitWriter;
|
||||||
|
|
||||||
|
use crate::dct::{fdct8x8, quantize8x8};
|
||||||
|
use crate::header::{FrameHeader, MAX_DIMENSION, PBU_PRIMARY_FRAME, write_frame_header, write_metadata_pbu};
|
||||||
|
use crate::tables::{DEFAULT_Q_MATRIX_VAL, ZIGZAG_8X8, select_level_and_band};
|
||||||
|
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, Frame, MasteringDisplay, Profile, Result};
|
||||||
|
|
||||||
|
/// Generate RFC 9924 §7.2.4 variable-length codeword (`h(v)`).
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn write_vlc(w: &mut BitWriter, symbol_val: u32, k_param: u32) {
|
||||||
|
const PREFIX_VLC: [[u32; 2]; 3] = [[1, 0], [0, 0], [0, 1]];
|
||||||
|
let mut symbol_value = symbol_val;
|
||||||
|
let val_prefix = ((symbol_val >> k_param) as usize).min(2);
|
||||||
|
let mut bit_count = 0usize;
|
||||||
|
let mut k = k_param;
|
||||||
|
|
||||||
|
while symbol_value >= (1u32 << k) {
|
||||||
|
symbol_value -= 1u32 << k;
|
||||||
|
if bit_count < 2 {
|
||||||
|
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||||
|
} else {
|
||||||
|
w.write_bits(0, 1);
|
||||||
|
}
|
||||||
|
if bit_count >= 2 {
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
bit_count += 1;
|
||||||
|
}
|
||||||
|
if bit_count < 2 {
|
||||||
|
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||||
|
} else {
|
||||||
|
w.write_bits(1, 1);
|
||||||
|
}
|
||||||
|
if k > 0 {
|
||||||
|
w.write_bits(symbol_value, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact bit length of a VLC codeword generated by [`write_vlc`].
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn vlc_bit_len(symbol_val: u32, k_param: u32) -> u32 {
|
||||||
|
if symbol_val < (1u32 << k_param) {
|
||||||
|
1 + k_param
|
||||||
|
} else if symbol_val < (2u32 << k_param) {
|
||||||
|
2 + k_param
|
||||||
|
} else {
|
||||||
|
let rem = (symbol_val >> k_param) - 1;
|
||||||
|
let zeros = 31 - rem.leading_zeros();
|
||||||
|
3 + k_param + 2 * zeros
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// APV encoder configuration.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EncoderConfig {
|
||||||
|
pub profile: Profile,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub chroma: ChromaFormat,
|
||||||
|
pub bit_depth: u8,
|
||||||
|
pub color: ColorInfo,
|
||||||
|
pub color_description_present: bool,
|
||||||
|
/// Fixed tile quantization parameter (`0..=51 + QpBdOffset`). When `None`, rate control
|
||||||
|
/// chooses a uniform QP per frame to fit [`Self::target_frame_bytes`].
|
||||||
|
pub qp: Option<u8>,
|
||||||
|
/// Target encoded frame size in bytes when `qp` is `None`.
|
||||||
|
pub target_frame_bytes: u32,
|
||||||
|
/// Tile width in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 16`).
|
||||||
|
pub tile_width_in_mbs: u32,
|
||||||
|
/// Tile height in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 8`).
|
||||||
|
pub tile_height_in_mbs: u32,
|
||||||
|
pub level_idc: u8,
|
||||||
|
pub band_idc: u8,
|
||||||
|
/// Optional custom per-component 8x8 quantization matrices (`y * 8 + x`).
|
||||||
|
pub q_matrix: Option<[[u8; 64]; 4]>,
|
||||||
|
/// Write `tile_size_in_fh` array in `frame_header()` (`tile_size_present_in_fh_flag = 1`).
|
||||||
|
pub write_tile_sizes_in_fh: bool,
|
||||||
|
pub mastering_display: Option<MasteringDisplay>,
|
||||||
|
pub content_light: Option<ContentLightLevel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncoderConfig {
|
||||||
|
pub fn new(profile: Profile, width: u32, height: u32) -> Self {
|
||||||
|
let chroma = profile.default_chroma();
|
||||||
|
let bit_depth = profile.default_bit_depth();
|
||||||
|
let w_mbs = width.div_ceil(16).max(1);
|
||||||
|
let h_mbs = height.div_ceil(16).max(1);
|
||||||
|
// Choose conforming tile dimensions (>= 16x8 MBs, <= 20x20 tiles) with ~4-8 tiles on HD/4K.
|
||||||
|
let tile_width_in_mbs = w_mbs.div_ceil(4).max(16).max(w_mbs.div_ceil(20));
|
||||||
|
let tile_height_in_mbs = h_mbs.div_ceil(4).max(8).max(h_mbs.div_ceil(20));
|
||||||
|
let target_frame_bytes = profile.nominal_frame_bytes(width, height);
|
||||||
|
let kbps = ((target_frame_bytes as u64) * 8 * 30 / 1000).max(1000) as u32;
|
||||||
|
let (level_idc, band_idc) = select_level_and_band((width as u64) * (height as u64) * 30, kbps);
|
||||||
|
Self {
|
||||||
|
profile,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
chroma,
|
||||||
|
bit_depth,
|
||||||
|
color: ColorInfo::BT709,
|
||||||
|
color_description_present: true,
|
||||||
|
qp: None,
|
||||||
|
target_frame_bytes,
|
||||||
|
tile_width_in_mbs,
|
||||||
|
tile_height_in_mbs,
|
||||||
|
level_idc,
|
||||||
|
band_idc,
|
||||||
|
q_matrix: None,
|
||||||
|
write_tile_sizes_in_fh: false,
|
||||||
|
mastering_display: None,
|
||||||
|
content_light: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// APV intra encoder.
|
||||||
|
pub struct Encoder {
|
||||||
|
cfg: EncoderConfig,
|
||||||
|
hdr: FrameHeader,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreparedTileComp {
|
||||||
|
/// Forward-transformed 8x8 blocks in MB-then-subblock scan order.
|
||||||
|
blocks: Vec<[i32; 64]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreparedTile {
|
||||||
|
tile_idx: usize,
|
||||||
|
comps: Vec<PreparedTileComp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder {
|
||||||
|
pub fn new(profile: Profile, width: u32, height: u32) -> Result<Self> {
|
||||||
|
Self::with_config(EncoderConfig::new(profile, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_config(cfg: EncoderConfig) -> Result<Self> {
|
||||||
|
if cfg.width == 0 || cfg.height == 0 || cfg.width > MAX_DIMENSION || cfg.height > MAX_DIMENSION {
|
||||||
|
return Err(Error::Input(format!("unsupported dimensions {}x{}", cfg.width, cfg.height)));
|
||||||
|
}
|
||||||
|
if cfg.chroma == ChromaFormat::Yuv422 && !cfg.width.is_multiple_of(2) {
|
||||||
|
return Err(Error::Input("4:2:2 requires even frame width".into()));
|
||||||
|
}
|
||||||
|
if !(10..=16).contains(&cfg.bit_depth) {
|
||||||
|
return Err(Error::Input(format!("unsupported coded bit depth {}", cfg.bit_depth)));
|
||||||
|
}
|
||||||
|
if !cfg.profile.supports(cfg.chroma, cfg.bit_depth) {
|
||||||
|
return Err(Error::Input(format!("profile {} does not support {:?} {}-bit", cfg.profile.name(), cfg.chroma, cfg.bit_depth)));
|
||||||
|
}
|
||||||
|
if cfg.tile_width_in_mbs == 0 || cfg.tile_height_in_mbs == 0 {
|
||||||
|
return Err(Error::Input("tile dimensions in MBs must be non-zero".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let w_mbs = cfg.width.div_ceil(16);
|
||||||
|
let h_mbs = cfg.height.div_ceil(16);
|
||||||
|
let tile_cols = w_mbs.div_ceil(cfg.tile_width_in_mbs);
|
||||||
|
let tile_rows = h_mbs.div_ceil(cfg.tile_height_in_mbs);
|
||||||
|
if tile_cols > 20 || tile_rows > 20 {
|
||||||
|
return Err(Error::Input(format!("tile grid {tile_cols}x{tile_rows} exceeds RFC 9924 limit of 20x20")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||||
|
let mut s = 0u32;
|
||||||
|
while s < w_mbs {
|
||||||
|
col_starts.push(s * 16);
|
||||||
|
s += cfg.tile_width_in_mbs;
|
||||||
|
}
|
||||||
|
col_starts.push(w_mbs * 16);
|
||||||
|
|
||||||
|
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||||
|
let mut s = 0u32;
|
||||||
|
while s < h_mbs {
|
||||||
|
row_starts.push(s * 16);
|
||||||
|
s += cfg.tile_height_in_mbs;
|
||||||
|
}
|
||||||
|
row_starts.push(h_mbs * 16);
|
||||||
|
|
||||||
|
if let Some(qm) = &cfg.q_matrix {
|
||||||
|
for c in 0..cfg.chroma.num_comps() {
|
||||||
|
if qm[c].contains(&0) {
|
||||||
|
return Err(Error::Input("q_matrix entries must be non-zero (1..=255)".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hdr = FrameHeader {
|
||||||
|
profile_idc: cfg.profile.idc(),
|
||||||
|
level_idc: cfg.level_idc,
|
||||||
|
band_idc: cfg.band_idc.min(3),
|
||||||
|
width: cfg.width,
|
||||||
|
height: cfg.height,
|
||||||
|
chroma: cfg.chroma,
|
||||||
|
bit_depth: cfg.bit_depth,
|
||||||
|
capture_time_distance: 0,
|
||||||
|
color_description_present: cfg.color_description_present,
|
||||||
|
color: cfg.color,
|
||||||
|
use_q_matrix: cfg.q_matrix.is_some(),
|
||||||
|
q_matrix: cfg.q_matrix.unwrap_or([[DEFAULT_Q_MATRIX_VAL; 64]; 4]),
|
||||||
|
tile_width_in_mbs: cfg.tile_width_in_mbs,
|
||||||
|
tile_height_in_mbs: cfg.tile_height_in_mbs,
|
||||||
|
tile_cols,
|
||||||
|
tile_rows,
|
||||||
|
col_starts,
|
||||||
|
row_starts,
|
||||||
|
tile_sizes_in_fh: None,
|
||||||
|
header_bytes: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self { cfg, hdr })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> &EncoderConfig {
|
||||||
|
&self.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_frame_bytes(&self) -> u32 {
|
||||||
|
self.cfg.target_frame_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode one frame into an APV Access Unit (`b"aPv1"...`), suitable for MP4/MOV (`apv1`) or MKV (`V_APV`).
|
||||||
|
pub fn encode(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||||
|
if frame.width != self.cfg.width || frame.height != self.cfg.height {
|
||||||
|
return Err(Error::Input(format!("frame is {}x{}, encoder is {}x{}", frame.width, frame.height, self.cfg.width, self.cfg.height)));
|
||||||
|
}
|
||||||
|
if frame.chroma != self.cfg.chroma {
|
||||||
|
return Err(Error::Input(format!("frame chroma {:?} does not match encoder {:?}", frame.chroma, self.cfg.chroma)));
|
||||||
|
}
|
||||||
|
if !(8..=16).contains(&frame.bit_depth) {
|
||||||
|
return Err(Error::Input(format!("unsupported input bit depth {}", frame.bit_depth)));
|
||||||
|
}
|
||||||
|
let n_luma = (frame.width as usize) * (frame.height as usize);
|
||||||
|
let n_chroma = (frame.chroma_width() as usize) * (frame.chroma_height() as usize);
|
||||||
|
if frame.y.len() != n_luma || frame.cb.len() != n_chroma || frame.cr.len() != n_chroma {
|
||||||
|
return Err(Error::Input("plane buffer lengths do not match frame geometry".into()));
|
||||||
|
}
|
||||||
|
if self.cfg.chroma == ChromaFormat::Yuv4444 && frame.alpha.as_ref().is_none_or(|a| a.len() != n_luma) {
|
||||||
|
return Err(Error::Input("4:4:4:4 profile requires an alpha plane of width * height samples".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prepared = self.prepare_tiles(frame);
|
||||||
|
let max_qp = (51 + self.hdr.qp_bd_offset()) as u8;
|
||||||
|
let qp = match self.cfg.qp {
|
||||||
|
Some(q) => q.min(max_qp),
|
||||||
|
None => self.select_qp(&prepared, max_qp),
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded_tiles: Vec<Vec<u8>> = {
|
||||||
|
#[cfg(feature = "threads")]
|
||||||
|
{
|
||||||
|
use rayon::prelude::*;
|
||||||
|
prepared.par_iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "threads"))]
|
||||||
|
{
|
||||||
|
prepared.iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut hdr = self.hdr.clone();
|
||||||
|
hdr.color = if !self.cfg.color_description_present {
|
||||||
|
ColorInfo::default()
|
||||||
|
} else if frame.color != ColorInfo::BT709 {
|
||||||
|
frame.color
|
||||||
|
} else {
|
||||||
|
self.cfg.color
|
||||||
|
};
|
||||||
|
if self.cfg.write_tile_sizes_in_fh {
|
||||||
|
hdr.tile_sizes_in_fh = Some(encoded_tiles.iter().map(|t| t.len() as u32).collect());
|
||||||
|
}
|
||||||
|
let fh_bytes = write_frame_header(&hdr);
|
||||||
|
|
||||||
|
let tiles_total_bytes: usize = encoded_tiles.iter().map(|t| 4 + t.len()).sum();
|
||||||
|
let pbu_size = 4 + fh_bytes.len() + tiles_total_bytes;
|
||||||
|
|
||||||
|
let mdcv = frame.mastering_display.as_ref().or(self.cfg.mastering_display.as_ref());
|
||||||
|
let cll = frame.content_light.as_ref().or(self.cfg.content_light.as_ref());
|
||||||
|
let meta_pbu = write_metadata_pbu(1, mdcv, cll);
|
||||||
|
let meta_total = meta_pbu.as_ref().map_or(0, |m| 4 + m.len());
|
||||||
|
|
||||||
|
let mut au = Vec::with_capacity(4 + 4 + pbu_size + meta_total);
|
||||||
|
au.extend_from_slice(b"aPv1");
|
||||||
|
// Primary frame PBU (pbu_type = 1, group_id = 1)
|
||||||
|
au.extend_from_slice(&(pbu_size as u32).to_be_bytes());
|
||||||
|
au.push(PBU_PRIMARY_FRAME);
|
||||||
|
au.extend_from_slice(&1u16.to_be_bytes());
|
||||||
|
au.push(0); // reserved_zero_8bits
|
||||||
|
au.extend_from_slice(&fh_bytes);
|
||||||
|
for t in &encoded_tiles {
|
||||||
|
au.extend_from_slice(&(t.len() as u32).to_be_bytes());
|
||||||
|
au.extend_from_slice(t);
|
||||||
|
}
|
||||||
|
if let Some(m) = meta_pbu {
|
||||||
|
au.extend_from_slice(&(m.len() as u32).to_be_bytes());
|
||||||
|
au.extend_from_slice(&m);
|
||||||
|
}
|
||||||
|
Ok(au)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode one frame as a `raw_bitstream_access_unit()` (prefixed by `au_size: u32 BE`, RFC 9924 Appendix A).
|
||||||
|
pub fn encode_raw_au(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||||
|
let au = self.encode(frame)?;
|
||||||
|
let mut raw = Vec::with_capacity(4 + au.len());
|
||||||
|
raw.extend_from_slice(&(au.len() as u32).to_be_bytes());
|
||||||
|
raw.extend_from_slice(&au);
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `APVDecoderConfigurationRecord` payload (for ISOBMFF `apvC` or Matroska CodecPrivate).
|
||||||
|
pub fn decoder_config_record(&self) -> Vec<u8> {
|
||||||
|
let mut v = Vec::with_capacity(24);
|
||||||
|
v.push(1); // configurationVersion = 1
|
||||||
|
v.push(1); // number_of_configuration_entry = 1
|
||||||
|
v.push(PBU_PRIMARY_FRAME); // pbu_type[0] = 1
|
||||||
|
v.push(1); // number_of_frame_info[0] = 1
|
||||||
|
let flags = if self.cfg.color_description_present { 0x02 } else { 0x00 } | 0x01; // capture_time_distance_ignored = 1
|
||||||
|
v.push(flags);
|
||||||
|
v.push(self.hdr.profile_idc);
|
||||||
|
v.push(self.hdr.level_idc);
|
||||||
|
v.push(self.hdr.band_idc);
|
||||||
|
v.extend_from_slice(&self.hdr.width.to_be_bytes());
|
||||||
|
v.extend_from_slice(&self.hdr.height.to_be_bytes());
|
||||||
|
v.push((self.hdr.chroma.idc() << 4) | (self.hdr.bit_depth.saturating_sub(8) & 0x0F));
|
||||||
|
v.push(0); // capture_time_distance = 0
|
||||||
|
if self.cfg.color_description_present {
|
||||||
|
v.push(self.cfg.color.primaries);
|
||||||
|
v.push(self.cfg.color.transfer);
|
||||||
|
v.push(self.cfg.color.matrix);
|
||||||
|
v.push(if self.cfg.color.full_range { 0x80 } else { 0x00 });
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_tiles(&self, frame: &Frame) -> Vec<PreparedTile> {
|
||||||
|
let num_tiles = self.hdr.num_tiles();
|
||||||
|
let prepare_one = |tile_idx: usize| -> PreparedTile {
|
||||||
|
let tx = tile_idx % (self.hdr.tile_cols as usize);
|
||||||
|
let ty = tile_idx / (self.hdr.tile_cols as usize);
|
||||||
|
let x0 = self.hdr.col_starts[tx];
|
||||||
|
let x1 = self.hdr.col_starts[tx + 1];
|
||||||
|
let y0 = self.hdr.row_starts[ty];
|
||||||
|
let y1 = self.hdr.row_starts[ty + 1];
|
||||||
|
let num_mb_cols = (x1 - x0) / 16;
|
||||||
|
let num_mb_rows = (y1 - y0) / 16;
|
||||||
|
let num_comps = self.hdr.chroma.num_comps();
|
||||||
|
let mut comps = Vec::with_capacity(num_comps);
|
||||||
|
|
||||||
|
for c in 0..num_comps {
|
||||||
|
let (plane, pw, ph, sub_w, sub_h) = match c {
|
||||||
|
0 => (frame.y.as_slice(), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||||
|
1 => (
|
||||||
|
frame.cb.as_slice(),
|
||||||
|
frame.chroma_width() as usize,
|
||||||
|
frame.chroma_height() as usize,
|
||||||
|
self.hdr.chroma.sub_width_c(),
|
||||||
|
self.hdr.chroma.sub_height_c(),
|
||||||
|
),
|
||||||
|
2 => (
|
||||||
|
frame.cr.as_slice(),
|
||||||
|
frame.chroma_width() as usize,
|
||||||
|
frame.chroma_height() as usize,
|
||||||
|
self.hdr.chroma.sub_width_c(),
|
||||||
|
self.hdr.chroma.sub_height_c(),
|
||||||
|
),
|
||||||
|
_ => (frame.alpha.as_deref().unwrap_or(&[]), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||||
|
};
|
||||||
|
let blk_w = (16 / sub_w) as usize;
|
||||||
|
let blk_h = (16 / sub_h) as usize;
|
||||||
|
let blocks_x = blk_w / 8;
|
||||||
|
let blocks_y = blk_h / 8;
|
||||||
|
let total_blocks = (num_mb_cols as usize) * (num_mb_rows as usize) * blocks_x * blocks_y;
|
||||||
|
let mut blocks = Vec::with_capacity(total_blocks);
|
||||||
|
|
||||||
|
let coded_depth = self.hdr.bit_depth;
|
||||||
|
let in_depth = frame.bit_depth;
|
||||||
|
|
||||||
|
for mb_y in 0..num_mb_rows {
|
||||||
|
for mb_x in 0..num_mb_cols {
|
||||||
|
let x_mb_c = ((x0 + mb_x * 16) / sub_w) as usize;
|
||||||
|
let y_mb_c = ((y0 + mb_y * 16) / sub_h) as usize;
|
||||||
|
for by in 0..blocks_y {
|
||||||
|
for bx in 0..blocks_x {
|
||||||
|
let bx0 = x_mb_c + bx * 8;
|
||||||
|
let by0 = y_mb_c + by * 8;
|
||||||
|
let mut samples = [0u16; 64];
|
||||||
|
for ry in 0..8 {
|
||||||
|
let sy = (by0 + ry).min(ph.saturating_sub(1));
|
||||||
|
for rx in 0..8 {
|
||||||
|
let sx = (bx0 + rx).min(pw.saturating_sub(1));
|
||||||
|
let v = plane[sy * pw + sx];
|
||||||
|
samples[ry * 8 + rx] = scale_sample(v, in_depth, coded_depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blocks.push(fdct8x8(&samples, coded_depth));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comps.push(PreparedTileComp { blocks });
|
||||||
|
}
|
||||||
|
PreparedTile { tile_idx, comps }
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "threads")]
|
||||||
|
{
|
||||||
|
use rayon::prelude::*;
|
||||||
|
(0..num_tiles).into_par_iter().map(prepare_one).collect()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "threads"))]
|
||||||
|
{
|
||||||
|
(0..num_tiles).map(prepare_one).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_qp(&self, tiles: &[PreparedTile], max_qp: u8) -> u8 {
|
||||||
|
let target_bytes = (self.cfg.target_frame_bytes as usize).max(64);
|
||||||
|
let frame_bytes_at = |qp: u8| -> usize {
|
||||||
|
let tile_bytes = |t: &PreparedTile| -> usize {
|
||||||
|
let num_comps = t.comps.len();
|
||||||
|
let mut sz = 4 + (5 + 5 * num_comps);
|
||||||
|
for (c, comp) in t.comps.iter().enumerate() {
|
||||||
|
let bits = self.estimate_comp_bits(comp, c, qp);
|
||||||
|
sz += (bits.div_ceil(8) as usize).max(1);
|
||||||
|
}
|
||||||
|
sz
|
||||||
|
};
|
||||||
|
#[cfg(feature = "threads")]
|
||||||
|
let sum: usize = {
|
||||||
|
use rayon::prelude::*;
|
||||||
|
tiles.par_iter().map(tile_bytes).sum()
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "threads"))]
|
||||||
|
let sum: usize = tiles.iter().map(tile_bytes).sum();
|
||||||
|
24 + sum
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lo = 0u8;
|
||||||
|
let mut hi = max_qp;
|
||||||
|
if frame_bytes_at(0) <= target_bytes {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
while lo < hi {
|
||||||
|
let mid = lo + (hi - lo) / 2;
|
||||||
|
if frame_bytes_at(mid) <= target_bytes {
|
||||||
|
hi = mid;
|
||||||
|
} else {
|
||||||
|
lo = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lo
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_comp_bits(&self, comp: &PreparedTileComp, c: usize, qp: u8) -> u64 {
|
||||||
|
let qm = &self.hdr.q_matrix[c];
|
||||||
|
let depth = self.hdr.bit_depth;
|
||||||
|
let mut prev_dc: i32 = 0;
|
||||||
|
let mut prev_dc_diff: u32 = 20;
|
||||||
|
let mut prev_1st_ac_level: u32 = 0;
|
||||||
|
let mut bits: u64 = 0;
|
||||||
|
|
||||||
|
for f_blk in &comp.blocks {
|
||||||
|
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||||
|
let dc = coeff[0] as i32;
|
||||||
|
let diff = dc - prev_dc;
|
||||||
|
let abs_dc_diff = diff.unsigned_abs();
|
||||||
|
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||||
|
bits += vlc_bit_len(abs_dc_diff, k_dc) as u64;
|
||||||
|
if abs_dc_diff > 0 {
|
||||||
|
bits += 1;
|
||||||
|
}
|
||||||
|
prev_dc = dc;
|
||||||
|
prev_dc_diff = abs_dc_diff;
|
||||||
|
|
||||||
|
let mut scan_pos = 1usize;
|
||||||
|
let mut first_ac = true;
|
||||||
|
let mut prev_level = prev_1st_ac_level;
|
||||||
|
let mut prev_run = 0u32;
|
||||||
|
|
||||||
|
while scan_pos < 64 {
|
||||||
|
let mut run = 0u32;
|
||||||
|
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
let k_run = (prev_run >> 2).min(2);
|
||||||
|
bits += vlc_bit_len(run, k_run) as u64;
|
||||||
|
scan_pos += run as usize;
|
||||||
|
prev_run = run;
|
||||||
|
if scan_pos < 64 {
|
||||||
|
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||||
|
let abs_level = level.unsigned_abs() as u32;
|
||||||
|
let k_lvl = (prev_level >> 2).min(4);
|
||||||
|
bits += vlc_bit_len(abs_level - 1, k_lvl) as u64 + 1;
|
||||||
|
scan_pos += 1;
|
||||||
|
prev_level = abs_level;
|
||||||
|
if first_ac {
|
||||||
|
first_ac = false;
|
||||||
|
prev_1st_ac_level = prev_level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bits
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_tile(&self, tile: &PreparedTile, qp: u8) -> Vec<u8> {
|
||||||
|
let num_comps = tile.comps.len();
|
||||||
|
let mut comp_payloads: Vec<Vec<u8>> = Vec::with_capacity(num_comps);
|
||||||
|
let depth = self.hdr.bit_depth;
|
||||||
|
|
||||||
|
for (c, comp) in tile.comps.iter().enumerate() {
|
||||||
|
let qm = &self.hdr.q_matrix[c];
|
||||||
|
let mut w = BitWriter::new();
|
||||||
|
let mut prev_dc: i32 = 0;
|
||||||
|
let mut prev_dc_diff: u32 = 20;
|
||||||
|
let mut prev_1st_ac_level: u32 = 0;
|
||||||
|
|
||||||
|
for f_blk in &comp.blocks {
|
||||||
|
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||||
|
let dc = coeff[0] as i32;
|
||||||
|
let diff = dc - prev_dc;
|
||||||
|
let abs_dc_diff = diff.unsigned_abs();
|
||||||
|
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||||
|
write_vlc(&mut w, abs_dc_diff, k_dc);
|
||||||
|
if abs_dc_diff > 0 {
|
||||||
|
w.write_bit(diff < 0);
|
||||||
|
}
|
||||||
|
prev_dc = dc;
|
||||||
|
prev_dc_diff = abs_dc_diff;
|
||||||
|
|
||||||
|
let mut scan_pos = 1usize;
|
||||||
|
let mut first_ac = true;
|
||||||
|
let mut prev_level = prev_1st_ac_level;
|
||||||
|
let mut prev_run = 0u32;
|
||||||
|
|
||||||
|
while scan_pos < 64 {
|
||||||
|
let mut run = 0u32;
|
||||||
|
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
let k_run = (prev_run >> 2).min(2);
|
||||||
|
write_vlc(&mut w, run, k_run);
|
||||||
|
scan_pos += run as usize;
|
||||||
|
prev_run = run;
|
||||||
|
if scan_pos < 64 {
|
||||||
|
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||||
|
let abs_level = level.unsigned_abs() as u32;
|
||||||
|
let k_lvl = (prev_level >> 2).min(4);
|
||||||
|
write_vlc(&mut w, abs_level - 1, k_lvl);
|
||||||
|
w.write_bit(level < 0);
|
||||||
|
scan_pos += 1;
|
||||||
|
prev_level = abs_level;
|
||||||
|
if first_ac {
|
||||||
|
first_ac = false;
|
||||||
|
prev_1st_ac_level = prev_level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comp_payloads.push(w.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||||
|
let tile_header_size = (5 + 5 * num_comps) as u16;
|
||||||
|
let total_size = tile_header_size as usize + comp_payloads.iter().map(Vec::len).sum::<usize>();
|
||||||
|
let mut out = Vec::with_capacity(total_size);
|
||||||
|
out.extend_from_slice(&tile_header_size.to_be_bytes());
|
||||||
|
out.extend_from_slice(&(tile.tile_idx as u16).to_be_bytes());
|
||||||
|
for cp in &comp_payloads {
|
||||||
|
out.extend_from_slice(&(cp.len() as u32).to_be_bytes());
|
||||||
|
}
|
||||||
|
for _ in 0..num_comps {
|
||||||
|
out.push(qp);
|
||||||
|
}
|
||||||
|
out.push(0); // reserved_zero_8bits
|
||||||
|
for cp in comp_payloads {
|
||||||
|
out.extend_from_slice(&cp);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn scale_sample(v: u16, from_depth: u8, to_depth: u8) -> u16 {
|
||||||
|
let max_in = (1u32 << from_depth) - 1;
|
||||||
|
let v = (v as u32).min(max_in);
|
||||||
|
if to_depth == from_depth {
|
||||||
|
v as u16
|
||||||
|
} else if to_depth > from_depth {
|
||||||
|
let shift = (to_depth - from_depth) as u32;
|
||||||
|
((v << shift) | (v >> ((from_depth as u32) - shift))) as u16
|
||||||
|
} else {
|
||||||
|
let shift = (from_depth - to_depth) as u32;
|
||||||
|
let round = 1u32 << (shift - 1);
|
||||||
|
let max_out = (1u32 << to_depth) - 1;
|
||||||
|
(((v + round) >> shift).min(max_out)) as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,460 @@
|
||||||
|
//! RFC 9924 §5.3 and §8 syntax structures: Access Unit, PBU, Frame Header, Tile Info, and Metadata.
|
||||||
|
|
||||||
|
use filmcraft_bitstream::{BitReader, BitWriter};
|
||||||
|
|
||||||
|
use crate::tables::DEFAULT_Q_MATRIX_VAL;
|
||||||
|
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, MasteringDisplay, Result};
|
||||||
|
|
||||||
|
/// Four-character signature `'aPv1'` (`0x61507631`) at the start of every APV Access Unit (RFC 9924 §5.3.1).
|
||||||
|
pub const AU_SIGNATURE: [u8; 4] = *b"aPv1";
|
||||||
|
|
||||||
|
/// Primitive Bitstream Unit (PBU) types (RFC 9924 §5.3.3 Table 3).
|
||||||
|
pub const PBU_PRIMARY_FRAME: u8 = 1;
|
||||||
|
pub const PBU_NON_PRIMARY_FRAME: u8 = 2;
|
||||||
|
pub const PBU_PREVIEW_FRAME: u8 = 25;
|
||||||
|
pub const PBU_DEPTH_FRAME: u8 = 26;
|
||||||
|
pub const PBU_ALPHA_FRAME: u8 = 27;
|
||||||
|
pub const PBU_AU_INFO: u8 = 65;
|
||||||
|
pub const PBU_METADATA: u8 = 66;
|
||||||
|
pub const PBU_FILLER: u8 = 67;
|
||||||
|
|
||||||
|
/// Maximum supported frame dimension in luma samples (guards against hostile allocations).
|
||||||
|
pub const MAX_DIMENSION: u32 = 16384;
|
||||||
|
|
||||||
|
/// Maximum tiles per axis (RFC 9924 §9.4.1 specifies 20; we allow up to 64 for robustness).
|
||||||
|
pub const MAX_TILE_DIM: u32 = 64;
|
||||||
|
|
||||||
|
/// Parsed `frame_header()` and derived tile geometry (RFC 9924 §5.3.5–5.3.8).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FrameHeader {
|
||||||
|
pub profile_idc: u8,
|
||||||
|
pub level_idc: u8,
|
||||||
|
pub band_idc: u8,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub chroma: ChromaFormat,
|
||||||
|
pub bit_depth: u8,
|
||||||
|
pub capture_time_distance: u8,
|
||||||
|
pub color_description_present: bool,
|
||||||
|
pub color: ColorInfo,
|
||||||
|
pub use_q_matrix: bool,
|
||||||
|
/// Per-component 8x8 quantization matrix in raster order (`y * 8 + x`).
|
||||||
|
pub q_matrix: [[u8; 64]; 4],
|
||||||
|
pub tile_width_in_mbs: u32,
|
||||||
|
pub tile_height_in_mbs: u32,
|
||||||
|
pub tile_cols: u32,
|
||||||
|
pub tile_rows: u32,
|
||||||
|
/// Tile column start positions in luma samples (`len == tile_cols + 1`).
|
||||||
|
pub col_starts: Vec<u32>,
|
||||||
|
/// Tile row start positions in luma samples (`len == tile_rows + 1`).
|
||||||
|
pub row_starts: Vec<u32>,
|
||||||
|
pub tile_sizes_in_fh: Option<Vec<u32>>,
|
||||||
|
/// Byte offset within the PBU payload where `frame_header()` ends (start of the tile loop).
|
||||||
|
pub header_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameHeader {
|
||||||
|
pub fn frame_width_in_mbs(&self) -> u32 {
|
||||||
|
self.width.div_ceil(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame_height_in_mbs(&self) -> u32 {
|
||||||
|
self.height.div_ceil(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn num_tiles(&self) -> usize {
|
||||||
|
(self.tile_cols as usize) * (self.tile_rows as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn qp_bd_offset(&self) -> i32 {
|
||||||
|
(self.bit_depth.saturating_sub(8) as i32) * 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed PBU slice inside an Access Unit.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PbuSlice<'a> {
|
||||||
|
pub pbu_type: u8,
|
||||||
|
pub group_id: u16,
|
||||||
|
/// Payload after the 4-byte `pbu_header()`.
|
||||||
|
pub payload: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap an Access Unit byte slice: accepts either a direct `access_unit` starting with `'aPv1'`
|
||||||
|
/// or a `raw_bitstream_access_unit` prefixed with a 32-bit big-endian `au_size` (RFC 9924 Appendix A).
|
||||||
|
pub fn unwrap_au(data: &[u8]) -> Result<&[u8]> {
|
||||||
|
if data.len() < 4 {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
if data[..4] == AU_SIGNATURE {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
if data.len() >= 8 && data[4..8] == AU_SIGNATURE {
|
||||||
|
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||||
|
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||||
|
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||||
|
}
|
||||||
|
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||||
|
return data.get(4..end).ok_or(Error::Truncated);
|
||||||
|
}
|
||||||
|
Err(Error::Invalid("missing aPv1 access unit signature"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a raw `.apv` bitstream (`raw_bitstream_access_unit()` sequence per RFC 9924 Appendix A)
|
||||||
|
/// into individual Access Unit slices (each starting with `'aPv1'`).
|
||||||
|
pub fn split_raw_bitstream(mut data: &[u8]) -> Result<Vec<&[u8]>> {
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
// If the buffer starts directly with 'aPv1', treat the entire buffer as a single AU.
|
||||||
|
if data.len() >= 4 && data[..4] == AU_SIGNATURE {
|
||||||
|
return Ok(vec![data]);
|
||||||
|
}
|
||||||
|
let mut aus = Vec::new();
|
||||||
|
while !data.is_empty() {
|
||||||
|
if data.len() < 8 {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||||
|
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||||
|
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||||
|
}
|
||||||
|
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||||
|
let au = data.get(4..end).ok_or(Error::Truncated)?;
|
||||||
|
if au[..4] != AU_SIGNATURE {
|
||||||
|
return Err(Error::Invalid("missing aPv1 signature in raw bitstream AU"));
|
||||||
|
}
|
||||||
|
aus.push(au);
|
||||||
|
data = &data[end..];
|
||||||
|
}
|
||||||
|
if aus.is_empty() {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
Ok(aus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the PBU list of a single Access Unit (RFC 9924 §5.3.1–5.3.3).
|
||||||
|
/// PBUs with `reserved_zero_8bits > 0` are ignored as required by RFC 9924 §5.3.3.
|
||||||
|
pub fn parse_au_pbus(data: &[u8]) -> Result<Vec<PbuSlice<'_>>> {
|
||||||
|
let au = unwrap_au(data)?;
|
||||||
|
if au.len() < 8 {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
let mut pos = 4usize;
|
||||||
|
let mut pbus = Vec::new();
|
||||||
|
while pos < au.len() {
|
||||||
|
let sz_bytes = au.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||||
|
let pbu_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||||
|
if pbu_size == 0 || pbu_size == 0xFFFF_FFFF {
|
||||||
|
return Err(Error::Invalid("invalid pbu_size"));
|
||||||
|
}
|
||||||
|
if pbu_size < 4 {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
pos += 4;
|
||||||
|
let pbu_end = pos.checked_add(pbu_size).ok_or(Error::Truncated)?;
|
||||||
|
let pbu = au.get(pos..pbu_end).ok_or(Error::Truncated)?;
|
||||||
|
pos = pbu_end;
|
||||||
|
|
||||||
|
let pbu_type = pbu[0];
|
||||||
|
let group_id = u16::from_be_bytes([pbu[1], pbu[2]]);
|
||||||
|
let reserved_zero_8bits = pbu[3];
|
||||||
|
if reserved_zero_8bits > 0 {
|
||||||
|
// RFC 9924 §5.3.3: Decoders MUST ignore PBU with reserved_zero_8bits > 0.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if group_id == 0xFFFF || (group_id == 0 && pbu_type <= 64) {
|
||||||
|
return Err(Error::Invalid("invalid PBU group_id"));
|
||||||
|
}
|
||||||
|
pbus.push(PbuSlice { pbu_type, group_id, payload: &pbu[4..] });
|
||||||
|
}
|
||||||
|
Ok(pbus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `byte_alignment()` and verify all alignment bits are zero (RFC 9924 §5.3.17).
|
||||||
|
pub fn read_byte_alignment(r: &mut BitReader) -> Result<()> {
|
||||||
|
while !r.is_byte_aligned() {
|
||||||
|
let bit = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||||
|
if bit {
|
||||||
|
return Err(Error::Invalid("non-zero alignment bit"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `frame_header()` from the payload of a frame PBU (after `pbu_header()`), per RFC 9924 §5.3.5–5.3.8.
|
||||||
|
/// Returns `Ok(None)` if any `reserved_zero_*` field is non-zero (RFC 9924 §5.3.5 requires ignoring the PBU).
|
||||||
|
pub fn parse_frame_header(payload: &[u8]) -> Result<Option<FrameHeader>> {
|
||||||
|
let mut r = BitReader::new(payload);
|
||||||
|
|
||||||
|
// frame_info() (RFC 9924 §5.3.6)
|
||||||
|
let profile_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let level_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let band_idc = r.read_bits(3).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let reserved_zero_5bits = r.read_bits(5).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let width = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||||
|
let height = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||||
|
let chroma_format_idc = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let bit_depth_minus8 = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let capture_time_distance = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let fi_reserved_8 = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
|
||||||
|
// frame_header() (RFC 9924 §5.3.5)
|
||||||
|
let fh_reserved_8_a = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
if reserved_zero_5bits > 0 || fi_reserved_8 > 0 || fh_reserved_8_a > 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
|
||||||
|
return Err(Error::Invalid("unsupported frame dimensions"));
|
||||||
|
}
|
||||||
|
let chroma = ChromaFormat::from_idc(chroma_format_idc).ok_or(Error::Invalid("invalid chroma_format_idc"))?;
|
||||||
|
if chroma == ChromaFormat::Yuv422 && !width.is_multiple_of(2) {
|
||||||
|
return Err(Error::Invalid("4:2:2 frame_width must be even"));
|
||||||
|
}
|
||||||
|
if !(2..=8).contains(&bit_depth_minus8) {
|
||||||
|
return Err(Error::Invalid("bit_depth_minus8 out of range 2..=8"));
|
||||||
|
}
|
||||||
|
let bit_depth = bit_depth_minus8 + 8;
|
||||||
|
|
||||||
|
let color_description_present = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||||
|
let color = if color_description_present {
|
||||||
|
let primaries = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let transfer = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let matrix = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
let full_range = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||||
|
ColorInfo { primaries, transfer, matrix, full_range }
|
||||||
|
} else {
|
||||||
|
ColorInfo::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let use_q_matrix = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||||
|
let mut q_matrix = [[DEFAULT_Q_MATRIX_VAL; 64]; 4];
|
||||||
|
if use_q_matrix {
|
||||||
|
for c in 0..chroma.num_comps() {
|
||||||
|
for y in 0..8 {
|
||||||
|
for x in 0..8 {
|
||||||
|
let q = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
if q == 0 {
|
||||||
|
return Err(Error::Invalid("q_matrix entry 0 is reserved"));
|
||||||
|
}
|
||||||
|
q_matrix[c][y * 8 + x] = q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tile_info() (RFC 9924 §5.3.8)
|
||||||
|
let tile_width_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||||
|
let tile_height_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||||
|
if tile_width_in_mbs == 0 || tile_height_in_mbs == 0 {
|
||||||
|
return Err(Error::Invalid("tile dimensions in MBs must be non-zero"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let w_mbs = width.div_ceil(16);
|
||||||
|
let h_mbs = height.div_ceil(16);
|
||||||
|
let tile_cols = w_mbs.div_ceil(tile_width_in_mbs);
|
||||||
|
let tile_rows = h_mbs.div_ceil(tile_height_in_mbs);
|
||||||
|
if tile_cols == 0 || tile_rows == 0 || tile_cols > MAX_TILE_DIM || tile_rows > MAX_TILE_DIM {
|
||||||
|
return Err(Error::Invalid("too many tiles in frame"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||||
|
let mut start_mb = 0u32;
|
||||||
|
while start_mb < w_mbs {
|
||||||
|
col_starts.push(start_mb * 16);
|
||||||
|
start_mb = start_mb.saturating_add(tile_width_in_mbs);
|
||||||
|
}
|
||||||
|
col_starts.push(w_mbs * 16);
|
||||||
|
|
||||||
|
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||||
|
let mut start_mb = 0u32;
|
||||||
|
while start_mb < h_mbs {
|
||||||
|
row_starts.push(start_mb * 16);
|
||||||
|
start_mb = start_mb.saturating_add(tile_height_in_mbs);
|
||||||
|
}
|
||||||
|
row_starts.push(h_mbs * 16);
|
||||||
|
|
||||||
|
let num_tiles = (tile_cols as usize) * (tile_rows as usize);
|
||||||
|
let tile_size_present_in_fh = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||||
|
let tile_sizes_in_fh = if tile_size_present_in_fh {
|
||||||
|
let mut sizes = Vec::with_capacity(num_tiles);
|
||||||
|
for _ in 0..num_tiles {
|
||||||
|
let sz = r.read_bits(32).map_err(|_| Error::Truncated)?;
|
||||||
|
if sz == 0 {
|
||||||
|
return Err(Error::Invalid("tile_size_in_fh cannot be 0"));
|
||||||
|
}
|
||||||
|
sizes.push(sz);
|
||||||
|
}
|
||||||
|
Some(sizes)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let fh_reserved_8_b = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||||
|
if fh_reserved_8_b > 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
read_byte_alignment(&mut r)?;
|
||||||
|
|
||||||
|
Ok(Some(FrameHeader {
|
||||||
|
profile_idc,
|
||||||
|
level_idc,
|
||||||
|
band_idc,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
chroma,
|
||||||
|
bit_depth,
|
||||||
|
capture_time_distance,
|
||||||
|
color_description_present,
|
||||||
|
color,
|
||||||
|
use_q_matrix,
|
||||||
|
q_matrix,
|
||||||
|
tile_width_in_mbs,
|
||||||
|
tile_height_in_mbs,
|
||||||
|
tile_cols,
|
||||||
|
tile_rows,
|
||||||
|
col_starts,
|
||||||
|
row_starts,
|
||||||
|
tile_sizes_in_fh,
|
||||||
|
header_bytes: r.byte_pos(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `frame_header()` to a byte vector (RFC 9924 §5.3.5–5.3.8).
|
||||||
|
pub fn write_frame_header(h: &FrameHeader) -> Vec<u8> {
|
||||||
|
let mut w = BitWriter::new();
|
||||||
|
// frame_info()
|
||||||
|
w.write_bits(h.profile_idc as u32, 8);
|
||||||
|
w.write_bits(h.level_idc as u32, 8);
|
||||||
|
w.write_bits((h.band_idc & 7) as u32, 3);
|
||||||
|
w.write_bits(0, 5); // reserved_zero_5bits
|
||||||
|
w.write_bits(h.width, 24);
|
||||||
|
w.write_bits(h.height, 24);
|
||||||
|
w.write_bits(h.chroma.idc() as u32, 4);
|
||||||
|
w.write_bits(h.bit_depth.saturating_sub(8) as u32, 4);
|
||||||
|
w.write_bits(h.capture_time_distance as u32, 8);
|
||||||
|
w.write_bits(0, 8); // reserved_zero_8bits
|
||||||
|
|
||||||
|
// frame_header()
|
||||||
|
w.write_bits(0, 8); // reserved_zero_8bits
|
||||||
|
w.write_bit(h.color_description_present);
|
||||||
|
if h.color_description_present {
|
||||||
|
w.write_bits(h.color.primaries as u32, 8);
|
||||||
|
w.write_bits(h.color.transfer as u32, 8);
|
||||||
|
w.write_bits(h.color.matrix as u32, 8);
|
||||||
|
w.write_bit(h.color.full_range);
|
||||||
|
}
|
||||||
|
w.write_bit(h.use_q_matrix);
|
||||||
|
if h.use_q_matrix {
|
||||||
|
for c in 0..h.chroma.num_comps() {
|
||||||
|
for y in 0..8 {
|
||||||
|
for x in 0..8 {
|
||||||
|
w.write_bits(h.q_matrix[c][y * 8 + x].max(1) as u32, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// tile_info()
|
||||||
|
w.write_bits(h.tile_width_in_mbs, 20);
|
||||||
|
w.write_bits(h.tile_height_in_mbs, 20);
|
||||||
|
if let Some(sizes) = &h.tile_sizes_in_fh {
|
||||||
|
w.write_bit(true);
|
||||||
|
for &sz in sizes {
|
||||||
|
w.write_bits(sz, 32);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.write_bit(false);
|
||||||
|
}
|
||||||
|
w.write_bits(0, 8); // reserved_zero_8bits
|
||||||
|
w.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `metadata()` PBU payload (after `pbu_header()`), extracting MDCV (`payloadType == 5`)
|
||||||
|
/// and CLL (`payloadType == 6`) if present (RFC 9924 §5.3.10 & §8).
|
||||||
|
pub fn parse_metadata_pbu(payload: &[u8], mdcv: &mut Option<MasteringDisplay>, cll: &mut Option<ContentLightLevel>) -> Result<()> {
|
||||||
|
if payload.len() < 4 {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
let metadata_size = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
|
||||||
|
let body = payload.get(4..4usize.checked_add(metadata_size).ok_or(Error::Truncated)?).ok_or(Error::Truncated)?;
|
||||||
|
let mut pos = 0usize;
|
||||||
|
while pos < body.len() {
|
||||||
|
let mut payload_type = 0u32;
|
||||||
|
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||||
|
payload_type = payload_type.checked_add(255).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
payload_type = payload_type.checked_add(*body.get(pos).ok_or(Error::Truncated)? as u32).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
let mut payload_size = 0usize;
|
||||||
|
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||||
|
payload_size = payload_size.checked_add(255).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
payload_size = payload_size.checked_add(*body.get(pos).ok_or(Error::Truncated)? as usize).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||||
|
pos += 1;
|
||||||
|
|
||||||
|
let end = pos.checked_add(payload_size).ok_or(Error::Truncated)?;
|
||||||
|
let item = body.get(pos..end).ok_or(Error::Truncated)?;
|
||||||
|
pos = end;
|
||||||
|
|
||||||
|
match payload_type {
|
||||||
|
5 if item.len() >= 24 => {
|
||||||
|
let u16_at = |i: usize| u16::from_be_bytes([item[i], item[i + 1]]);
|
||||||
|
let u32_at = |i: usize| u32::from_be_bytes([item[i], item[i + 1], item[i + 2], item[i + 3]]);
|
||||||
|
*mdcv = Some(MasteringDisplay {
|
||||||
|
primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))],
|
||||||
|
white_point: (u16_at(12), u16_at(14)),
|
||||||
|
max_luminance: u32_at(16),
|
||||||
|
min_luminance: u32_at(20),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
6 if item.len() >= 4 => {
|
||||||
|
*cll = Some(ContentLightLevel { max_cll: u16::from_be_bytes([item[0], item[1]]), max_fall: u16::from_be_bytes([item[2], item[3]]) });
|
||||||
|
}
|
||||||
|
// Filler metadata (§8.2.1): must be 0xFF bytes
|
||||||
|
10 if item.iter().any(|&b| b != 0xFF) => {
|
||||||
|
return Err(Error::Invalid("non-0xFF byte in filler metadata"));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// RFC 9924 §10: A decoder MUST NOT try to process metadata whose type is not recognized.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `metadata()` PBU (including its 4-byte `pbu_header`) carrying MDCV and/or CLL metadata.
|
||||||
|
pub fn write_metadata_pbu(group_id: u16, mdcv: Option<&MasteringDisplay>, cll: Option<&ContentLightLevel>) -> Option<Vec<u8>> {
|
||||||
|
if mdcv.is_none() && cll.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut body = Vec::new();
|
||||||
|
if let Some(m) = mdcv {
|
||||||
|
body.push(5); // payloadType = 5 (MDCV)
|
||||||
|
body.push(24); // payloadSize = 24
|
||||||
|
for &(x, y) in &m.primaries {
|
||||||
|
body.extend_from_slice(&x.to_be_bytes());
|
||||||
|
body.extend_from_slice(&y.to_be_bytes());
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&m.white_point.0.to_be_bytes());
|
||||||
|
body.extend_from_slice(&m.white_point.1.to_be_bytes());
|
||||||
|
body.extend_from_slice(&m.max_luminance.to_be_bytes());
|
||||||
|
body.extend_from_slice(&m.min_luminance.to_be_bytes());
|
||||||
|
}
|
||||||
|
if let Some(c) = cll {
|
||||||
|
body.push(6); // payloadType = 6 (CLL)
|
||||||
|
body.push(4); // payloadSize = 4
|
||||||
|
body.extend_from_slice(&c.max_cll.to_be_bytes());
|
||||||
|
body.extend_from_slice(&c.max_fall.to_be_bytes());
|
||||||
|
}
|
||||||
|
let mut pbu = Vec::with_capacity(8 + body.len());
|
||||||
|
pbu.push(PBU_METADATA);
|
||||||
|
pbu.extend_from_slice(&group_id.to_be_bytes());
|
||||||
|
pbu.push(0); // reserved_zero_8bits
|
||||||
|
pbu.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||||
|
pbu.extend_from_slice(&body);
|
||||||
|
Some(pbu)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
//! Clean-room Advanced Professional Video (APV) codec, implemented strictly from IETF RFC 9924
|
||||||
|
//! (February 2026).
|
||||||
|
//!
|
||||||
|
//! - **Decoder** ([`decode_frame`], [`decode_frame_with`], [`decode_frame_into`], [`probe`]):
|
||||||
|
//! every profile in RFC 9924 §9 (`422-10`, `422-12`, `444-10`, `444-12`, `4444-10`, `4444-12`,
|
||||||
|
//! `400-10`) at 10–16 bits per sample, custom quantization matrices, multi-tile parallel decoding
|
||||||
|
//! (behind the `threads` feature), 4:4:4:4 and auxiliary alpha PBU (`pbu_type == 27`) support,
|
||||||
|
//! and HDR static metadata (`MDCV` and `CLL`, RFC 9924 §8).
|
||||||
|
//! - **Encoder** ([`Encoder`]): progressive intra frame encoding across all 7 profiles with fixed-QP
|
||||||
|
//! or frame-budget rate control, plus `APVDecoderConfigurationRecord` (`apvC`) generation.
|
||||||
|
//!
|
||||||
|
//! See the crate README for specification edition, accuracy against external oracles, and design notes.
|
||||||
|
|
||||||
|
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||||
|
|
||||||
|
pub mod dct;
|
||||||
|
mod decode;
|
||||||
|
mod encode;
|
||||||
|
pub mod header;
|
||||||
|
pub mod tables;
|
||||||
|
|
||||||
|
pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe};
|
||||||
|
pub use encode::{Encoder, EncoderConfig};
|
||||||
|
pub use header::{AU_SIGNATURE, FrameHeader, split_raw_bitstream, unwrap_au};
|
||||||
|
|
||||||
|
/// APV codec errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("truncated APV bitstream")]
|
||||||
|
Truncated,
|
||||||
|
#[error("invalid APV bitstream: {0}")]
|
||||||
|
Invalid(&'static str),
|
||||||
|
#[error("corrupt APV tile {0}")]
|
||||||
|
CorruptTile(usize),
|
||||||
|
#[error("invalid APV encoder input: {0}")]
|
||||||
|
Input(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Chroma format (`chroma_format_idc` in RFC 9924 §4.1 Table 2).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ChromaFormat {
|
||||||
|
/// `chroma_format_idc == 0`: 4:0:0 monochrome (1 component).
|
||||||
|
Monochrome,
|
||||||
|
/// `chroma_format_idc == 2`: 4:2:2 YCbCr (3 components, `SubWidthC = 2, SubHeightC = 1`).
|
||||||
|
Yuv422,
|
||||||
|
/// `chroma_format_idc == 3`: 4:4:4 YCbCr (3 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||||
|
Yuv444,
|
||||||
|
/// `chroma_format_idc == 4`: 4:4:4:4 YCbCrA (4 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||||
|
Yuv4444,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChromaFormat {
|
||||||
|
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||||
|
match idc {
|
||||||
|
0 => Some(Self::Monochrome),
|
||||||
|
2 => Some(Self::Yuv422),
|
||||||
|
3 => Some(Self::Yuv444),
|
||||||
|
4 => Some(Self::Yuv4444),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn idc(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Monochrome => 0,
|
||||||
|
Self::Yuv422 => 2,
|
||||||
|
Self::Yuv444 => 3,
|
||||||
|
Self::Yuv4444 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn num_comps(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Monochrome => 1,
|
||||||
|
Self::Yuv422 | Self::Yuv444 => 3,
|
||||||
|
Self::Yuv4444 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sub_width_c(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::Yuv422 => 2,
|
||||||
|
_ => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sub_height_c(self) -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chroma_width(self, width: u32) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::Monochrome => 0,
|
||||||
|
Self::Yuv422 => width.div_ceil(2),
|
||||||
|
Self::Yuv444 | Self::Yuv4444 => width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chroma_height(self, height: u32) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::Monochrome => 0,
|
||||||
|
_ => height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_alpha(self) -> bool {
|
||||||
|
self == Self::Yuv4444
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// APV profiles defined in RFC 9924 §9.3.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||||
|
pub enum Profile {
|
||||||
|
/// `profile_idc == 33`: 422-10 profile (4:2:2, 10-bit).
|
||||||
|
#[default]
|
||||||
|
P422_10,
|
||||||
|
/// `profile_idc == 44`: 422-12 profile (4:2:2, 10–12-bit).
|
||||||
|
P422_12,
|
||||||
|
/// `profile_idc == 55`: 444-10 profile (4:2:2..4:4:4, 10-bit).
|
||||||
|
P444_10,
|
||||||
|
/// `profile_idc == 66`: 444-12 profile (4:2:2..4:4:4, 10–12-bit).
|
||||||
|
P444_12,
|
||||||
|
/// `profile_idc == 77`: 4444-10 profile (4:2:2..4:4:4:4, 10-bit).
|
||||||
|
P4444_10,
|
||||||
|
/// `profile_idc == 88`: 4444-12 profile (4:2:2..4:4:4:4, 10–12-bit).
|
||||||
|
P4444_12,
|
||||||
|
/// `profile_idc == 99`: 400-10 profile (4:0:0, 10-bit).
|
||||||
|
P400_10,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
pub const ALL: [Profile; 7] =
|
||||||
|
[Profile::P422_10, Profile::P422_12, Profile::P444_10, Profile::P444_12, Profile::P4444_10, Profile::P4444_12, Profile::P400_10];
|
||||||
|
|
||||||
|
pub fn idc(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::P422_10 => 33,
|
||||||
|
Self::P422_12 => 44,
|
||||||
|
Self::P444_10 => 55,
|
||||||
|
Self::P444_12 => 66,
|
||||||
|
Self::P4444_10 => 77,
|
||||||
|
Self::P4444_12 => 88,
|
||||||
|
Self::P400_10 => 99,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||||
|
Self::ALL.into_iter().find(|p| p.idc() == idc)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::P422_10 => "APV 422-10",
|
||||||
|
Self::P422_12 => "APV 422-12",
|
||||||
|
Self::P444_10 => "APV 444-10",
|
||||||
|
Self::P444_12 => "APV 444-12",
|
||||||
|
Self::P4444_10 => "APV 4444-10",
|
||||||
|
Self::P4444_12 => "APV 4444-12",
|
||||||
|
Self::P400_10 => "APV 400-10",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn short_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::P422_10 => "422-10",
|
||||||
|
Self::P422_12 => "422-12",
|
||||||
|
Self::P444_10 => "444-10",
|
||||||
|
Self::P444_12 => "444-12",
|
||||||
|
Self::P4444_10 => "4444-10",
|
||||||
|
Self::P4444_12 => "4444-12",
|
||||||
|
Self::P400_10 => "400-10",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_chroma(self) -> ChromaFormat {
|
||||||
|
match self {
|
||||||
|
Self::P422_10 | Self::P422_12 => ChromaFormat::Yuv422,
|
||||||
|
Self::P444_10 | Self::P444_12 => ChromaFormat::Yuv444,
|
||||||
|
Self::P4444_10 | Self::P4444_12 => ChromaFormat::Yuv4444,
|
||||||
|
Self::P400_10 => ChromaFormat::Monochrome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_bit_depth(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::P422_12 | Self::P444_12 | Self::P4444_12 => 12,
|
||||||
|
_ => 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether `(chroma, bit_depth)` conforms to this profile per RFC 9924 §9.3.
|
||||||
|
pub fn supports(self, chroma: ChromaFormat, bit_depth: u8) -> bool {
|
||||||
|
let c = chroma.idc();
|
||||||
|
let b = bit_depth.saturating_sub(8);
|
||||||
|
match self {
|
||||||
|
Self::P422_10 => c == 2 && b == 2,
|
||||||
|
Self::P422_12 => c == 2 && (2..=4).contains(&b),
|
||||||
|
Self::P444_10 => (2..=3).contains(&c) && b == 2,
|
||||||
|
Self::P444_12 => (2..=3).contains(&c) && (2..=4).contains(&b),
|
||||||
|
Self::P4444_10 => (2..=4).contains(&c) && b == 2,
|
||||||
|
Self::P4444_12 => (2..=4).contains(&c) && (2..=4).contains(&b),
|
||||||
|
Self::P400_10 => c == 0 && b == 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nominal target bytes per frame for `(width, height)`.
|
||||||
|
pub fn nominal_frame_bytes(self, width: u32, height: u32) -> u32 {
|
||||||
|
let bpp = match self {
|
||||||
|
Self::P400_10 => 1.5,
|
||||||
|
Self::P422_10 => 3.0,
|
||||||
|
Self::P422_12 => 3.8,
|
||||||
|
Self::P444_10 => 4.2,
|
||||||
|
Self::P444_12 => 5.0,
|
||||||
|
Self::P4444_10 => 5.2,
|
||||||
|
Self::P4444_12 => 6.2,
|
||||||
|
};
|
||||||
|
let bytes = ((width as f64) * (height as f64) * bpp / 8.0).round() as u32;
|
||||||
|
bytes.max(512)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nominal data rate in Mbit/s at 1920x1080 29.97 fps.
|
||||||
|
pub fn nominal_mbps_1080p30(self) -> f64 {
|
||||||
|
(self.nominal_frame_bytes(1920, 1080) as f64) * 8.0 * 29.97 / 1e6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable profile name from `profile_idc` (e.g. `"APV 422-10"`).
|
||||||
|
pub fn profile_name(profile_idc: u8) -> String {
|
||||||
|
Profile::from_idc(profile_idc).map(|p| p.name().to_string()).unwrap_or_else(|| format!("APV (profile {profile_idc})"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour description from `frame_header()` (ITU-T H.273 code points, RFC 9924 §5.3.5).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ColorInfo {
|
||||||
|
pub primaries: u8,
|
||||||
|
pub transfer: u8,
|
||||||
|
pub matrix: u8,
|
||||||
|
pub full_range: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ColorInfo {
|
||||||
|
/// Inferred values when `color_description_present_flag == 0` (RFC 9924 §5.3.5: 2, 2, 2, 0).
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { primaries: 2, transfer: 2, matrix: 2, full_range: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorInfo {
|
||||||
|
pub const BT709: Self = Self { primaries: 1, transfer: 1, matrix: 1, full_range: false };
|
||||||
|
pub const BT2020_PQ: Self = Self { primaries: 9, transfer: 16, matrix: 9, full_range: false };
|
||||||
|
pub const BT2020_HLG: Self = Self { primaries: 9, transfer: 18, matrix: 9, full_range: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mastering Display Color Volume metadata (`payloadType == 5`, RFC 9924 §8.2.3).
|
||||||
|
///
|
||||||
|
/// Chromaticities are in 0.16 fixed-point (1/65536), order `[R, G, B]`;
|
||||||
|
/// `max_luminance` is 24.8 fixed-point cd/m²; `min_luminance` is 18.14 fixed-point cd/m².
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MasteringDisplay {
|
||||||
|
pub primaries: [(u16, u16); 3],
|
||||||
|
pub white_point: (u16, u16),
|
||||||
|
pub max_luminance: u32,
|
||||||
|
pub min_luminance: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content Light-Level Information metadata (`payloadType == 6`, RFC 9924 §8.2.4).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ContentLightLevel {
|
||||||
|
pub max_cll: u16,
|
||||||
|
pub max_fall: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded (or to-be-encoded) APV frame: planar `u16` samples, rows packed without padding.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Frame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub chroma: ChromaFormat,
|
||||||
|
/// Significant bits per sample (`10..=16`, or `8..=16` when rescaled via [`DecodeOptions`]).
|
||||||
|
pub bit_depth: u8,
|
||||||
|
pub y: Vec<u16>,
|
||||||
|
pub cb: Vec<u16>,
|
||||||
|
pub cr: Vec<u16>,
|
||||||
|
pub alpha: Option<Vec<u16>>,
|
||||||
|
pub color: ColorInfo,
|
||||||
|
pub profile_idc: u8,
|
||||||
|
pub level_idc: u8,
|
||||||
|
pub band_idc: u8,
|
||||||
|
pub mastering_display: Option<MasteringDisplay>,
|
||||||
|
pub content_light: Option<ContentLightLevel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Frame {
|
||||||
|
/// Allocate a black video-range frame.
|
||||||
|
pub fn new(width: u32, height: u32, chroma: ChromaFormat, bit_depth: u8, with_alpha: bool) -> Self {
|
||||||
|
let n = (width as usize) * (height as usize);
|
||||||
|
let cw = chroma.chroma_width(width) as usize;
|
||||||
|
let ch = chroma.chroma_height(height) as usize;
|
||||||
|
let cn = cw * ch;
|
||||||
|
let shift = bit_depth.saturating_sub(8) as u32;
|
||||||
|
let has_alpha = with_alpha || chroma == ChromaFormat::Yuv4444;
|
||||||
|
Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
chroma,
|
||||||
|
bit_depth,
|
||||||
|
y: vec![(16u32 << shift) as u16; n],
|
||||||
|
cb: vec![(128u32 << shift) as u16; cn],
|
||||||
|
cr: vec![(128u32 << shift) as u16; cn],
|
||||||
|
alpha: has_alpha.then(|| vec![((1u32 << bit_depth) - 1) as u16; n]),
|
||||||
|
color: ColorInfo::BT709,
|
||||||
|
profile_idc: Profile::P422_10.idc(),
|
||||||
|
level_idc: 90,
|
||||||
|
band_idc: 2,
|
||||||
|
mastering_display: None,
|
||||||
|
content_light: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chroma_width(&self) -> u32 {
|
||||||
|
self.chroma.chroma_width(self.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chroma_height(&self) -> u32 {
|
||||||
|
self.chroma.chroma_height(self.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
//! Robustness: mutated, truncated, spliced, and random bitstreams must never panic (errors are fine).
|
||||||
|
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
|
|
||||||
|
use filmcraft_apv::*;
|
||||||
|
|
||||||
|
struct Rng(u64);
|
||||||
|
impl Rng {
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
self.0 ^= self.0 << 13;
|
||||||
|
self.0 ^= self.0 >> 7;
|
||||||
|
self.0 ^= self.0 << 17;
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn below(&mut self, n: usize) -> usize {
|
||||||
|
(self.next() % n.max(1) as u64) as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seeds() -> Vec<Vec<u8>> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for (p, w, h) in
|
||||||
|
[(Profile::P422_10, 64u32, 48u32), (Profile::P422_12, 70, 38), (Profile::P444_10, 48, 32), (Profile::P4444_10, 64, 32), (Profile::P400_10, 32, 32)]
|
||||||
|
{
|
||||||
|
let chroma = p.default_chroma();
|
||||||
|
let depth = p.default_bit_depth();
|
||||||
|
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||||
|
for (i, s) in f.y.iter_mut().enumerate() {
|
||||||
|
*s = (64 + (i * 37) % 800) as u16;
|
||||||
|
}
|
||||||
|
if let Some(a) = f.alpha.as_mut() {
|
||||||
|
for (i, s) in a.iter_mut().enumerate() {
|
||||||
|
*s = ((i * 19) % 1024) as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cfg = EncoderConfig::new(p, w, h);
|
||||||
|
cfg.qp = Some(16);
|
||||||
|
let mut enc = Encoder::with_config(cfg).expect("seed encoder");
|
||||||
|
v.push(enc.encode(&f).expect("seed encode"));
|
||||||
|
v.push(enc.encode_raw_au(&f).expect("seed encode_raw_au"));
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mutated_streams_never_panic() {
|
||||||
|
let seeds = seeds();
|
||||||
|
for s in &seeds {
|
||||||
|
decode_frame(s).expect("seed decodes");
|
||||||
|
}
|
||||||
|
let mut rng = Rng(0x9e3779b97f4a7c15);
|
||||||
|
let mut decoded_ok = 0usize;
|
||||||
|
let iters = 3000usize;
|
||||||
|
|
||||||
|
for i in 0..iters {
|
||||||
|
let mut d = seeds[i % seeds.len()].clone();
|
||||||
|
match rng.below(6) {
|
||||||
|
0 => {
|
||||||
|
// Bit flips
|
||||||
|
for _ in 0..1 + rng.below(8) {
|
||||||
|
let p = rng.below(d.len());
|
||||||
|
d[p] ^= 1 << rng.below(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// Random byte overwrites
|
||||||
|
for _ in 0..1 + rng.below(4) {
|
||||||
|
let p = rng.below(d.len());
|
||||||
|
d[p] = rng.next() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// Truncation
|
||||||
|
let n = rng.below(d.len());
|
||||||
|
d.truncate(n);
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// Corrupt header fields
|
||||||
|
if d.len() > 8 {
|
||||||
|
let p = 4 + rng.below(24.min(d.len() - 4));
|
||||||
|
d[p] = rng.next() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
// Corrupt tile payload region
|
||||||
|
let start = 24.min(d.len().saturating_sub(1));
|
||||||
|
let off = start + rng.below(d.len().saturating_sub(start).max(1));
|
||||||
|
for b in d.iter_mut().skip(off).take(1 + rng.below(48)) {
|
||||||
|
*b = rng.next() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Splice random bytes
|
||||||
|
let p = rng.below(d.len());
|
||||||
|
let extra: Vec<u8> = (0..1 + rng.below(16)).map(|_| rng.next() as u8).collect();
|
||||||
|
d.splice(p..p, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
for depth in [None, Some(8), Some(16)] {
|
||||||
|
let opts = DecodeOptions { bit_depth: depth, threads: i % 2 == 0 };
|
||||||
|
if let Ok(f) = decode_frame_with(&d, &opts) {
|
||||||
|
decoded_ok += 1;
|
||||||
|
assert_eq!(f.y.len(), (f.width as usize) * (f.height as usize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = probe(&d);
|
||||||
|
let _ = split_raw_bitstream(&d);
|
||||||
|
}));
|
||||||
|
assert!(res.is_ok(), "panic on mutated stream iteration {i}");
|
||||||
|
}
|
||||||
|
println!("{decoded_ok} of {} mutated decodes succeeded", iters * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_bytes_never_panic() {
|
||||||
|
let mut rng = Rng(123456789);
|
||||||
|
for i in 0..2000 {
|
||||||
|
let n = rng.below(400);
|
||||||
|
let mut d: Vec<u8> = (0..n).map(|_| rng.next() as u8).collect();
|
||||||
|
if d.len() >= 8 && rng.below(2) == 0 {
|
||||||
|
d[..4].copy_from_slice(b"aPv1");
|
||||||
|
} else if d.len() >= 12 && rng.below(2) == 0 {
|
||||||
|
let au_sz = (n - 4) as u32;
|
||||||
|
d[..4].copy_from_slice(&au_sz.to_be_bytes());
|
||||||
|
d[4..8].copy_from_slice(b"aPv1");
|
||||||
|
}
|
||||||
|
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let _ = decode_frame(&d);
|
||||||
|
let _ = probe(&d);
|
||||||
|
let _ = split_raw_bitstream(&d);
|
||||||
|
}));
|
||||||
|
assert!(res.is_ok(), "panic on random bytes iteration {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
//! Conformance, bit-exactness, and quality tests against ffmpeg's APV decoder (external oracle).
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use filmcraft_apv::{
|
||||||
|
ChromaFormat, ContentLightLevel, DecodeOptions, Encoder, EncoderConfig, Frame, MasteringDisplay, Profile, decode_frame, decode_frame_with, probe,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ffmpeg() -> Option<PathBuf> {
|
||||||
|
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_dir() -> PathBuf {
|
||||||
|
filmcraft_testkit::fixtures_dir("apv")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_pix_fmt(profile: Profile) -> &'static str {
|
||||||
|
match profile {
|
||||||
|
Profile::P422_10 => "yuv422p10le",
|
||||||
|
Profile::P422_12 => "yuv422p12le",
|
||||||
|
Profile::P444_10 => "yuv444p10le",
|
||||||
|
Profile::P444_12 => "yuv444p12le",
|
||||||
|
Profile::P4444_10 => "yuva444p10le",
|
||||||
|
Profile::P4444_12 => "yuva444p12le",
|
||||||
|
Profile::P400_10 => "gray10le",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a raw `.apv` file with ffmpeg (`-v error -xerror`) to planar `u16` samples.
|
||||||
|
fn ffmpeg_decode_raw(ff: &Path, apv_path: &Path, pix_fmt: &str) -> (Vec<u16>, String) {
|
||||||
|
let out = Command::new(ff)
|
||||||
|
.args(["-hide_banner", "-v", "error", "-xerror", "-f", "apv", "-i"])
|
||||||
|
.arg(apv_path)
|
||||||
|
.args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
|
||||||
|
.output()
|
||||||
|
.expect("run ffmpeg");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||||
|
assert!(out.status.success(), "ffmpeg failed on {:?}: {}", apv_path, stderr);
|
||||||
|
let samples = out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
|
||||||
|
(samples, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_frame(profile: Profile, w: u32, h: u32) -> Frame {
|
||||||
|
let chroma = profile.default_chroma();
|
||||||
|
let depth = profile.default_bit_depth();
|
||||||
|
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||||
|
let max_v = (1u32 << depth) - 1;
|
||||||
|
let lo = 16u32 << (depth - 8);
|
||||||
|
let hi = 235u32 << (depth - 8);
|
||||||
|
let span = hi - lo;
|
||||||
|
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
// Mix smooth gradients, diagonal edges, and high-frequency patterns
|
||||||
|
let grad = (x * 7 + y * 13) % span;
|
||||||
|
let checker = if ((x / 3) ^ (y / 5)) & 1 == 0 { 40 << (depth - 10) } else { 0 };
|
||||||
|
f.y[(y * w + x) as usize] = (lo + (grad + checker) % span).min(max_v) as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cw = f.chroma_width();
|
||||||
|
let ch = f.chroma_height();
|
||||||
|
for y in 0..ch {
|
||||||
|
for x in 0..cw {
|
||||||
|
let u = lo + ((x * 11 + y * 5 + 100) % span);
|
||||||
|
let v = lo + ((x * 3 + y * 17 + 300) % span);
|
||||||
|
f.cb[(y * cw + x) as usize] = u.min(max_v) as u16;
|
||||||
|
f.cr[(y * cw + x) as usize] = v.min(max_v) as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(a) = f.alpha.as_mut() {
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let dx = x as i32 - (w as i32) / 2;
|
||||||
|
let dy = y as i32 - (h as i32) / 2;
|
||||||
|
a[(y * w + x) as usize] =
|
||||||
|
if dx * dx + dy * dy < ((w.min(h) as i32) / 3).pow(2) { max_v as u16 } else { ((x * 19 + y * 23) % (max_v + 1)) as u16 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_frame(f: &Frame) -> Vec<u16> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
v.extend_from_slice(&f.y);
|
||||||
|
v.extend_from_slice(&f.cb);
|
||||||
|
v.extend_from_slice(&f.cr);
|
||||||
|
if let Some(a) = &f.alpha {
|
||||||
|
v.extend_from_slice(a);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn psnr(a: &[u16], b: &[u16], peak: f64) -> f64 {
|
||||||
|
if a.is_empty() {
|
||||||
|
return 99.0;
|
||||||
|
}
|
||||||
|
let mse: f64 = a.iter().zip(b).map(|(&x, &y)| (x as f64 - y as f64).powi(2)).sum::<f64>() / a.len() as f64;
|
||||||
|
if mse == 0.0 { 99.0 } else { 10.0 * (peak * peak / mse).log10() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_profiles_bit_exact_with_ffmpeg() {
|
||||||
|
let Some(ff) = ffmpeg() else { return };
|
||||||
|
let dir = fixture_dir();
|
||||||
|
|
||||||
|
for profile in Profile::ALL {
|
||||||
|
let (w, h) = (320u32, 192u32);
|
||||||
|
let src = synthetic_frame(profile, w, h);
|
||||||
|
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||||
|
cfg.qp = Some(12);
|
||||||
|
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||||
|
|
||||||
|
let raw_au = enc.encode_raw_au(&src).expect("encode_raw_au");
|
||||||
|
let apv_path = dir.join(format!("oracle_{}.apv", profile.short_name()));
|
||||||
|
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||||
|
|
||||||
|
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||||
|
assert!(stderr.trim().is_empty(), "{profile:?}: ffmpeg stderr: {stderr}");
|
||||||
|
|
||||||
|
let ours = decode_frame(&raw_au).expect("our decode");
|
||||||
|
let our_samples = flatten_frame(&ours);
|
||||||
|
assert_eq!(our_samples.len(), ff_samples.len(), "{profile:?}: sample count mismatch");
|
||||||
|
|
||||||
|
let max_diff = our_samples.iter().zip(&ff_samples).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).max().unwrap_or(0);
|
||||||
|
assert_eq!(max_diff, 0, "{profile:?}: our decoder must be 100% bit-exact with ffmpeg (max_diff={max_diff})");
|
||||||
|
|
||||||
|
let peak = ((1u32 << profile.default_bit_depth()) - 1) as f64;
|
||||||
|
let y_psnr = psnr(&ours.y, &src.y, peak);
|
||||||
|
assert!(y_psnr > 45.0, "{profile:?}: expected high PSNR at qp=12, got {y_psnr:.2} dB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn odd_dimensions_and_multi_tile_bit_exact_with_ffmpeg() {
|
||||||
|
let Some(ff) = ffmpeg() else { return };
|
||||||
|
let dir = fixture_dir();
|
||||||
|
|
||||||
|
for (profile, w, h) in [
|
||||||
|
(Profile::P422_10, 640u32, 360u32),
|
||||||
|
(Profile::P422_10, 334, 202),
|
||||||
|
(Profile::P444_10, 333, 201),
|
||||||
|
(Profile::P4444_12, 512, 288),
|
||||||
|
(Profile::P400_10, 70, 38),
|
||||||
|
] {
|
||||||
|
let mut src = synthetic_frame(profile, w, h);
|
||||||
|
src.mastering_display = Some(MasteringDisplay {
|
||||||
|
primaries: [(46400, 19136), (11141, 52232), (8585, 3015)],
|
||||||
|
white_point: (20493, 21561),
|
||||||
|
max_luminance: 1000 << 8,
|
||||||
|
min_luminance: 1,
|
||||||
|
});
|
||||||
|
src.content_light = Some(ContentLightLevel { max_cll: 1000, max_fall: 400 });
|
||||||
|
|
||||||
|
let mut enc = Encoder::new(profile, w, h).expect("encoder init");
|
||||||
|
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||||
|
let apv_path = dir.join(format!("oracle_geom_{}_{w}x{h}.apv", profile.short_name()));
|
||||||
|
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||||
|
|
||||||
|
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||||
|
assert!(stderr.trim().is_empty(), "{profile:?} {w}x{h}: {stderr}");
|
||||||
|
|
||||||
|
let hdr = probe(&raw_au).expect("probe");
|
||||||
|
assert_eq!((hdr.width, hdr.height), (w, h));
|
||||||
|
|
||||||
|
let ours = decode_frame(&raw_au).expect("decode");
|
||||||
|
assert_eq!(ours.mastering_display, src.mastering_display);
|
||||||
|
assert_eq!(ours.content_light, src.content_light);
|
||||||
|
|
||||||
|
let our_samples = flatten_frame(&ours);
|
||||||
|
assert_eq!(our_samples, ff_samples, "{profile:?} {w}x{h}: bit-exact match with ffmpeg failed");
|
||||||
|
|
||||||
|
// Also verify single-threaded decode produces identical output
|
||||||
|
let ours_st = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: None, threads: false }).expect("decode single-threaded");
|
||||||
|
assert_eq!(ours, ours_st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_q_matrix_and_fh_tile_sizes_bit_exact_with_ffmpeg() {
|
||||||
|
let Some(ff) = ffmpeg() else { return };
|
||||||
|
let dir = fixture_dir();
|
||||||
|
|
||||||
|
let profile = Profile::P444_12;
|
||||||
|
let (w, h) = (512u32, 256u32);
|
||||||
|
let src = synthetic_frame(profile, w, h);
|
||||||
|
|
||||||
|
let mut q_matrix = [[16u8; 64]; 4];
|
||||||
|
for (c, qm) in q_matrix.iter_mut().enumerate() {
|
||||||
|
for y in 0..8 {
|
||||||
|
for x in 0..8 {
|
||||||
|
// Intentionally asymmetric (x != y weight) to verify exact (x, y) matrix orientation
|
||||||
|
qm[y * 8 + x] = (10 + c * 3 + y * 5 + x * 2) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||||
|
cfg.qp = Some(18);
|
||||||
|
cfg.q_matrix = Some(q_matrix);
|
||||||
|
cfg.write_tile_sizes_in_fh = true;
|
||||||
|
cfg.tile_width_in_mbs = 16;
|
||||||
|
cfg.tile_height_in_mbs = 8;
|
||||||
|
|
||||||
|
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||||
|
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||||
|
let apv_path = dir.join("oracle_custom_qmat_444-12.apv");
|
||||||
|
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||||
|
|
||||||
|
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||||
|
assert!(stderr.trim().is_empty(), "ffmpeg stderr: {stderr}");
|
||||||
|
|
||||||
|
let hdr = probe(&raw_au).expect("probe");
|
||||||
|
assert!(hdr.use_q_matrix);
|
||||||
|
assert!(hdr.tile_sizes_in_fh.is_some());
|
||||||
|
assert_eq!(hdr.num_tiles(), 4);
|
||||||
|
|
||||||
|
let ours = decode_frame(&raw_au).expect("decode");
|
||||||
|
let our_samples = flatten_frame(&ours);
|
||||||
|
assert_eq!(our_samples, ff_samples, "asymmetric q_matrix decode must be bit-exact with ffmpeg");
|
||||||
|
|
||||||
|
// Verify 8-bit and 16-bit rescaled output
|
||||||
|
let d8 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(8), threads: true }).expect("decode 8-bit");
|
||||||
|
assert_eq!(d8.bit_depth, 8);
|
||||||
|
assert!(d8.y.iter().all(|&s| s <= 255));
|
||||||
|
|
||||||
|
let d16 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(16), threads: true }).expect("decode 16-bit");
|
||||||
|
assert_eq!(d16.bit_depth, 16);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ filmcraft-vp9 = { workspace = true }
|
||||||
filmcraft-prores = { workspace = true }
|
filmcraft-prores = { workspace = true }
|
||||||
filmcraft-dnx = { workspace = true }
|
filmcraft-dnx = { workspace = true }
|
||||||
filmcraft-av1 = { workspace = true }
|
filmcraft-av1 = { workspace = true }
|
||||||
|
filmcraft-apv = { workspace = true }
|
||||||
filmcraft-aac = { workspace = true }
|
filmcraft-aac = { workspace = true }
|
||||||
filmcraft-opus = { workspace = true }
|
filmcraft-opus = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
//! Standalone APV raw bitstream source (RFC 9924 Appendix A: `.apv` elementary streams).
|
||||||
|
//!
|
||||||
|
//! Each access unit in a raw bitstream is prefixed by a 4-byte big-endian `au_size` followed by
|
||||||
|
//! the `'aPv1'` signature (unprefixed single-AU streams starting directly with `'aPv1'` are also
|
||||||
|
//! accepted). Every frame is intra-coded, so random access is direct.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use filmcraft_color::{ColorInfo, Matrix, Range, Transfer};
|
||||||
|
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||||
|
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
|
||||||
|
use filmcraft_time::{FrameRate, Tick};
|
||||||
|
|
||||||
|
use crate::CodecError;
|
||||||
|
use crate::gop::{GopCache, VideoSamples};
|
||||||
|
use crate::video::{ApvDecoder, VideoDecoder, primaries_from_code};
|
||||||
|
|
||||||
|
/// Sniff a raw APV bitstream: either a `raw_bitstream_access_unit()` (`[au_size: u32]['aPv1']`) or
|
||||||
|
/// an unprefixed `access_unit()` starting with `'aPv1'`.
|
||||||
|
pub fn sniff(head: &[u8]) -> bool {
|
||||||
|
(head.len() >= 8 && &head[4..8] == b"aPv1" && u32::from_be_bytes([head[0], head[1], head[2], head[3]]) >= 8) || head.starts_with(b"aPv1")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApvSource {
|
||||||
|
info: MediaInfo,
|
||||||
|
bytes: crate::Src,
|
||||||
|
samples: Vec<(u64, u32)>,
|
||||||
|
rate: FrameRate,
|
||||||
|
video: GopCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apv_pixfmt_label(chroma: filmcraft_apv::ChromaFormat, bit_depth: u8) -> String {
|
||||||
|
let sub = match chroma {
|
||||||
|
filmcraft_apv::ChromaFormat::Monochrome => "Monochrome",
|
||||||
|
filmcraft_apv::ChromaFormat::Yuv422 => "YUV 4:2:2",
|
||||||
|
filmcraft_apv::ChromaFormat::Yuv444 => "YUV 4:4:4",
|
||||||
|
filmcraft_apv::ChromaFormat::Yuv4444 => "YUVA 4:4:4:4",
|
||||||
|
};
|
||||||
|
format!("{sub} {bit_depth}-bit")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApvSource {
|
||||||
|
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
|
||||||
|
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
|
||||||
|
let len = reader.len();
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
let mut pos = 0u64;
|
||||||
|
let mut hdr = [0u8; 8];
|
||||||
|
while pos + 8 <= len {
|
||||||
|
if reader.read_at(pos, &mut hdr).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if &hdr[4..8] == b"aPv1" {
|
||||||
|
let sz = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
|
||||||
|
if sz < 8 || pos + 4 + sz as u64 > len {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
samples.push((pos + 4, sz));
|
||||||
|
pos += 4 + sz as u64;
|
||||||
|
} else if pos == 0 && &hdr[0..4] == b"aPv1" && len <= u32::MAX as u64 {
|
||||||
|
samples.push((0, len as u32));
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(&(off0, sz0)) = samples.first() else {
|
||||||
|
return Err(CodecError::Container("APV: no valid access units".into()));
|
||||||
|
};
|
||||||
|
let first = filmcraft_media::reader::read_range(&*reader, off0, sz0 as usize).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||||
|
let fh = filmcraft_apv::probe(&first).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||||
|
let mut color = ColorInfo { matrix: filmcraft_frame::default_matrix(fh.width, fh.height), ..ColorInfo::REC709 };
|
||||||
|
let mut explicit_color = None;
|
||||||
|
if fh.color_description_present {
|
||||||
|
if let Some(m) = Matrix::from_code(fh.color.matrix) {
|
||||||
|
color.matrix = m;
|
||||||
|
}
|
||||||
|
if let Some(t) = Transfer::from_code(fh.color.transfer) {
|
||||||
|
color.transfer = t;
|
||||||
|
}
|
||||||
|
if let Some(p) = primaries_from_code(fh.color.primaries) {
|
||||||
|
color.primaries = p;
|
||||||
|
}
|
||||||
|
if fh.color.full_range {
|
||||||
|
color.range = Range::Full;
|
||||||
|
}
|
||||||
|
explicit_color = Some(color);
|
||||||
|
}
|
||||||
|
let rate = FrameRate::FPS_24;
|
||||||
|
let duration = Tick::from_rational(samples.len() as i64 * rate.den, 1, rate.num);
|
||||||
|
let total_bits: u64 = samples.iter().map(|s| s.1 as u64 * 8).sum();
|
||||||
|
let secs = duration.seconds();
|
||||||
|
let bitrate = (secs > 0.0).then(|| (total_bits as f64 / secs) as u64);
|
||||||
|
let codec = match filmcraft_apv::Profile::from_idc(fh.profile_idc) {
|
||||||
|
Some(p) => p.name().into(),
|
||||||
|
None => "APV".into(),
|
||||||
|
};
|
||||||
|
let info = MediaInfo {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind: MediaKind::Movie,
|
||||||
|
duration,
|
||||||
|
video: Some(VideoStreamInfo {
|
||||||
|
width: fh.width,
|
||||||
|
height: fh.height,
|
||||||
|
frame_rate: rate,
|
||||||
|
par: (1, 1),
|
||||||
|
codec,
|
||||||
|
pixel_format: apv_pixfmt_label(fh.chroma, fh.bit_depth),
|
||||||
|
color,
|
||||||
|
has_alpha: fh.chroma == filmcraft_apv::ChromaFormat::Yuv4444,
|
||||||
|
bitrate,
|
||||||
|
hdr: None,
|
||||||
|
}),
|
||||||
|
audio: None,
|
||||||
|
container: "APV".into(),
|
||||||
|
start_timecode: None,
|
||||||
|
file_size: Some(len),
|
||||||
|
};
|
||||||
|
Ok(Self { info, bytes: crate::Src(reader), samples, rate, video: GopCache::new(explicit_color) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApvVideo<'a>(&'a ApvSource);
|
||||||
|
|
||||||
|
impl VideoSamples for ApvVideo<'_> {
|
||||||
|
fn count(&self) -> usize {
|
||||||
|
self.0.samples.len()
|
||||||
|
}
|
||||||
|
fn pts(&self, i: usize) -> i64 {
|
||||||
|
i as i64
|
||||||
|
}
|
||||||
|
fn sync_before(&self, i: usize) -> usize {
|
||||||
|
i
|
||||||
|
}
|
||||||
|
fn sample_at(&self, t: i64) -> Option<usize> {
|
||||||
|
(!self.0.samples.is_empty()).then(|| t.clamp(0, self.0.samples.len() as i64 - 1) as usize)
|
||||||
|
}
|
||||||
|
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
|
||||||
|
let (off, sz) = self.0.samples.get(i).copied().ok_or_else(|| CodecError::Container("sample index out of range".into()))?;
|
||||||
|
filmcraft_media::reader::read_range(&*self.0.bytes.0, off, sz as usize).map_err(|e| CodecError::Container(e.to_string()))
|
||||||
|
}
|
||||||
|
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
|
||||||
|
Ok(Box::new(ApvDecoder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSource for ApvSource {
|
||||||
|
fn info(&self) -> &MediaInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
|
||||||
|
let frame_idx = self.rate.frame_at(req.time.max(Tick::ZERO));
|
||||||
|
Ok(self.video.frame(&ApvVideo(self), frame_idx)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||||
|
Err(MediaError::NoStream("audio"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
|
||||||
|
if !sniff(&bytes) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ApvSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
|
||||||
|
if !sniff(head) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ApvSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
#![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 audio;
|
||||||
pub mod gop;
|
pub mod gop;
|
||||||
pub mod hw;
|
pub mod hw;
|
||||||
|
|
@ -30,6 +31,7 @@ pub mod video;
|
||||||
|
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
pub use apv::ApvSource;
|
||||||
pub use audio::AudioFileSource;
|
pub use audio::AudioFileSource;
|
||||||
pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders};
|
pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders};
|
||||||
pub use mkv::MkvSource;
|
pub use mkv::MkvSource;
|
||||||
|
|
@ -65,11 +67,12 @@ impl From<CodecError> for filmcraft_media::MediaError {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Our own (pure-Rust) decoders, in the order they are tried.
|
/// 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::h264_factory,
|
||||||
video::hevc_factory,
|
video::hevc_factory,
|
||||||
video::vp9_factory,
|
video::vp9_factory,
|
||||||
video::av1_factory,
|
video::av1_factory,
|
||||||
|
video::apv_factory,
|
||||||
video::prores_factory,
|
video::prores_factory,
|
||||||
video::dnx_factory,
|
video::dnx_factory,
|
||||||
video::mjpeg_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
|
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
|
||||||
/// MPEG-1/2 video elementary streams, standalone audio).
|
/// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio).
|
||||||
pub fn openers() -> Vec<filmcraft_media::Opener> {
|
pub fn openers() -> Vec<filmcraft_media::Opener> {
|
||||||
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, audio::opener]
|
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, audio::opener]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
|
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
|
||||||
static R: std::sync::OnceLock<RwLock<Vec<filmcraft_media::ReaderOpener>>> = std::sync::OnceLock::new();
|
static R: std::sync::OnceLock<RwLock<Vec<filmcraft_media::ReaderOpener>>> = std::sync::OnceLock::new();
|
||||||
R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener]))
|
R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener, apv::reader_opener]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Openers that read containers through a [`filmcraft_media::ByteReader`] (index now, samples on
|
/// Openers that read containers through a [`filmcraft_media::ByteReader`] (index now, samples on
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI
|
||||||
Some(match c {
|
Some(match c {
|
||||||
Codec::Vp9 { private } => SampleEntry::video(FourCc(*b"vp09"), CodecConfig::Vp9(vp9_config(private, v)), w, h),
|
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::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::Avc { avcc } => SampleEntry::avc(AvcConfig::parse(avcc).ok()?, w, h),
|
||||||
Codec::Hevc { hvcc } => SampleEntry::hevc(HevcConfig::parse(hvcc).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),
|
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::Vp8 => "VP8".into(),
|
||||||
Codec::Vp9 { .. } => "VP9".into(),
|
Codec::Vp9 { .. } => "VP9".into(),
|
||||||
Codec::Av1 { .. } => "AV1".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::ProRes { .. } => "Apple ProRes".into(),
|
||||||
Codec::Mjpeg => "Motion JPEG".into(),
|
Codec::Mjpeg => "Motion JPEG".into(),
|
||||||
Codec::Aac { .. } => "AAC".into(),
|
Codec::Aac { .. } => "AAC".into(),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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 };
|
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 {
|
let vpc = match &entry.codec {
|
||||||
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||||
primaries: v.colour_primaries as u16,
|
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,
|
matrix: v.matrix_coefficients as u16,
|
||||||
full_range: v.full_range,
|
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,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
|
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),
|
codec: codec_label(&entry.codec),
|
||||||
pixel_format: pixfmt_label(&entry.codec),
|
pixel_format: pixfmt_label(&entry.codec),
|
||||||
color,
|
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,
|
bitrate,
|
||||||
hdr: entry.video.as_ref().and_then(|v| hdr_metadata(v.mastering_display.as_ref(), v.content_light)),
|
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::Hevc(_) => "HEVC".into(),
|
||||||
CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile),
|
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::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 {
|
CodecConfig::ProRes { fourcc } => match &fourcc.0 {
|
||||||
b"apco" => "Apple ProRes 422 Proxy".into(),
|
b"apco" => "Apple ProRes 422 Proxy".into(),
|
||||||
b"apcs" => "Apple ProRes 422 LT".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))
|
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 { fourcc } if fourcc.0[2] == b'4' => "YUVA 4:4:4 12-bit".into(),
|
||||||
CodecConfig::ProRes { .. } => "YUV 4:2:2 10-bit".into(),
|
CodecConfig::ProRes { .. } => "YUV 4:2:2 10-bit".into(),
|
||||||
CodecConfig::Jpeg { .. } => "YUV 4:2:x 8-bit".into(),
|
CodecConfig::Jpeg { .. } => "YUV 4:2:x 8-bit".into(),
|
||||||
|
|
|
||||||
|
|
@ -578,3 +578,40 @@ fn opus_mp4_stereo() {
|
||||||
"MPEG-4",
|
"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());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -676,6 +676,72 @@ pub fn prores_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>>
|
||||||
matches!(e.codec, CodecConfig::ProRes { .. }).then(|| Ok(Box::new(ProResDecoder) as Box<dyn VideoDecoder>))
|
matches!(e.codec, CodecConfig::ProRes { .. }).then(|| Ok(Box::new(ProResDecoder) as Box<dyn VideoDecoder>))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Our APV (Advanced Professional Video, RFC 9924) decoder (every frame is intra; tiles decode in
|
||||||
|
/// parallel).
|
||||||
|
pub struct ApvDecoder;
|
||||||
|
|
||||||
|
/// Convert a decoded APV frame to a [`VideoFrame`].
|
||||||
|
pub fn apv_to_video_frame(f: filmcraft_apv::Frame) -> VideoFrame {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let chroma = match f.chroma {
|
||||||
|
filmcraft_apv::ChromaFormat::Yuv422 => filmcraft_frame::Chroma::C422,
|
||||||
|
filmcraft_apv::ChromaFormat::Monochrome | filmcraft_apv::ChromaFormat::Yuv444 | filmcraft_apv::ChromaFormat::Yuv4444 => filmcraft_frame::Chroma::C444,
|
||||||
|
};
|
||||||
|
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(f.width, f.height), ..filmcraft_color::ColorInfo::REC709 };
|
||||||
|
if let Some(m) = filmcraft_color::Matrix::from_code(f.color.matrix) {
|
||||||
|
color.matrix = m;
|
||||||
|
}
|
||||||
|
if let Some(t) = filmcraft_color::Transfer::from_code(f.color.transfer) {
|
||||||
|
color.transfer = t;
|
||||||
|
}
|
||||||
|
if let Some(p) = primaries_from_code(f.color.primaries) {
|
||||||
|
color.primaries = p;
|
||||||
|
}
|
||||||
|
if f.color.full_range {
|
||||||
|
color.range = filmcraft_color::Range::Full;
|
||||||
|
}
|
||||||
|
let neutral = 1u16 << f.bit_depth.saturating_sub(1);
|
||||||
|
let (cb, cr) = if f.chroma == filmcraft_apv::ChromaFormat::Monochrome {
|
||||||
|
let u = vec![neutral; (f.width as usize) * (f.height as usize)];
|
||||||
|
(u.clone(), u)
|
||||||
|
} else {
|
||||||
|
(f.cb, f.cr)
|
||||||
|
};
|
||||||
|
let data = if f.bit_depth == 8 {
|
||||||
|
let to8 = |p: Vec<u16>| Arc::new(p.into_iter().map(|v| v as u8).collect::<Vec<u8>>());
|
||||||
|
filmcraft_frame::PixelData::Yuv8 { planes: [to8(f.y), to8(cb), to8(cr)], chroma, alpha: f.alpha.map(to8) }
|
||||||
|
} else {
|
||||||
|
filmcraft_frame::PixelData::Yuv16 {
|
||||||
|
planes: [Arc::new(f.y), Arc::new(cb), Arc::new(cr)],
|
||||||
|
chroma,
|
||||||
|
bits: f.bit_depth as u32,
|
||||||
|
alpha: f.alpha.map(Arc::new),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
VideoFrame { width: f.width, height: f.height, data, color, par: (1, 1), pts: filmcraft_time::Tick::ZERO }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoDecoder for ApvDecoder {
|
||||||
|
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
|
||||||
|
let f = filmcraft_apv::decode_frame(sample).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||||
|
Ok(vec![DecodedFrame { pts, frame: apv_to_video_frame(f), draft: false }])
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
fn reset(&mut self) {}
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"FilmCraft APV"
|
||||||
|
}
|
||||||
|
fn intra_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apv_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
|
||||||
|
matches!(e.codec, CodecConfig::Apv(_)).then(|| Ok(Box::new(ApvDecoder) as Box<dyn VideoDecoder>))
|
||||||
|
}
|
||||||
|
|
||||||
/// Our DNxHD / DNxHR (VC-3) decoder (every frame is intra; macroblock rows decode in parallel).
|
/// Our DNxHD / DNxHR (VC-3) decoder (every frame is intra; macroblock rows decode in parallel).
|
||||||
pub struct DnxDecoder;
|
pub struct DnxDecoder;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -987,7 +987,7 @@ fn build() -> Vec<CommandSpec> {
|
||||||
"Media…",
|
"Media…",
|
||||||
["File", "Export"],
|
["File", "Export"],
|
||||||
None,
|
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,
|
has_seq,
|
||||||
crate::export_tools::export_media
|
crate::export_tools::export_media
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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!((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}");
|
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" => {
|
"mxf-op1a" => {
|
||||||
assert_eq!(fmt_name, "mxf", "{name}");
|
assert_eq!(fmt_name, "mxf", "{name}");
|
||||||
let codec = match settings["mxfVideoCodec"].as_str().unwrap() {
|
let codec = match settings["mxfVideoCodec"].as_str().unwrap() {
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
if let Some(v) = str_p(p, "dnxProfile") {
|
||||||
settings.dnx_profile = v.to_string();
|
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") {
|
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"))?;
|
settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ filmcraft-text = { workspace = true }
|
||||||
filmcraft-isobmff = { workspace = true }
|
filmcraft-isobmff = { workspace = true }
|
||||||
filmcraft-prores = { workspace = true }
|
filmcraft-prores = { workspace = true }
|
||||||
filmcraft-dnx = { workspace = true }
|
filmcraft-dnx = { workspace = true }
|
||||||
|
filmcraft-apv = { workspace = true }
|
||||||
filmcraft-mxf = { workspace = true }
|
filmcraft-mxf = { workspace = true }
|
||||||
filmcraft-aac = { workspace = true }
|
filmcraft-aac = { workspace = true }
|
||||||
filmcraft-h264enc = { workspace = true }
|
filmcraft-h264enc = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ pub struct Exporter {
|
||||||
|
|
||||||
/// Whether [`Exporter`] handles a format.
|
/// Whether [`Exporter`] handles a format.
|
||||||
pub fn stepped(format: Format) -> bool {
|
pub fn stepped(format: Format) -> bool {
|
||||||
matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
|
matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> {
|
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> {
|
||||||
|
|
@ -102,9 +102,9 @@ impl Exporter {
|
||||||
}
|
}
|
||||||
settings.validate()?;
|
settings.validate()?;
|
||||||
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
|
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 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();
|
let mut settings = settings.clone();
|
||||||
settings.signal = match (hdr_out, pipe.working) {
|
settings.signal = match (hdr_out, pipe.working) {
|
||||||
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
|
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ pub enum Format {
|
||||||
/// QuickTime, Avid DNxHR (HQ unless the settings pick another profile) + PCM.
|
/// QuickTime, Avid DNxHR (HQ unless the settings pick another profile) + PCM.
|
||||||
#[serde(rename = "dnxhr", alias = "DnxHr")]
|
#[serde(rename = "dnxhr", alias = "DnxHr")]
|
||||||
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.
|
/// QuickTime, Motion-JPEG + PCM.
|
||||||
#[serde(rename = "mjpeg", alias = "Mjpeg")]
|
#[serde(rename = "mjpeg", alias = "Mjpeg")]
|
||||||
Mjpeg,
|
Mjpeg,
|
||||||
|
|
@ -100,6 +104,7 @@ impl Format {
|
||||||
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
|
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
|
||||||
"prores" | "mov" | "appleprores" => Format::ProRes,
|
"prores" | "mov" | "appleprores" => Format::ProRes,
|
||||||
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
|
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
|
||||||
|
"apv" | "apv1" => Format::Apv,
|
||||||
"mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a,
|
"mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a,
|
||||||
"mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom,
|
"mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom,
|
||||||
"mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg,
|
"mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg,
|
||||||
|
|
@ -118,6 +123,7 @@ impl Format {
|
||||||
Format::H264 => "h264",
|
Format::H264 => "h264",
|
||||||
Format::ProRes => "prores",
|
Format::ProRes => "prores",
|
||||||
Format::DnxHr => "dnxhr",
|
Format::DnxHr => "dnxhr",
|
||||||
|
Format::Apv => "apv",
|
||||||
Format::Mjpeg => "mjpeg",
|
Format::Mjpeg => "mjpeg",
|
||||||
Format::PngSequence => "png",
|
Format::PngSequence => "png",
|
||||||
Format::TiffSequence => "tiff",
|
Format::TiffSequence => "tiff",
|
||||||
|
|
@ -132,7 +138,7 @@ impl Format {
|
||||||
pub fn extension(self) -> &'static str {
|
pub fn extension(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Format::H264 => "mp4",
|
Format::H264 => "mp4",
|
||||||
Format::ProRes | Format::DnxHr | Format::Mjpeg => "mov",
|
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov",
|
||||||
Format::PngSequence => "png",
|
Format::PngSequence => "png",
|
||||||
Format::TiffSequence => "tif",
|
Format::TiffSequence => "tif",
|
||||||
Format::BmpSequence => "bmp",
|
Format::BmpSequence => "bmp",
|
||||||
|
|
@ -147,6 +153,7 @@ impl Format {
|
||||||
Format::H264 => "H.264",
|
Format::H264 => "H.264",
|
||||||
Format::ProRes => "Apple ProRes",
|
Format::ProRes => "Apple ProRes",
|
||||||
Format::DnxHr => "Avid DNxHR",
|
Format::DnxHr => "Avid DNxHR",
|
||||||
|
Format::Apv => "APV",
|
||||||
Format::Mjpeg => "QuickTime (Motion JPEG)",
|
Format::Mjpeg => "QuickTime (Motion JPEG)",
|
||||||
Format::PngSequence => "PNG",
|
Format::PngSequence => "PNG",
|
||||||
Format::TiffSequence => "TIFF",
|
Format::TiffSequence => "TIFF",
|
||||||
|
|
@ -162,10 +169,11 @@ impl Format {
|
||||||
pub fn is_mxf(self) -> bool {
|
pub fn is_mxf(self) -> bool {
|
||||||
matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
|
matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
|
||||||
}
|
}
|
||||||
pub const ALL: [Format; 12] = [
|
pub const ALL: [Format; 13] = [
|
||||||
Format::H264,
|
Format::H264,
|
||||||
Format::ProRes,
|
Format::ProRes,
|
||||||
Format::DnxHr,
|
Format::DnxHr,
|
||||||
|
Format::Apv,
|
||||||
Format::Mjpeg,
|
Format::Mjpeg,
|
||||||
Format::PngSequence,
|
Format::PngSequence,
|
||||||
Format::TiffSequence,
|
Format::TiffSequence,
|
||||||
|
|
@ -215,6 +223,9 @@ pub struct ExportSettings {
|
||||||
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dnx_profile: String,
|
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).
|
/// Video codec of the MXF formats (DNxHR unless set; the ProRes / DNxHR profile fields apply).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mxf_video_codec: MxfVideoCodec,
|
pub mxf_video_codec: MxfVideoCodec,
|
||||||
|
|
@ -407,6 +418,7 @@ impl Default for ExportSettings {
|
||||||
part_of_batch: false,
|
part_of_batch: false,
|
||||||
prores_profile: String::new(),
|
prores_profile: String::new(),
|
||||||
dnx_profile: String::new(),
|
dnx_profile: String::new(),
|
||||||
|
apv_profile: String::new(),
|
||||||
mxf_video_codec: MxfVideoCodec::default(),
|
mxf_video_codec: MxfVideoCodec::default(),
|
||||||
sdr: false,
|
sdr: false,
|
||||||
frame_size: None,
|
frame_size: None,
|
||||||
|
|
@ -534,7 +546,7 @@ pub type AudioEncoderFactory = fn(format: Format, sample_rate: u32, channels: u3
|
||||||
|
|
||||||
fn video_factories() -> &'static RwLock<Vec<EncoderFactory>> {
|
fn video_factories() -> &'static RwLock<Vec<EncoderFactory>> {
|
||||||
static F: OnceLock<RwLock<Vec<EncoderFactory>>> = OnceLock::new();
|
static F: OnceLock<RwLock<Vec<EncoderFactory>>> = OnceLock::new();
|
||||||
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, mjpeg_factory]))
|
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, apv_factory, mjpeg_factory]))
|
||||||
}
|
}
|
||||||
fn audio_factories() -> &'static RwLock<Vec<AudioEncoderFactory>> {
|
fn audio_factories() -> &'static RwLock<Vec<AudioEncoderFactory>> {
|
||||||
static F: OnceLock<RwLock<Vec<AudioEncoderFactory>>> = OnceLock::new();
|
static F: OnceLock<RwLock<Vec<AudioEncoderFactory>>> = OnceLock::new();
|
||||||
|
|
@ -794,6 +806,99 @@ fn dnx_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The APV profile named by [`ExportSettings::apv_profile`].
|
||||||
|
pub fn apv_profile(name: &str) -> filmcraft_apv::Profile {
|
||||||
|
use filmcraft_apv::Profile;
|
||||||
|
match name.to_ascii_lowercase().replace([' ', '_'], "-").trim_start_matches("apv-").trim_start_matches("apv") {
|
||||||
|
"422-12" | "42212" => Profile::P422_12,
|
||||||
|
"444-10" | "44410" => Profile::P444_10,
|
||||||
|
"444-12" | "44412" => Profile::P444_12,
|
||||||
|
"4444-10" | "444410" => Profile::P4444_10,
|
||||||
|
"4444-12" | "444412" => Profile::P4444_12,
|
||||||
|
"400-10" | "40010" => Profile::P400_10,
|
||||||
|
_ => Profile::P422_10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// APV encoder (RFC 9924: 10/12-bit 4:2:2 or 4:4:4 intra).
|
||||||
|
struct ApvEncoder {
|
||||||
|
enc: filmcraft_apv::Encoder,
|
||||||
|
chroma: filmcraft_apv::ChromaFormat,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
rate: FrameRate,
|
||||||
|
signal: ColorSignal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoEncoder for ApvEncoder {
|
||||||
|
fn sample_entry(&self) -> SampleEntry {
|
||||||
|
let apvc = filmcraft_isobmff::ApvConfig::parse(&self.enc.decoder_config_record()).unwrap_or_default();
|
||||||
|
let mut e = SampleEntry::apv(apvc, self.w as u16, self.h as u16);
|
||||||
|
self.signal.apply_to(&mut e, true);
|
||||||
|
e
|
||||||
|
}
|
||||||
|
fn timescale(&self) -> u32 {
|
||||||
|
self.rate.num as u32
|
||||||
|
}
|
||||||
|
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
|
||||||
|
let (w, h) = (f.width as usize, f.height as usize);
|
||||||
|
let mut fr = filmcraft_apv::Frame::new(f.width, f.height, self.chroma, 10, self.chroma == filmcraft_apv::ChromaFormat::Yuv4444);
|
||||||
|
let mut y422 = vec![0u16; w * h];
|
||||||
|
let cw = w.div_ceil(2);
|
||||||
|
let mut cb422 = vec![0u16; cw * h];
|
||||||
|
let mut cr422 = vec![0u16; cw * h];
|
||||||
|
match f.hdr {
|
||||||
|
Some(rgb) => {
|
||||||
|
let (kr, kb) = self.signal.kr_kb();
|
||||||
|
rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut y422, &mut cb422, &mut cr422);
|
||||||
|
}
|
||||||
|
None => rgba_to_yuv422_10(f.rgba, w, h, &mut y422, &mut cb422, &mut cr422),
|
||||||
|
}
|
||||||
|
fr.y = y422;
|
||||||
|
match self.chroma {
|
||||||
|
filmcraft_apv::ChromaFormat::Monochrome => {
|
||||||
|
fr.cb.clear();
|
||||||
|
fr.cr.clear();
|
||||||
|
}
|
||||||
|
filmcraft_apv::ChromaFormat::Yuv422 => {
|
||||||
|
fr.cb = cb422;
|
||||||
|
fr.cr = cr422;
|
||||||
|
}
|
||||||
|
filmcraft_apv::ChromaFormat::Yuv444 | filmcraft_apv::ChromaFormat::Yuv4444 => {
|
||||||
|
for row in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
fr.cb[row * w + x] = cb422[row * cw + x / 2];
|
||||||
|
fr.cr[row * w + x] = cr422[row * cw + x / 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(a) = fr.alpha.as_mut() {
|
||||||
|
for (i, dst) in a.iter_mut().enumerate() {
|
||||||
|
*dst = f.rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let data = self.enc.encode_raw_au(&fr).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
|
Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }])
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> Result<Vec<EncodedPacket>> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apv_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
|
||||||
|
(format == Format::Apv).then(|| {
|
||||||
|
let profile = apv_profile(&s.apv_profile);
|
||||||
|
let mut cfg = filmcraft_apv::EncoderConfig::new(profile, w, h);
|
||||||
|
if s.signal.is_hdr() {
|
||||||
|
cfg.color = filmcraft_apv::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false };
|
||||||
|
}
|
||||||
|
let chroma = cfg.chroma;
|
||||||
|
let enc = filmcraft_apv::Encoder::with_config(cfg).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
|
Ok(Box::new(ApvEncoder { enc, chroma, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// H.264 High (our encoder): sRGB/709 RGBA8 → 8-bit limited-range BT.709 4:2:0, VBR at the
|
/// 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.
|
/// requested bitrate, length-prefixed samples with the `avcC` in the sample entry.
|
||||||
struct H264Encoder {
|
struct H264Encoder {
|
||||||
|
|
@ -1117,7 +1222,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||||
}
|
}
|
||||||
(total, count)
|
(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.
|
// 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)));
|
return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ fn mov(format: Format, profile: &str) -> ExportSettings {
|
||||||
};
|
};
|
||||||
match format {
|
match format {
|
||||||
Format::ProRes => s.prores_profile = profile.into(),
|
Format::ProRes => s.prores_profile = profile.into(),
|
||||||
|
Format::Apv => s.apv_profile = profile.into(),
|
||||||
_ => s.dnx_profile = profile.into(),
|
_ => s.dnx_profile = profile.into(),
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
|
|
@ -124,6 +125,8 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
||||||
p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")),
|
p("Avid DNxHR 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 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("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 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 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, "")),
|
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, "")),
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,15 @@ impl ExportSettings {
|
||||||
};
|
};
|
||||||
mbps * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97
|
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::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::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,
|
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::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),
|
Format::Mjpeg => v += &format!(", quality {}", self.quality),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -516,7 +526,7 @@ impl ExportSettings {
|
||||||
let container = match self.format {
|
let container = match self.format {
|
||||||
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
|
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
|
||||||
Format::H264 => "MP4",
|
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::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(),
|
||||||
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
|
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
|
||||||
_ => "",
|
_ => "",
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ pub enum CodecConfig {
|
||||||
Dnx {
|
Dnx {
|
||||||
fourcc: FourCc,
|
fourcc: FourCc,
|
||||||
},
|
},
|
||||||
|
/// Advanced Professional Video (`apv1` + `apvC`).
|
||||||
|
Apv(ApvConfig),
|
||||||
Aac(AacConfig),
|
Aac(AacConfig),
|
||||||
/// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`).
|
/// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`).
|
||||||
Mp3,
|
Mp3,
|
||||||
|
|
@ -215,6 +217,7 @@ impl CodecConfig {
|
||||||
CodecConfig::ProRes { .. } => "prores",
|
CodecConfig::ProRes { .. } => "prores",
|
||||||
CodecConfig::Jpeg { .. } => "mjpeg",
|
CodecConfig::Jpeg { .. } => "mjpeg",
|
||||||
CodecConfig::Dnx { .. } => "dnxhd",
|
CodecConfig::Dnx { .. } => "dnxhd",
|
||||||
|
CodecConfig::Apv(_) => "apv",
|
||||||
CodecConfig::Aac(_) => "aac",
|
CodecConfig::Aac(_) => "aac",
|
||||||
CodecConfig::Mp3 => "mp3",
|
CodecConfig::Mp3 => "mp3",
|
||||||
CodecConfig::Pcm(_) => "pcm",
|
CodecConfig::Pcm(_) => "pcm",
|
||||||
|
|
@ -722,6 +725,112 @@ impl TimecodeConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `apvC` — APVDecoderConfigurationBox (`FullBox('apvC', version = 0, flags = 0)` containing
|
||||||
|
/// `APVDecoderConfigurationRecord`).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub struct ApvConfig {
|
||||||
|
pub profile_idc: u8,
|
||||||
|
pub level_idc: u8,
|
||||||
|
pub band_idc: u8,
|
||||||
|
pub frame_width: u32,
|
||||||
|
pub frame_height: u32,
|
||||||
|
pub chroma_format_idc: u8,
|
||||||
|
pub bit_depth_minus8: u8,
|
||||||
|
pub capture_time_distance: u8,
|
||||||
|
pub capture_time_distance_ignored: bool,
|
||||||
|
pub color_description_present: bool,
|
||||||
|
pub color_primaries: u8,
|
||||||
|
pub transfer_characteristics: u8,
|
||||||
|
pub matrix_coefficients: u8,
|
||||||
|
pub full_range: bool,
|
||||||
|
/// Raw `APVDecoderConfigurationRecord` bytes (after the 4-byte FullBox header).
|
||||||
|
pub raw_record: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApvConfig {
|
||||||
|
/// Build from a raw `APVDecoderConfigurationRecord` (starting with `configurationVersion = 1`).
|
||||||
|
pub fn from_record(record: Vec<u8>) -> Result<Self> {
|
||||||
|
let mut c = Cur::new(&record, "apvC");
|
||||||
|
let version = c.u8()?;
|
||||||
|
if version != 1 {
|
||||||
|
return Err(Error::Invalid(format!("unsupported APVDecoderConfigurationRecord version {version}")));
|
||||||
|
}
|
||||||
|
let num_entries = c.u8()?;
|
||||||
|
let mut cfg = ApvConfig { raw_record: record.clone(), ..Default::default() };
|
||||||
|
if num_entries >= 1 && c.remaining() >= 2 {
|
||||||
|
let _pbu_type = c.u8()?;
|
||||||
|
let num_fi = c.u8()?;
|
||||||
|
if num_fi >= 1 && c.remaining() >= 14 {
|
||||||
|
let flags = c.u8()?;
|
||||||
|
cfg.color_description_present = (flags & 0x02) != 0;
|
||||||
|
cfg.capture_time_distance_ignored = (flags & 0x01) != 0;
|
||||||
|
cfg.profile_idc = c.u8()?;
|
||||||
|
cfg.level_idc = c.u8()?;
|
||||||
|
cfg.band_idc = c.u8()?;
|
||||||
|
cfg.frame_width = c.u32()?;
|
||||||
|
cfg.frame_height = c.u32()?;
|
||||||
|
let cb = c.u8()?;
|
||||||
|
cfg.chroma_format_idc = cb >> 4;
|
||||||
|
cfg.bit_depth_minus8 = cb & 0x0F;
|
||||||
|
cfg.capture_time_distance = c.u8()?;
|
||||||
|
if cfg.color_description_present && c.remaining() >= 4 {
|
||||||
|
cfg.color_primaries = c.u8()?;
|
||||||
|
cfg.transfer_characteristics = c.u8()?;
|
||||||
|
cfg.matrix_coefficients = c.u8()?;
|
||||||
|
cfg.full_range = (c.u8()? & 0x80) != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the payload of an `apvC` box (with or without the 4-byte `FullBox` header).
|
||||||
|
pub fn parse(data: &[u8]) -> Result<Self> {
|
||||||
|
if data.len() >= 5 && data[..4] == [0, 0, 0, 0] && data[4] == 1 {
|
||||||
|
Self::from_record(data[4..].to_vec())
|
||||||
|
} else if !data.is_empty() && data[0] == 1 {
|
||||||
|
Self::from_record(data.to_vec())
|
||||||
|
} else {
|
||||||
|
let mut c = Cur::new(data, "apvC");
|
||||||
|
let (v, _) = c.full_header()?;
|
||||||
|
if v != 0 {
|
||||||
|
return Err(Error::Invalid(format!("unsupported apvC FullBox version {v}")));
|
||||||
|
}
|
||||||
|
Self::from_record(c.rest().to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the full `apvC` box payload (4-byte `FullBox` header + `APVDecoderConfigurationRecord`).
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut b = BoxBuf::new();
|
||||||
|
b.u32(0); // FullBox version = 0, flags = 0
|
||||||
|
if !self.raw_record.is_empty() {
|
||||||
|
b.bytes(&self.raw_record);
|
||||||
|
return b.buf;
|
||||||
|
}
|
||||||
|
b.u8(1); // configurationVersion = 1
|
||||||
|
b.u8(1); // number_of_configuration_entry = 1
|
||||||
|
b.u8(1); // pbu_type = 1 (primary frame)
|
||||||
|
b.u8(1); // number_of_frame_info = 1
|
||||||
|
let flags = (if self.color_description_present { 0x02 } else { 0 }) | (if self.capture_time_distance_ignored { 0x01 } else { 0 });
|
||||||
|
b.u8(flags);
|
||||||
|
b.u8(self.profile_idc);
|
||||||
|
b.u8(self.level_idc);
|
||||||
|
b.u8(self.band_idc);
|
||||||
|
b.u32(self.frame_width);
|
||||||
|
b.u32(self.frame_height);
|
||||||
|
b.u8((self.chroma_format_idc << 4) | (self.bit_depth_minus8 & 0x0F));
|
||||||
|
b.u8(self.capture_time_distance);
|
||||||
|
if self.color_description_present {
|
||||||
|
b.u8(self.color_primaries);
|
||||||
|
b.u8(self.transfer_characteristics);
|
||||||
|
b.u8(self.matrix_coefficients);
|
||||||
|
b.u8(if self.full_range { 0x80 } else { 0 });
|
||||||
|
}
|
||||||
|
b.buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// Parsing
|
// Parsing
|
||||||
|
|
||||||
|
|
@ -741,6 +850,7 @@ fn is_video_fourcc(f: &[u8; 4]) -> bool {
|
||||||
| b"apco"
|
| b"apco"
|
||||||
| b"ap4h"
|
| b"ap4h"
|
||||||
| b"ap4x"
|
| b"ap4x"
|
||||||
|
| b"apv1"
|
||||||
| b"jpeg"
|
| b"jpeg"
|
||||||
| b"mjpa"
|
| b"mjpa"
|
||||||
| b"mjpb"
|
| 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"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"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"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"colr" => v.color = parse_colr(p).ok(),
|
||||||
b"mdcv" => v.mastering_display = MasteringDisplay::parse(p),
|
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]]))),
|
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"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"jpeg" | b"mjpa" | b"mjpb" => CodecConfig::Jpeg { fourcc: format },
|
||||||
b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format },
|
b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format },
|
||||||
|
b"apv1" => CodecConfig::Apv(ApvConfig::default()),
|
||||||
_ => unknown(),
|
_ => unknown(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1181,6 +1293,14 @@ impl SampleEntry {
|
||||||
}
|
}
|
||||||
e
|
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`).
|
/// Motion-JPEG entry (`jpeg`).
|
||||||
pub fn jpeg(width: u16, height: u16) -> Self {
|
pub fn jpeg(width: u16, height: u16) -> Self {
|
||||||
let f = FourCc(*b"jpeg");
|
let f = FourCc(*b"jpeg");
|
||||||
|
|
@ -1251,6 +1371,7 @@ impl SampleEntry {
|
||||||
CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()),
|
CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()),
|
||||||
CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()),
|
CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()),
|
||||||
CodecConfig::Vp9(c) => b.leaf(b"vpcC", &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 {
|
if let Some(g) = v.gamma {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ mod source;
|
||||||
|
|
||||||
pub use bytes::FourCc;
|
pub use bytes::FourCc;
|
||||||
pub use codec::{
|
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,
|
MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc,
|
||||||
};
|
};
|
||||||
pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open};
|
pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open};
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ pub enum Codec {
|
||||||
Vp9 { private: Vec<u8> },
|
Vp9 { private: Vec<u8> },
|
||||||
/// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C).
|
/// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C).
|
||||||
Av1 { av1c: Vec<u8> },
|
Av1 { av1c: Vec<u8> },
|
||||||
|
/// `V_APV`; payload is an `APVDecoderConfigurationRecord` (apvC), if present.
|
||||||
|
Apv { apvc: Vec<u8> },
|
||||||
/// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present.
|
/// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present.
|
||||||
ProRes { fourcc: Option<[u8; 4]> },
|
ProRes { fourcc: Option<[u8; 4]> },
|
||||||
/// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC.
|
/// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC.
|
||||||
|
|
@ -62,6 +64,7 @@ impl Codec {
|
||||||
Codec::Vp8 => "vp8".into(),
|
Codec::Vp8 => "vp8".into(),
|
||||||
Codec::Vp9 { .. } => "vp9".into(),
|
Codec::Vp9 { .. } => "vp9".into(),
|
||||||
Codec::Av1 { .. } => "av1".into(),
|
Codec::Av1 { .. } => "av1".into(),
|
||||||
|
Codec::Apv { .. } => "apv".into(),
|
||||||
Codec::ProRes { .. } => "prores".into(),
|
Codec::ProRes { .. } => "prores".into(),
|
||||||
Codec::Mjpeg => "mjpeg".into(),
|
Codec::Mjpeg => "mjpeg".into(),
|
||||||
Codec::VfwFourcc { fourcc, .. } => String::from_utf8_lossy(fourcc).trim().to_ascii_lowercase(),
|
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_VP8" => Codec::Vp8,
|
||||||
"V_VP9" => Codec::Vp9 { private: p },
|
"V_VP9" => Codec::Vp9 { private: p },
|
||||||
"V_AV1" => Codec::Av1 { av1c: 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_PRORES" => Codec::ProRes { fourcc: private.get(..4).and_then(|f| f.try_into().ok()) },
|
||||||
"V_MJPEG" => Codec::Mjpeg,
|
"V_MJPEG" => Codec::Mjpeg,
|
||||||
"V_MS/VFW/FOURCC" => {
|
"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"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"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"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 },
|
_ => Codec::VfwFourcc { fourcc, bitmap_info_header: p },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +205,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn mapping() {
|
fn mapping() {
|
||||||
assert_eq!(map_codec("V_MPEG4/ISO/AVC", &[1, 2], None), Codec::Avc { avcc: vec![1, 2] });
|
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") });
|
assert_eq!(map_codec("V_PRORES", b"apcn", None), Codec::ProRes { fourcc: Some(*b"apcn") });
|
||||||
let mut bih = vec![0u8; 40];
|
let mut bih = vec![0u8; 40];
|
||||||
bih[16..20].copy_from_slice(b"MJPG");
|
bih[16..20].copy_from_slice(b"MJPG");
|
||||||
|
|
|
||||||
|
|
@ -139,8 +139,9 @@ pub type SharedSource = Arc<dyn MediaSource>;
|
||||||
/// File extensions we recognise at import (the media browser filters on these).
|
/// 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 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 AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"];
|
||||||
pub const VIDEO_EXTENSIONS: &[&str] =
|
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"];
|
"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 {
|
pub fn is_importable(path: &Path) -> bool {
|
||||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||||
|
|
|
||||||
|
|
@ -462,7 +462,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||||
if (s.has_audio()
|
if (s.has_audio()
|
||||||
|| !s.has_video()
|
|| !s.has_video()
|
||||||
|| s.format == Format::H264
|
|| 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.format.is_mxf())
|
||||||
&& !s.is_image_sequence()
|
&& !s.is_image_sequence()
|
||||||
&& s.format != Format::Gif
|
&& 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) {
|
if section(ui, &mut reg, &mut ex.open_sections, "effects", "Effects", &t) {
|
||||||
pick_overlay = effects_section(ui, &mut reg, s, &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)
|
&& section(ui, &mut reg, &mut ex.open_sections, "metadata", "Metadata", &t)
|
||||||
{
|
{
|
||||||
let m = &mut s.metadata;
|
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 => {
|
Format::Mjpeg => {
|
||||||
row(ui, t, "Quality", |ui| {
|
row(ui, t, "Quality", |ui| {
|
||||||
let mut q = s.quality as f64;
|
let mut q = s.quality as f64;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ Design principles:
|
||||||
L2 edit · codecs · interchange · captions · speech
|
L2 edit · codecs · interchange · captions · speech
|
||||||
L1 frame · media · project · audio-dsp · text
|
L1 frame · media · project · audio-dsp · text
|
||||||
L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only)
|
L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only)
|
||||||
codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · aac · ac3 · opus
|
codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · aac · ac3 · opus
|
||||||
```
|
```
|
||||||
|
|
||||||
Crates are named `filmcraft-<dir>` (`crates/time` is `filmcraft-time`). The apps are `filmcraft`
|
Crates are named `filmcraft-<dir>` (`crates/time` is `filmcraft-time`). The apps are `filmcraft`
|
||||||
|
|
@ -49,6 +49,7 @@ and `filmcraft-cli`.
|
||||||
| `hevc` | L0 | H.265 Main/Main 10 decoder |
|
| `hevc` | L0 | H.265 Main/Main 10 decoder |
|
||||||
| `prores` | L0 | ProRes decoder and encoder |
|
| `prores` | L0 | ProRes decoder and encoder |
|
||||||
| `dnx` | L0 | DNxHD / DNxHR (SMPTE ST 2019-1 VC-3) decoder and DNxHR 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) |
|
| `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)) |
|
| `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 |
|
| `aac` | L0 | AAC-LC decoder and encoder |
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const LAYERS: &[(&str, u8)] = &[
|
||||||
("prores", 0),
|
("prores", 0),
|
||||||
("mjpeg", 0),
|
("mjpeg", 0),
|
||||||
("dnx", 0),
|
("dnx", 0),
|
||||||
|
("apv", 0),
|
||||||
("av1", 0),
|
("av1", 0),
|
||||||
("aac", 0),
|
("aac", 0),
|
||||||
("opus", 0),
|
("opus", 0),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue