From e7c208434b6a966f67731aa98abe625651f52778 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 00:27:45 +0100 Subject: [PATCH] #474: pin OpenEXR pictures and aux channels to exact hashes Synthetic single-layer, multi-layer, multi-part and windowed files in half, float and uint, with RLE, ZIP, PIZ and DWAA, decoded through the media pool picture first and channels first. The hashes are taken on main before the decode is reworked. Co-Authored-By: Claude Opus 5.5 --- crates/media/tests/it/exr_golden.rs | 338 ++++++++++++++++++++++++++++ crates/media/tests/it/main.rs | 1 + 2 files changed, 339 insertions(+) create mode 100644 crates/media/tests/it/exr_golden.rs diff --git a/crates/media/tests/it/exr_golden.rs b/crates/media/tests/it/exr_golden.rs new file mode 100644 index 000000000..498944882 --- /dev/null +++ b/crates/media/tests/it/exr_golden.rs @@ -0,0 +1,338 @@ +//! OpenEXR pictures and aux channels pinned to exact hashes (#474). +//! +//! Synthetic files (single-layer, multi-layer with and without an unnamed RGB layer, multi-part, +//! data windows smaller and larger than the display window, half / float / uint channels, RLE, +//! ZIP, PIZ and DWAA) are decoded through the media pool as the renderer asks for them: the +//! picture in each alpha interpretation, and the channels EXtractoR and Cryptomatte read. The +//! hashes were taken before the picture and the channels started sharing one decode, so a change +//! to how either is decoded must leave every bit as it was. Only bit-exact float steps are +//! pinned (linear light, no `powf`), so the hashes hold on every platform; DWAA (whose decoder +//! uses `powf`) and the sRGB / gamma 2.4 curves are checked for agreement between the two ways a +//! frame can be decoded instead. Re-pin with `EXR_GOLDEN_PRINT=1` after an intended change. + +use std::sync::Arc; + +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::{AlphaMode, Footage, ItemId}; +use effectcraft_render::{FloatCurve, FootageSource}; +use effectcraft_time::Tick; +use exr::prelude::*; + +const W: usize = 37; +const H: usize = 23; + +/// A deterministic value for pixel (x, y) of channel `c`: negative, in range and above 1. +fn value(x: usize, y: usize, c: usize) -> f32 { + ((x * 7 + y * 13 + c * 29) % 97) as f32 / 40.0 - 0.3 +} + +/// A deterministic alpha in 0..=1 (0 included, for the premultiplied divide). +fn alpha(x: usize, y: usize) -> f32 { + ((x * 3 + y * 5) % 11) as f32 / 10.0 +} + +#[derive(Clone, Copy)] +enum Kind { + Half, + Float, + Uint, +} + +/// A channel `name` of `size` filled from [`value`] (or [`alpha`] for an `A` / `a` channel). +fn channel(name: &str, kind: Kind, size: (usize, usize), c: usize) -> AnyChannel { + let is_alpha = name.rsplit('.').next().is_some_and(|n| n.eq_ignore_ascii_case("a")); + let px = |i: usize| { + let (x, y) = (i % size.0, i / size.0); + if is_alpha { alpha(x, y) } else { value(x, y, c) } + }; + let n = size.0 * size.1; + let samples = match kind { + Kind::Half => FlatSamples::F16((0..n).map(|i| f16::from_f32(px(i))).collect()), + Kind::Float => FlatSamples::F32((0..n).map(px).collect()), + Kind::Uint => FlatSamples::U32((0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) >> 8).collect()), + }; + AnyChannel::new(name, samples) +} + +fn channels(list: &[(&str, Kind)], size: (usize, usize)) -> AnyChannels { + AnyChannels::sort(list.iter().enumerate().map(|(c, (n, k))| channel(n, *k, size, c)).collect::>().into()) +} + +fn encoding(compression: Compression) -> Encoding { + Encoding { compression, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing } +} + +fn with_manifest(mut attrs: LayerAttributes) -> LayerAttributes { + attrs.other.insert(Text::from("cryptomatte/abc1234/name"), AttributeValue::Text(Text::from("CryptoObject"))); + attrs.other.insert(Text::from("cryptomatte/abc1234/manifest"), AttributeValue::Text(Text::from(r#"{"bunny":"13851a76","floor":"0a1b2c3d"}"#))); + attrs +} + +/// One part: `list` channels over a data window of `size` at `pos`. +fn part(name: Option<&str>, list: &[(&str, Kind)], size: (usize, usize), pos: (i32, i32), compression: Compression) -> Layer> { + let mut attrs = name.map_or_else(LayerAttributes::default, LayerAttributes::named); + attrs.layer_position = Vec2(pos.0, pos.1); + Layer::new(size, with_manifest(attrs), encoding(compression), channels(list, size)) +} + +/// Encode `layers` with the display window `display` (position, size). +fn write(layers: Vec>>, display: ((i32, i32), (usize, usize))) -> Vec { + let mut attrs = ImageAttributes::new(IntegerBounds::new(display.0, display.1)); + attrs.pixel_aspect = 1.0; + let image = Image::from_layers(attrs, layers); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().non_parallel().to_buffered(&mut bytes).unwrap(); + bytes.into_inner() +} + +const RGBA_HALF: [(&str, Kind); 4] = [("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half), ("A", Kind::Half)]; +const CRYPTO: [(&str, Kind); 4] = + [("CryptoObject00.r", Kind::Float), ("CryptoObject00.g", Kind::Float), ("CryptoObject00.b", Kind::Float), ("CryptoObject00.a", Kind::Float)]; + +/// The synthetic files: (name, bytes, whether the hashes are pinned). +fn files() -> Vec<(&'static str, Vec, bool)> { + let full = (W, H); + let origin = ((0, 0), full); + let cat = |a: &[(&'static str, Kind)], b: &[(&'static str, Kind)]| -> Vec<(&'static str, Kind)> { a.iter().chain(b).copied().collect() }; + let layered: Vec<(&str, Kind)> = cat( + &[("Image.R", Kind::Half), ("Image.G", Kind::Half), ("Image.B", Kind::Half), ("Image.A", Kind::Half), ("depth.Z", Kind::Float), ("ObjectID", Kind::Uint)], + &CRYPTO, + ); + let unnamed_plus: Vec<(&str, Kind)> = + cat(&cat(&RGBA_HALF, &[("Diffuse.R", Kind::Half), ("Diffuse.G", Kind::Half), ("Diffuse.B", Kind::Half), ("Z", Kind::Float)]), &CRYPTO); + let rgb_float = [("R", Kind::Float), ("G", Kind::Float), ("B", Kind::Float), ("depth.Z", Kind::Float)]; + vec![ + ("rgba_half_zip16", write(vec![part(None, &RGBA_HALF, full, (0, 0), Compression::ZIP16)], origin), true), + ("rgb_float_piz", write(vec![part(None, &[("R", Kind::Float), ("G", Kind::Float), ("B", Kind::Float), ("Z", Kind::Float)], full, (0, 0), Compression::PIZ)], origin), true), + ("layered_rle", write(vec![part(None, &layered, full, (0, 0), Compression::RLE)], origin), true), + ("unnamed_rgb_plus_layers_zip1", write(vec![part(None, &unnamed_plus, full, (0, 0), Compression::ZIP1)], origin), true), + ( + "multipart_zip16", + write( + vec![ + part(Some("Image"), &RGBA_HALF, full, (0, 0), Compression::ZIP16), + part(Some("depth"), &[("Z", Kind::Float)], full, (0, 0), Compression::ZIP16), + part(Some("CryptoObject00"), &[("r", Kind::Float), ("g", Kind::Float), ("b", Kind::Float), ("a", Kind::Float)], full, (0, 0), Compression::ZIP16), + ], + origin, + ), + true, + ), + ( + // The first part with R, G and B is not the beauty pass. + "multipart_diffuse_then_beauty_zip1", + write( + vec![ + part(Some("diffuse"), &[("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half)], full, (0, 0), Compression::ZIP1), + part(Some("beauty"), &RGBA_HALF, (20, 10), (5, 7), Compression::ZIP1), + part(Some("depth"), &[("Z", Kind::Float)], full, (0, 0), Compression::ZIP1), + ], + origin, + ), + true, + ), + ( + "multipart_blender5_rle", + write( + vec![ + part(Some("ViewLayer.Depth"), &[("ViewLayer.Depth.Z", Kind::Float)], full, (0, 0), Compression::RLE), + part( + Some("ViewLayer.Combined"), + &[("ViewLayer.Combined.R", Kind::Half), ("ViewLayer.Combined.G", Kind::Half), ("ViewLayer.Combined.B", Kind::Half), ("ViewLayer.Combined.A", Kind::Half)], + full, + (0, 0), + Compression::RLE, + ), + ], + origin, + ), + true, + ), + ("unnamed_rgb_window_smaller_zip16", write(vec![part(None, &unnamed_plus, (20, 10), (5, 7), Compression::ZIP16)], origin), true), + ("rgb_float_window_larger_piz", write(vec![part(None, &rgb_float, (45, 30), (-1, -5), Compression::PIZ)], ((3, -2), full)), true), + ("layered_window_smaller_zip16", write(vec![part(None, &layered, (20, 10), (5, 7), Compression::ZIP16)], origin), true), + ("layered_window_larger_piz", write(vec![part(None, &layered, (45, 30), (-4, -3), Compression::PIZ)], origin), true), + ("unnamed_rgb_plus_layers_dwaa", write(vec![part(None, &unnamed_plus, full, (0, 0), Compression::DWAA(None))], origin), false), + ("layered_dwaa", write(vec![part(None, &layered, full, (0, 0), Compression::DWAA(None))], origin), false), + ] +} + +/// FNV-1a over 32-bit words. +fn fnv(words: impl IntoIterator) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for w in words { + for b in w.to_le_bytes() { + h ^= u64::from(b); + h = h.wrapping_mul(0x0100_0000_01b3); + } + } + h +} + +fn picture_hash(pool: &MediaPool, f: &Footage, float: FloatCurve, size: Option<(u32, u32)>) -> u64 { + let img = pool.frame_with(ItemId(1), f, Tick::ZERO, size, float).expect("picture"); + fnv([img.width, img.height].into_iter().chain(img.data.iter().flat_map(|p| p.map(f32::to_bits)))) +} + +fn aux_hash(pool: &MediaPool, f: &Footage) -> u64 { + let Some(aux) = pool.aux(ItemId(1), f, Tick::ZERO) else { return 0 }; + let mut words = vec![aux.width, aux.height, aux.scale.to_bits() as u32]; + for (name, plane) in &aux.channels { + words.extend(name.bytes().map(u32::from)); + words.extend(plane.iter().map(|v| v.to_bits())); + } + for (name, objects) in &aux.manifests { + words.extend(name.bytes().map(u32::from)); + for (o, h) in objects { + words.extend(o.bytes().map(u32::from)); + words.push(*h); + } + } + fnv(words) +} + +/// The interpretations a picture is decoded in: (label, footage, curve, size, pinned). +fn variants(f: &Footage) -> Vec<(String, Footage, FloatCurve, Option<(u32, u32)>, bool)> { + let mut out = vec![]; + for (mode, label) in [(None, "probed"), (Some(AlphaMode::Straight), "straight"), (Some(AlphaMode::Premultiplied), "premul"), (Some(AlphaMode::Ignore), "ignore")] { + let mut g = f.clone(); + if let Some(m) = mode { + g.alpha = m; + } + g.premul_color = [0.2, 0.4, 0.6]; + out.push((format!("{label}/linear"), g.clone(), FloatCurve::Linear, None, true)); + out.push((format!("{label}/srgb"), g.clone(), FloatCurve::Srgb, None, false)); + out.push((format!("{label}/gamma24"), g.clone(), FloatCurve::Gamma24, None, false)); + } + let preserve = Footage { preserve_rgb: true, ..f.clone() }; + out.push(("preserve_rgb".into(), preserve, FloatCurve::Srgb, None, true)); + out.push(("half_size/srgb".into(), f.clone(), FloatCurve::Srgb, Some((f.width.div_ceil(2), f.height.div_ceil(2))), false)); + out +} + +/// Every hash of `file`: the size and alpha import finds, each picture and the channels, decoded +/// picture first (the footage alone) or channels first (an effect reads them). +fn hashes(name: &str, bytes: &[u8], aux_first: bool) -> Vec<(String, u64, bool)> { + let order = if aux_first { "aux-first" } else { "picture-first" }; + let path = format!("/exr-golden-474/{order}/{name}.exr"); + let b: Arc<[u8]> = bytes.into(); + let pool = MediaPool::new(); + pool.add_bytes(&path, b.clone()); + let f = probe_bytes(&path, b).expect("probe"); + let mut out = vec![("probe".to_string(), fnv([f.width, f.height, f.alpha as u32]), true)]; + let aux = if aux_first { Some(aux_hash(&pool, &f)) } else { None }; + for (label, g, float, size, pinned) in variants(&f) { + out.push((label, picture_hash(&pool, &g, float, size), pinned)); + } + out.push(("aux".into(), aux.unwrap_or_else(|| aux_hash(&pool, &f)), true)); + out +} + +/// Hashes taken on main before #474's shared decode (`EXR_GOLDEN_PRINT=1`). +const PINNED: &[(&str, &str, u64)] = &[ + ("rgba_half_zip16", "probe", 0x0a32e074a28c1f66), + ("rgba_half_zip16", "probed/linear", 0xa84600283dce98f6), + ("rgba_half_zip16", "straight/linear", 0xf028e1367a2fbb30), + ("rgba_half_zip16", "premul/linear", 0xa84600283dce98f6), + ("rgba_half_zip16", "ignore/linear", 0xa90a57f9a67f7468), + ("rgba_half_zip16", "preserve_rgb", 0xc8e0050f5efe75b7), + ("rgba_half_zip16", "aux", 0xdd555e6cf67d75f3), + ("rgb_float_piz", "probe", 0x6a38346c4c5662d5), + ("rgb_float_piz", "probed/linear", 0xa6e8fbbad371a2ae), + ("rgb_float_piz", "straight/linear", 0xa6e8fbbad371a2ae), + ("rgb_float_piz", "premul/linear", 0x515f476c176229c9), + ("rgb_float_piz", "ignore/linear", 0xa6e8fbbad371a2ae), + ("rgb_float_piz", "preserve_rgb", 0xa6e8fbbad371a2ae), + ("rgb_float_piz", "aux", 0x554623d237c2e5ef), + ("layered_rle", "probe", 0x0a32e074a28c1f66), + ("layered_rle", "probed/linear", 0xa84600283dce98f6), + ("layered_rle", "straight/linear", 0xf028e1367a2fbb30), + ("layered_rle", "premul/linear", 0xa84600283dce98f6), + ("layered_rle", "ignore/linear", 0xa90a57f9a67f7468), + ("layered_rle", "preserve_rgb", 0xc8e0050f5efe75b7), + ("layered_rle", "aux", 0xa6b9bd9f02c192ad), + ("unnamed_rgb_plus_layers_zip1", "probe", 0x0a32e074a28c1f66), + ("unnamed_rgb_plus_layers_zip1", "probed/linear", 0xa84600283dce98f6), + ("unnamed_rgb_plus_layers_zip1", "straight/linear", 0xf028e1367a2fbb30), + ("unnamed_rgb_plus_layers_zip1", "premul/linear", 0xa84600283dce98f6), + ("unnamed_rgb_plus_layers_zip1", "ignore/linear", 0xa90a57f9a67f7468), + ("unnamed_rgb_plus_layers_zip1", "preserve_rgb", 0xc8e0050f5efe75b7), + ("unnamed_rgb_plus_layers_zip1", "aux", 0x27cc9b53d0862db1), + ("multipart_zip16", "probe", 0x0a32e074a28c1f66), + ("multipart_zip16", "probed/linear", 0xa84600283dce98f6), + ("multipart_zip16", "straight/linear", 0xf028e1367a2fbb30), + ("multipart_zip16", "premul/linear", 0xa84600283dce98f6), + ("multipart_zip16", "ignore/linear", 0xa90a57f9a67f7468), + ("multipart_zip16", "preserve_rgb", 0xc8e0050f5efe75b7), + ("multipart_zip16", "aux", 0x567ad99a6690d03d), + ("multipart_diffuse_then_beauty_zip1", "probe", 0x6a38346c4c5662d5), + ("multipart_diffuse_then_beauty_zip1", "probed/linear", 0xa90a57f9a67f7468), + ("multipart_diffuse_then_beauty_zip1", "straight/linear", 0xa90a57f9a67f7468), + ("multipart_diffuse_then_beauty_zip1", "premul/linear", 0x7ab18932e25c5cc8), + ("multipart_diffuse_then_beauty_zip1", "ignore/linear", 0xa90a57f9a67f7468), + ("multipart_diffuse_then_beauty_zip1", "preserve_rgb", 0xa90a57f9a67f7468), + ("multipart_diffuse_then_beauty_zip1", "aux", 0x6724d6dfd56a04cb), + ("multipart_blender5_rle", "probe", 0x0a32e074a28c1f66), + ("multipart_blender5_rle", "probed/linear", 0xa84600283dce98f6), + ("multipart_blender5_rle", "straight/linear", 0xf028e1367a2fbb30), + ("multipart_blender5_rle", "premul/linear", 0xa84600283dce98f6), + ("multipart_blender5_rle", "ignore/linear", 0xa90a57f9a67f7468), + ("multipart_blender5_rle", "preserve_rgb", 0xc8e0050f5efe75b7), + ("multipart_blender5_rle", "aux", 0x522ae4819dd92e89), + ("unnamed_rgb_window_smaller_zip16", "probe", 0x0a32e074a28c1f66), + ("unnamed_rgb_window_smaller_zip16", "probed/linear", 0x19eb3b1a88d108a7), + ("unnamed_rgb_window_smaller_zip16", "straight/linear", 0x2c73f9acaa936420), + ("unnamed_rgb_window_smaller_zip16", "premul/linear", 0x19eb3b1a88d108a7), + ("unnamed_rgb_window_smaller_zip16", "ignore/linear", 0x3b86057d18a16bf0), + ("unnamed_rgb_window_smaller_zip16", "preserve_rgb", 0x5b999014e4a64bb2), + ("unnamed_rgb_window_smaller_zip16", "aux", 0x8f78d3233450acf3), + ("rgb_float_window_larger_piz", "probe", 0x6a38346c4c5662d5), + ("rgb_float_window_larger_piz", "probed/linear", 0x8e3847d48f9967a6), + ("rgb_float_window_larger_piz", "straight/linear", 0x8e3847d48f9967a6), + ("rgb_float_window_larger_piz", "premul/linear", 0x0998eefc456dcdc9), + ("rgb_float_window_larger_piz", "ignore/linear", 0x8e3847d48f9967a6), + ("rgb_float_window_larger_piz", "preserve_rgb", 0x8e3847d48f9967a6), + ("rgb_float_window_larger_piz", "aux", 0x7ccc9f86e6ff6140), + ("layered_window_smaller_zip16", "probe", 0x0a32e074a28c1f66), + ("layered_window_smaller_zip16", "probed/linear", 0x19eb3b1a88d108a7), + ("layered_window_smaller_zip16", "straight/linear", 0x2c73f9acaa936420), + ("layered_window_smaller_zip16", "premul/linear", 0x19eb3b1a88d108a7), + ("layered_window_smaller_zip16", "ignore/linear", 0x3b86057d18a16bf0), + ("layered_window_smaller_zip16", "preserve_rgb", 0x5b999014e4a64bb2), + ("layered_window_smaller_zip16", "aux", 0x443480a1d789e63c), + ("layered_window_larger_piz", "probe", 0x0a32e074a28c1f66), + ("layered_window_larger_piz", "probed/linear", 0x443afe4029748836), + ("layered_window_larger_piz", "straight/linear", 0xb32b44880f746826), + ("layered_window_larger_piz", "premul/linear", 0x443afe4029748836), + ("layered_window_larger_piz", "ignore/linear", 0xaa01706accce2cc4), + ("layered_window_larger_piz", "preserve_rgb", 0xb2cbb40b10bc56b4), + ("layered_window_larger_piz", "aux", 0xb7d91c8eea56a772), +]; + +#[test] +fn exr_pictures_and_channels_are_bit_identical() { + let print = std::env::var_os("EXR_GOLDEN_PRINT").is_some(); + let mut failures = vec![]; + for (name, bytes, pinned_file) in files() { + let first = hashes(name, &bytes, false); + let second = hashes(name, &bytes, true); + for ((label, h, pinned), (_, h2, _)) in first.iter().zip(&second) { + if h != h2 { + failures.push(format!("{name} {label}: picture first {h:#018x}, channels first {h2:#018x}")); + } + if print { + println!(" (\"{name}\", \"{label}\", {h:#018x}),{}", if *pinned && pinned_file { "" } else { " // not pinned" }); + continue; + } + if !(*pinned && pinned_file) { + continue; + } + match PINNED.iter().find(|(n, l, _)| *n == name && l == label) { + Some((_, _, want)) if want == h => {} + Some((_, _, want)) => failures.push(format!("{name} {label}: {h:#018x}, pinned {want:#018x}")), + None => failures.push(format!("{name} {label}: {h:#018x} is not pinned")), + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs index 69bed4a9f..08c5ec389 100644 --- a/crates/media/tests/it/main.rs +++ b/crates/media/tests/it/main.rs @@ -3,6 +3,7 @@ mod aac_layout; mod downmix; mod exif_orientation; +mod exr_golden; mod footage; mod open_in_place; mod tga;