From 63cfcfbb907127ee75b6bb2aea14cb6d1409d0f4 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:13:18 +0200 Subject: [PATCH 1/2] Timeline: UU reveals an effect's changed parameters only The modified reveal showed every effect in full. It now compares each effect with a new instance and reveals the parameters whose value differs, plus the groups a new instance lacks (puppet meshes and pins, paint strokes). An effect whose plug-in is missing still shows in full. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/animation.rs | 41 +++++++++++++++++++++++-- crates/engine/src/tests_timeline.rs | 21 +++++++++++++ crates/ui-egui/src/panels/timeline.rs | 11 +++++-- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index c700123d..a9425683 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -349,6 +349,25 @@ fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec) { } } +/// What of effect group `g` differs from `new`, a new instance of the effect: parameters with +/// another value, and in full the groups a new instance lacks (puppet meshes and pins, paint +/// strokes: what the user made). +fn effect_changes(g: &PropGroup, new: &PropGroup, out: &mut Vec) { + for c in &g.children { + match c { + Node::Prop(pr) => { + if new.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) { + out.push(pr.uid); + } + } + Node::Group(sg) => match new.sub(&sg.match_id) { + Some(d) => effect_changes(sg, d, out), + None => out.push(sg.uid), + }, + } + } +} + fn reveal(s: &mut Session, p: &Value) -> Result { let kind = str_p(p, "kind").unwrap_or("keyframes").to_string(); let cid = super::comp_id(s, p)?; @@ -389,12 +408,30 @@ fn reveal(s: &mut Session, p: &Value) -> Result { found.push(pr.uid); } }); - // Modified: effects and masks count as modifications in full (AE reveals them). - if kind == "modified" && matches!(top.match_id.as_str(), "effects" | "masks") { + // Modified: masks count as modifications in full (AE reveals them). + if kind == "modified" && top.match_id == "masks" { for g in top.groups() { found.push(g.uid); } } + // Modified: an effect's parameters that differ from a new instance's. + if kind == "modified" && top.match_id == "effects" { + let size = effectcraft_render::effect_bounds(&s.project, comp, l).0; + for g in top.groups() { + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + }; + // A missing plug-in's defaults aren't known: the effect shows in full. + let Some(spec) = spec else { + found.push(g.uid); + continue; + }; + let mut next = 0; + let new = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), "", size); + effect_changes(g, &new, &mut found); + } + } if kind == "modified" && top.match_id == "text" { for g in top.group("animators").into_iter().flat_map(|a| a.groups()) { found.push(g.uid); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 009af9e5..665e18be 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -814,6 +814,27 @@ fn modified_reveal_takes_in_options_and_layer_styles() { assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]); } +/// UU shows the parameters of an effect that differ from a new instance's, not the whole effect: +/// a just-applied effect shows nothing, and a reset parameter goes again. +#[test] +fn modified_reveal_shows_changed_effect_parameters_only() { + let (mut s, l) = setup(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap(); + let modified = |s: &mut Session| -> Vec { + let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap(); + r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect() + }; + assert!(modified(&mut s).is_empty(), "{:?}", modified(&mut s)); + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/blurriness", "value": 12})).unwrap(); + assert_eq!(modified(&mut s), ["/effects/ec.blur.gaussian/blurriness"]); + // Keyframed at its default value: animated, so still shown. + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/blurriness", "value": 0})).unwrap(); + assert!(modified(&mut s).is_empty(), "{:?}", modified(&mut s)); + s.execute("prop.toggleAnimation", json!({"layer": l, "path": "effects/#1/blurriness"})).unwrap(); + assert_eq!(modified(&mut s), ["/effects/ec.blur.gaussian/blurriness"]); +} + /// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts /// the end key at the source's end, not at the extended out point (#290). #[test] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..8cb05101 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3980,6 +3980,8 @@ mod tests { let n_keys = app.ui.timeline.reveal_props.len(); assert_eq!(app.ui.timeline.reveal, vec!["props"]); assert!(n_keys >= 1); + let bx = app.session.state.selected_layers[0].0; + app.session.execute("prop.set", json!({"layer": bx, "path": "effects/#1/blurriness", "value": 12})).unwrap(); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too"); } @@ -4007,9 +4009,14 @@ mod tests { let at = |n: &str| rows.iter().position(|r| r == n).unwrap(); assert_eq!(rows[at("Plate") + 1].trim(), "Scale", "{rows:?}"); assert_eq!(rows[at("Box") + 1].trim(), "Position", "{rows:?}"); - // UU (modified), then U later: Box shows its keyframed properties only again. + // UU (modified): the effect's changed parameter only, under the effect twirled open. + // Then U later: Box shows its keyframed properties only again. + app.session.execute("prop.set", json!({"layer": bx, "path": "effects/#1/blurriness", "value": 12})).unwrap(); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); - assert!(labels(&app).iter().any(|r| r.trim() == ">Gaussian Blur"), "UU shows the effect"); + let rows = labels(&app); + let fx = rows.iter().position(|r| r.trim().ends_with("Gaussian Blur")).unwrap_or_else(|| panic!("UU shows the effect: {rows:?}")); + assert_eq!(rows[fx + 1].trim(), "Blurriness", "{rows:?}"); + assert!(!rows.iter().any(|r| r.trim() == "Blur Dimensions"), "unchanged parameters stay hidden: {rows:?}"); app.last_reveal = Some(("keyframes".into(), -10.0)); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); app.last_reveal = Some(("keyframes".into(), -10.0)); From 0c343212ae24b30174f7686e0d9b06e5f768550d Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:48:13 +0200 Subject: [PATCH 2/2] Timeline: a group twirled open by hand under a reveal shows all its properties Collapsing and expanding an effect that shows only its revealed parameters (U, UU) now lists every parameter. The next reveal on the layer filters it again. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 44 +++++++++++++++++++-------- crates/ui-egui/src/state.rs | 18 +++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8cb05101..2e7d35e5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -945,7 +945,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec } wanted.extend(picked.iter().copied()); } - revealed_rows(rows, l, &wanted, &shown_groups, &tl.open_groups); + revealed_rows(rows, l, &wanted, &shown_groups, &tl.open_groups, tl.full_groups.get(&l.id.0)); if has("waveform") && let Some(item) = super::waveform::audio_item(&app.session.project, l) { @@ -956,13 +956,22 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec /// Rows for the revealed properties `shown` of `l`, in tree order. Those inside an effect show /// under the effect and its groups, as in After Effects (Puppet ▸ Mesh 1 ▸ Deform ▸ Puppet /// Pin 1 ▸ Position: every pin's Position is told apart, #273); the others (Transform, masks…) -/// on their own. Groups in `skip` already show with their contents. -fn revealed_rows(rows: &mut Vec, l: &Layer, shown: &std::collections::BTreeSet, skip: &[u64], open: &std::collections::BTreeSet) { +/// on their own. Groups in `skip` already show with their contents; those in `full` (twirled +/// open by hand since the reveal) show all of theirs. +fn revealed_rows( + rows: &mut Vec, + l: &Layer, + shown: &std::collections::BTreeSet, + skip: &[u64], + open: &std::collections::BTreeSet, + full: Option<&std::collections::BTreeSet>, +) { struct Tree<'a> { l: &'a Layer, wanted: &'a dyn Fn(&Property) -> bool, skip: &'a [u64], open: &'a std::collections::BTreeSet, + full: Option<&'a std::collections::BTreeSet>, } fn holds(g: &PropGroup, wanted: &dyn Fn(&Property) -> bool) -> bool { g.children.iter().any(|c| match c { @@ -981,6 +990,10 @@ fn revealed_rows(rows: &mut Vec, l: &Layer, shown: &std::collections::BTree if !o { return; } + if t.full.is_some_and(|f| f.contains(&g.uid)) { + push_group(rows, t.l, g, depth + 1, t.open); + return; + } for c in &g.children { match c { Node::Prop(p) if (t.wanted)(p) => rows.push(Row { layer: t.l.id, depth: depth + 1, kind: RowKind::Prop { uid: p.uid } }), @@ -990,7 +1003,7 @@ fn revealed_rows(rows: &mut Vec, l: &Layer, shown: &std::collections::BTree } } let wanted = |p: &Property| shown.contains(&p.uid) && prop_visible(p, l); - let tree = Tree { l, wanted: &wanted, skip, open }; + let tree = Tree { l, wanted: &wanted, skip, open, full }; for c in &l.props.children { match c { Node::Group(fx) if fx.match_id == "effects" => { @@ -2177,7 +2190,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if *has_children { let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0)); if widgets::twirl(ui, tw, *open, egui::Id::new(("gtw", uid)), &t).clicked() { - ui_actions.push(UiAct::ToggleGroup(*uid)); + ui_actions.push(UiAct::ToggleGroup(row.layer.0, *uid)); } app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name); } @@ -2246,7 +2259,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); actions.push(("window.panel".into(), json!({"panel": "EffectControls"}))); } else { - ui_actions.push(UiAct::ToggleGroup(*uid)); + ui_actions.push(UiAct::ToggleGroup(row.layer.0, *uid)); } } } @@ -3085,6 +3098,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Twirled by hand, the layer shows its whole tree again. tl.reveal.clear(); tl.layer_reveal.remove(&id); + tl.full_groups.remove(&id); let open = !tl.open_layers.contains(&id); if open { tl.open_layers.insert(id); @@ -3103,11 +3117,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - UiAct::ToggleGroup(uid) => { - if !app.ui.timeline.open_groups.remove(&uid) { - app.ui.timeline.open_groups.insert(uid); - } - } + UiAct::ToggleGroup(layer, uid) => app.ui.timeline.toggle_group(layer, uid), UiAct::SetTime(tt) => app.session.set_time(tt), UiAct::EndMerge => app.session.history.merge_key = None, UiAct::ChangeRenderer => { @@ -3229,7 +3239,7 @@ fn layer_cells( enum UiAct { ToggleLayer(u64, bool), - ToggleGroup(u64), + ToggleGroup(u64, u64), SetTime(Tick), EndMerge, /// Composition Settings on its 3D Renderer tab. @@ -4017,6 +4027,16 @@ mod tests { let fx = rows.iter().position(|r| r.trim().ends_with("Gaussian Blur")).unwrap_or_else(|| panic!("UU shows the effect: {rows:?}")); assert_eq!(rows[fx + 1].trim(), "Blurriness", "{rows:?}"); assert!(!rows.iter().any(|r| r.trim() == "Blur Dimensions"), "unchanged parameters stay hidden: {rows:?}"); + // Collapsed and expanded by hand, the effect shows all its parameters; UU filters again. + let fx_uid = app.session.active_comp().unwrap().layer(LayerId(bx)).unwrap().effects().unwrap().groups().next().unwrap().uid; + app.ui.timeline.toggle_group(bx, fx_uid); + assert!(!labels(&app).iter().any(|r| r.trim() == "Blurriness"), "collapsed"); + app.ui.timeline.toggle_group(bx, fx_uid); + let rows = labels(&app); + assert!(rows.iter().any(|r| r.trim() == "Blurriness") && rows.iter().any(|r| r.trim() == "Blur Dimensions"), "{rows:?}"); + app.last_reveal = Some(("keyframes".into(), f64::MAX)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert!(!labels(&app).iter().any(|r| r.trim() == "Blur Dimensions"), "UU again shows the changed ones only"); app.last_reveal = Some(("keyframes".into(), -10.0)); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); app.last_reveal = Some(("keyframes".into(), -10.0)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d0a23279..a73e25cf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -305,6 +305,7 @@ impl TimelineState { /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { for id in layers { + self.full_groups.remove(id); if kinds.is_empty() { self.open_layers.remove(id); self.layer_reveal.remove(id); @@ -315,6 +316,18 @@ impl TimelineState { } self.reveal = kinds; } + + /// Twirl group `uid` of `layer` by hand. Opened under a reveal, it shows all its properties + /// (collapse and expand an effect that shows only its revealed parameters to see them all). + pub fn toggle_group(&mut self, layer: u64, uid: u64) { + if self.open_groups.remove(&uid) { + return; + } + self.open_groups.insert(uid); + if self.layer_reveal.contains_key(&layer) { + self.full_groups.entry(layer).or_default().insert(uid); + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -344,6 +357,10 @@ pub struct TimelineState { /// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…). #[serde(default)] pub reveal_props: BTreeSet, + /// Groups of a revealed layer (by layer id) twirled open by hand since the reveal: they show + /// their whole contents, not just the revealed properties. + #[serde(default)] + pub full_groups: BTreeMap>, /// 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")] @@ -414,6 +431,7 @@ impl Default for TimelineState { reveal: vec![], layer_reveal: BTreeMap::new(), reveal_props: BTreeSet::new(), + full_groups: BTreeMap::new(), graph_mode: auto_graph(), graph_show_selected: true, graph_auto_zoom: true,