Files
craft-libs/crates/video/tests/h264_encode/common/mod.rs
T
c2b7dac5a2 craft-video: FilmCraft's later H.264 encoder work (High 10, threads, sub-pel diamond) and its H.264 and ProRes tests
craft-video: FilmCraft's later H.264 encoder work (High 10, threads, sub-pel diamond) and its H.264 and ProRes tests

* craft-video: H.264 High 10 and High 10 Intra in the encoder

The encoder is generic over the sample type (u8 at 8 bits, u16 at 9-10
bits): QP' = QP + QpBdOffset for quantisation and lambdas, mb_qp_delta
range and wrap, chroma QP mapping, deblocking thresholds scaled to the bit
depth, clipping in prediction, interpolation and reconstruction. SPS bit
depths, profile_idc 110, constraint_set3 for High 10 Intra, avcC
high-profile fields, the High 10 cpbBrVclFactor for level choice. New
YuvFrame16 / Encoder::encode16, EncoderConfig::bit_depth / intra_only.
8-bit output is unchanged.

Merged onto craft-video's copy: its input stride checks, B-frame timestamp
check, configuration validation, level 6.2 refusal, motion search start
candidates and codec_params are kept.

Ported from storytold/filmcraft#1580 (commit d24ce15e).

* craft-video: the H.264 encoder honours threads: 1 and reuses MbInfo buffers; raw-YUV bench

- A pool is built for every thread count, including 1: rayon calls outside
  install run on the global pool (all cores), so threads: 1 used to run on
  every core. A pool that cannot be built is an InvalidConfig error.
- Up to eight macroblock-info buffers are recycled between pictures and
  cleared on reuse, instead of allocating one per picture.
- examples/h264_h264enc_bench.rs encodes a raw YUV file and reports speed;
  its argument checks run as tests.

Ported from storytold/filmcraft#3205 (merge 08f8075a).

* craft-video: H.264 encoder sub-pel refinement on a 4-point diamond; 16x8 and 8x16 only after 8x8 beat 16x16

Sub-pel refinement tries the four axis neighbours instead of all eight, and
the 16x8 / 8x16 partitions are searched only when 8x8 beat 16x16: fewer
SATD evaluations for about the same decisions. craft-video's own motion
search start candidates are kept.

Ported from storytold/filmcraft#3207 (merge 8db5334c).

* craft-video: FilmCraft's H.264 encoder tests; EncoderConfig::writer names the program in the SEI

The encoder had no integration tests here. FilmCraft's come along as the
h264_encode test target: ffmpeg/ffprobe oracle decodes (8-bit and High 10,
bit-exact against the reconstruction), pinned decisions, CRF level choice
and the settings SEI, thread counts and VBR overshoot.

