M5.16: Key Light: keyer eyedroppers pick from the effect input (effect.pickColor); "keylight" search

- effect.pickColor {effect, param|prop, x, y (layer px), average?}: the colour of the effect's
  input at a point, set on the parameter (one undo step). Keying effects' Effect Controls
  eyedroppers use it, since the shown frame is already keyed; Ctrl/Cmd+click averages 5x5.
- effects::aliases / name_matches: Effects & Presets and effect.list (MCP list_effects) find
  Key Light by After Effects' name ("Keylight (1.2)").
- End-to-end test: a green-screen plate with an uneven screen, spill and soft edges keyed via
  effect.apply "Keylight (1.2)", pick, Clip Black / White and a render; UI eyedropper test.
- Docs: parity, agents (green-screen recipe).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 11:04:10 +07:00
1 parent 47f2a5a3e6
commit 73b2607c9a
10 files changed
+272 -10

No files matched your search

+3
View File
@@ -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.
+15 -4
View File
@@ -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))
+58 -3
View File
@@ -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<Value> {
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<f64> = (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<Value> {
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<Value> {
}
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let filter = str_p(p, "filter").map(str::to_lowercase);
let v: Vec<Value> = 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::<Vec<_>>()})
@@ -371,6 +417,15 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
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),
+2
View File
@@ -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;
+135
View File
@@ -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<Arc<Image>> {
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<Footage, String> {
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<f64> = 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");
}
+17 -1
View File
@@ -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)
{
+2 -1
View File
@@ -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<Apply> = 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.
@@ -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));
}
+1
View File
@@ -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"}` |
+8 -1
View File
@@ -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