Merge pull request #48 from storytold/graph-auto-visible-keys

M5.15–M5.16: Timeline-visible J/K and Select All, Graph Editor Auto-Select Graph Type, Key Light eyedropper
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 11:05:02 +07:00
commit a008a904af
19 files changed
+474 -34

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))
+17 -2
View File
@@ -139,7 +139,14 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
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}))),
+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),
+7
View File
@@ -517,6 +517,13 @@ pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec<LayerId>, 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<Vec<(LayerId, effectcraft_project::Uid)>> {
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") {
+57 -11
View File
@@ -71,6 +71,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
"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<Tick> = 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<Tick> = comp.markers.iter().map(|m| m.time).collect();
@@ -107,14 +125,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Tick>, 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<CommandSpec> {
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<CommandSpec> {
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
),
]
}
+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");
}
+24
View File
@@ -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);
}
+9
View File
@@ -196,6 +196,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::unsaved::guard(app, id, &params) {
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, &params)? {
return Ok(Value::Null);
+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.
+34 -4
View File
@@ -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;
+12
View File
@@ -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<serde_json::Value> {
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<Row> {
let tl = &app.ui.timeline;
let search = tl.search.trim().to_lowercase();
+6 -5
View File
@@ -363,8 +363,9 @@ pub struct TimelineState {
/// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…).
#[serde(default)]
pub reveal_props: BTreeSet<u64>,
/// 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<String> {
["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,
@@ -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));
}
+28
View File
@@ -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");
}
+3
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"}` |
@@ -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
+14 -3
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. |
@@ -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