diff --git a/crates/effects/src/keylight.rs b/crates/effects/src/keylight.rs index 4ccfea42..84085f4e 100644 --- a/crates/effects/src/keylight.rs +++ b/crates/effects/src/keylight.rs @@ -610,6 +610,9 @@ mod tests { let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default()); assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]); assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight")); + // Searches find it by After Effects' name as well as its own. + let spec = crate::find("ec.keying.keylight").unwrap(); + assert!(crate::name_matches(spec, "keylight") && crate::name_matches(spec, "key light") && !crate::name_matches(spec, "blur")); } /// Instances saved with the flat layout get their parameters moved into the twirl-downs. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 30d75a99..fd28626f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -504,14 +504,25 @@ pub fn categories() -> Vec<&'static str> { v } +/// After Effects' third-party display names of effects we register under a generic name. +const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)]; + +/// Other names effect `id` answers to (in [`lookup`] and searches). +pub fn aliases(id: &str) -> &'static [&'static str] { + ALIASES.iter().find(|(i, _)| *i == id).map_or(&[], |(_, a)| a) +} + +/// Search (Effects & Presets, `effect.list`): the display name or one of its [`aliases`] +/// contains `query` (lowercase). +pub fn name_matches(spec: &EffectSpec, query: &str) -> bool { + std::iter::once(spec.name).chain(aliases(spec.id).iter().copied()).any(|n| n.to_lowercase().contains(query)) +} + /// Find by id or (case-insensitive) display name. pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> { find(name_or_id) .or_else(|| all().into_iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))) - .or_else(|| { - // After Effects' third-party display names we register under a generic name. - keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten() - }) + .or_else(|| ALIASES.iter().find(|(_, names)| names.iter().any(|a| a.eq_ignore_ascii_case(name_or_id))).and_then(|(id, _)| find(id))) .or_else(|| { // Display names of earlier versions. migrate::EFFECT_NAME_ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name_or_id)).and_then(|(_, id)| find(id)) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 91575239..9eea8063 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -4,7 +4,7 @@ use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid}; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p}; +use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; /// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust). @@ -132,6 +132,52 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It Ok((cid, lid, g.uid)) } +/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input +/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a +/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it. +fn pick_color(s: &mut Session, p: &Value) -> Result { + let cmd = "effect.pickColor"; + let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; + let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?; + let index = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?; + let g = fx.groups().nth(index).ok_or_else(|| bad(cmd, "no such effect"))?; + let prop = match (p.get("prop").and_then(Value::as_u64), str_p(p, "param")) { + (Some(uid), _) => g.find(uid), + (None, Some(path)) => g.prop(path), + _ => return Err(bad(cmd, "missing `param` (e.g. `screenColour`) or `prop` (uid)")), + } + .filter(|pr| pr.ui == effectcraft_project::ParamUi::Color) + .ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?; + let t = super::time_p(s, p, Some(comp)); + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); + r.expr = s.expr.as_deref(); + r.cache = Some(&s.layer_cache); + let buf = r.layer_input(&ctx, layer, index).ok_or_else(|| bad(cmd, "this layer has no pixels"))?; + let (px, py) = buf.to_px([x, y]); + let (cx, cy) = (px.floor() as i64, py.floor() as i64); + let rad = if b_p(p, "average") == Some(true) { 2 } else { 0 }; + let mut sum = [0.0f32; 4]; + for yy in cy.saturating_sub(rad)..=cy.saturating_add(rad) { + for xx in cx.saturating_sub(rad)..=cx.saturating_add(rad) { + // Outside the layer reads as transparent. + let c = buf.img.get(xx, yy); + (0..4).for_each(|i| sum[i] += c[i]); + } + } + if sum[3] <= 1e-6 { + return Err(bad(cmd, "nothing to sample there (transparent or outside the layer)")); + } + // Straight colour of the (averaged) premultiplied pixels. + let color: Vec = (0..3).map(|i| (sum[i] / sum[3]).clamp(0.0, 1.0) as f64).chain([1.0]).collect(); + let uid = prop.uid; + s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": color}))?; + Ok(json!({"prop": uid, "color": color})) +} + fn remove(s: &mut Session, p: &Value) -> Result { let cid = super::comp_id(s, p)?; let fx = effects_p(s, p, "effect.remove")?; @@ -355,11 +401,11 @@ fn last(s: &mut Session, p: &Value) -> Result { } fn list(_: &mut Session, p: &Value) -> Result { - let filter = str_p(p, "filter").map(str::to_ascii_lowercase); + let filter = str_p(p, "filter").map(str::to_lowercase); let v: Vec = effectcraft_effects::all() .into_iter() .filter(|e| { - filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f)) + filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f)) }) .map(|e| { json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::>()}) @@ -371,6 +417,15 @@ fn list(_: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply), + cmd!( + "effect.pickColor", + "Pick Effect Colour", + [], + None, + "{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there", + has_layers, + pick_color + ), cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last), cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all), cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f8abaa08..f14b505b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -954,6 +954,8 @@ mod tests_fidelity; #[cfg(test)] mod tests_frame_export; #[cfg(test)] +mod tests_keylight; +#[cfg(test)] mod tests_lottie; #[cfg(test)] mod tests_m137; diff --git a/crates/engine/src/tests_keylight.rs b/crates/engine/src/tests_keylight.rs new file mode 100644 index 00000000..71ec2c5f --- /dev/null +++ b/crates/engine/src/tests_keylight.rs @@ -0,0 +1,135 @@ +//! Key Light end to end: a green-screen plate (uneven screen, a subject with green spill and a +//! soft edge) keyed through the commands an agent uses: `effect.apply` by its After Effects name, +//! `effect.pickColor` on the screen (the effect's input, as the eyedropper does) and a render. + +use std::sync::Arc; + +use effectcraft_project::{Footage, FootageKind, ItemId}; +use effectcraft_raster::Image; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +use crate::Session; +use crate::render::{FootageSource, RenderOpts}; + +const W: u32 = 96; +const H: u32 = 64; +const SKIN: [f32; 3] = [0.86, 0.62, 0.52]; + +/// The plate's straight colour and alpha (opaque everywhere) at a pixel. +fn plate(x: u32, y: u32) -> [f32; 3] { + // Screen: a slightly uneven, noisy green. + let noise = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.02; + let screen = [0.18 + noise, 0.66 + (x as f32 / W as f32 - 0.5) * 0.04 + noise, 0.30 - noise]; + let r = ((x as f32 + 0.5 - 48.0).powi(2) + (y as f32 + 0.5 - 32.0).powi(2)).sqrt(); + // Subject: skin, with green spill in its outer 3 px and a 3 px soft edge. + let mut subject = SKIN; + if r > 13.0 { + subject[1] += 0.12; + } + let a = ((19.0 - r) / 3.0).clamp(0.0, 1.0); + [0, 1, 2].map(|i| subject[i] * a + screen[i] * (1.0 - a)) +} + +struct Plate; + +impl FootageSource for Plate { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let mut img = Image::new(W, H); + for y in 0..H { + for x in 0..W { + let c = plate(x, y); + img.set(x, y, [c[0], c[1], c[2], 1.0]); + } + } + Some(Arc::new(img)) + } +} + +struct Probe; +impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + Ok(Footage { + path: path.into(), + kind: FootageKind::Still, + width: W, + height: H, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration: Tick::ZERO, + ..Default::default() + }) + } +} + +fn keyed() -> (Session, ItemId) { + let mut s = Session { footage: Arc::new(Plate), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/plates/greenscreen.png"]})).unwrap()["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Key", "width": W, "height": H, "frameRate": 24, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + // After Effects' name finds it; MCP's add_effect / list_effects go through the same lookup. + let r = s.execute_checked("effect.apply", json!({"effect": "Keylight (1.2)"})).unwrap(); + assert_eq!(r["effect"], "ec.keying.keylight"); + (s, c) +} + +#[test] +fn key_light_removes_a_green_screen() { + let (mut s, c) = keyed(); + let steps = s.history.undo.len(); + // The default screen colour (pure green) already keys the screen, so the shown frame is + // transparent there; the eyedropper samples the effect's input instead. + let picked = s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 6, "y": 6, "average": true})).unwrap(); + let col: Vec = picked["color"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let want = plate(6, 6); + assert!((0..3).all(|i| (col[i] - want[i] as f64).abs() < 0.02), "{col:?} vs {want:?}"); + let tree = s.execute("layer.tree", json!({})).unwrap().to_string(); + assert!(tree.contains("Screen Colour"), "{tree}"); + // One undo step. + assert_eq!(s.history.undo.len(), steps + 1); + // Clean the matte as one does in Screen Matte view: Clip Black for the uneven screen, Clip + // White for the spill's partial transparency. + for (path, v) in [("effects/#1/screenMatte/clipBlack", 10), ("effects/#1/screenMatte/clipWhite", 85)] { + s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap(); + } + + let img = s.render(c, Tick::ZERO, RenderOpts::default()); + let px = |x, y| img.get(x, y); + // The whole screen goes (corners, both ends of the gradient, noise). + for (x, y) in [(1, 1), (94, 1), (1, 62), (94, 62), (20, 32), (76, 32)] { + assert!(px(x, y)[3] < 0.03, "screen at {x},{y}: {:?}", px(x, y)); + } + // The subject stays, with its colour. + let fg = px(48, 32); + assert!(fg[3] > 0.99 && (0..3).all(|i| (fg[i] - SKIN[i]).abs() < 0.02), "{fg:?}"); + // Spill on the subject's edge is suppressed: no green cast left. + let sp = px(48, 32 - 14); + assert!(sp[3] > 0.99 && sp[1] <= 0.5 * (sp[0] + sp[2]) + 0.01, "spill {sp:?}"); + // The soft edge keeps partial alpha, and its recovered colour has no green fringe. + let e = px(48, 32 - 18); + assert!(e[3] > 0.15 && e[3] < 0.85, "edge {e:?}"); + let a = e[3]; + assert!(e[1] / a <= 0.5 * (e[0] + e[2]) / a + 0.02, "edge fringe {e:?}"); +} + +#[test] +fn pick_color_rejects_bad_targets_and_screen_matte_view_shows_the_key() { + let (mut s, c) = keyed(); + // Not a colour parameter / outside the layer. + assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenGain", "x": 6, "y": 6})).is_err()); + assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": -50, "y": 6})).is_err()); + assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 1e300, "y": -1e300, "average": true})).is_err()); + assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour"})).is_err()); + s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 90, "y": 60})).unwrap(); + // View ▸ Screen Matte: black screen (once Clip Black takes the uneven screen), white subject. + for (path, v) in [("effects/#1/view", 4), ("effects/#1/screenMatte/clipBlack", 25)] { + s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap(); + } + let img = s.render(c, Tick::ZERO, RenderOpts::default()); + assert!(img.get(2, 2)[0] < 0.03 && img.get(48, 32)[0] > 0.97, "{:?} {:?}", img.get(2, 2), img.get(48, 32)); + // The Effects & Presets / list_effects search finds it by After Effects' name too. + let list = s.execute("effect.list", json!({"filter": "Keylight (1.2)"})).unwrap(); + assert_eq!(list.as_array().map(|a| a.len()), Some(1), "{list}"); + assert_eq!(list[0]["id"], "ec.keying.keylight"); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index a5b854bd..5215685a 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1098,7 +1098,13 @@ pub fn viewer_hook( app.ui.fx_pick = None; return; }; - if pick.kind == "color" { + // Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`. + let keyer = (pick.kind == "color") + .then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some())) + .flatten() + .filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying")) + .map(|g| g.uid); + if pick.kind == "color" && keyer.is_none() { // GPU frames: read the shown frame back for sampling. app.viewer_pixels(); } @@ -1113,6 +1119,7 @@ pub fn viewer_hook( let c = map.to_comp(hp); app.pointer_comp = Some([c[0] as f32, c[1] as f32]); if pick.kind == "color" + && keyer.is_none() && let Some(img) = &app.viewer_image && let Some(s) = fw::sample_frame(img, map.comp, c) { @@ -1129,6 +1136,15 @@ pub fn viewer_hook( if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) { actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); } + } else if let Some(fx) = keyer { + // Ctrl/Cmd+click averages the 5 × 5 pixels around the point. + if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) { + let average = ui.input(|i| i.modifiers.command); + actions.push(( + "effect.pickColor".into(), + json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}), + )); + } } else if let Some(img) = &app.viewer_image && let Some(s) = fw::sample_frame(img, map.comp, c) { diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 21d63dec..77df1d08 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -323,7 +323,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = list.min.y - scroll; let mut apply: Option = None; let reg = effectcraft_engine::effects::all(); - let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query)); + let keep = + |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || effectcraft_engine::effects::name_matches(e, &query)); let lookup = |id: &str| reg.iter().find(|e| e.id == id); // Favorites and Recently Used. diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 6ad12524..42f81c67 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -165,3 +165,34 @@ fn crosshair_and_eyedropper_pick_from_the_viewer() { } assert_eq!(c[3], 1.0); } + +/// Key Light's Screen Colour eyedropper picks from the effect's input: the shown frame is +/// already keyed (the default screen colour takes most of a green), so sampling it would miss. +#[test] +fn keyer_eyedropper_picks_the_screen_from_the_effect_input() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Key", "width": 320, "height": 180, "frameRate": 30, "duration": 1})).unwrap(); + let screen = s.execute("layer.newSolid", json!({"name": "Screen", "color": "#30b050"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Key Light"})).unwrap()["effects"][0].as_u64().unwrap(); + let prop = layer(&s, screen).effects().unwrap().groups().find(|g| g.uid == fx).and_then(|g| g.get("screenColour")).map(|p| p.uid).unwrap(); + s.state.selected_layers = vec![LayerId(screen)]; + 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); + h.state_mut().ui.fx_pick = Some(FxPick { kind: "color".into(), layer: screen, prop, name: "Screen Colour".into() }); + h.step(); + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + click(&mut h, pos); + assert!(h.state().ui.fx_pick.is_none(), "the pick is used up"); + let v = layer(&h.state().session, screen).effects().unwrap().find(prop).unwrap().value.clone(); + let KV::Color(c) = v else { panic!("{v:?}") }; + let want = [0x30 as f64 / 255.0, 0xb0 as f64 / 255.0, 0x50 as f64 / 255.0]; + for k in 0..3 { + assert!((c[k] - want[k]).abs() < 0.01, "{c:?} vs {want:?}"); + } + // The picked screen keys the whole solid out. + let s = &h.state().session; + let img = s.render(s.active_comp_id().unwrap(), s.time(), Default::default()); + assert!(img.get(160, 90)[3] < 0.01, "{:?}", img.get(160, 90)); +} diff --git a/docs/agents.md b/docs/agents.md index 8cc26a9e..37c39c99 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -94,6 +94,7 @@ Short recipes; every step is one tool call. | Title card | `execute_command comp.new {...}` → `execute_command layer.newText {"text":"Hi","size":120}` → `render_frame {"time":0}` | | Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` | | Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` | +| Key a green screen | `add_effect {"layer":2,"effect":"Keylight (1.2)"}` → `execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true}` (a screen pixel, layer space; samples the effect's input) → `set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10}` → `render_frame {"transparent":true}` | | Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` | | Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) | | Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` | diff --git a/docs/parity.md b/docs/parity.md index 6f20c92d..636c8359 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -189,7 +189,7 @@ Mocha and Cineware are third-party and not counted. | Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. | | Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. | | Simulation | CC Hair, Particle Playground | Particle Playground: cannon, grid, layer exploder, layer map, gravity, repel, wall, persistent property mapper (no Particle Exploder, text particles or ephemeral mapper yet). | -| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` finds it. | +| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` and the Effects & Presets search ("keylight") find it. Keyers' colour eyedroppers sample the effect's input (`effect.pickColor`), so a screen that is already keyed can still be picked. | | Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). | | Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. | @@ -422,6 +422,13 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph Editor keys are small squares whatever their interpolation, as in After Effects. +Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on a green-screen +plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip +Black / White, render). The colour eyedroppers of Keying effects sample the effect's input +rather than the keyed frame (Ctrl/Cmd+click averages 5 × 5 pixels); agents do the same with +`effect.pickColor {effect, param, x, y, average?}` (layer pixels). Searching "keylight" in +Effects & Presets, or `list_effects`, finds it. + ## Update: M4.9–M4.11 nested comps and motion blur - **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and