Files
effectcraft/crates/host/tests/it/cryptomatte_pick.rs
T
vangel76andClaude Opus 5.5 50cbbfe310 Cryptomatte: Matte Only puts the matte in RGB over alpha 1 (#3127)
With Matte Only on, Cryptomatte wrote the matte into all four channels:
a premultiplied white only as opaque as the matte. After Effects'
Cryptomatte plug-in shows Matte Only as an opaque grey picture, the
matte in RGB with alpha 1 (its Matte Only render of 2026-11-05 is opaque
black where nothing is selected). The CPU effect and the GPU shader now
both give (m, m, m, 1).

Projects saved before keep their render: a hidden Matte Only In Alpha
switch (off for new instances, presets and AEP imports) is turned on by
migrate::legacy_default when an older instance is upgraded on open, and
then keeps the matte in every channel on CPU and GPU.

Tests: the CPU path for a partly covered pixel, an unselected one and
outside the aux area, in both forms; the upgrade of a saved instance;
the GPU parity test over every display with Matte Only off, on and on
with the legacy switch; the host pick tests read whole pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:38:36 +01:00

129 lines
7.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Cryptomatte eyedropper on real OpenEXR footage (#473): `effect.pickCryptomatte` reads the
//! file's Cryptomatte ranks and manifest, edits the Selection, and the rendered matte follows,
//! including undo.
use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use effectcraft_raster::channels3d::{crypto_float, crypto_hash};
use serde_json::json;
const RIGHT: &str = "Right, \"Box\"";
/// An 8 × 4 EXR: white RGBA, and a CryptoObject layer with `Left` in columns before `split` and
/// the comma-and-quotes name from there, both named in its manifest.
fn crypto_exr(tag: &str, split: usize) -> String {
use exr::prelude::*;
let (w, h) = (8usize, 4usize);
let ids: Vec<f32> = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < split { "Left" } else { RIGHT }))).collect();
let ch = |n: &str, v: Vec<f32>| AnyChannel::new(n, FlatSamples::F32(v));
let one = || vec![1.0f32; w * h];
let channels = AnyChannels::sort(
vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(),
);
let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels);
let manifest = json!({"Left": format!("{:08x}", crypto_hash("Left")), RIGHT: format!("{:08x}", crypto_hash(RIGHT))}).to_string();
for (k, v) in [("name", "CryptoObject".to_string()), ("manifest", manifest)] {
layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{k}").as_str()), AttributeValue::Text(Text::from(v.as_str())));
}
// A folder of its own: numbered siblings would import as an image sequence.
let dir = std::env::temp_dir().join(format!("ec-crypto-pick-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("crypto.exr");
Image::from_layer(layer).write().to_file(&path).unwrap();
path.to_string_lossy().to_string()
}
/// A session with the EXR as a layer of a 2 s comp, under a Cryptomatte effect.
fn footage(tag: &str, split: usize) -> (Session, String) {
let path = crypto_exr(tag, split);
let mut s = effectcraft_host::session();
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
s.execute("comp.new", json!({"name": "Crypto", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
(s, path)
}
/// The matte's alpha over the left and right halves.
fn matte(s: &Session) -> [f32; 2] {
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
[img.get(1, 1)[3], img.get(6, 2)[3]]
}
#[test]
fn picking_on_a_cryptomatte_exr_edits_the_selection_and_the_matte() {
let (mut s, path) = footage("edit", 4);
s.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 1})).unwrap();
assert_eq!(matte(&s), [0.0, 0.0], "nothing selected");
let pick = |s: &mut Session, x: f64, mode: &str| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1, "mode": mode})).unwrap();
assert_eq!(pick(&mut s, 1.0, "replace")["selection"], "Left");
assert_eq!(matte(&s), [1.0, 0.0]);
let r = pick(&mut s, 6.0, "add");
assert_eq!((r["name"].as_str(), r["layer"].as_str()), (Some(RIGHT), Some("CryptoObject")), "{r}");
assert_eq!(r["selection"], r#"Left, "Right, \"Box\"""#);
assert_eq!(matte(&s), [1.0, 1.0], "the quoted name selects the right half");
pick(&mut s, 2.0, "remove");
assert_eq!(matte(&s), [0.0, 1.0]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(matte(&s), [1.0, 1.0]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(matte(&s), [1.0, 0.0]);
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
}
/// At half resolution a preview pixel shows the aux texel at its centre (layer x 3 for preview
/// pixel 1): `scale` makes the pick read that one, so what is clicked is what lights up.
#[test]
fn a_pick_at_preview_resolution_reads_the_pixel_the_preview_shows() {
let (mut s, path) = footage("half", 3);
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 2.5, "y": 1.5, "scale": 0.5})).unwrap();
assert_eq!(r["name"], RIGHT, "{r}");
let opts = effectcraft_engine::render::RenderOpts { scale: 0.5, ..Default::default() };
// Matte Only: the matte in RGB over alpha 1 (#3127).
assert_eq!(s.render(s.active_comp_id().unwrap(), Tick::ZERO, opts).get(1, 0), [1.0; 4]);
// At full resolution the same point is Left's pixel.
let r = s.execute_checked("effect.cryptomatteAt", json!({"effect": 1, "x": 2.5, "y": 1.5})).unwrap();
assert_eq!(r["name"], "Left");
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
}
/// A keyframed Selection picked at `time` is edited at that time, not at the current time.
#[test]
fn a_keyframed_selection_is_edited_where_it_was_read() {
let (mut s, path) = footage("key", 4);
for t in [0.0, 1.0] {
s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": format!("<{:08x}>", crypto_hash(RIGHT))})).unwrap();
}
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 0.5, "y": 1.5, "mode": "add", "time": 1.0})).unwrap();
assert_eq!((r["changed"].as_bool(), r["selected"].as_bool()), (Some(true), Some(true)), "{r}");
let sel = |s: &mut Session, t: f64| {
let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t})).unwrap();
r["selected"].as_bool()
};
assert_eq!((sel(&mut s, 0.0), sel(&mut s, 1.0)), (Some(false), Some(true)), "only the key at 1 s gains Left");
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
}
/// A precomp whose 3D layer an expression moves: the pick finds it where the render keys it
/// (the pick's aux pass runs the session's expressions).
#[test]
fn a_pick_on_a_precomp_follows_expressions() {
let mut s = effectcraft_host::session();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap()["comp"].clone();
let l = s.execute("layer.newSolid", json!({"name": "Ball", "width": 6, "height": 6})).unwrap()["layer"].clone();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute_checked("prop.setExpression", json!({"layer": l, "path": "transform/position", "expression": "[33, 10, 0]"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap();
s.execute("layer.addItem", json!({"item": inner})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 33.5, "y": 10.5})).unwrap();
assert_eq!(r["selection"], "Ball", "{r}");
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
assert_eq!((img.get(33, 10), img.get(20, 10)), ([1.0; 4], [0.0, 0.0, 0.0, 1.0]));
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 20.5, "y": 10.5})).unwrap(), serde_json::Value::Null);
}