diff --git a/Cargo.lock b/Cargo.lock index 8aafd0039e..99bcea48dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4293,6 +4293,7 @@ dependencies = [ "png 0.18.1", "proptest", "rayon", + "serde_json", "thiserror 2.0.21", "tiff", "zune-core 0.5.3", diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 56673c1033..a55dcf1213 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -25,6 +25,7 @@ png = "0.18" tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] } exr = { version = "1.73", default-features = false } half = "2" +serde_json.workspace = true flate2.workspace = true image-webp = "0.2" zune-jpeg = "0.5" diff --git a/crates/codecs/README.md b/crates/codecs/README.md index af6b4b776a..1795be17bb 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -160,6 +160,12 @@ the same. * Reads at full resolution, from the data window. * Channel names are matched by suffix, so `layer.R` counts as `R`. * Subsampled channels are unsupported. + * Cryptomatte layers (specification 1.2, as written by Arnold, V-Ray, Redshift, Mantra/Karma + and Cycles) are listed and decoded structurally: `cryptomatte_layers` reads the header + attributes, channels and embedded manifest without pixels, `decode_cryptomatte` returns the + per-pixel (ID, coverage) pairs, and `cryptomatte_id`/`cryptomatte_key`/`cryptomatte_preview_color` + hash names to IDs the way the reference implementation does (official test vectors in the + test suite). * Deep data (scanlines or single-level tiles, compressed NONE, RLE or ZIPS) opens as a flat image: each pixel's samples are composited into one, with `DecodeWarning::DeepFlattened` because per-pixel depth is not kept. Deep ZIP (16-line diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs new file mode 100644 index 0000000000..f28c57ab47 --- /dev/null +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -0,0 +1,249 @@ +//! Cryptomatte (specification 1.2): per-pixel object/material IDs with coverage in EXR +//! channels, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles. Layers are +//! announced by `cryptomatte//name|hash|conversion|manifest` header attributes; the +//! samples live in `NN.red` (ID) / `NN.green` (coverage) channel pairs. +//! +//! IDs are the float *values*: MurmurHash3 x86-32 (seed 0) of the object name, its +//! exponent fixed away from 0/255 so no ID is denormal, infinite or NaN, read as an f32 +//! bit pattern. Everything here compares IDs by value, never by bit pattern. + +use std::io::Cursor; + +use exr::meta::MetaData; +use exr::meta::attribute::AttributeValue; +use exr::prelude::{ReadChannels, ReadLayers, read}; + +use crate::Format; +use crate::error::CodecError; +use crate::image::{CryptomatteBuffer, CryptomatteLayer}; +use crate::options::Limits; + +const F: Format = Format::OpenExr; + +/// A Cryptomatte stream channel (`crypto_asset00.red`) splits into its stream name +/// (`crypto_asset00`), the layer prefix without digits (`crypto_asset`), the stream index, +/// and whether it is the coverage (green) channel instead of the ID (red). +pub(crate) struct StreamChannel { + pub stream: String, + pub index: u32, + pub coverage: bool, +} + +/// `NN` where NN is one or more digits (the reference readers match `\d+`), with a +/// `.red`/`.r` (ID) or `.green`/`.g` (coverage) suffix. +pub(crate) fn split_stream(channel: &str) -> Option { + let (stream, suffix) = channel.rsplit_once('.')?; + let coverage = match suffix { + "red" | "r" => false, + "green" | "g" => true, + _ => return None, + }; + let digits = stream.chars().rev().take_while(|c| c.is_ascii_digit()).count(); + if digits == 0 || digits == stream.len() { + return None; + } + let split = stream.len() - digits; + let index = stream[split..].parse().ok()?; + Some(StreamChannel { stream: stream.to_string(), index, coverage }) +} + +/// MurmurHash3, x86-32 bit variant, seed 0 (public-domain algorithm, Austin Appleby). +fn murmur3_32(data: &[u8]) -> u32 { + const C1: u32 = 0xcc9e_2d51; + const C2: u32 = 0x1b87_3593; + let mut h = 0u32; + let (chunks, rest) = data.as_chunks::<4>(); + for c in chunks { + let mut k = u32::from_le_bytes(*c); + k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2); + h ^= k; + h = h.rotate_left(13).wrapping_mul(5).wrapping_add(0xe654_6b64); + } + if !rest.is_empty() { + let mut k = [0u8; 4]; + k[..rest.len()].copy_from_slice(rest); + let mut k = u32::from_le_bytes(k); + k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2); + h ^= k; + } + h ^= data.len() as u32; + h ^= h >> 16; + h = h.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + h ^ (h >> 16) +} + +/// The Cryptomatte ID of a name: the MurmurHash3 bits with the exponent fixed away from +/// 0 and 255, as an f32 value. +pub fn cryptomatte_id(name: &str) -> f32 { + let bits = murmur3_32(name.as_bytes()); + let exp = (bits >> 23) & 0xff; + let bits = if exp == 0 || exp == 255 { bits ^ (1 << 23) } else { bits }; + f32::from_bits(bits) +} + +/// The 7-hex-digit metadata key of a layer name (the ID bits without their last digit). +pub fn cryptomatte_key(name: &str) -> String { + format!("{:08x}", cryptomatte_id(name).to_bits())[..7].to_string() +} + +/// The preview colour of an ID (the reference formula): green and blue channels shifted +/// out of the ID's bit pattern, red unused. +pub fn cryptomatte_preview_color(id: f32) -> [f32; 3] { + let bits = id.to_bits(); + [0.0, f32::from_bits(bits << 8), f32::from_bits(bits << 16)] +} + +/// The `cryptomatte//` text attribute of a header, if present. +fn attribute(other: &std::collections::HashMap, key: &str, field: &str) -> Option { + let full = exr::meta::attribute::Text::new_or_none(format!("cryptomatte/{key}/{field}").as_str())?; + match other.get(&full)? { + AttributeValue::Text(t) => Some(t.to_string()), + _ => None, + } +} + +/// Parse a manifest JSON (`{"object name": "<8-hex ID bits>"}`) into (ID value, name) +/// pairs. Entries that are not strings of up to eight hex digits are skipped, like the +/// reference readers tolerate a partially broken manifest; a broken document yields none. +fn parse_manifest(json: &str) -> Vec<(f32, String)> { + let Ok(value) = serde_json::from_str::(json) else { return Vec::new() }; + let Some(map) = value.as_object() else { return Vec::new() }; + let mut out = Vec::with_capacity(map.len().min(1 << 20)); + for (name, hex) in map { + let Some(hex) = hex.as_str() else { continue }; + let Some(bits) = u32::from_str_radix(hex.trim_start_matches("0x"), 16).ok() else { continue }; + out.push((f32::from_bits(bits), name.clone())); + } + out +} + +/// The stream channel names of a layer (`crypto_asset00`, `crypto_asset01`, …, exactly as +/// the file spells them), matched leniently: the channel prefix and the `name` attribute +/// differ in case or `_`/`-` between renderers (`CryptoAsset` vs `crypto_asset`). +fn layer_streams(header: &exr::meta::header::Header, name: &str) -> Vec { + let normalize = |s: &str| s.to_ascii_lowercase().replace(['_', '-'], ""); + let want = normalize(name); + let mut found: Vec<(u32, String)> = header + .channels + .list + .iter() + .filter_map(|c| { + let s = split_stream(&c.name.to_string())?; + (!s.coverage).then_some((s.index, s.stream)) + }) + .filter(|(_, stream)| normalize(stream.trim_end_matches(|c: char| c.is_ascii_digit())) == want) + .collect(); + found.sort(); + found.dedup(); + found.into_iter().map(|(_, stream)| stream).collect() +} + +/// Lists every Cryptomatte layer of the file from the headers alone: what a Cryptomatte +/// picker offers. The embedded manifest (capped, JSON) is parsed into (ID, name) pairs. +pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result, CodecError> { + let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?; + // Manifests can be large; bound the embedded text before parsing anything. + const MANIFEST_CAP: usize = 16 << 20; + let mut out = Vec::new(); + for (part, header) in meta.headers.iter().enumerate() { + let size = header.layer_size; + let (w, h) = (u32::try_from(size.0).unwrap_or(u32::MAX), u32::try_from(size.1).unwrap_or(u32::MAX)); + limits.check_bytes(w, h, 8)?; + for key in cryptomatte_keys(&header.own_attributes.other) { + let Some(name) = attribute(&header.own_attributes.other, &key, "name") else { continue }; + let channels = layer_streams(header, name.as_str()); + if channels.is_empty() { + continue; + } + let manifest = attribute(&header.own_attributes.other, &key, "manifest") + .filter(|m| m.len() <= MANIFEST_CAP) + .map(|m| parse_manifest(m.as_str())) + .unwrap_or_default(); + out.push(CryptomatteLayer { + part, + name, + key: key.clone(), + conversion: attribute(&header.own_attributes.other, &key, "conversion"), + manifest, + sidecar_manifest: attribute(&header.own_attributes.other, &key, "manif_file"), + channels, + }); + } + } + out.sort_by(|a, b| (&a.part, &a.name).cmp(&(&b.part, &b.name))); + Ok(out) +} + +/// Every `cryptomatte//` key of a header, in file order, without duplicates. +fn cryptomatte_keys(other: &std::collections::HashMap) -> Vec { + let mut keys = Vec::new(); + for name in other.keys() { + let full = name.to_string(); + let Some(rest) = full.strip_prefix("cryptomatte/") else { continue }; + let Some((key, _field)) = rest.split_once('/') else { continue }; + if !key.is_empty() && !keys.iter().any(|k| k == key) { + keys.push(key.to_string()); + } + } + keys +} + +/// Decodes the samples of the Cryptomatte layer called `layer_name` (a +/// [`CryptomatteLayer::name`]): per pixel its (ID, coverage) pairs, coverage above zero, +/// sorted by descending coverage. Refuses files that also contain deep parts, like the +/// other flat part decoders. +pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result { + let found = layers(bytes, limits)?; + let layer = found + .iter() + .find(|l| l.name.eq_ignore_ascii_case(layer_name)) + .ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?; + let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?; + if meta.requirements.has_deep_data { + return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files")); + } + let header = meta.headers.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?; + let size = header.layer_size; + let (w, h) = (size.0, size.1); + let npx = w.checked_mul(h).ok_or_else(|| CodecError::malformed(F, "the layer is too large"))?; + let too_large = || CodecError::malformed(F, "the layer is too large"); + let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?); + // Each pixel also gets a pair list, so count its header with the channel samples. + let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::>() as u64); + limits.check_bytes(w32, h32, per_pixel)?; + + let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err( + |e| match e { + exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")), + e => CodecError::malformed(F, e), + }, + )?; + let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?; + + let mut ids: Vec> = vec![Vec::new(); layer.channels.len()]; + let mut coverages: Vec> = vec![Vec::new(); layer.channels.len()]; + for c in &ex_layer.channel_data.list { + let Some(stream) = split_stream(&c.name.to_string()) else { continue }; + let Some(slot) = layer.channels.iter().position(|s| *s == stream.stream) else { continue }; + let values: Vec = c.sample_data.values_as_f32().collect(); + if values.len() != npx { + return Err(CodecError::malformed(F, "a Cryptomatte channel is smaller than its layer")); + } + let bucket = if stream.coverage { &mut coverages } else { &mut ids }; + bucket[slot] = values; + } + + let mut pixels = vec![Vec::new(); npx]; + for (p, out) in pixels.iter_mut().enumerate() { + for (id, cov) in ids.iter().zip(&coverages) { + let (id, cov) = (id.get(p).copied().unwrap_or(0.0), cov.get(p).copied().unwrap_or(0.0)); + if cov > 0.0 && id.is_finite() { + out.push((id, cov)); + } + } + out.sort_by(|a, b| b.1.total_cmp(&a.1)); + } + Ok(CryptomatteBuffer { width: w32, height: h32, pixels }) +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 80cacb2766..3a6352ed82 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod deep_exr; pub(crate) mod exr; +pub(crate) mod exr_cryptomatte; pub(crate) mod heif; pub(crate) mod jpeg; pub(crate) mod png; diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index c0323b612f..01f5d4cad0 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -277,6 +277,55 @@ pub(crate) fn base_channel_name(name: &str) -> &str { name.rsplit('.').next().unwrap_or(name) } +/// One Cryptomatte layer of an EXR file (Cryptomatte specification 1.2): per-pixel object or +/// material IDs with coverage, as written by Arnold, V-Ray, Redshift, Mantra/Karma or Cycles. +/// Header facts plus the parsed manifest; the samples come from +/// [`crate::decode_cryptomatte`]. +#[derive(Debug, Clone, PartialEq)] +pub struct CryptomatteLayer { + /// The part (see [`crate::exr_info`]) the channels and attributes live in. + pub part: usize, + /// The `cryptomatte//name` attribute, e.g. `CryptoAsset` or `crypto_asset`. + pub name: String, + /// The 7-hex-digit metadata key of the layer. + pub key: String, + /// The `conversion` attribute, when recorded (canonically `uint32_to_float32`). + pub conversion: Option, + /// The manifest: object names with their ID values. A sidecar manifest file is only + /// recorded in [`Self::sidecar_manifest`] and not read here (the codec is I/O free). + pub manifest: Vec<(f32, String)>, + /// The `manif_file` sidecar attribute, when the manifest is not embedded. + pub sidecar_manifest: Option, + /// The stream channel bases in file order, e.g. `["crypto_asset00", "crypto_asset01"]`; + /// each holds an ID (`.red`) and a coverage (`.green`) channel. + pub channels: Vec, +} + +impl CryptomatteLayer { + /// The manifest name for an ID value, when the manifest lists it. + pub fn name_of(&self, id: f32) -> Option<&str> { + self.manifest.iter().find(|(i, _)| *i == id).map(|(_, n)| n.as_str()) + } +} + +/// Decoded Cryptomatte samples: per pixel the (ID, coverage) pairs with coverage above zero, +/// sorted by descending coverage. The IDs are the float values as stored (compared by value, +/// never by bit pattern, like the reference readers). +#[derive(Debug, Clone, PartialEq)] +pub struct CryptomatteBuffer { + pub width: u32, + pub height: u32, + pub pixels: Vec>, +} + +impl CryptomatteBuffer { + /// The pairs of one pixel; empty for a pixel covered by nothing. + pub fn at(&self, x: u32, y: u32) -> &[(f32, f32)] { + let i = y as usize * self.width as usize + x as usize; + self.pixels.get(i).map_or(&[], |v| v) + } +} + /// A single flat raster image. #[derive(Debug, Clone, PartialEq)] pub struct Image { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index e1b240ba47..4ad8a0ef3e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -23,13 +23,16 @@ pub mod orientation; pub mod resolution; pub mod web; +pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::png::encode_indexed as encode_png_indexed; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension}; -pub use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType}; +pub use crate::image::{ + ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, +}; pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; @@ -145,6 +148,21 @@ pub fn decode_exr_part(bytes: &[u8], part: usize, opts: &DecodeOptions) -> Resul finish_decode(img, 1, opts) } +/// Lists every Cryptomatte layer of an EXR file (specification 1.2: object/material IDs +/// with coverage, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles) from the +/// headers alone: name, metadata key, channels and the parsed embedded manifest. The +/// samples come from [`decode_cryptomatte`]; names hash to IDs with [`cryptomatte_id`]. +pub fn cryptomatte_layers(bytes: &[u8], limits: &Limits) -> Result, CodecError> { + codecs::exr_cryptomatte::layers(bytes, limits) +} + +/// Decodes the samples of the Cryptomatte layer called `layer_name` (a +/// [`CryptomatteLayer::name`]): per pixel its (ID, coverage) pairs, coverage above zero, +/// sorted by descending coverage. +pub fn decode_cryptomatte(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result { + codecs::exr_cryptomatte::decode(bytes, layer_name, limits) +} + /// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists /// (colour, alpha and Z per sample), for callers that composite deep data themselves — /// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a diff --git a/crates/codecs/tests/exr_cryptomatte.rs b/crates/codecs/tests/exr_cryptomatte.rs new file mode 100644 index 0000000000..f5f76c3adb --- /dev/null +++ b/crates/codecs/tests/exr_cryptomatte.rs @@ -0,0 +1,221 @@ +//! Cryptomatte (specification 1.2): the hash function is checked against the official +//! test vectors from the reference repository, synthetic files carry ID/coverage streams +//! with header attributes and a manifest, and the info/decode tests check the listing, +//! the manifest round trip and the per-pixel pairs. Malformed inputs must error or +//! degrade, never panic. + +use exr::meta::attribute::{AttributeValue, Text}; +use exr::prelude::{ + AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, ImageAttributes, IntegerBounds, Layer, LayerAttributes, LineOrder, WritableImage, +}; +use photocraft_codecs::{Limits, cryptomatte_id, cryptomatte_key, cryptomatte_layers, cryptomatte_preview_color, decode_cryptomatte}; + +// ------------------------------------------------------------------------------------------- +// The official hash test vectors (cryptomatte_utilities_tests.py, CryptoHashing). + +#[test] +// The values are quoted verbatim from the reference test suite; their extra digits are +// intentional documentation, not a precision mistake. +#[allow(clippy::excessive_precision)] +fn mm3_hash_matches_the_official_vectors() { + // The values are the f32 IDs the reference implementation produces (single precision). + let vectors: &[(&str, f64)] = &[ + ("hello", 6.0705627102400005616e-17), + ("cube", -4.08461912519e+15), + ("sphere", 2.79018604383e+15), + ("plane", 3.66557617593e-11), + ("равнина", -1.3192631212399999468e-25), + ("mädchen", 6.2361298211599995797e+25), + ]; + for (name, want) in vectors { + let got = cryptomatte_id(name); + assert_eq!(got.to_bits(), (*want as f32).to_bits(), "ID bits of {name:?}"); + } +} + +#[test] +fn every_id_is_a_finite_normal_float() { + // The exponent fix keeps IDs away from denormals, infinity and NaN, whatever the name. + for name in ["", "a", "hello", "CryptoAsset", "very long object name with ünïcödé"] { + let id = cryptomatte_id(name); + assert!(id.is_finite() && id.abs() >= f32::MIN_POSITIVE, "{name:?} -> {id}"); + } +} + +#[test] +fn key_is_seven_hex_digits_of_the_id() { + let key = cryptomatte_key("CryptoAsset"); + assert_eq!(key.len(), 7); + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); + let full = format!("{:08x}", cryptomatte_id("CryptoAsset").to_bits()); + assert_eq!(key, full[..7]); +} + +#[test] +fn preview_color_shifts_the_id_bits() { + let id = cryptomatte_id("cube"); + let [r, g, b] = cryptomatte_preview_color(id); + assert_eq!((r, g, b), (0.0, f32::from_bits(id.to_bits() << 8), f32::from_bits(id.to_bits() << 16))); +} + +// ------------------------------------------------------------------------------------------- +// Synthetic Cryptomatte files. + +fn text(s: &str) -> Text { + Text::new_or_panic(s) +} + +/// A 4x4 EXR with one Cryptomatte layer `crypto_asset` of two streams: pixel (x, y) holds +/// `cube` (coverage 0.6) and `sphere` (coverage 0.25) where the streams' values say so. +fn cryptomatte_file(name: &str) -> Vec { + let (w, h) = (4usize, 4usize); + let cube = cryptomatte_id("cube"); + let sphere = cryptomatte_id("sphere"); + // Stream 00: cube everywhere at 0.6; stream 01: sphere only in the first column. + let s0: Vec = vec![cube; w * h]; + let s0c: Vec = vec![0.6; w * h]; + let s1: Vec = (0..w * h).map(|p| if p % w == 0 { sphere } else { 0.0 }).collect(); + let s1c: Vec = (0..w * h).map(|p| if p % w == 0 { 0.25 } else { 0.0 }).collect(); + let mk = |n: String, v: Vec| AnyChannel::new(n.as_str(), FlatSamples::F32(v)); + let list = vec![mk(format!("{name}00.red"), s0), mk(format!("{name}00.green"), s0c), mk(format!("{name}01.red"), s1), mk(format!("{name}01.green"), s1c)]; + let key = cryptomatte_key("CryptoAsset"); // attributes always use the canonical name + let manifest = format!("{{\"cube\":\"{:08x}\",\"sphere\":\"{:08x}\"}}", cube.to_bits(), sphere.to_bits()); + let mut attrs = LayerAttributes::named(name); + attrs.other.insert(text(&format!("cryptomatte/{key}/name")), AttributeValue::Text(text("CryptoAsset"))); + attrs.other.insert(text(&format!("cryptomatte/{key}/hash")), AttributeValue::Text(text(&key))); + attrs.other.insert(text(&format!("cryptomatte/{key}/conversion")), AttributeValue::Text(text("uint32_to_float32"))); + attrs.other.insert(text(&format!("cryptomatte/{key}/manifest")), AttributeValue::Text(text(&manifest))); + let layer = Layer::new( + (w, h), + attrs, + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).expect("synthetic cryptomatte exr writes"); + out.into_inner() +} + +/// The same layer, but with the short `.r`/`.g` channel suffixes Nuke also matches. +fn cryptomatte_file_short_suffix() -> Vec { + let (w, h) = (4usize, 4usize); + let id = cryptomatte_id("cube"); + let mk = |n: String, v: Vec| AnyChannel::new(n.as_str(), FlatSamples::F32(v)); + let list = vec![mk("crypto_asset00.r".into(), vec![id; w * h]), mk("crypto_asset00.g".into(), vec![1.0; w * h])]; + let key = cryptomatte_key("CryptoAsset"); + let mut attrs = LayerAttributes::named("crypto_asset"); + attrs.other.insert(text(&format!("cryptomatte/{key}/name")), AttributeValue::Text(text("CryptoAsset"))); + let layer = Layer::new( + (w, h), + attrs, + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).expect("synthetic cryptomatte exr writes"); + out.into_inner() +} + +#[test] +fn layers_list_name_streams_and_manifest() { + let bytes = cryptomatte_file("crypto_asset"); + let found = cryptomatte_layers(&bytes, &Limits::default()).unwrap(); + assert_eq!(found.len(), 1); + let layer = &found[0]; + assert_eq!(layer.part, 0); + assert_eq!(layer.name, "CryptoAsset"); + assert_eq!(layer.key, cryptomatte_key("CryptoAsset")); + assert_eq!(layer.conversion.as_deref(), Some("uint32_to_float32")); + assert_eq!(layer.channels, ["crypto_asset00", "crypto_asset01"]); + // The manifest maps names to their hash IDs, hex bits -> float value. + assert_eq!(layer.manifest.len(), 2); + let cube = cryptomatte_id("cube"); + let sphere = cryptomatte_id("sphere"); + assert!(layer.manifest.contains(&(cube, "cube".to_string()))); + assert!(layer.manifest.contains(&(sphere, "sphere".to_string()))); + assert_eq!(layer.name_of(cube), Some("cube")); +} + +#[test] +fn decode_gives_the_pairs_per_pixel_sorted_by_coverage() { + let bytes = cryptomatte_file("crypto_asset"); + let buf = decode_cryptomatte(&bytes, "CryptoAsset", &Limits::default()).unwrap(); + assert_eq!((buf.width, buf.height), (4, 4)); + let cube = cryptomatte_id("cube"); + let sphere = cryptomatte_id("sphere"); + for y in 0..4 { + // First column: cube (0.6) over sphere (0.25), sorted by coverage. + let both = buf.at(0, y); + assert_eq!(both, &[(cube, 0.6), (sphere, 0.25)], "column 0, row {y}"); + // Other columns: cube only. + for x in 1..4 { + assert_eq!(buf.at(x, y), &[(cube, 0.6)], "({x}, {y})"); + } + } +} + +#[test] +fn short_channel_suffixes_decode_too() { + let bytes = cryptomatte_file_short_suffix(); + let buf = decode_cryptomatte(&bytes, "CryptoAsset", &Limits::default()).unwrap(); + let cube = cryptomatte_id("cube"); + assert_eq!(buf.at(3, 3), &[(cube, 1.0)]); +} + +#[test] +fn unknown_layer_name_is_unsupported() { + let bytes = cryptomatte_file("crypto_asset"); + let e = decode_cryptomatte(&bytes, "CryptoMaterial", &Limits::default()).unwrap_err(); + assert!(e.to_string().contains("no Cryptomatte layer"), "{e}"); +} + +#[test] +fn plain_exr_has_no_cryptomatte_layers() { + // A normal RGB file (the multi-part fixture style) lists nothing. + let (w, h) = (2usize, 2usize); + let mk = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let list = vec![mk("R", 0.5), mk("G", 0.5), mk("B", 0.5)]; + let layer = Layer::new( + (w, h), + LayerAttributes::named("beauty"), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).expect("synthetic exr writes"); + assert!(cryptomatte_layers(&out.into_inner(), &Limits::default()).unwrap().is_empty()); +} + +#[test] +fn a_broken_manifest_degrades_to_empty_not_a_panic() { + let mut bytes = cryptomatte_file("crypto_asset"); + // Corrupt the embedded manifest text (keep the attribute length: overwrite with '#'). + let needle = b"{\"cube\":"; + let at = bytes.windows(needle.len()).position(|w| w == needle).expect("manifest json"); + for b in &mut bytes[at..at + 8] { + *b = b'#'; + } + let found = cryptomatte_layers(&bytes, &Limits::default()).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].manifest.is_empty(), "a broken manifest yields no entries"); + // The samples still decode. + let buf = decode_cryptomatte(&bytes, "CryptoAsset", &Limits::default()).unwrap(); + assert_eq!(buf.at(1, 0).len(), 1); +} + +#[test] +fn hostile_input_never_panics() { + let bytes = cryptomatte_file("crypto_asset"); + for end in (8..bytes.len()).step_by(7) { + let cut = &bytes[..end]; + let _ = cryptomatte_layers(cut, &Limits::default()); + let _ = decode_cryptomatte(cut, "CryptoAsset", &Limits::default()); + let _ = decode_cryptomatte(cut, "nothing", &Limits::default()); + } + // Tiny limits refuse before allocating. + let e = cryptomatte_layers(&bytes, &Limits { max_alloc: 8, ..Limits::default() }).unwrap_err(); + assert!(e.to_string().contains("exceed"), "{e}"); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index b547ef3ba1..5c669f1ab8 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1026,10 +1026,8 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option