From 8c986a1bec195b847ccc4828062d51d6b2b47136 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:07:27 +1100 Subject: [PATCH 1/2] Keep closed comps' layer selections across scripts (#1001) sanitize_state dropped every selected layer not in the active comp at the end of each script run, so a layer selected by script in a comp that was not open was gone by the next script call. Every comp keeps its own selection as in After Effects; only ids gone from every comp are dropped now. UI and engine lookups resolve selections per comp and tolerate foreign ids. --- crates/engine/src/lib.rs | 8 +++++--- crates/script/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6243e44..5f3593ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -757,15 +757,17 @@ impl Session { true } - /// Drop selections that no longer exist. + /// Drop selections that no longer exist. Every comp keeps its own layer selection (After + /// Effects), so selections in comps that are not open survive; only ids that are gone from + /// every comp are dropped (#1001). pub fn sanitize_state(&mut self) { let p = self.project.clone(); self.state.open_comps.retain(|c| p.comp(*c).is_some()); if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) { self.state.active_comp = self.state.open_comps.first().copied(); } - if let Some(c) = self.active_comp() { - let ids: Vec = c.layers.iter().map(|l| l.id).collect(); + { + let ids: Vec = p.comps().flat_map(|(_, c)| c.layers.iter().map(|l| l.id)).collect(); self.state.selected_layers.retain(|l| ids.contains(l)); self.state.selected_props.retain(|(l, _)| ids.contains(l)); self.state.selected_keys.retain(|k| ids.contains(&k.layer)); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820..c18faee7 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -698,6 +698,36 @@ fn group_selected_false_deselects_masks_and_effects() { assert_eq!(out.result, json!([[true, true], [false, true], [false, false]])); } +/// A layer selected by script in a comp that is not open stays selected for the next script: +/// every comp keeps its own layer selection (#1001). +#[test] +fn layer_selection_in_a_closed_comp_survives_to_the_next_script() { + let mut s = session(); + let out = ok( + &mut s, + r#" + var a = app.project.items.addComp("Active", 400, 300, 1, 2, 25); + a.openInViewer(); + var c = app.project.items.addComp("Other", 400, 300, 1, 2, 25); + c.layers.addText("A"); + c.layers.addText("B"); + c.layer("B").selected = true; + c.selectedLayers.length + " " + c.layer("B").selected + "#, + ); + assert_eq!(out.result, json!("1 true")); + // A second script call, the way a panel talks to the app over the control channel. + let out = ok( + &mut s, + r#" + var c = null; + for (var k = 1; k <= app.project.numItems; k++) { if (app.project.item(k).name === "Other") c = app.project.item(k); } + c.selectedLayers.length + " " + c.layer("B").selected + "#, + ); + assert_eq!(out.result, json!("1 true")); +} + /// Probes every file as a 64x32 still (the media layer's single-file probe). struct Stills; impl effectcraft_engine::Importer for Stills { From c46f80faa296b8f002c224e830bd95debc78a6bf Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 17:46:02 +1100 Subject: [PATCH 2/2] Store each comp's layer selection on its own. A single selected_layers list kept ids from every comp, so the active comp looked selected whenever another comp was. Opening a comp and running a script against a different comp now park and restore that selection. --- crates/engine/src/commands/comp.rs | 9 +-- crates/engine/src/lib.rs | 104 +++++++++++++++++++++++++---- crates/script/src/model.rs | 25 ++++++- crates/script/src/runtime.rs | 4 +- crates/script/src/tests.rs | 4 +- 5 files changed, 123 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 3fcf9c6d..c611f453 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -159,10 +159,11 @@ fn close(s: &mut Session, p: &Value) -> Result { let id = comp_id(s, p)?; s.state.open_comps.retain(|c| *c != id); if s.state.active_comp == Some(id) { - s.state.active_comp = s.state.open_comps.last().copied(); - s.state.selected_layers.clear(); - s.state.selected_keys.clear(); - s.state.selected_props.clear(); + if let Some(next) = s.state.open_comps.last().copied() { + s.activate_comp(next); + } else { + s.activate_comp_none(); + } } Ok(Value::Null) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5f3593ac..5e3edc54 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -194,6 +194,22 @@ pub struct VertexRef { pub index: usize, } +/// One composition's selection. The active comp's copy lives in [`EditorState::selected_layers`] +/// (and the property, key, and vertex lists); every other comp keeps its own here (#1001). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CompSelection { + pub layers: Vec, + pub props: Vec<(LayerId, Uid)>, + pub keys: Vec, + pub vertices: Vec, +} + +impl CompSelection { + fn is_empty(&self) -> bool { + self.layers.is_empty() && self.props.is_empty() && self.keys.is_empty() && self.vertices.is_empty() + } +} + /// Editing state that commands depend on (headless-relevant, serde for agents). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct EditorState { @@ -202,7 +218,11 @@ pub struct EditorState { pub open_comps: Vec, /// Current time per comp (the CTI). pub times: std::collections::BTreeMap, + /// Layer selection of the active comp. Other comps' selections are in [`Self::comp_selections`]. pub selected_layers: Vec, + /// Selections of comps that are not active, keyed by comp id (#1001). + #[serde(default)] + pub comp_selections: std::collections::BTreeMap, /// Selected properties / groups (by uid) of selected layers. pub selected_props: Vec<(LayerId, Uid)>, pub selected_keys: Vec, @@ -757,17 +777,29 @@ impl Session { true } - /// Drop selections that no longer exist. Every comp keeps its own layer selection (After - /// Effects), so selections in comps that are not open survive; only ids that are gone from - /// every comp are dropped (#1001). + /// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id + /// counts only inside the comp that owns it, so the active comp never sees another comp's layers. pub fn sanitize_state(&mut self) { let p = self.project.clone(); self.state.open_comps.retain(|c| p.comp(*c).is_some()); + self.state.comp_selections.retain(|cid, sel| { + let Some(c) = p.comp(*cid) else { return false }; + let ids: Vec = c.layers.iter().map(|l| l.id).collect(); + sel.layers.retain(|l| ids.contains(l)); + sel.props.retain(|(l, _)| ids.contains(l)); + sel.keys.retain(|k| ids.contains(&k.layer)); + sel.vertices.retain(|v| ids.contains(&v.layer)); + !sel.is_empty() + }); if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) { + self.clear_live_selection(); self.state.active_comp = self.state.open_comps.first().copied(); + if let Some(id) = self.state.active_comp { + self.recall_selection(id); + } } - { - let ids: Vec = p.comps().flat_map(|(_, c)| c.layers.iter().map(|l| l.id)).collect(); + if let Some(c) = self.active_comp() { + let ids: Vec = c.layers.iter().map(|l| l.id).collect(); self.state.selected_layers.retain(|l| ids.contains(l)); self.state.selected_props.retain(|(l, _)| ids.contains(l)); self.state.selected_keys.retain(|k| ids.contains(&k.layer)); @@ -872,6 +904,59 @@ impl Session { } } + /// Make `id` the active comp, parking the previous comp's selection and restoring this one's. + pub fn activate_comp(&mut self, id: ItemId) { + if self.project.comp(id).is_none() || self.state.active_comp == Some(id) { + return; + } + self.remember_selection(); + self.state.active_comp = Some(id); + self.recall_selection(id); + self.state.current_track = None; + } + + /// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`]. + pub fn activate_comp_none(&mut self) { + self.remember_selection(); + self.state.active_comp = None; + self.clear_live_selection(); + self.state.current_track = None; + } + + fn remember_selection(&mut self) { + let Some(id) = self.state.active_comp else { return }; + let sel = CompSelection { + layers: self.state.selected_layers.clone(), + props: self.state.selected_props.clone(), + keys: self.state.selected_keys.clone(), + vertices: self.state.selected_vertices.clone(), + }; + if sel.is_empty() { + self.state.comp_selections.remove(&id); + } else { + self.state.comp_selections.insert(id, sel); + } + } + + fn recall_selection(&mut self, id: ItemId) { + match self.state.comp_selections.remove(&id) { + Some(sel) => { + self.state.selected_layers = sel.layers; + self.state.selected_props = sel.props; + self.state.selected_keys = sel.keys; + self.state.selected_vertices = sel.vertices; + } + None => self.clear_live_selection(), + } + } + + fn clear_live_selection(&mut self) { + self.state.selected_layers.clear(); + self.state.selected_props.clear(); + self.state.selected_keys.clear(); + self.state.selected_vertices.clear(); + } + /// Open a comp in the viewer/timeline and make it active. pub fn open_comp(&mut self, id: ItemId) { if self.project.comp(id).is_none() { @@ -880,14 +965,7 @@ impl Session { if !self.state.open_comps.contains(&id) { self.state.open_comps.push(id); } - if self.state.active_comp != Some(id) { - self.state.active_comp = Some(id); - self.state.selected_layers.clear(); - self.state.selected_props.clear(); - self.state.selected_keys.clear(); - self.state.selected_vertices.clear(); - self.state.current_track = None; - } + self.activate_comp(id); self.events.push(Event::OpenComp(id)); } diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf..2066a274 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -96,6 +96,27 @@ fn label_index(l: Label) -> usize { /// A comp's selected properties as `[layer, uid]`: the selected ones, then those with selected /// keyframes (selecting a keyframe selects its property in After Effects, so scripts find /// selected keys through `selectedProperties`). +/// Layer ids selected in `comp`, including a comp that is not the active one (#1001). +fn selected_layer_ids(s: &Session, comp: effectcraft_project::ItemId) -> Vec { + let layers = if s.active_comp_id() == Some(comp) { + &s.state.selected_layers + } else { + match s.state.comp_selections.get(&comp) { + Some(sel) => &sel.layers, + None => return Vec::new(), + } + }; + let Some(c) = s.project.comp(comp) else { return Vec::new() }; + layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect() +} + +fn layer_selected(s: &Session, comp: effectcraft_project::ItemId, layer: effectcraft_project::LayerId) -> bool { + if s.active_comp_id() == Some(comp) { + return s.state.selected_layers.contains(&layer); + } + s.state.comp_selections.get(&comp).is_some_and(|sel| sel.layers.contains(&layer)) +} + fn selected_props(s: &Session, c: &effectcraft_project::Comp) -> Vec { let mut v: Vec<(u64, u64)> = vec![]; let keyed = s.state.selected_keys.iter().map(|k| (k.layer, k.prop)); @@ -129,7 +150,7 @@ fn item(s: &Session, a: &J) -> R { ItemKind::Comp(c) => { let cid = id; o["kind"] = json!("comp"); - let sel: Vec = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect(); + let sel = selected_layer_ids(s, cid); let fps = c.frame_rate.as_f64(); if let Some(m) = o.as_object_mut() { m.extend( @@ -237,7 +258,7 @@ fn layer(s: &Session, a: &J) -> R { "isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)), "label": label_index(l.label), "comment": l.comment, - "selected": s.state.selected_layers.contains(&l.id), + "selected": layer_selected(s, cid, l.id), "autoOrient": format!("{:?}", l.auto_orient), "width": w, "height": h, "hasVideo": l.source.is_av(), diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index c7559856..cf785bce 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -151,7 +151,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { if let Some(c) = target && prev != Some(c) { - s.state.active_comp = Some(c); + s.activate_comp(c); } if CREATES_LAYER.contains(&id) { s.state.selected_layers.clear(); @@ -163,7 +163,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { && id != "comp.open" { match prev { - Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p), + Some(p) if s.project.comp(p).is_some() => s.activate_comp(p), _ => { if !s.state.open_comps.contains(&c) { s.state.open_comps.push(c); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index c18faee7..e7d02c26 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -712,10 +712,10 @@ fn layer_selection_in_a_closed_comp_survives_to_the_next_script() { c.layers.addText("A"); c.layers.addText("B"); c.layer("B").selected = true; - c.selectedLayers.length + " " + c.layer("B").selected + c.selectedLayers.length + " " + c.layer("B").selected + " " + a.selectedLayers.length "#, ); - assert_eq!(out.result, json!("1 true")); + assert_eq!(out.result, json!("1 true 0")); // A second script call, the way a panel talks to the app over the control channel. let out = ok( &mut s,