EncoderConfig::writer is the program and version the identification SEI
names (craft-h264enc and this crate's version by default), so an
application's files keep naming it.

Two expectations differ from FilmCraft's copy: the pinned slice hashes
(this encoder also starts the 16x16 search from the skip, zero and
co-located vectors; with those left out and FilmCraft's writer name, it
gives FilmCraft's four hashes exactly) and a CRF that is not a number,
which this encoder refuses.

* craft-video: FilmCraft's ProRes tests (oracle decode and encode, round trip, fuzz, perf)

craft-video's ProRes copy came with only its hostile-input test. FilmCraft's
ffmpeg oracle decode and encode tests, round trips, mutation fuzz and the
ignored throughput tests come along; craft-video's own fuzz_hostile stays.
Frame::new returns a Result here, so the tests unwrap it.

---------

Co-authored-by: JordyUSA <75485565+JordyUSA@users.noreply.github.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 22:43:42 -05:00

238 lines
9.5 KiB
Rust

//! Shared helpers for the encoder tests: a synthetic content generator and ffmpeg/ffprobe oracle wrappers.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Yuv {
pub w: usize,
pub h: usize,
pub y: Vec<u8>,
pub u: Vec<u8>,
pub v: Vec<u8>,
}
impl Yuv {
pub fn frame(&self) -> craft_video::h264::YuvFrame<'_> {
craft_video::h264::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w, uv_stride: self.w / 2 }
}
pub fn bytes(&self) -> Vec<u8> {
let mut v = self.y.clone();
v.extend_from_slice(&self.u);
v.extend_from_slice(&self.v);
v
}
}
fn hash(mut x: u32) -> u32 {
x ^= x >> 16;
x = x.wrapping_mul(0x7feb352d);
x ^= x >> 15;
x = x.wrapping_mul(0x846ca68b);
x ^= x >> 16;
x
}
/// Synthetic test picture `t`: four regions with a moving gradient, film-grain-like noise,
/// text-like hard edges and a sub-pixel panning texture.
pub fn synth(w: usize, h: usize, t: usize) -> Yuv {
let mut y = vec![0u8; w * h];
let mut u = vec![0u8; w * h / 4];
let mut v = vec![0u8; w * h / 4];
let tf = t as f32;
for yy in 0..h {
for xx in 0..w {
let region = (xx * 2 / w) + 2 * (yy * 2 / h);
let val: f32 = match region {
0 => {
// moving smooth gradient
let fx = xx as f32 / w as f32;
let fy = yy as f32 / h as f32;
128.0 + 90.0 * ((fx * 6.0 + tf * 0.07).sin() * (fy * 4.0 - tf * 0.05).cos())
}
1 => {
// mild noise over a slow ramp (like film grain)
let n =
(hash((xx as u32).wrapping_mul(7919) ^ (yy as u32).wrapping_mul(104729) ^ (t as u32).wrapping_mul(1_000_003)) & 31) as f32;
60.0 + (xx as f32 * 0.1 + yy as f32 * 0.05) % 120.0 + n
}
2 => {
// text-like strokes: glyph grid scrolling slowly upwards
let sy = yy + t;
let gx = xx / 12;
let gy = sy / 16;
let lx = xx % 12;
let ly = sy % 16;
let bits = hash((gx as u32) * 31 + gy as u32 * 977);
let inr = |v: usize, a: usize, b: usize| (a..=b).contains(&v);
let bar = inr(lx, 2, 9);
let stroke = (inr(lx, 2, 3) && bits & 1 != 0)
|| (inr(lx, 8, 9) && bits & 2 != 0)
|| (inr(ly, 3, 4) && bar && bits & 4 != 0)
|| (inr(ly, 8, 9) && bar && bits & 8 != 0)
|| (inr(ly, 12, 13) && bar && bits & 16 != 0);
if ly < 14 && lx < 11 && stroke { 235.0 } else { 20.0 }
}
_ => {
// panning texture with sub-pixel motion
let px = xx as f32 + tf * 1.75;
let py = yy as f32 + tf * 0.5;
let c = ((px / 9.0).sin() + (py / 7.0).cos() + ((px + py) / 23.0).sin()) * 40.0;
let checker = if ((px / 32.0).floor() as i64 + (py / 32.0).floor() as i64) % 2 == 0 { 30.0 } else { -30.0 };
128.0 + c + checker
}
};
y[yy * w + xx] = val.clamp(0.0, 255.0) as u8;
}
}
for yy in 0..h / 2 {
for xx in 0..w / 2 {
let fx = xx as f32 / (w / 2) as f32;
let fy = yy as f32 / (h / 2) as f32;
u[yy * w / 2 + xx] = (128.0 + 60.0 * (fx * 3.0 + tf * 0.03).sin()) as u8;
v[yy * w / 2 + xx] = (128.0 + 50.0 * (fy * 5.0 - tf * 0.04).cos() * fx) as u8;
}
}
Yuv { w, h, y, u, v }
}
/// ffmpeg (see `craft_media_testkit`); `None` after printing `SKIPPED`.
pub fn ffmpeg() -> Option<PathBuf> {
craft_media_testkit::ffmpeg_or_skip("h264enc oracle")
}
/// ffprobe (see `craft_media_testkit`); `None` after printing `SKIPPED`.
pub fn ffprobe() -> Option<PathBuf> {
craft_media_testkit::ffprobe().or_else(|| craft_media_testkit::skip("h264enc oracle: ffprobe not found"))
}
/// Per-test output directory under `<workspace>/target/fixtures/h264enc/`.
pub fn out_dir(name: &str) -> PathBuf {
craft_media_testkit::fixtures_dir(&format!("h264enc/{name}"))
}
/// Strict decode of an Annex-B file with ffmpeg; returns (diagnostics, raw yuv420p bytes). Any diagnostic
/// text is a failure for the caller.
///
/// - `-v warning`: ffmpeg reports a picture with a damaged, missing or concealed slice as
/// `corrupt decoded frame`, which is a warning. At `-v error` a stream with a whole slice missing
/// decodes silently (#74).
/// - Error concealment stays at ffmpeg's default. With `-ec 0`, ffmpeg (7.1, 8.1) flags every picture
/// that has more than one slice as corrupt, libx264's multi-slice streams included, so that mode
/// can't tell a real defect from a clean multi-slice picture. Concealment can't hide a defect here:
/// a concealed picture is still reported, and the caller compares every decoded pixel with the
/// encoder's reconstruction.
/// - `-err_detect`: also report the non-conformances ffmpeg tolerates by default.
pub fn ffmpeg_decode(file: &Path) -> (String, Vec<u8>) {
ffmpeg_decode_as(file, "yuv420p")
}
/// [`ffmpeg_decode`] to raw frames of `pix_fmt` (`yuv420p10le` for High 10).
pub fn ffmpeg_decode_as(file: &Path, pix_fmt: &str) -> (String, Vec<u8>) {
let out = Command::new(ffmpeg().unwrap())
.args(["-nostdin", "-hide_banner", "-v", "warning", "-err_detect", "+crccheck+bitstream+buffer+explode", "-i"])
.arg(file)
.args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
.output()
.expect("run ffmpeg");
let mut diag = String::from_utf8_lossy(&out.stderr).into_owned();
if !out.status.success() {
diag.push_str(&format!("ffmpeg exited with {}", out.status));
}
(diag, out.stdout)
}
/// Annex-B NAL units of `stream` as byte ranges (start code included).
pub fn annexb_nals(stream: &[u8]) -> Vec<std::ops::Range<usize>> {
let starts: Vec<usize> = (0..stream.len().saturating_sub(3)).filter(|&i| stream[i..i + 3] == [0, 0, 1]).collect();
starts.iter().enumerate().map(|(k, &s)| s..starts.get(k + 1).copied().unwrap_or(stream.len())).collect()
}
/// Raw frames produced by ffmpeg from a lavfi source.
pub fn ffmpeg_source(src: &str, w: usize, h: usize, frames: usize) -> Vec<Yuv> {
let out = Command::new(ffmpeg().unwrap())
.args(["-v", "error", "-f", "lavfi", "-i"])
.arg(format!("{src}=size={w}x{h}:rate=30"))
.args(["-frames:v", &frames.to_string(), "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
.output()
.expect("run ffmpeg");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let fs = w * h * 3 / 2;
out.stdout
.chunks_exact(fs)
.map(|c| Yuv { w, h, y: c[..w * h].to_vec(), u: c[w * h..w * h * 5 / 4].to_vec(), v: c[w * h * 5 / 4..].to_vec() })
.collect()
}
pub fn psnr(a: &[u8], b: &[u8]) -> f64 {
assert_eq!(a.len(), b.len());
let se: f64 = a.iter().zip(b).map(|(&x, &y)| (x as f64 - y as f64).powi(2)).sum();
if se == 0.0 {
return 99.0;
}
10.0 * (255.0f64 * 255.0 * a.len() as f64 / se).log10()
}
/// A 4:2:0 picture with 10-bit samples.
pub struct Yuv16 {
pub w: usize,
pub h: usize,
pub y: Vec<u16>,
pub u: Vec<u16>,
pub v: Vec<u16>,
}
impl Yuv16 {
pub fn frame(&self) -> craft_video::h264::YuvFrame16<'_> {
craft_video::h264::YuvFrame16 { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w, uv_stride: self.w / 2 }
}
/// The planes as `yuv420p10le` bytes.
pub fn bytes(&self) -> Vec<u8> {
self.y.iter().chain(&self.u).chain(&self.v).flat_map(|s| s.to_le_bytes()).collect()
}
}
/// 10-bit synthetic picture `t`: [`synth`]'s content scaled to 10 bits, with the smooth moving gradient
/// (top-left quarter) and the chroma evaluated at full 10-bit precision. 8 bits can only show those as
/// bands (one 8-bit step spans four 10-bit codes), so a 10-bit encode has detail to keep.
pub fn synth10(w: usize, h: usize, t: usize) -> Yuv16 {
let b = synth(w, h, t);
let tf = t as f32;
let mut y: Vec<u16> = b.y.iter().map(|&v| v as u16 * 4).collect();
for yy in 0..h / 2 {
for xx in 0..w / 2 {
let fx = xx as f32 / w as f32;
let fy = yy as f32 / h as f32;
let v = 512.0 + 360.0 * ((fx * 6.0 + tf * 0.07).sin() * (fy * 4.0 - tf * 0.05).cos());
y[yy * w + xx] = v.round().clamp(0.0, 1023.0) as u16;
}
}
let (cw, ch) = (w / 2, h / 2);
let mut u = vec![0u16; cw * ch];
let mut v = vec![0u16; cw * ch];
for yy in 0..ch {
for xx in 0..cw {
let fx = xx as f32 / cw as f32;
let fy = yy as f32 / ch as f32;
u[yy * cw + xx] = (512.0 + 240.0 * (fx * 3.0 + tf * 0.03).sin()).round() as u16;
v[yy * cw + xx] = (512.0 + 200.0 * (fy * 5.0 - tf * 0.04).cos() * fx).round() as u16;
}
}
Yuv16 { w, h, y, u, v }
}
/// Luma PSNR of 10-bit planes.
pub fn psnr10(a: &[u16], b: &[u16]) -> f64 {
assert_eq!(a.len(), b.len());
let se: f64 = a.iter().zip(b).map(|(&x, &y)| (x as f64 - y as f64).powi(2)).sum();
if se == 0.0 {
return 99.0;
}
10.0 * (1023.0f64 * 1023.0 * a.len() as f64 / se).log10()
}
/// Little-endian 16-bit samples.
pub fn le16(b: &[u8]) -> Vec<u16> {
b.as_chunks::<2>().0.iter().map(|&c| u16::from_le_bytes(c)).collect()
}