filmcraft/crates/platform/tests/nvenc_hevc_hdr_export.rs

363 lines
18 KiB
Rust

//! HDR export as H.265 Main 10 (NVENC) through the real export pipeline: an HDR (Rec. 2100 PQ / HLG)
//! sequence gives MP4 / QuickTime files that ffprobe (an external oracle only) describes as `hvc1`
//! Main 10, yuv420p10le, BT.2020 with the right transfer, limited range, with HDR10 static metadata for
//! PQ; that ffmpeg decodes without errors to the same 10-bit pictures our own decoder makes; that
//! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same
//! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder
//! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder.
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use filmcraft_color::{ColorPipeline, WorkingSpace};
use filmcraft_export::{BitrateMode, ColorSignal, EncoderFrame, ExportSettings, Format, Progress, export, hw_encode_stats};
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{DemoScene, FrameRequest, Generator, MediaSource};
use filmcraft_platform::nvenc::export::factory;
use filmcraft_platform::nvenc::{hevc_available, hevc_hdr_available};
use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
use filmcraft_render::SourceMap;
use filmcraft_time::{FrameRate, Tick, TimeRange};
/// The encoder counters are process-wide: one test at a time.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
const W: u32 = 1280;
const H: u32 = 720;
const FRAMES: i64 = 24;
fn project(working: WorkingSpace) -> (Arc<Project>, filmcraft_project::ItemId, SourceMap) {
let rate = FrameRate::FPS_24;
let mut p = Project::new("x");
let g = GeneratorSource::new(Generator::Demo(DemoScene::OceanSunset), W, H, rate, rate.tick_of(FRAMES));
let info = g.info().clone();
let clip = p.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(g.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let color = ColorPipeline { working, ..ColorPipeline::REC709 };
let seq = p.new_sequence("s", SequenceSettings { width: W, height: H, frame_rate: rate, color, ..Default::default() }, 1, 1, None);
let v = p.make_track_item(clip, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(FRAMES)), rate).unwrap();
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(v);
let mut m = SourceMap::default();
m.0.insert(clip, Arc::new(g));
(Arc::new(p), seq, m)
}
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("fc-nvenc-hdr-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
fn decode(path: &str) -> Vec<Arc<filmcraft_frame::VideoFrame>> {
let bytes: Arc<[u8]> = std::fs::read(path).unwrap().into();
let src = filmcraft_codecs::open_bytes(path, bytes).unwrap();
let rate = src.info().frame_rate();
let n = rate.frame_at(src.info().duration);
(0..n).map(|i| src.video_frame(FrameRequest::full(rate.tick_of(i))).unwrap()).collect()
}
fn hevc(path: String) -> ExportSettings {
ExportSettings { format: Format::Hevc, path, include_audio: false, bitrate_kbps: 10_000, bitrate_mode: BitrateMode::Vbr1Pass, ..Default::default() }
}
fn probe(ffprobe: &Path, path: &str) -> std::collections::HashMap<String, String> {
let entries = "stream=codec_name,profile,codec_tag_string,pix_fmt,color_space,color_transfer,color_primaries,color_range,width,height,nb_frames,level";
let out = Command::new(ffprobe).args(["-v", "error", "-select_streams", "v:0", "-show_entries", entries, "-of", "default=nw=1", path]).output().unwrap();
assert!(out.status.success(), "ffprobe: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
/// The side data ffprobe reports for the first frame.
fn first_frame_side_data(ffprobe: &Path, path: &str) -> String {
let out = Command::new(ffprobe)
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-read_intervals",
"%+#1",
"-show_frames",
"-show_entries",
"frame=side_data_list",
"-of",
"default=nw=1",
path,
])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).to_string()
}
fn assert_ffmpeg_decodes(ffmpeg: &Path, path: &str) {
let out = Command::new(ffmpeg).args(["-v", "error", "-xerror", "-i", path, "-f", "null", "-"]).output().unwrap();
assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr));
}
/// The first picture as ffmpeg decodes it to yuv420p10le: (Y, Cb, Cr) code values.
fn ffmpeg_first_picture(ffmpeg: &Path, path: &str, w: usize, h: usize) -> (Vec<u16>, Vec<u16>, Vec<u16>) {
let out = Command::new(ffmpeg).args(["-v", "error", "-i", path, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "yuv420p10le", "-"]).output().unwrap();
assert!(out.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr));
let samples: Vec<u16> = out.stdout.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes(*b)).collect();
assert_eq!(samples.len(), w * h * 3 / 2, "one yuv420p10le picture");
let (y, c) = samples.split_at(w * h);
let (u, v) = c.split_at(c.len() / 2);
(y.to_vec(), u.to_vec(), v.to_vec())
}
fn frame<'a>(rgba: &'a [u8], hdr: Option<&'a [f32]>, index: u64) -> EncoderFrame<'a> {
EncoderFrame { width: 640, height: 360, rgba, hdr, index }
}
fn psnr10(a: &[u16], b: &[u16]) -> f64 {
let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { 99.0 } else { 10.0 * (1023.0f64 * 1023.0 / mse).log10() }
}
fn delta(before: filmcraft_export::HwEncodeStats) -> (u64, u64, u64) {
let after = hw_encode_stats();
(after.frames - before.frames, after.sessions - before.sessions, after.declined - before.declined)
}
fn yuv16(f: &filmcraft_frame::VideoFrame) -> ([Arc<Vec<u16>>; 3], u32) {
match &f.data {
filmcraft_frame::PixelData::Yuv16 { planes, bits, .. } => (planes.clone(), *bits),
_ => panic!("16-bit planes expected, got {}", f.format_label()),
}
}
#[test]
fn hdr_sequences_export_as_main_10() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
assert_eq!(filmcraft_export::hdr_available(Format::Hevc), hevc_hdr_available(), "HDR H.265 is available iff NVENC can encode Main 10");
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 here (hevc: {})", hevc_available());
return;
}
let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hdr export");
let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("nvenc hdr export");
for (ws, ext, ff_transfer, pq) in [
(WorkingSpace::Rec2100Pq, "mp4", "smpte2084", true),
(WorkingSpace::Rec2100Hlg, "mp4", "arib-std-b67", false),
(WorkingSpace::Rec2100Pq, "mov", "smpte2084", true),
] {
let name = format!("{}.{ext}", ws.id());
let (p, seq, m) = project(ws);
// the ProRes HDR export of the same sequence is the reference for the pixels (10-bit BT.2020, same matrix and range)
let ref_path = tmp(&format!("ref-{}.mov", ws.id()));
let before = hw_encode_stats();
export(
&p,
seq,
&ExportSettings { format: Format::ProRes, path: ref_path.clone(), include_audio: false, ..Default::default() },
&m,
&Progress::default(),
)
.unwrap();
assert_eq!(delta(before), (0, 0, 0), "ProRes never touches NVENC");
let reference = decode(&ref_path);
let path = tmp(&name);
let s = hevc(path.clone());
let before = hw_encode_stats();
let report = export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(report.frames, FRAMES as u64, "{name}");
assert_eq!(delta(before), (FRAMES as u64, 1, 0), "{name}: every picture through one NVENC session, nothing declined");
// our importer: PQ / HLG, BT.2020, 10-bit planes
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
let src = filmcraft_codecs::open_bytes(&path, bytes.clone()).unwrap();
let v = src.info().video.clone().unwrap();
let (want_transfer, want_hdr) = if pq { (filmcraft_color::Transfer::Pq, true) } else { (filmcraft_color::Transfer::Hlg, false) };
assert_eq!(
(v.color.transfer, v.color.primaries, v.color.matrix),
(want_transfer, filmcraft_color::Primaries::Bt2020, filmcraft_color::Matrix::Bt2020Ncl),
"{name}"
);
if want_hdr {
let hdr = v.hdr.unwrap_or_else(|| panic!("{name}: no HDR metadata"));
assert_eq!((hdr.mastering_max_nits, hdr.max_cll, hdr.peak_nits()), (Some(1000.0), None, Some(1000.0)), "{name}");
}
// and the sample entry boxes
let file = filmcraft_isobmff::open(&bytes[..]).unwrap();
let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).expect("a video track");
let ve = file.tracks[vt].entries[0].video.clone().expect("a video sample entry");
assert!(matches!(ve.color, Some(filmcraft_isobmff::ColorInfo::Nclx { primaries: 9, matrix: 9, full_range: false, .. })), "{name}: {:?}", ve.color);
assert_eq!(ve.mastering_display.is_some() && ve.content_light == Some((0, 0)), pq, "{name}: mdcv / clli");
let pictures = decode(&path);
assert_eq!(pictures.len(), FRAMES as usize, "{name}");
let (mut worst, mut best_levels) = (99.0f64, usize::MAX);
let (mut peak, mut ref_peak) = (0u16, 0u16);
for (a, b) in pictures.iter().zip(&reference) {
let (pa, bits) = yuv16(a);
let (pb, _) = yuv16(b);
assert_eq!(bits, 10, "{name}: decoded bit depth");
assert!(pa[0].iter().all(|c| *c < 1024));
worst = worst.min(psnr10(&pa[0], &pb[0]));
peak = peak.max(pa[0].iter().copied().max().unwrap_or(0));
ref_peak = ref_peak.max(pb[0].iter().copied().max().unwrap_or(0));
let mut levels: Vec<u16> = pa[0].to_vec();
levels.sort_unstable();
levels.dedup();
best_levels = best_levels.min(levels.len());
}
eprintln!(
"{name}: {} bytes, worst luma PSNR against the ProRes HDR export {worst:.1} dB, {best_levels} distinct luma levels per picture (min), peak luma code {peak} (ProRes {ref_peak})",
report.bytes
);
assert!(worst > 35.0, "{name}: {worst:.1} dB against the ProRes HDR export");
assert!(best_levels > 256, "{name}: {best_levels} distinct luma levels: not 10-bit");
assert!(peak.abs_diff(ref_peak) < 24, "{name}: peak {peak} against {ref_peak}");
let (Some(ffprobe), Some(ffmpeg)) = (&ffprobe, &ffmpeg) else { continue };
let f = probe(ffprobe, &path);
let get = |k: &str| f.get(k).map(String::as_str).unwrap_or("?");
assert_eq!((get("codec_name"), get("profile"), get("codec_tag_string"), get("pix_fmt")), ("hevc", "Main 10", "hvc1", "yuv420p10le"), "{name}: {f:?}");
assert_eq!(
(get("color_primaries"), get("color_transfer"), get("color_space"), get("color_range")),
("bt2020", ff_transfer, "bt2020nc", "tv"),
"{name}: {f:?}"
);
assert_eq!((get("width"), get("height"), get("nb_frames")), ("1280", "720", "24"), "{name}: {f:?}");
let side = first_frame_side_data(ffprobe, &path);
if pq {
assert!(
side.contains("Mastering display metadata") && side.contains("max_luminance=10000000/10000") && side.contains("min_luminance=1/10000"),
"{name}: {side}"
);
assert!(side.contains("Content light level metadata") && side.contains("max_content=0") && side.contains("max_average=0"), "{name}: {side}");
} else {
assert!(!side.contains("Mastering display") && !side.contains("Content light level"), "{name}: {side}");
}
assert_ffmpeg_decodes(ffmpeg, &path);
// ffmpeg's yuv420p10le picture is our decoder's picture
let (fy, fu, fv) = ffmpeg_first_picture(ffmpeg, &path, W as usize, H as usize);
let (ours, _) = yuv16(&pictures[0]);
let diff = |a: &[u16], b: &[u16]| a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
eprintln!(
"{name}: ffprobe {f:?}\n{name}: first-frame side data {:?}\n{name}: ffmpeg vs our decoder, max code difference Y {} Cb {} Cr {}",
side.lines().collect::<Vec<_>>(),
diff(&fy, &ours[0]),
diff(&fu, &ours[1]),
diff(&fv, &ours[2])
);
assert_eq!(
(diff(&fy, &ours[0]), diff(&fu, &ours[1]), diff(&fv, &ours[2])),
(0, 0, 0),
"{name}: our HEVC decoder and ffmpeg disagree on the 10-bit pictures"
);
}
}
#[test]
fn sdr_stays_main_8_bit_and_the_sdr_setting_tone_maps_hdr_sequences() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 here");
return;
}
let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hdr export");
// an HDR sequence with SDR asked for, and a plain SDR sequence: Main, 8-bit, BT.709
for (what, ws, sdr) in [
("HDR sequence, settings.sdr", WorkingSpace::Rec2100Pq, true),
("HLG sequence, settings.sdr", WorkingSpace::Rec2100Hlg, true),
("SDR sequence", ColorPipeline::REC709.working, false),
] {
let (p, seq, m) = project(ws);
let path = tmp(&format!("sdr-{}.mp4", what.replace([' ', ','], "-")));
let s = ExportSettings { sdr, ..hevc(path.clone()) };
let before = hw_encode_stats();
export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(delta(before), (FRAMES as u64, 1, 0), "{what}");
let pictures = decode(&path);
assert!(matches!(&pictures[0].data, filmcraft_frame::PixelData::Yuv8 { .. }), "{what}: 8-bit planes");
assert_eq!(pictures[0].color.transfer, filmcraft_color::Transfer::Bt709, "{what}");
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
let file = filmcraft_isobmff::open(&bytes[..]).unwrap();
let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap();
let ve = file.tracks[vt].entries[0].video.clone().unwrap();
assert!(ve.mastering_display.is_none() && ve.content_light.is_none(), "{what}: no HDR boxes");
if let Some(ffprobe) = &ffprobe {
let f = probe(ffprobe, &path);
let get = |k: &str| f.get(k).map(String::as_str).unwrap_or("?");
assert_eq!(
(get("profile"), get("pix_fmt"), get("color_primaries"), get("color_transfer"), get("color_space"), get("color_range")),
("Main", "yuv420p", "bt709", "bt709", "bt709", "tv"),
"{what}: {f:?}"
);
assert!(!first_frame_side_data(ffprobe, &path).contains("Mastering"), "{what}");
}
}
}
#[test]
fn the_factory_rejects_mismatched_pictures_and_clamps_hostile_floats() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
filmcraft_platform::register();
if !hevc_hdr_available() {
eprintln!("SKIPPED: no NVENC HEVC Main 10 here");
return;
}
let rate = FrameRate::FPS_24;
let (w, h) = (640u32, 360u32);
let rgba = vec![128u8; (w * h * 4) as usize];
let hdr_settings = ExportSettings { signal: ColorSignal::PQ, ..hevc(tmp("x.mp4")) };
let sdr_settings = hevc(tmp("y.mp4"));
let Some(Ok(mut ten)) = factory(Format::Hevc, w, h, rate, &hdr_settings) else { panic!("a Main 10 encoder") };
let Some(Ok(mut eight)) = factory(Format::Hevc, w, h, rate, &sdr_settings) else { panic!("a Main encoder") };
let good = vec![0.5f32; (w * h * 3) as usize];
// the HDR encoder wants the HDR picture, the SDR encoder the RGBA one
assert!(ten.encode(&frame(&rgba, None, 0)).is_err());
assert!(eight.encode(&frame(&rgba, Some(&good), 0)).is_err());
// wrong-length floats (short, long, empty) and a wrong picture size: errors, nothing submitted
for bad in [&good[..good.len() - 1], &[][..], &vec![0.5f32; good.len() + 3][..]] {
assert!(ten.encode(&frame(&rgba, Some(bad), 0)).is_err(), "{} floats", bad.len());
}
assert!(ten.encode(&EncoderFrame { width: w + 2, height: h, rgba: &rgba, hdr: Some(&good), index: 0 }).is_err());
// NaN, infinities and out-of-range values clamp: the pictures encode
let mut hostile = vec![0.25f32; good.len()];
for (i, x) in hostile.iter_mut().enumerate() {
*x = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 7.0, 0.5][i % 6];
}
let mut packets = 0;
for i in 0..4 {
packets += ten.encode(&frame(&rgba, Some(&hostile), i)).unwrap().len();
}
packets += ten.flush().unwrap().len();
assert_eq!(packets, 4);
// the sample entry of the HDR encoder carries colr + mdcv + clli, the SDR one nothing HDR
let e = ten.sample_entry();
let v = e.video.as_ref().unwrap();
assert!(v.color.is_some() && v.mastering_display.is_some() && v.content_light == Some((0, 0)));
assert!(eight.sample_entry().video.as_ref().unwrap().color.is_none());
// HLG: colr only
let Some(Ok(hlg)) = factory(Format::Hevc, w, h, rate, &ExportSettings { signal: ColorSignal::HLG, ..sdr_settings.clone() }) else {
panic!("an HLG encoder")
};
let v = hlg.sample_entry().video.unwrap();
assert!(v.color.is_some() && v.mastering_display.is_none() && v.content_light.is_none());
// H.264 still declines HDR (software signalling), and says why
let h264 = ExportSettings { format: Format::H264, signal: ColorSignal::PQ, ..sdr_settings };
assert!(factory(Format::H264, w, h, rate, &h264).is_none());
}