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/anim.rs b/crates/engine/src/commands/anim.rs index 0747290e..7b830bf0 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -139,7 +139,14 @@ fn select_all(s: &mut Session, p: &Value) -> Result { let (cid, mut layers) = layers_p(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let mut sel = vec![]; - if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() { + if let Some(visible) = super::visible_p(p) { + // Ctrl+Alt+A in the Timeline: every key it shows (its revealed properties). + for (lid, uid) in visible { + if let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) { + sel.extend(pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time })); + } + } + } else if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() { for (lid, uid) in &s.state.selected_props { if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) { sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); @@ -422,7 +429,15 @@ pub fn specs() -> Vec { vec![ cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy), cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste), - cmd!("keys.selectAll", "Select All Keyframes", [], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all), + cmd!( + "keys.selectAll", + "Select All Keyframes", + [], + Some("Cmd+Alt+A"), + "{layers?, visible?: [{layer, prop}] (every key of these properties)}", + keys_or_layers, + select_all + ), cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge), cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))), cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))), 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/commands/mod.rs b/crates/engine/src/commands/mod.rs index 52c90e3e..546eaa44 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -517,6 +517,13 @@ pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec, cmd: &str) - } } +/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All +/// Keyframes use only those, as in After Effects). `None` when not given. +pub(crate) fn visible_p(p: &Value) -> Option> { + let a = p.get("visible")?.as_array()?; + Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect()) +} + /// `time` (seconds) or `frame` param, else the CTI. pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { if let Some(t) = f_p(p, "time") { diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 1c4c1c64..9ed41f47 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -71,6 +71,7 @@ fn go(s: &mut Session, p: &Value) -> Result { let t = s.time(); let to = str_p(p, "to").unwrap_or("start"); let sel_layer = s.state.selected_layers.first().and_then(|id| comp.layer(*id)); + let visible = super::visible_p(p); let target = match to { "start" => Tick::ZERO, "end" => comp.duration - comp.frame_duration(), @@ -78,6 +79,23 @@ fn go(s: &mut Session, p: &Value) -> Result { "workEnd" => comp.work_area.1 - comp.frame_duration(), "layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t), "layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t), + "nextKey" | "prevKey" if visible.is_some() => { + // What the Timeline shows: keys of its revealed properties, layer and comp markers + // and the work area (After Effects' "visible items"). + let mut times: Vec = comp.markers.iter().map(|m| m.time).collect(); + times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]); + for l in comp.layers.iter().filter(|l| !(comp.hide_shy && l.switches.shy)) { + times.extend(l.markers.iter().map(|m| l.comp_time(m.time))); + } + for (lid, uid) in visible.iter().flatten() { + if let Some(l) = comp.layer(*lid) + && let Some(pr) = l.props.find(*uid) + { + times.extend(pr.keys.iter().map(|k| l.comp_time(k.time))); + } + } + step_to(times, t, comp.frame_duration(), to == "nextKey") + } "nextKey" | "prevKey" => { // Keyframes (of visible/selected layers), markers and the work area, in comp time. let mut times: Vec = comp.markers.iter().map(|m| m.time).collect(); @@ -107,14 +125,7 @@ fn go(s: &mut Session, p: &Value) -> Result { times.extend(l.markers.iter().map(|m| l.comp_time(m.time))); } } - times.sort(); - times.dedup(); - let half = comp.frame_duration().0 / 2; - if to == "nextKey" { - times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t) - } else { - times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t) - } + step_to(times, t, comp.frame_duration(), to == "nextKey") } _ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")), }; @@ -122,6 +133,22 @@ fn go(s: &mut Session, p: &Value) -> Result { Ok(report(s)) } +/// The nearest time in `times` after (or before) `t`, more than half a frame away; `t` itself +/// when there is none. +fn step_to(mut times: Vec, t: Tick, frame: Tick, next: bool) -> Tick { + times.sort(); + times.dedup(); + let half = frame.0 / 2; + if next { times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t) } else { times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t) } +} + +/// `p` with `to` set (J / K pass their other parameters on). +fn with_to(p: &Value, to: &str) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["to"] = json!(to); + p +} + pub fn specs() -> Vec { vec![ cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set), @@ -134,8 +161,27 @@ pub fn specs() -> Vec { cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))), cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))), cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))), - cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))), - cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))), - cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go), + cmd!( + "time.nextKey", + "Go to Next Keyframe or Marker", + [], + Some("K"), + "{visible?: [{layer, prop}] (only these properties' keys, as the Timeline shows them)}", + has_comp, + |s, p| go(s, &with_to(p, "nextKey")) + ), + cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{visible?: [{layer, prop}]}", has_comp, |s, p| go( + s, + &with_to(p, "prevKey") + )), + cmd!( + "time.go", + "Go To", + [], + None, + "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}", + has_comp, + go + ), ] } 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/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 7f1fce5f..59c1403b 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1030,3 +1030,27 @@ fn j_and_k_stop_at_keys_markers_and_the_work_area() { let last = (3.0 * 30.0 - 1.0) / 30.0; assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]); } + +#[test] +fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + opacity_keys(&mut s, a, &[1.0]); + s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap(); + let op = layer(&s, a).props.prop("transform/opacity").unwrap().uid; + // Only Opacity is revealed: K skips the hidden Rotation key at 2 s, stopping at 1 s and then + // at the work area's end. + let visible = json!([{"layer": a, "prop": op}]); + s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap(); + assert_eq!(s.time().seconds(), 1.0); + s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap(); + assert!(s.time().seconds() > 3.9, "work area end, not the hidden key: {}", s.time().seconds()); + // Without `visible` (agents), every key of the layers counts, as before. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("time.nextKey", json!({})).unwrap(); + assert_eq!(s.time().seconds(), 2.0); + // Select All Keyframes: the shown properties' keys only. + s.execute_checked("keys.selectAll", json!({"visible": visible})).unwrap(); + assert_eq!(s.state.selected_keys.len(), 1); + assert_eq!(s.state.selected_keys[0].prop, op); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 68d62b58..77fa0016 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -196,6 +196,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if crate::panels::unsaved::guard(app, id, ¶ms) { return Ok(json!({"dialog": "unsavedChanges"})); } + // J / K and Select All Keyframes act on what the Timeline shows (its revealed properties). + if matches!(id, "time.nextKey" | "time.previousKey" | "keys.selectAll") + && ["visible", "layers", "layer", "prop"].iter().all(|k| params.get(*k).is_none()) + && let Some(c) = app.session.active_comp_arc() + { + let mut p = if params.is_object() { params.clone() } else { json!({}) }; + p["visible"] = json!(crate::panels::timeline::visible_props(app, &c)); + return run_engine(app, ctx, id, p); + } // New Camera/Light and Camera/Light Settings without parameters open their dialogs. if crate::panels::dialogs_3d::route(app, id, ¶ms)? { return Ok(Value::Null); 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/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index d34bace3..0b03ef39 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -63,6 +63,22 @@ struct Curve<'a> { } /// Properties shown: the selected ones, or every animated property of the selected layers. +/// Whether the speed graph shows: Edit Speed Graph, or Auto-Select Graph Type with only spatial +/// properties shown (Position, Anchor Point… move along a path: their speed is what to edit). +fn speed_graph(app: &EffectcraftApp, comp: &Comp) -> bool { + match app.ui.timeline.graph_mode.as_str() { + "speed" => true, + "value" => false, + _ => { + let props = shown_props(app, comp); + !props.is_empty() + && props.iter().all(|(lid, uid)| { + comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(|pr| pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_))) + }) + } + } +} + fn shown_props(app: &EffectcraftApp, comp: &Comp) -> Vec<(LayerId, u64)> { let st = &app.session.state; let numeric = |pr: &Property| !pr.value.components().is_empty() && pr.value.interpolates(); @@ -179,7 +195,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag()); p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d)); app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone()); - let speed = app.ui.timeline.graph_mode == "speed"; + let speed = speed_graph(app, comp); let cs = curves(app, comp, ectx, tm, plot, speed); // Vertical range: auto-zoom to the curves + handles, or the stored manual range. @@ -408,11 +424,25 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let y = bar.center().y; let mut x = bar.min.x + 8.0; let tl = &app.ui.timeline; - let (is_value, show_sel, auto_on) = (tl.graph_mode != "speed", tl.graph_show_selected, tl.graph_auto_zoom); - if text_button(app, ui, p, &mut x, y, "Value", is_value, "valueGraph", "Edit Value Graph") { + let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom); + if text_button( + app, + ui, + p, + &mut x, + y, + "Auto", + auto_type, + "autoSelectGraphType", + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + ) { + app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into(); + } + // Choosing a graph type turns Auto-Select off, as in After Effects. + if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") { app.ui.timeline.graph_mode = "value".into(); } - if text_button(app, ui, p, &mut x, y, "Speed", !is_value, "speedGraph", "Edit Speed Graph") { + if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { app.ui.timeline.graph_mode = "speed".into(); } x += 6.0; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d1a93618..f467b489 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -515,6 +515,18 @@ fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])> }) } +/// The properties the Timeline shows (revealed rows), as `{layer, prop}`: J / K and Select All +/// Keyframes act on these, as in After Effects. +pub(crate) fn visible_props(app: &EffectcraftApp, comp: &Comp) -> Vec { + build_rows(app, comp) + .iter() + .filter_map(|r| match r.kind { + RowKind::Prop { uid } => Some(json!({"layer": r.layer.0, "prop": uid})), + _ => None, + }) + .collect() +} + fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { let tl = &app.ui.timeline; let search = tl.search.trim().to_lowercase(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aea71dfe..d0744141 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -363,8 +363,9 @@ pub struct TimelineState { /// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…). #[serde(default)] pub reveal_props: BTreeSet, - /// Graph Editor: `value` or `speed` graph. - #[serde(default = "value_graph")] + /// Graph Editor: `auto` (Auto-Select Graph Type, the default: the speed graph when every + /// property shown is spatial, else the value graph), `value` or `speed`. + #[serde(default = "auto_graph")] pub graph_mode: String, /// Show only the selected properties (else every animated property of the selected layers). #[serde(default = "yes")] @@ -401,8 +402,8 @@ pub fn default_tl_columns() -> BTreeSet { ["av", "label", "num", "switches", "parent"].map(String::from).into_iter().collect() } -fn value_graph() -> String { - "value".into() +fn auto_graph() -> String { + "auto".into() } fn yes() -> bool { true @@ -423,7 +424,7 @@ impl Default for TimelineState { open_groups: BTreeSet::new(), reveal: vec![], reveal_props: BTreeSet::new(), - graph_mode: value_graph(), + graph_mode: auto_graph(), graph_show_selected: true, graph_auto_zoom: true, graph_range: None, 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/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index dd269d9f..55b2ab75 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -208,3 +208,31 @@ fn double_click_a_key_to_edit_its_value() { let k = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[1].clone(); assert_eq!((k.time.seconds(), k.value.as_f64()), (1.0, 100.0)); } + +#[test] +fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_position() { + let (mut h, id, _) = harness(); + // A Rotation key at 2 s on a property that isn't revealed: K skips it. + h.state_mut().session.execute("prop.addKey", json!({"layer": id.0, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap(); + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0)); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: egui::Key::K, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().session.time().seconds() > 3.9, "past the hidden key to the work area end: {}", h.state().session.time().seconds()); + // Auto-Select Graph Type: Position shows its speed (one curve), Opacity its value. + let s = &mut h.state_mut().session; + for (t, v) in [(0.0, [40.0, 90.0]), (1.0, [280.0, 90.0])] { + s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/position", "time": t, "value": v})).unwrap(); + } + let pos = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().uid; + s.execute("prop.select", json!({"layer": id.0, "prop": pos})).unwrap(); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + assert_eq!(h.state().ui.timeline.graph_mode, "auto"); + assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.0.0")).is_some()); + assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_none(), "speed graph: one curve"); + assert!(h.state().auto.find("timeline.graph.autoSelectGraphType").is_some()); + h.state_mut().ui.timeline.graph_mode = "value".into(); + h.run_steps(3); + assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_some(), "value graph: X and Y"); +} diff --git a/docs/agents.md b/docs/agents.md index 396d8aaf..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"}` | @@ -139,6 +140,8 @@ build real projects through these interfaces; they are worked examples of everyt `keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` at the current time (keys copied from several layers go to as many selected layers in order). + `time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act + only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do). * **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`, then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or diff --git a/docs/parity.md b/docs/parity.md index 3c1d5294..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. | @@ -415,8 +415,19 @@ Browser in the web app (landed in M13.10: browser storage and File System Access - **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected key's property, time and value. -Not yet: J / K and Select All Keyframes use every property of the layers, not only the revealed -ones; Graph Editor keys are drawn as squares whatever their interpolation; Auto-Select Graph Type. +Since (M5.15): J / K and Select All Keyframes use what the Timeline shows — the revealed +properties' keys, layer and comp markers and the work area (`visible: [{layer, prop}]` on +`time.nextKey` / `previousKey` / `keys.selectAll`; without it, agents get every property of the +layers); the Graph Editor has Auto-Select Graph Type (the default: the speed graph when only +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