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.
This commit is contained in:
Dylan M. Taylor
2026-11-11 16:40:32 -05:00
committed by GitHub
parent a9243c8c40
commit e7c3dda7f3
9 changed files with 387 additions and 59 deletions
+8
View File
@@ -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"]
+1 -1
View File
@@ -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
+258 -51
View File
@@ -157,7 +157,8 @@ impl Default for TxScratch {
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
struct Snapshot {
planes: [Vec<u16>; 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<u16>| -> f64 {
let sub_cost = |m: [i32; 2], buf: &mut Vec<u16>, 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::<u64>();
}
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::<u32>() 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<const K: usize>(&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::<u64>();
}
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::<u64>();
}
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<u16> = (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<u16> = (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}");
}
}
}
}
}
}
}
+10 -3
View File
@@ -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
+1 -1
View File
@@ -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`).
+10
View File
@@ -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,
};
+6 -3
View File
@@ -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
+50
View File
@@ -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<u8> {
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}");
}
}
}
+43
View File
@@ -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}");
}
}
}