From 698ade94ac8d9017ae6b9a6680127928a109a8d3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 23:10:32 +0700 Subject: [PATCH] Import: layered EXRs show the beauty pass, never Cryptomatte (#412) Cause: a multi-layer OpenEXR without an unnamed RGB layer showed its first colour layer unless one was named combined / beauty / rgba. A Blender compositor File Output names the beauty pass "Image", and its Cryptomatte layers (lowercase r, g, b, a) sort first, so the picture was CryptoMaterial00. Fix: the picture is the unnamed RGB layer, else the beauty pass by the last part of its name (Combined, Beauty, Image, RGBA, RGB, Color), else the first colour layer that isn't a data pass (Cryptomatte, depth, mist, normals, vectors, positions, IDs). A layer without R, G and B shows its own first channel as grey. After Effects shows a file's main RGBA and leaves the other layers to EXtractoR, which is unchanged. Tests: exr_channels::layered_exr_shows_the_beauty_pass_not_cryptomatte (the reported Blender layout, ViewLayer.Combined, no beauty name, data-only files). Co-Authored-By: Claude Opus 5.5 --- crates/media/src/exr_channels.rs | 101 +++++++++++++++++++++++++++---- docs/agents.md | 4 +- docs/footage.md | 10 +++ docs/parity.md | 2 +- 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 00c6fd70..93836dcc 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_raster::AuxChannels; -use effectcraft_raster::channels3d::BACKGROUND_DEPTH; +use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; fn is_depth(name: &str) -> bool { let last = name.rsplit('.').next().unwrap_or(name); @@ -94,13 +94,23 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { Some(aux) } -/// The picture of a multi-layer OpenEXR file without an unnamed RGB layer (Blender's -/// `ViewLayer.Combined.R`, Nuke's `beauty.R`, …), which the image decoder rejects: its colour -/// layer (preferring one named combined, beauty or rgba), else its first channel as grey. -/// Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR. -pub fn layered_image(bytes: &[u8]) -> Option { - let aux = read_exr_channels(bytes)?; - let layers = aux.layers(); +/// Names of the beauty pass, most preferred first, matched against the last part of a layer's +/// name (Blender's `ViewLayer.Combined` is `Combined`): Blender's Combined pass and its +/// compositor's File Output default `Image`, the beauty / rgba / rgb / color of Nuke, Arnold and +/// V-Ray. +const BEAUTY: [&str; 7] = ["combined", "beauty", "image", "rgba", "rgb", "color", "colour"]; + +/// A data pass, never the picture: Cryptomatte (`CryptoMaterial00`), depth, mist, normals, +/// motion vectors, positions, UVs and object / material indices. +fn is_data_layer(layer: &str) -> bool { + let last = layer.rsplit('.').next().unwrap_or(layer).to_ascii_lowercase(); + last.starts_with("crypto") + || ["z", "uv", "id", "mist", "indexob", "indexma"].contains(&last.as_str()) + || ["depth", "normal", "vector", "position", "velocity", "motion", "objectid", "materialid"].iter().any(|k| last.contains(k)) +} + +/// The layer whose colour a multi-layer file shows (see [`layered_image`]). +fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> { // Colour layers: three different channels (not one channel, such as depth, shown in all). let rgb: Vec<&str> = layers .iter() @@ -110,13 +120,33 @@ pub fn layered_image(bytes: &[u8]) -> Option { !r.is_empty() && !g.is_empty() && !b.is_empty() && r != g }) .collect(); - let colour = rgb - .iter() - .find(|l| ["combined", "beauty", "rgba"].iter().any(|k| l.to_ascii_lowercase().contains(k))) + let last = |l: &str| l.rsplit('.').next().unwrap_or(l).to_ascii_lowercase(); + let pictures = || rgb.iter().filter(|l| !is_data_layer(l)); + // The unnamed layer (plain R, G, B), then the beauty pass by name. + rgb.iter() + .find(|l| l.is_empty()) + .or_else(|| BEAUTY.iter().find_map(|k| rgb.iter().find(|l| last(l) == *k))) + .or_else(|| pictures().find(|l| ["combined", "beauty"].iter().any(|k| last(l).contains(k)))) + .or_else(|| pictures().next()) + .or_else(|| layers.iter().find(|l| !is_data_layer(l))) .or_else(|| rgb.first()) - .or_else(|| layers.first())?; + .or_else(|| layers.first()) + .copied() +} + +/// The picture of a multi-layer OpenEXR file without an unnamed RGB layer (Blender's +/// `ViewLayer.Combined.R`, the compositor's `Image.R`, Nuke's `beauty.R`, …), which the image +/// decoder rejects. After Effects shows a file's main RGBA channels and leaves the other layers +/// to EXtractoR; here that is the beauty pass by name ([`BEAUTY`]), else the first colour layer +/// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel +/// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR. +pub fn layered_image(bytes: &[u8]) -> Option { + let aux = read_exr_channels(bytes)?; + let layers = aux.layers(); + let colour = picture_layer(&aux, &layers)?; let names = aux.layer_rgba(colour); - let first = aux.channels.first().map(|(n, _)| n.as_str()).unwrap_or_default(); + // A layer without R, G and B channels shows its first channel as grey. + let first = aux.names().into_iter().find(|n| split_channel(n).0 == colour).unwrap_or_default(); let plane = |k: usize| -> Option<&[f32]> { aux.get(names.get(k).map(String::as_str).filter(|n| !n.is_empty()).unwrap_or(first)) }; let (r, g, b) = (plane(0)?, plane(1)?, plane(2)?); let a = names.get(3).filter(|n| !n.is_empty()).and_then(|n| aux.get(n)); @@ -213,4 +243,49 @@ mod tests { assert_eq!(aux.layers(), ["depth", "diffuse"]); assert_eq!(aux.layer_rgba("diffuse"), ["diffuse.R", "diffuse.G", "diffuse.B", ""].map(String::from)); } + + /// Straight RGBA of [`layered_image`] at pixel 0 for a file of single-value channels. + fn picture>(channels: &[(S, f32)]) -> Vec { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let list: Vec> = channels.iter().map(|(n, v)| AnyChannel::new(n.as_ref(), FlatSamples::F32(vec![*v; w * h]))).collect(); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, AnyChannels::sort(list.into()))); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let img = layered_image(bytes.get_ref()).unwrap().to_rgba32f(); + img.get_pixel(0, 0).0.to_vec() + } + + /// #412: a Blender compositor File Output (multilayer) names its beauty pass `Image`, and its + /// Cryptomatte layers (lowercase `r, g, b, a`) sort first. The picture was CryptoMaterial00; + /// it is the Image layer, and data passes are never the picture. + #[test] + fn layered_exr_shows_the_beauty_pass_not_cryptomatte() { + let mut blender = vec![]; + for layer in ["CryptoMaterial00", "CryptoMaterial01", "CryptoMaterial02"] { + blender.extend(["r", "g", "b", "a"].map(|c| (format!("{layer}.{c}"), 0.9))); + } + for (layer, v) in [("GlossCol", 0.3), ("GlossDir", 0.4), ("Image", 0.5), ("VolDir", 0.6)] { + blender.extend(["R", "G", "B", "A"].map(|c| (format!("{layer}.{c}"), v))); + } + blender.push(("Mist.Z".into(), 0.7)); + assert_eq!(picture(&blender), [0.5; 4], "the Image layer"); + // Blender's render-layer names and Nuke's beauty. + assert_eq!( + picture(&[ + ("ViewLayer.CryptoObject00.r", 0.9), + ("ViewLayer.CryptoObject00.g", 0.9), + ("ViewLayer.CryptoObject00.b", 0.9), + ("ViewLayer.Combined.R", 0.2), + ("ViewLayer.Combined.G", 0.2), + ("ViewLayer.Combined.B", 0.2) + ]), + [0.2, 0.2, 0.2, 1.0] + ); + // No beauty name: the first colour layer that isn't a data pass. + let no_beauty: Vec<(String, f32)> = blender.iter().filter(|(n, _)| !n.starts_with("Image")).cloned().collect(); + assert_eq!(picture(&no_beauty), [0.3; 4], "GlossCol, not Cryptomatte"); + // Only data and a grey pass: the grey pass, not depth. + assert_eq!(picture(&[("Depth.Z", 9.0), ("AO.Y", 0.25)]), [0.25, 0.25, 0.25, 1.0]); + } } diff --git a/docs/agents.md b/docs/agents.md index 71d52afe..a023154a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -219,7 +219,9 @@ build real projects through these interfaces; they are worked examples of everyt colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new length). See [footage.md](footage.md). -* **Multi-layer OpenEXR**: `layer.channels {layer}` lists a footage layer's EXR layers and +* **Multi-layer OpenEXR**: the footage shows the beauty pass (an unnamed RGBA layer, else + Combined / Beauty / Image / RGBA…, never Cryptomatte or depth). `layer.channels {layer}` + lists a footage layer's EXR layers and channels (`{"layers":[{"name":"diffuse","rgba":["diffuse.R","diffuse.G","diffuse.B",""]}]}`); EXtractoR shows them when its `red` / `green` / `blue` / `alpha` are set to those names (`prop.set {"path":"effects/#1/red","value":"depth.Z"}`). Effect Controls offers them as popups. diff --git a/docs/footage.md b/docs/footage.md index 82558484..3595e983 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -74,6 +74,16 @@ Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premul "startTimecode": 1001}` (timecode or a frame number; `"overrideStart": false` goes back to Use Source File Timecode). +## Multi-layer OpenEXR + +Float OpenEXR renders hold linear light; EffectCraft sRGB-encodes them as they are read, like +After Effects with Interpret As Linear Light for 32 bpc. +A multi-layer file shows its main RGBA channels, as in After Effects. Renders that keep every +pass in named layers (Blender's `ViewLayer.Combined`, its compositor's File Output `Image`, +Nuke's `beauty`) show the beauty pass: the layer named Combined, Beauty, Image, RGBA, RGB or +Color, else the first colour layer that isn't a data pass. Cryptomatte, depth, mist, normals, +vectors, positions and IDs are never the picture. EXtractoR reads every other layer and channel. + ## Replacing and reloading - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered diff --git a/docs/parity.md b/docs/parity.md index c89bee97..0d52b90b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -190,7 +190,7 @@ Mocha and Cineware are third-party and not counted. | Category | Effects | Notes | |---|---|---| -| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header; EXtractoR picks a layer and channels from popups of the footage's layers and channels, `layer.channels`, #295) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. | +| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header; EXtractoR picks a layer and channels from popups of the footage's layers and channels, `layer.channels`, #295; the footage itself shows the beauty pass, never Cryptomatte or depth, #412) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. | | Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere to Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. | | Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are read (Configuration ▸ Custom: colorspaces, roles, displays/views; Matrix, File, Exponent, Log, LogAffine, CDL, Range and Group transforms). | | Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. |