Files
craft-libs/crates/video/tests/prores/oracle_encode.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

139 lines
6.5 KiB
Rust

//! Encoder conformance and quality against ffmpeg's ProRes decoder (oracle only).
use crate::common;
use common::*;
use craft_video::prores::{ChromaFormat, DecodeOptions, Encoder, Frame, Profile, decode_frame, decode_frame_with};
struct Content {
name: &'static str,
lavfi: &'static str,
/// Detailed enough that every profile's rate control is budget-bound.
busy: bool,
}
const W: u32 = 1920;
const H: u32 = 1080;
fn contents(chroma: ChromaFormat) -> Vec<Content> {
match chroma {
ChromaFormat::Yuv422 => vec![
Content { name: "src422_testsrc2", lavfi: "testsrc2=s=1920x1080:r=25", busy: false },
Content { name: "src422_mandel", lavfi: "mandelbrot=s=1920x1080:r=25", busy: false },
Content { name: "src422_noise", lavfi: "testsrc2=s=1920x1080:r=25,noise=alls=40:allf=t", busy: true },
],
ChromaFormat::Yuv444 => vec![
Content {
name: "src444a_testsrc2_disc",
lavfi: "testsrc2=s=1920x1080:r=25,format=yuva444p10le,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='1023*lt(hypot(X-W/2,Y-H/2),400)'",
busy: false,
},
Content { name: "src444a_noise", lavfi: "testsrc2=s=1920x1080:r=25,noise=alls=40:allf=t,format=yuva444p10le", busy: true },
],
}
}
fn native_fmt(chroma: ChromaFormat, alpha: bool) -> &'static str {
match (chroma, alpha) {
(ChromaFormat::Yuv422, _) => "yuv422p10le",
(ChromaFormat::Yuv444, false) => "yuv444p12le",
(ChromaFormat::Yuv444, true) => "yuva444p12le",
}
}
#[test]
fn encoder_output_decodes_in_ffmpeg() {
let Some(ff) = ffmpeg() else { return };
let mut report = vec![format!(
"{:<9} {:<18} {:>9} {:>9} {:>6} {:>8} {:>8} {:>7} {:>7}",
"profile", "content", "target", "avg", "ratio", "psnrY", "psnrC", "ffΔmax", "ffΔ=0%"
)];
let mut failures = Vec::new();
for profile in Profile::ALL {
let chroma = profile.chroma();
for c in contents(chroma) {
let alpha = chroma == ChromaFormat::Yuv444;
let src = source_frames(&ff, SourceFrameSpec { name: c.name, lavfi: c.lavfi, frames: 2, width: W, height: H, chroma, alpha });
let mut enc = Encoder::new(profile, W, H);
let coded: Vec<Vec<u8>> = src.iter().map(|f| enc.encode(f).unwrap()).collect();
let target = enc.target_frame_bytes() as f64;
let avg = coded.iter().map(|c| c.len()).sum::<usize>() as f64 / coded.len() as f64;
let mov = fixture_dir().join(format!("enc_{}_{}.mov", String::from_utf8_lossy(&profile.fourcc()), c.name));
write_mov(&mov, &profile.fourcc(), W as u16, H as u16, if alpha { 32 } else { 24 }, &coded);
let fmt = native_fmt(chroma, alpha);
let (ffraw, err) = ffmpeg_decode(&ff, &mov, fmt);
if !err.trim().is_empty() {
failures.push(format!("{profile:?}/{}: ffmpeg reported: {err}", c.name));
}
let per = frame_samples(W as usize, H as usize, chroma, alpha);
assert_eq!(ffraw.len(), per * coded.len(), "{profile:?}/{}", c.name);
let (mut ffd, mut py, mut pc) = (Stats::default(), 0.0, 0.0);
for (i, (cf, s)) in coded.iter().zip(&src).enumerate() {
// Our decode of our stream agrees with ffmpeg's decode of it.
let ours = decode_frame(cf).unwrap();
let (l, cc) = compare(&ours, &ffraw[i * per..(i + 1) * per]);
ffd.merge(&l);
ffd.merge(&cc);
// Quality against the 10-bit source.
let d10 = decode_frame_with(cf, &DecodeOptions { bit_depth: Some(10), threads: true }).unwrap();
py += psnr(&d10.y, &s.y, 1023.0) / coded.len() as f64;
pc += psnr(&[d10.cb.clone(), d10.cr.clone()].concat(), &[s.cb.clone(), s.cr.clone()].concat(), 1023.0) / coded.len() as f64;
if let (Some(a), Some(b)) = (&d10.alpha, &s.alpha) {
assert_eq!(a, b, "alpha is lossless");
}
}
let ratio = avg / target;
report.push(format!(
"{:<9} {:<18} {:>9.0} {:>9.0} {:>6.3} {:>8.2} {:>8.2} {:>7} {:>7.2}",
format!("{profile:?}"),
c.name,
target,
avg,
ratio,
py,
pc,
ffd.max,
ffd.exact_pct()
));
if ffd.max > 1 {
failures.push(format!("{profile:?}/{}: ffmpeg decode differs from ours by {}", c.name, ffd.max));
}
if ratio > 1.15 || (c.busy && ratio < 0.85) {
failures.push(format!("{profile:?}/{}: frame size {avg:.0} vs target {target:.0}", c.name));
}
if profile == Profile::Hq && !c.busy && py.min(pc) < 45.0 {
failures.push(format!("HQ/{}: PSNR {py:.2}/{pc:.2} dB below 45", c.name));
}
}
}
let text = report.join("\n");
println!("{text}");
std::fs::write(fixture_dir().join("encode_report.txt"), &text).unwrap();
assert!(failures.is_empty(), "{}\n{text}", failures.join("\n"));
}
/// Odd sizes and small slices round-trip through ffmpeg too.
#[test]
fn odd_geometry_encodes() {
let Some(ff) = ffmpeg() else { return };
for (profile, w, h) in [(Profile::Standard, 1918u32, 1080u32), (Profile::Proxy, 70, 38), (Profile::P4444, 333, 201)] {
let chroma = profile.chroma();
let alpha = chroma == ChromaFormat::Yuv444;
let lav = if alpha {
format!("mandelbrot=s={w}x{h}:r=25,format=yuva444p10le,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='mod(3*X+Y,1024)'")
} else {
format!("mandelbrot=s={w}x{h}:r=25")
};
let name = format!("src_odd_{w}x{h}");
let src = source_frames(&ff, SourceFrameSpec { name: &name, lavfi: &lav, frames: 1, width: w, height: h, chroma, alpha });
let mut enc = Encoder::new(profile, w, h);
let coded = vec![enc.encode(&src[0]).unwrap()];
let mov = fixture_dir().join(format!("enc_odd_{w}x{h}.mov"));
write_mov(&mov, &profile.fourcc(), w as u16, h as u16, 24, &coded);
let (ffraw, err) = ffmpeg_decode(&ff, &mov, native_fmt(chroma, alpha));
assert!(err.trim().is_empty(), "{err}");
let ours: Frame = decode_frame(&coded[0]).unwrap();
let (l, c) = compare(&ours, &ffraw);
assert!(l.max <= 1 && c.max <= 1, "{profile:?} {w}x{h}: {l:?} {c:?}");
}
}