From 519769299f6390e62b778d469422497577799ea2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Fri, 6 Nov 2026 17:38:13 +0100 Subject: [PATCH] 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 * Cryptomatte Layer popup: timeline editor, one menu helper, probe and fallback tests (#473) Co-Authored-By: Claude Sonnet 5.5 * 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 * Format; note same-kind layers in the footage docs Co-Authored-By: Claude Sonnet 5.5 * Format the merged code with rustfmt Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/effects/src/channel3d.rs | 61 +++++++++- crates/effects/src/channel3d/tests.rs | 108 ++++++++++++++++++ crates/effects/src/lib.rs | 3 +- crates/engine/src/cmd/effect.rs | 3 +- crates/engine/src/tests/crypto_pick.rs | 54 +++++++++ crates/gpu/src/fx_depth.rs | 7 +- .../src/panels/effect_controls/rows.rs | 29 +++++ crates/ui-egui/src/panels/fx_editors.rs | 12 ++ crates/ui-egui/src/panels/timeline.rs | 22 +++- crates/ui-egui/tests/it/ui_effect_controls.rs | 57 +++++++++ docs/footage.md | 9 +- 11 files changed, 345 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index f331bf195..a4c935a65 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -409,8 +409,62 @@ pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec 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) { + 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 { + 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); diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index 1f6bc91aa..d872e34a9 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -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 = (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::>(), [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 = (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 = (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() { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d732f160b..39e66ab46 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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}; diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index ba9fefaba..a2675d134 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -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() { diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index 0afcd34fd..e3a8cd50c 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -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> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + 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"); +} diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index 04f57e0ca..9d161de19 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -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 { 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 diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 5a5a4e712..c14630af4 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -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 = 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; diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 85b6be25f..45dbcb1d7 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -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> { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8675eacbf..4bdda89a6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::>())), + 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); diff --git a/crates/ui-egui/tests/it/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs index 357596681..1db059220 100644 --- a/crates/ui-egui/tests/it/ui_effect_controls.rs +++ b/crates/ui-egui/tests/it/ui_effect_controls.rs @@ -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")] { diff --git a/docs/footage.md b/docs/footage.md index a41a67f77..e2effa996 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -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