M5.15: J / K and Select All Keyframes use what the Timeline shows; Graph Editor Auto-Select Graph Type

- J / K (Go to Previous / Next Keyframe) and Ctrl+Alt+A (Select All Keyframes) act on what the
  Timeline shows, as in After Effects: the keys of its revealed properties, layer and comp
  markers and the work area (they used every property of the selected or all layers, revealed
  or not). The commands take `visible: [{layer, prop}]`, which the Timeline fills in; agents
  calling them without it keep the previous behaviour.
- Graph Editor ▸ Auto-Select Graph Type (an "Auto" button, on by default as in After Effects):
  the speed graph when every property shown is spatial (Position, Anchor Point…), else the
  value graph; choosing Value or Speed turns it off.
- Docs: parity, agents.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 10:36:55 +07:00
1 parent c90fd35ff8
commit 47f2a5a3e6
11 files changed
+202 -24

No files matched your search

+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}))),
+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
),
]
}
+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);
+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,
+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");
}
+2
View File
@@ -139,6 +139,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
+6 -2
View File
@@ -415,8 +415,12 @@ 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.
## Update: M4.9–M4.11 nested comps and motion blur