Cryptomatte: the Layer popup lists the footage's Cryptomatte layers (#473) (#3020)

* Cryptomatte: the Layer popup lists the footage's Cryptomatte layers (#473)

The popup was the constant CryptoObject / CryptoMaterial / CryptoAsset. It now lists the
Cryptomatte layers of the layer's OpenEXR footage, and those three when there are none. The
stored value keeps its meaning (0-2 the standard layers, 3 and up the file's other layers in
name order), so older projects render the same.

Refs #473

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Cryptomatte Layer popup: timeline editor, one menu helper, probe and fallback tests (#473)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Cryptomatte Layer popup: give further layers of a standard kind a custom slot

A.CryptoObject keeps slot 0; B.CryptoObject and later ones get 3+ instead of
being dropped, so they can be selected, picked and rendered.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Format; note same-kind layers in the footage docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Format the merged code with rustfmt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Sonnet 5.5 authored and GitHub committed 2026-11-06 17:38:13 +01:00
1 parent fdcc715829
commit 519769299f
11 files changed
+345 -20

No files matched your search

+55 -6
View File
@@ -409,8 +409,62 @@ pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u3
out
}
/// The Cryptomatte layers every renderer names, and the Layer popup's options when the footage
/// has no Cryptomatte metadata. The stored Layer value is an index: 0-2 are these (an exact
/// match or a suffix such as `ViewLayer.CryptoObject`), 3 and up are the file's other Cryptomatte
/// layers in name order, so a project saved before the popup followed the footage loads unchanged.
pub const CRYPTO_LAYERS: [&str; 3] = ["CryptoObject", "CryptoMaterial", "CryptoAsset"];
/// The Layer popup's entries for Cryptomatte `layers` (sorted, as [`AuxChannels::crypto_layers`]
/// returns them): the stored value and the layer's name, in value order. Of several layers
/// that fit one standard slot (`A.CryptoObject`, `B.CryptoObject`) the first one keeps the standard
/// slot, so existing projects load unchanged, and the others get the next custom slots.
fn crypto_layer_slots(layers: &[String]) -> Vec<(usize, &str)> {
let mut out: Vec<(usize, &str)> = vec![];
let mut custom = CRYPTO_LAYERS.len();
for l in layers {
match CRYPTO_LAYERS.iter().position(|w| l == w || l.ends_with(w)) {
Some(i) if !out.iter().any(|(j, _)| *j == i) => out.push((i, l)),
_ => {
out.push((custom, l));
custom += 1;
}
}
}
out.sort_by_key(|(i, _)| *i);
out
}
/// What the Layer popup lists for the footage `aux`: the Cryptomatte layers the file has, as
/// (stored value, name), or the three standard ones when there is none (no footage, not an
/// OpenEXR, no Cryptomatte channels).
pub fn crypto_layer_choices(aux: Option<&AuxChannels>) -> Vec<(usize, String)> {
crypto_layer_menu(aux, 0).0
}
/// The Layer popup for the stored value `index`: [`crypto_layer_choices`], and the name of the
/// entry the effect reads (the first when `index` names none, as the effect does).
pub fn crypto_layer_menu(aux: Option<&AuxChannels>, index: usize) -> (Vec<(usize, String)>, Option<String>) {
let layers = aux.map(AuxChannels::crypto_layers).unwrap_or_default();
if layers.is_empty() {
let all: Vec<(usize, String)> = CRYPTO_LAYERS.iter().enumerate().map(|(i, n)| (i, n.to_string())).collect();
let shown = all.iter().find(|c| c.0 == index).or(all.first()).map(|c| c.1.clone());
return (all, shown);
}
let slots = crypto_layer_slots(&layers);
let shown = slots.iter().find(|(i, _)| *i == index).map(|(_, n)| n.to_string()).or_else(|| layers.first().cloned());
(slots.into_iter().map(|(i, n)| (i, n.to_string())).collect(), shown)
}
/// The Cryptomatte layer of `aux` that the stored Layer value `index` reads: its slot's layer, else
/// the first layer (what a file without that layer has always shown). `None` without any.
pub fn crypto_layer_name(aux: &AuxChannels, index: usize) -> Option<String> {
if aux.crypto_layers().is_empty() {
return None;
}
crypto_layer_menu(Some(aux), index).1
}
/// Rank planes as [`AuxChannels::crypto_rank_planes`] returns them.
type RankPlanes<'a> = [(&'a [f32], &'a [f32])];
@@ -524,12 +578,7 @@ pub fn matte_only_px(m: f32, in_alpha: bool) -> Px {
fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
let layers = a.crypto_layers();
if layers.is_empty() {
return b;
}
let want = CRYPTO_LAYERS[(ctx.params.e("layer") as usize).min(2)];
let layer = layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).unwrap_or(&layers[0]).clone();
let Some(layer) = crypto_layer_name(&a, ctx.params.e("layer") as usize) else { return b };
let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice());
let sel = selection_hashes(ctx.params.s("selection"), manifest);
let idx = index_map(&b, &a);
+108
View File
@@ -443,6 +443,114 @@ fn instance3d_replaces_the_pixels_with_the_referenced_layer() {
assert_eq!(out.img.data, solid().data);
}
/// A 20×10 layer of one Cryptomatte layer `name` that holds "left" on its left half and
/// "right" on its right half, as [`scene`] does for `CryptoObject`.
fn crypto_only(names: &[&str]) -> AuxHost {
let (w, h) = (20usize, 10usize);
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
for name in names {
let ids: Vec<f32> = (0..w * h).map(|i| crypto_float(if i % w < 10 { hl } else { hr })).collect();
a.channels.push((format!("{name}00.R"), ids));
a.channels.push((format!("{name}00.G"), vec![1.0; w * h]));
a.manifests.push((name.to_string(), vec![("left".into(), hl), ("right".into(), hr)]));
}
AuxHost(Arc::new(a))
}
fn choices(names: &[&str]) -> Vec<(usize, String)> {
crypto_layer_choices(Some(&crypto_only(names).0))
}
/// #473: the Layer popup lists the Cryptomatte layers the footage has, and the three standard
/// ones when there is no footage, no OpenEXR or no Cryptomatte in it.
#[test]
fn crypto_layer_popup_lists_the_layers_of_the_footage() {
let standard: Vec<(usize, String)> = CRYPTO_LAYERS.iter().enumerate().map(|(i, n)| (i, n.to_string())).collect();
assert_eq!(crypto_layer_choices(None), standard);
assert_eq!(crypto_layer_choices(Some(&AuxChannels::new(1, 1, 1.0))), standard, "an EXR without Cryptomatte");
assert_eq!(crypto_layer_choices(Some(&scene().0)), vec![(0, "CryptoObject".to_string())]);
// Only CryptoMaterial: it, not CryptoObject, with its own stored value.
assert_eq!(choices(&["CryptoMaterial"]), vec![(1, "CryptoMaterial".to_string())]);
// Standard layers come in slot order whatever the names sort like; prefixed ones fill their slot.
assert_eq!(choices(&["CryptoAsset", "ViewLayer.CryptoObject"]), vec![(0, "ViewLayer.CryptoObject".to_string()), (2, "CryptoAsset".to_string())]);
// A layer under another name gets a value after the standard ones, in name order.
assert_eq!(choices(&["Zed", "CryptoMaterial", "Alpha"]), vec![(1, "CryptoMaterial".to_string()), (3, "Alpha".to_string()), (4, "Zed".to_string())]);
// A name that only ends in a standard one fills its slot, as the effect always matched it.
assert_eq!(
choices(&["MyCryptoObject", "ViewLayer.CryptoMaterial", "Other"]),
vec![(0, "MyCryptoObject".to_string()), (1, "ViewLayer.CryptoMaterial".to_string()), (3, "Other".to_string())]
);
// The entry shown: the value's own, else the first layer; with no layers, the standard one.
let shown = |names: &[&str], i| crypto_layer_menu(Some(&crypto_only(names).0), i).1;
assert_eq!(
[0, 1, 7].map(|i| shown(&["CryptoMaterial"], i)),
[Some("CryptoMaterial".to_string()), Some("CryptoMaterial".to_string()), Some("CryptoMaterial".to_string())]
);
assert_eq!(crypto_layer_menu(None, 2).1.as_deref(), Some("CryptoAsset"));
assert_eq!(crypto_layer_menu(None, 9).1.as_deref(), Some("CryptoObject"), "a custom value with no footage");
// Two layers for one slot: the first keeps it, the next gets a custom value.
assert_eq!(choices(&["A.CryptoObject", "B.CryptoObject"]), vec![(0, "A.CryptoObject".to_string()), (3, "B.CryptoObject".to_string())]);
assert_eq!(choices(&["A.CryptoObject", "B.CryptoObject", "C.CryptoObject", "Mine"]).iter().map(|c| c.0).collect::<Vec<_>>(), [0, 3, 4, 5]);
}
/// A project saved with Layer = CryptoObject / CryptoMaterial / CryptoAsset renders the same layer
/// as before: the stored value's own layer, else the file's first; and the custom layers are read
/// by their value.
#[test]
fn crypto_layer_values_keep_their_meaning() {
let all = crypto_only(&["CryptoAsset", "CryptoMaterial", "CryptoObject", "Mine"]).0;
let name = |i| crypto_layer_name(&all, i);
assert_eq!([0, 1, 2, 3].map(name), ["CryptoObject", "CryptoMaterial", "CryptoAsset", "Mine"].map(|n| Some(n.to_string())));
// A value the file has no layer for reads its first layer, as the effect always did.
assert_eq!(name(4).as_deref(), Some("CryptoAsset"));
let only = crypto_only(&["CryptoMaterial"]).0;
assert_eq!(
[0, 1, 2].map(|i| crypto_layer_name(&only, i)),
[Some("CryptoMaterial".to_string()), Some("CryptoMaterial".to_string()), Some("CryptoMaterial".to_string())]
);
assert_eq!(crypto_layer_name(&AuxChannels::new(1, 1, 1.0), 0), None);
}
/// The effect keys the layer the Layer value names: a Material-only file renders its matte
/// whether Layer says Object (a saved project) or Material (what the popup now stores), and a
/// custom layer by its own value.
#[test]
fn cryptomatte_renders_the_layer_the_value_names() {
let sel = ("selection", Value::Str("left".into()));
let matte = |layer: u32, host: &AuxHost| run("ec.3d.cryptomatte", &[("layer", Value::Enum(layer)), sel.clone(), ("display", Value::Enum(1))], host);
let only = crypto_only(&["CryptoMaterial"]);
let (old, new) = (matte(0, &only), matte(1, &only));
assert_eq!(old.data, new.data);
assert_eq!((new.get(2, 5)[3], new.get(12, 5)[3]), (1.0, 0.0));
// Two layers: "left" is in both, under hashes the manifests share, so tell them apart by
// which has it on the right half.
let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
let mut a = AuxChannels::new(20, 10, 1.0);
for (name, flip) in [("CryptoObject", false), ("Mine", true)] {
let ids: Vec<f32> = (0..200).map(|i| crypto_float(if (i % 20 < 10) != flip { hl } else { hr })).collect();
a.channels.push((format!("{name}00.R"), ids));
a.channels.push((format!("{name}00.G"), vec![1.0; 200]));
}
let two = AuxHost(Arc::new(a));
let (object, mine) = (matte(0, &two), matte(3, &two));
assert_eq!((object.get(2, 5)[3], object.get(12, 5)[3]), (1.0, 0.0));
assert_eq!((mine.get(2, 5)[3], mine.get(12, 5)[3]), (0.0, 1.0), "Layer 3 is the custom layer");
// A second view's layer of a standard kind is selectable too: B.CryptoObject is Layer 3.
let mut a = AuxChannels::new(20, 10, 1.0);
for (name, flip) in [("A.CryptoObject", false), ("B.CryptoObject", true)] {
let ids: Vec<f32> = (0..200).map(|i| crypto_float(if (i % 20 < 10) != flip { hl } else { hr })).collect();
a.channels.push((format!("{name}00.R"), ids));
a.channels.push((format!("{name}00.G"), vec![1.0; 200]));
}
let views = AuxHost(Arc::new(a));
assert_eq!(crypto_layer_name(&views.0, 0).as_deref(), Some("A.CryptoObject"));
assert_eq!(crypto_layer_name(&views.0, 3).as_deref(), Some("B.CryptoObject"));
let (first, second) = (matte(0, &views), matte(3, &views));
assert_eq!((first.get(2, 5)[3], first.get(12, 5)[3]), (1.0, 0.0));
assert_eq!((second.get(2, 5)[3], second.get(12, 5)[3]), (0.0, 1.0), "Layer 3 renders B");
}
/// #474: [`reads_aux`] names exactly the 3D Channel effects that ask the host for channels.
#[test]
fn reads_aux_names_the_effects_that_read_channels() {
+2 -1
View File
@@ -116,7 +116,8 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{
CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, matte_only_px, reads_aux, selection_hashes,
CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
crypto_layer_name, id_color, matte_only_px, reads_aux, selection_hashes,
};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
+1 -2
View File
@@ -517,12 +517,11 @@ impl CryptoProbe {
media_overrides: &[],
};
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
if !live() {
return Ok(None);
}
let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?;
let layer_name = effectcraft_effects::crypto_layer_name(&aux, params.e("layer") as usize).ok_or(NO_CRYPTO)?;
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale
// up, proxies have their own size, earlier effects can pad it), rendered and cached.
if !live() {
+54
View File
@@ -42,6 +42,10 @@ fn aux_at(t: Tick) -> AuxChannels {
aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone()));
}
aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())];
// A second view's layer of the same kind (`Z.CryptoObject`, stored Layer 3): "right" everywhere.
aux.channels.push(("Z.CryptoObject00.R".into(), vec![crypto_float(crypto_hash("right")); w * h]));
aux.channels.push(("Z.CryptoObject00.G".into(), vec![1.0; w * h]));
aux.manifests.push(("Z.CryptoObject".into(), vec![("right".into(), crypto_hash("right"))]));
aux
}
@@ -130,6 +134,17 @@ fn picks_replace_add_and_remove_in_one_undo_step_each() {
assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n));
}
/// A second layer that fits a standard slot (`Z.CryptoObject` beside `CryptoObject`) is picked by
/// its own Layer value (3); Layer 0 still reads the first one.
#[test]
fn pick_reads_the_second_layer_of_a_kind() {
let mut s = session();
assert_eq!(pick(&mut s, 1.0, "replace")["name"], "left");
s.execute_checked("prop.set", json!({"path": "effects/#1/layer", "value": 3})).unwrap();
assert_eq!(pick(&mut s, 1.0, "replace")["name"], "right");
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1.0, "y": 1.5})).unwrap()["name"], "right");
}
#[test]
fn bad_points_layers_and_modes_are_errors_and_change_nothing() {
let mut s = session();
@@ -896,3 +911,42 @@ fn highlight_cost_with_six_ranks() {
);
}
}
/// 8 × 4 footage whose Cryptomatte layers are `Mine` (`left` / `right` halves) and `CryptoMaterial`
/// (the other way round).
struct Layers;
impl FootageSource for Layers {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
let mut aux = AuxChannels::new(8, 4, 1.0);
for (name, flip) in [("Mine", false), ("CryptoMaterial", true)] {
let ids = (0..32).map(|i| crypto_float(crypto_hash(if (i % 8 < 4) != flip { "left" } else { "right" }))).collect();
aux.channels.push((format!("{name}00.R"), ids));
aux.channels.push((format!("{name}00.G"), vec![1.0; 32]));
}
Some(Arc::new(aux))
}
}
/// #473: the probe reads the layer the Layer value names, as the effect does: 0 (a saved
/// CryptoObject) on a file without it reads the file's first layer, 1 is CryptoMaterial, and 3
/// is the file's other layer.
#[test]
fn pick_reads_the_layer_the_layer_value_names() {
let mut s = Session { footage: Arc::new(Layers), importer: Some(Arc::new(Probe)), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/renders/layers.exr"]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let name_at = |s: &mut Session, layer: u32| {
s.execute_checked("prop.set", json!({"path": "effects/#1/layer", "value": layer})).unwrap();
let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5})).unwrap();
(r["name"].clone(), r["layer"].clone())
};
// The files' layers sort as CryptoMaterial, Mine (no manifest here, so the IDs have no name).
assert_eq!(name_at(&mut s, 0).1, "CryptoMaterial", "a saved CryptoObject: the first layer");
assert_eq!(name_at(&mut s, 1).1, "CryptoMaterial");
assert_eq!(name_at(&mut s, 3).1, "Mine");
}
+1 -6
View File
@@ -268,12 +268,7 @@ fn identifier(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<
fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option<GBuf> {
let pr = ctx.params;
let layers = a.crypto_layers();
if layers.is_empty() {
return Some(b);
}
let want = effectcraft_effects::CRYPTO_LAYERS[(pr.e("layer") as usize).min(2)];
let layer = layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).unwrap_or(&layers[0]).clone();
let Some(layer) = effectcraft_effects::crypto_layer_name(a, pr.e("layer") as usize) else { return Some(b) };
let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice());
let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest);
// Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A), on all
@@ -219,6 +219,8 @@ fn prop_row(
Value::Vec2(_) | Value::Vec3(_) => dims = value_vector(app, ui, &cx, &value, actions),
Value::Color(_) => value_color(app, ui, &cx, &value, actions),
Value::Bool(_) => value_toggle(app, ui, &cx, &value, actions),
// Cryptomatte's Layer: the Cryptomatte layers the footage's metadata names.
Value::Enum(_) if crate::panels::fx_editors::is_crypto_layer(group, prop) => value_crypto_layer(app, ui, &cx, &value, actions),
Value::Enum(_) => value_popup(app, ui, &cx, &value, actions),
Value::Layer(_) => value_layer_ref(app, ui, &cx, &value, actions),
// EXtractoR's red / green / blue / alpha: a popup of the footage's OpenEXR channels (#295).
@@ -407,6 +409,33 @@ fn value_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
}
}
/// Cryptomatte's Layer popup: the Cryptomatte layers of the layer's OpenEXR footage (the three
/// standard ones when there are none). The stored value stays the index
/// [`effectcraft_engine::effects::crypto_layer_name`] reads, so older projects load unchanged.
fn value_crypto_layer(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Enum(i) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, p, layer, prop, .. } = *cx;
// The channels of the frame the render just read: decoded files are cached.
let aux = app.session.layer_channels(layer.id);
let (choices, shown) = effectcraft_engine::effects::crypto_layer_menu(aux.as_deref(), *i as usize);
let names: Vec<String> = choices.iter().map(|c| c.1.clone()).collect();
let widest = names.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max);
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0));
let pop = egui::Id::unique(("ec-epop", uid));
if widgets::dropdown(ui, dr, shown.as_deref().unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
let cur = names.iter().position(|n| Some(n) == shown.as_ref());
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &names, cur)
&& let Some(c) = choices.get(ni)
{
cx.set_step(actions, json!(c.0));
}
}
fn value_layer_ref(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Layer(l) = value else {
return;
+12
View File
@@ -39,6 +39,7 @@ pub const COLORAMA: &str = "ec.color.colorama";
pub const GLOW: &str = "ec.stylize.glow";
pub const RESHAPE: &str = "ec.distort.reshape";
pub const EXTRACTOR: &str = "ec.3d.extractor";
pub const CRYPTOMATTE: &str = "ec.3d.cryptomatte";
pub const MESH_WARP: &str = "ec.distort.meshwarp";
fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String {
@@ -838,6 +839,17 @@ pub fn is_extractor_channel(g: &PropGroup, prop: &effectcraft_engine::project::P
matches!(&g.kind, GroupKind::Effect { effect } if effect == EXTRACTOR) && EXTRACTOR_CHANNELS.contains(&prop.match_id.as_str())
}
/// Whether `prop` (in group `g`) is the Cryptomatte effect's Layer.
pub fn is_crypto_layer(g: &PropGroup, prop: &effectcraft_engine::project::Property) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == CRYPTOMATTE) && prop.match_id == "layer"
}
/// Whether `prop` is the Layer of a Cryptomatte effect on `layer` (the timeline finds it without
/// its group).
pub fn is_crypto_layer_of(layer: &Layer, prop: &effectcraft_engine::project::Property) -> bool {
layer.effects().is_some_and(|fx| fx.groups().any(|g| g.match_id == CRYPTOMATTE && g.get("layer").is_some_and(|q| q.uid == prop.uid)))
}
/// The OpenEXR layer EXtractoR's red, green and blue come from (`""`: the unnamed layer), or
/// `None` when they mix layers.
fn extractor_layer(vals: &[String; 4]) -> Option<&str> {
+18 -4
View File
@@ -5895,16 +5895,30 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
let t = app.tokens;
let x = at.x;
if let Some(options) = layer.popup_options(prop) {
// Cryptomatte's Layer lists the footage's Cryptomatte layers (value k is the choice's own).
let crypto = crate::panels::fx_editors::is_crypto_layer_of(layer, prop).then(|| {
let aux = app.session.layer_channels(layer.id);
effectcraft_engine::effects::crypto_layer_menu(aux.as_deref(), *i as usize)
});
let options = match &crypto {
Some((choices, _)) => Some(std::borrow::Cow::Owned(choices.iter().map(|c| c.1.clone()).collect::<Vec<_>>())),
None => layer.popup_options(prop),
};
if let Some(options) = options {
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
let label = options.get(*i as usize).cloned().unwrap_or_default();
let label = match &crypto {
Some((_, shown)) => shown.clone().unwrap_or_default(),
None => options.get(*i as usize).cloned().unwrap_or_default(),
};
let cur = if crypto.is_some() { options.iter().position(|o| *o == label) } else { Some(*i as usize) };
let pop = egui::Id::unique(("epop", uid));
if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), &options, Some(*i as usize)) {
ve.set_step(actions, json!(ni));
if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), &options, cur) {
let value = crypto.as_ref().and_then(|(choices, _)| choices.get(ni)).map_or(ni, |c| c.0);
ve.set_step(actions, json!(value));
}
} else {
p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.for_value(prop.has_expression()).hot_text);
@@ -555,6 +555,63 @@ fn extractor_offers_the_exr_layers_and_channels() {
let _ = std::fs::remove_file(path);
}
/// #473: Cryptomatte's Layer popup lists the Cryptomatte layers of the footage's OpenEXR (a file
/// with only CryptoMaterial shows CryptoMaterial, not CryptoObject), and the three standard ones
/// when the layer has no such footage. The stored value stays the standard index.
#[test]
fn cryptomatte_layer_popup_follows_the_footage() {
use exr::prelude::*;
let (w, hh) = (8usize, 4usize);
let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * hh]));
let channels = AnyChannels::sort(vec![ch("R", 0.5), ch("G", 0.5), ch("B", 0.5), ch("CryptoMaterial00.R", 1.0), ch("CryptoMaterial00.G", 1.0)].into());
let mut exr_layer = exr::prelude::Layer::new((w, hh), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels);
exr_layer.attributes.other.insert(Text::from("cryptomatte/abc1234/name"), AttributeValue::Text(Text::from("CryptoMaterial")));
exr_layer.attributes.other.insert(Text::from("cryptomatte/abc1234/manifest"), AttributeValue::Text(Text::from(r#"{"wood":"13851a76"}"#)));
let path = std::env::temp_dir().join(format!("ec-ui-exr-crypto-{}.exr", std::process::id()));
Image::from_layer(exr_layer).write().to_file(&path).unwrap();
let mut s = effectcraft_host::session();
let item = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "EXR", "width": 8, "height": 4, "frameRate": 24, "duration": 1})).unwrap();
let l = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let apply = |s: &mut Session, l: u64| s.execute("effect.apply", json!({"layers": [l], "effect": "Cryptomatte"})).unwrap()["effects"][0].as_u64().unwrap();
let (fx, solid_fx) = (apply(&mut s, l), apply(&mut s, solid));
let layer_prop = |s: &Session, l: u64, fx: u64| {
layer(s, l).effects().and_then(|f| f.groups().find(|g| g.uid == fx)).and_then(|g| g.get("layer")).map(|p| (p.uid, p.value.as_enum())).unwrap()
};
let (uid, stored) = layer_prop(&s, l, fx);
assert_eq!(stored, 0, "a new effect stores CryptoObject");
let (solid_uid, _) = layer_prop(&s, solid, solid_fx);
s.state.selected_layers = vec![LayerId(l)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
// The file has only CryptoMaterial, so that is what the closed popup shows, and all it lists.
assert!(h.query_by_label("CryptoMaterial").is_some() && h.query_by_label("CryptoObject").is_none());
let at = rect_of(&h, &format!("effectControls.prop.{uid}.value")).center();
click(&mut h, at);
assert!(h.query_by_label("CryptoObject").is_none() && h.query_by_label("CryptoAsset").is_none(), "only the file's layers");
assert_eq!(h.query_all_by_label("CryptoMaterial").count(), 2, "the popup and its entry");
h.query_all_by_label("CryptoMaterial").last().unwrap().click();
h.run_steps(3);
assert_eq!(layer_prop(&h.state().session, l, fx).1, 1, "stored as the standard CryptoMaterial value");
// A layer without Cryptomatte footage lists the standard three.
h.state_mut().session.state.selected_layers = vec![LayerId(solid)];
settle(&mut h);
let at = rect_of(&h, &format!("effectControls.prop.{solid_uid}.value")).center();
click(&mut h, at);
for name in ["CryptoMaterial", "CryptoAsset"] {
assert!(h.query_by_label(name).is_some(), "the standard layer {name}");
}
h.get_by_label("CryptoAsset").click();
h.run_steps(3);
assert_eq!(layer_prop(&h.state().session, solid, solid_fx).1, 2);
let _ = std::fs::remove_file(path);
}
#[test]
fn point_picker_handle_follows_hover_then_commits_once_across_effects() {
for (effect, parameter) in [("ec.control.point", "point"), ("ec.blur.ccradialfast", "center"), ("ec.generate.gradientramp", "start")] {
+8 -1
View File
@@ -133,7 +133,14 @@ The Cryptomatte effect keys objects by their Cryptomatte ID passes, with the nam
file's manifest. Pick them with the eyedropper next to its Selection, then click in the
Composition viewer (Shift adds, Alt or Ctrl removes). Matte Only shows the matte as grey in
RGB over full alpha, as After Effects' Cryptomatte plug-in does; projects saved before that
change keep their matte in alpha as well.
change keep their matte in alpha as well. Its Layer popup lists the Cryptomatte
layers the footage has (a file with only `CryptoMaterial` shows just that), and the standard
`CryptoObject`, `CryptoMaterial` and `CryptoAsset` when the layer has no Cryptomatte footage. The
stored Layer value is unchanged for those three, so older projects load as they were while each kind appears once. With
several layers of one kind (`A.CryptoObject`, `B.CryptoObject`) the first keeps the standard
value; the others, like any other layers, are stored as 3 and up in the file's name order (so a value of 3 reads another layer in
another file). The popup lists them in Effect Controls and the timeline; scripts and
`prop.get` still see the three standard options.
## Replacing and reloading