mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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>
139 lines
6.5 KiB
Rust
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:?}");
|
|
}
|
|
}
|