From e7c3dda7f3e339dd2c8d40fc4eb405472be1a1e4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:40:32 -0500 Subject: [PATCH] craft-video: AV1 and HEVC take the colour description; AV1 motion search from EffectCraft (#829) Three-way merge of the changes EffectCraft made to its av1enc and hevcenc crates after craft-video took them: - hevc: EncoderConfig carries colour_primaries, transfer_characteristics and matrix_coeffs (H.273 code points, BT.709 by default) and the VUI writes them. - av1: EncoderConfig carries color_primaries, transfer_characteristics and matrix_coefficients and color_config writes them. - av1: motion search keeps a visited set and stops a SAD once it cannot win, SATD has fixed-size kernels, and a decision snapshot saves only the block's BlockDecoded flags. Output is unchanged. ffprobe tests check the colour description of both streams. --- crates/video/Cargo.toml | 8 + crates/video/src/av1/README.md | 2 +- crates/video/src/av1/decide.rs | 309 +++++++++++++++++++++---- crates/video/src/av1/mod.rs | 13 +- crates/video/src/hevc/encode/README.md | 2 +- crates/video/src/hevc/encode/mod.rs | 10 + crates/video/src/hevc/encode/params.rs | 9 +- crates/video/tests/av1/main.rs | 50 ++++ crates/video/tests/hevc/main.rs | 43 ++++ 9 files changed, 387 insertions(+), 59 deletions(-) create mode 100644 crates/video/tests/av1/main.rs create mode 100644 crates/video/tests/hevc/main.rs diff --git a/crates/video/Cargo.toml b/crates/video/Cargo.toml index 3e4f54ca..ed760cac 100644 --- a/crates/video/Cargo.toml +++ b/crates/video/Cargo.toml @@ -89,3 +89,11 @@ required-features = ["vp9-decode"] [[test]] name = "prores" required-features = ["prores"] + +[[test]] +name = "hevc" +required-features = ["hevc-encode"] + +[[test]] +name = "av1" +required-features = ["av1"] diff --git a/crates/video/src/av1/README.md b/crates/video/src/av1/README.md index 1bff955b..aa160470 100644 --- a/crates/video/src/av1/README.md +++ b/crates/video/src/av1/README.md @@ -5,7 +5,7 @@ no `unsafe`, builds for `wasm32-unknown-unknown`). ## What is implemented -- **Stream**: Main profile (`seq_profile` 0), 4:2:0, 8 or 10 bit, BT.709 colour description, +- **Stream**: Main profile (`seq_profile` 0), 4:2:0, 8 or 10 bit, the configured colour description (BT.709 by default), limited or full range. Sequence header with one operating point; `seq_level_idx` given or the lowest Annex A level whose MaxPicSize / MaxH/VSize / MaxDisplayRate (and, in bitrate mode, Main tier MaxBitrate) fit. Order hints, film grain, superres, CDEF, loop restoration, intra edge diff --git a/crates/video/src/av1/decide.rs b/crates/video/src/av1/decide.rs index c35a980d..5eb2f967 100644 --- a/crates/video/src/av1/decide.rs +++ b/crates/video/src/av1/decide.rs @@ -157,7 +157,8 @@ impl Default for TxScratch { /// Saved encoder state of a block area (reconstruction, BlockDecoded flags). struct Snapshot { planes: [Vec; 3], - decoded: Box<[[[bool; 35]; 35]; 3]>, + /// BlockDecoded flags of the block area only (the only flags coding inside it can change). + decoded: [[[bool; 16]; 16]; 3], } pub(crate) struct Decider<'a> { @@ -267,6 +268,13 @@ impl<'a> Decider<'a> { } } + /// The BlockDecoded cells `mark_decoded` can set for blocks inside (r, c, n4): rows and + /// columns `by + 1 .. by + 1 + step` / `bx + 1 ..` of `decoded[plane]`. + fn decoded_region(plane: usize, r: usize, c: usize, n4: usize) -> (usize, usize, usize) { + let s = (plane > 0) as usize; + ((r & 15) >> s, (c & 15) >> s, (n4 >> s).max(1)) + } + fn snapshot(&self, r: usize, c: usize, n4: usize) -> Snapshot { let planes = std::array::from_fn(|p| { let s = (p > 0) as usize; @@ -278,7 +286,14 @@ impl<'a> Decider<'a> { } v }); - Snapshot { planes, decoded: self.decoded.clone() } + let mut decoded = [[[false; 16]; 16]; 3]; + for (p, d) in decoded.iter_mut().enumerate() { + let (by, bx, step) = Self::decoded_region(p, r, c, n4); + for i in 0..step { + d[i][..step].copy_from_slice(&self.decoded[p][by + i + 1][bx + 1..bx + 1 + step]); + } + } + Snapshot { planes, decoded } } fn restore(&mut self, snap: &Snapshot, r: usize, c: usize, n4: usize) { @@ -290,7 +305,12 @@ impl<'a> Decider<'a> { pl.data[y * pl.stride + x0..y * pl.stride + x0 + n].copy_from_slice(&snap.planes[p][i * n..i * n + n]); } } - self.decoded.clone_from(&snap.decoded); + for p in 0..3 { + let (by, bx, step) = Self::decoded_region(p, r, c, n4); + for i in 0..step { + self.decoded[p][by + i + 1][bx + 1..bx + 1 + step].copy_from_slice(&snap.decoded[p][i][..step]); + } + } } fn set_mvs(&mut self, r: usize, c: usize, n4: usize, mv: [i32; 2]) { @@ -598,16 +618,22 @@ impl<'a> Decider<'a> { let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) }; comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1]) }; - let cost = |m: [i32; 2]| -> f64 { - self.sad_ref(&refr.planes[0], x0 + (m[1] >> 3), y0 + (m[0] >> 3), c * 4, r * 4, n) as f64 + self.lambda_sad * mv_bits(m) + // `bound` is the cost to beat: the SAD stops early once the cost cannot be below it (the + // callers only accept a strictly lower cost, so the result is unchanged) + let cost = |m: [i32; 2], bound: f64| -> f64 { + let lb = self.lambda_sad * mv_bits(m); + self.sad_ref(&refr.planes[0], [x0 + (m[1] >> 3), y0 + (m[0] >> 3)], [c * 4, r * 4], n, [lb, bound]) as f64 + lb }; + // positions already evaluated cannot beat `best` again (costs are deterministic) + let mut seen: Vec<[i32; 2]> = Vec::with_capacity(64); let mut best = ([0, 0], f64::MAX); for &m in &cands { let m = [m[0] & !7, m[1] & !7]; - if !valid(m) { + if !valid(m) || seen.contains(&m) { continue; } - let cst = cost(m); + seen.push(m); + let cst = cost(m, best.1); if cst < best.1 { best = (m, cst); } @@ -628,10 +654,11 @@ impl<'a> Decider<'a> { let center = best.0; for d in [[-step, 0], [step, 0], [0, -step], [0, step]] { let m = [center[0] + d[0], center[1] + d[1]]; - if !valid(m) { + if !valid(m) || seen.contains(&m) { continue; } - let cst = cost(m); + seen.push(m); + let cst = cost(m, best.1); if cst < best.1 { best = (m, cst); moved = true; @@ -642,15 +669,18 @@ impl<'a> Decider<'a> { } // half- then quarter-sample refinement (plus the exact neighbour candidates) let mut buf = vec![0u16; n * n]; - let sub_cost = |m: [i32; 2], buf: &mut Vec| -> f64 { + let sub_cost = |m: [i32; 2], buf: &mut Vec, bound: f64| -> f64 { self.search_block(m, c * 4, r * 4, n, buf); - self.sad(0, c * 4, r * 4, n, buf) as f64 + self.lambda_sad * mv_bits(m) + let lb = self.lambda_sad * mv_bits(m); + self.sad_bounded(0, [c * 4, r * 4], n, buf, [lb, bound]) as f64 + lb }; + let mut seen_sub: Vec<[i32; 2]> = Vec::with_capacity(24); for m in cands { if m[0] & 7 != 0 || m[1] & 7 != 0 { let m = [m[0] & !1, m[1] & !1]; - if valid(m) { - let cst = sub_cost(m, &mut buf); + if valid(m) && !seen_sub.contains(&m) { + seen_sub.push(m); + let cst = sub_cost(m, &mut buf, best.1); if cst < best.1 { best = (m, cst); } @@ -661,10 +691,11 @@ impl<'a> Decider<'a> { let center = best.0; for d in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]] { let m = [center[0] + d[0] * step, center[1] + d[1] * step]; - if !valid(m) { + if !valid(m) || seen_sub.contains(&m) { continue; } - let cst = sub_cost(m, &mut buf); + seen_sub.push(m); + let cst = sub_cost(m, &mut buf, best.1); if cst < best.1 { best = (m, cst); } @@ -750,7 +781,8 @@ impl<'a> Decider<'a> { } /// SAD of the source block at (x, y) (luma) against the reference block at (rx, ry). - fn sad_ref(&self, refp: &Plane, rx: i32, ry: i32, x: usize, y: usize, n: usize) -> u64 { + /// Stops after a row once `sad + lb >= bound` (the cost can no longer win). + fn sad_ref(&self, refp: &Plane, [rx, ry]: [i32; 2], [x, y]: [usize; 2], n: usize, [lb, bound]: [f64; 2]) -> u64 { let (vw, vh) = (self.g.width, self.g.height); let w = n.min(vw.saturating_sub(x)); let h = n.min(vh.saturating_sub(y)); @@ -770,45 +802,16 @@ impl<'a> Decider<'a> { sad += (s[j] as i32 - b as i32).unsigned_abs() as u64; } } + if sad as f64 + lb >= bound { + return sad; + } } sad } - /// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction - /// against the source, scaled to about the size of the SAD. - fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { - let src = &self.src[p]; - let k = n.min(8); - let mut total = 0u64; - let mut d = [0i32; 64]; - for by in (0..n).step_by(k) { - for bx in (0..n).step_by(k) { - for i in 0..k { - let row = &src.row(y + by + i)[x + bx..x + bx + k]; - for j in 0..k { - d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32; - } - } - for i in 0..k { - hadamard(&mut d[i * k..i * k + k]); - } - let mut col = [0i32; 8]; - let mut s = 0u64; - for j in 0..k { - for i in 0..k { - col[i] = d[i * k + j]; - } - hadamard(&mut col[..k]); - s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::(); - } - total += s / (k as u64 / 2); - } - } - total - } - - /// SAD of a prediction against the source over the visible part of the block. - fn sad(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { + /// SAD of a prediction against the source over the visible part of the block. Stops after a row + /// once `sad + lb >= bound` (the cost can no longer win). + fn sad_bounded(&self, p: usize, [x, y]: [usize; 2], n: usize, pred: &[u16], [lb, bound]: [f64; 2]) -> u64 { let (vw, vh) = self.g.visible(p); let w = n.min(vw.saturating_sub(x)); let h = n.min(vh.saturating_sub(y)); @@ -817,10 +820,60 @@ impl<'a> Decider<'a> { for i in 0..h { let row = &src.row(y + i)[x..x + w]; s += row.iter().zip(&pred[i * n..i * n + w]).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).sum::() as u64; + if s as f64 + lb >= bound { + return s; + } } s } + /// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction + /// against the source, scaled to about the size of the SAD. + fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { + if n >= 8 { self.satd_k::<8>(p, x, y, n, pred) } else { self.satd_k::<4>(p, x, y, n, pred) } + } + + /// `satd` with K x K Hadamard sub-blocks (fixed sizes so the butterflies vectorise). + fn satd_k(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { + let src = &self.src[p]; + let mut total = 0u64; + for by in (0..n).step_by(K) { + for bx in (0..n).step_by(K) { + // d[i] holds row i of the differences + let mut d = [[0i32; K]; K]; + for (i, di) in d.iter_mut().enumerate() { + let row = &src.row(y + by + i)[x + bx..x + bx + K]; + let pr = &pred[(by + i) * n + bx..(by + i) * n + bx + K]; + for j in 0..K { + di[j] = row[j] as i32 - pr[j] as i32; + } + } + // vertical butterflies across rows (all columns at once) + let mut h = 1; + while h < K { + for i0 in (0..K).step_by(2 * h) { + for i in i0..i0 + h { + let (a, b) = (d[i], d[i + h]); + for j in 0..K { + d[i][j] = a[j] + b[j]; + d[i + h][j] = a[j] - b[j]; + } + } + } + h *= 2; + } + // horizontal butterflies within each row + let mut s = 0u64; + for di in d.iter_mut() { + hadamard(di); + s += di.iter().map(|v| v.unsigned_abs() as u64).sum::(); + } + total += s / (K as u64 / 2); + } + } + total + } + fn sse(&self, p: usize, x: usize, y: usize, n: usize, blk: &[u16]) -> f64 { let (vw, vh) = self.g.visible(p); let w = n.min(vw.saturating_sub(x)); @@ -1073,4 +1126,158 @@ mod tests { assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}"); } } + + /// The SATD as it was before the fixed-size version: runtime block size, column gather. + fn satd_ref(src: &Plane, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 { + let k = n.min(8); + let mut total = 0u64; + let mut d = [0i32; 64]; + for by in (0..n).step_by(k) { + for bx in (0..n).step_by(k) { + for i in 0..k { + let row = &src.row(y + by + i)[x + bx..x + bx + k]; + for j in 0..k { + d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32; + } + } + for i in 0..k { + hadamard(&mut d[i * k..i * k + k]); + } + let mut col = [0i32; 8]; + let mut s = 0u64; + for j in 0..k { + for i in 0..k { + col[i] = d[i * k + j]; + } + hadamard(&mut col[..k]); + s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::(); + } + total += s / (k as u64 / 2); + } + } + total + } + + #[test] + fn satd_fixed_size_matches_runtime_size() { + let g = FrameGeom::new(100, 80); + let mut state = 0x9e37_79b9_7f4a_7c15u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for bd in [8u32, 10] { + let max = (1u64 << bd) - 1; + let mut mk = |w: usize, h: usize| { + let mut p = Plane::new(w, h); + p.data.iter_mut().for_each(|v| *v = (next() % (max + 1)) as u16); + p + }; + let src = [mk(128, 128), mk(64, 64), mk(64, 64)]; + let dec = Decider::new(&g, bd, 100, true, &src, None); + for n in [4usize, 8, 16, 32] { + let pred: Vec = (0..n * n).map(|_| (next() % (max + 1)) as u16).collect(); + for (p, x, y) in [(0, 0, 0), (0, 32, 40), (1, 8, 4)] { + assert_eq!(dec.satd(p, x, y, n, &pred), satd_ref(&src[p], x, y, n, &pred), "n {n} plane {p}"); + } + } + } + } + + fn xorshift(seed: u64) -> impl FnMut() -> u64 { + let mut state = seed; + move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + } + } + + /// A snapshot keeps only the BlockDecoded cells of the block area. Coding inside the block + /// marks sub-blocks of every smaller size; restoring must give back the whole 3x35x35 array + /// as it was, for every block size of a 64x64 superblock and every aligned position. + #[test] + fn snapshot_restore_brings_back_every_blockdecoded_flag() { + let g = FrameGeom::new(256, 256); + let mut next = xorshift(0x2545_f491_4f6c_dd1d); + let src = [Plane::new(256, 256), Plane::new(128, 128), Plane::new(128, 128)]; + let mut dec = Decider::new(&g, 8, 100, true, &src, None); + for random_start in [false, true] { + for n4 in [1usize, 2, 4, 8, 16] { + for sb in [0usize, 16] { + for r in (sb..sb + 16).step_by(n4) { + for c in (sb..sb + 16).step_by(n4) { + for plane in dec.decoded.iter_mut() { + for row in plane.iter_mut() { + for v in row.iter_mut() { + *v = random_start && next() & 1 == 1; + } + } + } + let before = dec.decoded.clone(); + let snap = dec.snapshot(r, c, n4); + // every sub-block of every size inside the block, as coding marks them + let mut m = n4; + loop { + for rr in (r..r + n4).step_by(m) { + for cc in (c..c + n4).step_by(m) { + for p in 0..3 { + dec.mark_decoded(p, rr, cc, m); + } + } + } + if m == 1 { + break; + } + m /= 2; + } + dec.restore(&snap, r, c, n4); + assert!(dec.decoded == before, "n4 {n4} at ({r}, {c}), random start {random_start}"); + } + } + } + } + } + } + + /// The bounded SADs return the full SAD whenever the cost can still win (`sad + lb < bound`) + /// and otherwise a partial sum that already loses, so the callers' strict `<` decides the same. + #[test] + fn bounded_sad_stops_only_once_the_cost_cannot_win() { + let g = FrameGeom::new(100, 80); + let mut next = xorshift(0x9e37_79b9_7f4a_7c15); + let mut mk = |w: usize, h: usize| { + let mut p = Plane::new(w, h); + p.data.iter_mut().for_each(|v| *v = (next() % 256) as u16); + p + }; + let src = [mk(128, 128), mk(64, 64), mk(64, 64)]; + let refp = mk(128, 128); + let mut next = xorshift(0x1234_5678_9abc_def1); + let dec = Decider::new(&g, 8, 100, false, &src, None); + for n in [4usize, 8, 16, 32] { + for (x, y, rx, ry) in [(0usize, 0usize, 0i32, 0i32), (32, 40, 29, 43), (96, 72, 90, 70), (8, 8, -5, -3)] { + let full_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [0.0, f64::INFINITY]); + let pred: Vec = (0..n * n).map(|_| (next() % 256) as u16).collect(); + let full_pred = dec.sad_bounded(0, [x, y], n, &pred, [0.0, f64::INFINITY]); + for _ in 0..50 { + let lb = (next() % 64) as f64 * 0.75; + let b_ref = (next() % (2 * full_ref + 2)) as f64; + let b_pred = (next() % (2 * full_pred + 2)) as f64; + let got_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [lb, b_ref]); + let got_pred = dec.sad_bounded(0, [x, y], n, &pred, [lb, b_pred]); + for (got, full, bound) in [(got_ref, full_ref, b_ref), (got_pred, full_pred, b_pred)] { + if full as f64 + lb < bound { + assert_eq!(got, full, "n {n} at ({x}, {y}) bound {bound}"); + } else { + assert!(got as f64 + lb >= bound && got <= full, "n {n} at ({x}, {y}) bound {bound}"); + } + } + } + } + } + } } diff --git a/crates/video/src/av1/mod.rs b/crates/video/src/av1/mod.rs index c6382f78..0d8e2b04 100644 --- a/crates/video/src/av1/mod.rs +++ b/crates/video/src/av1/mod.rs @@ -55,6 +55,10 @@ pub struct EncoderConfig { pub keyint: u32, /// BT.709 limited range is signalled unless this is set. pub full_range: bool, + /// The colour description of `color_config` (ITU-T H.273 code points); BT.709 by default. + pub color_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coefficients: u8, } impl EncoderConfig { @@ -69,6 +73,9 @@ impl EncoderConfig { rate: RateControl::ConstantQ(100), keyint: 60, full_range: false, + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, } } } @@ -389,9 +396,9 @@ impl Encoder { w.write_bits((c.bit_depth > 8) as u32, 1); // high_bitdepth w.write_bits(0, 1); // mono_chrome w.write_bits(1, 1); // color_description_present_flag - w.write_bits(1, 8); // color_primaries: BT.709 - w.write_bits(1, 8); // transfer_characteristics: BT.709 - w.write_bits(1, 8); // matrix_coefficients: BT.709 + w.write_bits(c.color_primaries as u32, 8); // color_primaries + w.write_bits(c.transfer_characteristics as u32, 8); // transfer_characteristics + w.write_bits(c.matrix_coefficients as u32, 8); // matrix_coefficients w.write_bits(c.full_range as u32, 1); // color_range w.write_bits(0, 2); // chroma_sample_position: unknown w.write_bits(0, 1); // separate_uv_delta_q diff --git a/crates/video/src/hevc/encode/README.md b/crates/video/src/hevc/encode/README.md index b7ccb54a..96fafe11 100644 --- a/crates/video/src/hevc/encode/README.md +++ b/crates/video/src/hevc/encode/README.md @@ -8,7 +8,7 @@ no `unsafe`, builds for `wasm32-unknown-unknown`). - **Profiles:** Main (8-bit) and Main 10 (10-bit), 4:2:0, Main tier. `general_level_idc` is taken from the config or chosen as the lowest level whose MaxLumaPs, MaxLumaSr (and, in bitrate mode, MaxBR) fit (Table A.8). -- **Parameter sets:** VPS, SPS (with VUI: BT.709 colour description, `video_full_range_flag`, +- **Parameter sets:** VPS, SPS (with VUI: the configured colour description, BT.709 by default, `video_full_range_flag`, timing info from the frame rate) and PPS, as raw NAL units (`Encoder::parameter_sets`) and as an `hvcC` record (ISO/IEC 14496-15 ยง8.3.3.1, `lengthSizeMinusOne` 3, complete VPS / SPS / PPS arrays, `Encoder::hvcc`). diff --git a/crates/video/src/hevc/encode/mod.rs b/crates/video/src/hevc/encode/mod.rs index 4af719fb..554f9aee 100644 --- a/crates/video/src/hevc/encode/mod.rs +++ b/crates/video/src/hevc/encode/mod.rs @@ -84,6 +84,10 @@ pub struct EncoderConfig { pub keyint: u32, /// BT.709 limited range is signalled in the VUI unless this is set. pub full_range: bool, + /// The VUI colour description (ITU-T H.273 code points); BT.709 by default. + pub colour_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coeffs: u8, } impl EncoderConfig { @@ -98,6 +102,9 @@ impl EncoderConfig { rate: RateControl::ConstantQp(28), keyint: 60, full_range: false, + colour_primaries: 1, + transfer_characteristics: 1, + matrix_coeffs: 1, } } } @@ -257,6 +264,9 @@ impl Encoder { profile_idc: cfg.profile.idc(), level_idc, full_range: cfg.full_range, + colour_primaries: cfg.colour_primaries, + transfer_characteristics: cfg.transfer_characteristics, + matrix_coeffs: cfg.matrix_coeffs, fps_num: cfg.fps_num, fps_den: cfg.fps_den, }; diff --git a/crates/video/src/hevc/encode/params.rs b/crates/video/src/hevc/encode/params.rs index ef90c8fc..cfd490f2 100644 --- a/crates/video/src/hevc/encode/params.rs +++ b/crates/video/src/hevc/encode/params.rs @@ -43,6 +43,9 @@ pub struct SeqParams { pub profile_idc: u8, pub level_idc: u8, pub full_range: bool, + pub colour_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coeffs: u8, pub fps_num: u32, pub fps_den: u32, } @@ -185,9 +188,9 @@ fn vui(w: &mut BitWriter, p: &SeqParams) { w.write_bits(5, 3); // video_format: unspecified w.write_bit(p.full_range); w.write_bits(1, 1); // colour_description_present_flag - w.write_bits(1, 8); // colour_primaries BT.709 - w.write_bits(1, 8); // transfer_characteristics BT.709 - w.write_bits(1, 8); // matrix_coeffs BT.709 + w.write_bits(p.colour_primaries as u32, 8); // colour_primaries + w.write_bits(p.transfer_characteristics as u32, 8); // transfer_characteristics + w.write_bits(p.matrix_coeffs as u32, 8); // matrix_coeffs w.write_bits(0, 1); // chroma_loc_info_present_flag w.write_bits(0, 1); // neutral_chroma_indication_flag w.write_bits(0, 1); // field_seq_flag diff --git a/crates/video/tests/av1/main.rs b/crates/video/tests/av1/main.rs new file mode 100644 index 00000000..7f7b7974 --- /dev/null +++ b/crates/video/tests/av1/main.rs @@ -0,0 +1,50 @@ +//! ffprobe as the external oracle for what the AV1 sequence header says. + +use std::process::Command; + +use craft_video::av1::{Encoder, EncoderConfig, Frame}; + +/// IVF with one temporal unit (temporal delimiter and the packet). +fn ivf(w: usize, h: usize, packet: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"DKIF"); + v.extend_from_slice(&0u16.to_le_bytes()); + v.extend_from_slice(&32u16.to_le_bytes()); + v.extend_from_slice(b"AV01"); + v.extend_from_slice(&(w as u16).to_le_bytes()); + v.extend_from_slice(&(h as u16).to_le_bytes()); + v.extend_from_slice(&30u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&((packet.len() + 2) as u32).to_le_bytes()); + v.extend_from_slice(&0u64.to_le_bytes()); + v.extend_from_slice(&[0x12, 0x00]); // OBU_TEMPORAL_DELIMITER + v.extend_from_slice(packet); + v +} + +/// `color_config` carries the configured colour description, and BT.709 when none is set. +#[test] +fn colour_description_reaches_the_sequence_header() { + let Some(ffprobe) = craft_media_testkit::ffprobe() else { return }; + let (w, h) = (64usize, 64usize); + let (y, c) = (vec![100u16; w * h], vec![128u16; w * h / 4]); + for (codes, names) in [(None, ["bt709", "bt709", "bt709"]), (Some([9u8, 16, 9]), ["bt2020", "smpte2084", "bt2020nc"])] { + let mut cfg = EncoderConfig::new(w as u32, h as u32, 30, 1); + if let Some([p, t, m]) = codes { + (cfg.color_primaries, cfg.transfer_characteristics, cfg.matrix_coefficients) = (p, t, m); + } + let mut enc = Encoder::new(cfg).unwrap(); + let packet = enc.encode(&Frame { y: &y, u: &c, v: &c, y_stride: w, uv_stride: w / 2 }).unwrap(); + let path = craft_media_testkit::fixture_subdir("av1").join(format!("colour-{}-{}.ivf", std::process::id(), codes.is_some())); + std::fs::write(&path, ivf(w, h, &packet.data)).unwrap(); + let out = Command::new(&ffprobe).args(["-v", "error", "-show_streams", "-of", "flat"]).arg(&path).output().unwrap(); + let _ = std::fs::remove_file(&path); + let s = String::from_utf8_lossy(&out.stdout); + let [p, t, m] = names; + for want in [format!("color_primaries=\"{p}\""), format!("color_transfer=\"{t}\""), format!("color_space=\"{m}\"")] { + assert!(s.contains(&want), "ffprobe output lacks {want}:\n{s}"); + } + } +} diff --git a/crates/video/tests/hevc/main.rs b/crates/video/tests/hevc/main.rs new file mode 100644 index 00000000..499b9898 --- /dev/null +++ b/crates/video/tests/hevc/main.rs @@ -0,0 +1,43 @@ +//! ffprobe as the external oracle for what the HEVC parameter sets say. + +use std::process::Command; + +use craft_video::hevc::{Encoder, EncoderConfig, Frame}; + +/// The VUI carries the configured colour description, and BT.709 when none is set. +#[test] +fn colour_description_reaches_the_vui() { + let Some(ffprobe) = craft_media_testkit::ffprobe() else { return }; + let (w, h) = (64usize, 64usize); + let (y, c) = (vec![100u16; w * h], vec![128u16; w * h / 4]); + for (codes, names) in [(None, ["bt709", "bt709", "bt709"]), (Some([9u8, 16, 9]), ["bt2020", "smpte2084", "bt2020nc"])] { + let mut cfg = EncoderConfig::new(w as u32, h as u32, 30, 1); + if let Some([p, t, m]) = codes { + (cfg.colour_primaries, cfg.transfer_characteristics, cfg.matrix_coeffs) = (p, t, m); + } + let mut enc = Encoder::new(cfg).unwrap(); + // Annex B: the parameter sets, then the picture's length-prefixed NAL units. + let mut stream = Vec::new(); + for set in enc.parameter_sets() { + stream.extend_from_slice(&[0, 0, 0, 1]); + stream.extend_from_slice(&set); + } + let packet = enc.encode(&Frame { y: &y, u: &c, v: &c, y_stride: w, uv_stride: w / 2 }).unwrap(); + let mut rest = packet.data.as_slice(); + while let Some((len, tail)) = rest.split_first_chunk::<4>() { + let (nal, tail) = tail.split_at(u32::from_be_bytes(*len) as usize); + stream.extend_from_slice(&[0, 0, 0, 1]); + stream.extend_from_slice(nal); + rest = tail; + } + let path = craft_media_testkit::fixture_subdir("hevc").join(format!("colour-{}-{}.hevc", std::process::id(), codes.is_some())); + std::fs::write(&path, stream).unwrap(); + let out = Command::new(&ffprobe).args(["-v", "error", "-show_streams", "-of", "flat"]).arg(&path).output().unwrap(); + let _ = std::fs::remove_file(&path); + let s = String::from_utf8_lossy(&out.stdout); + let [p, t, m] = names; + for want in [format!("color_primaries=\"{p}\""), format!("color_transfer=\"{t}\""), format!("color_space=\"{m}\"")] { + assert!(s.contains(&want), "ffprobe output lacks {want}:\n{s}"); + } + } +}