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.
This commit is contained in:
Vikram Bhaskaran 2026-10-13 17:46:02 +11:00
parent 8c986a1bec
commit c46f80faa2
5 changed files with 123 additions and 23 deletions

View File

@ -159,10 +159,11 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
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)
}

View File

@ -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<LayerId>,
pub props: Vec<(LayerId, Uid)>,
pub keys: Vec<KeyRef>,
pub vertices: Vec<VertexRef>,
}
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<ItemId>,
/// Current time per comp (the CTI).
pub times: std::collections::BTreeMap<ItemId, Tick>,
/// Layer selection of the active comp. Other comps' selections are in [`Self::comp_selections`].
pub selected_layers: Vec<LayerId>,
/// Selections of comps that are not active, keyed by comp id (#1001).
#[serde(default)]
pub comp_selections: std::collections::BTreeMap<ItemId, CompSelection>,
/// Selected properties / groups (by uid) of selected layers.
pub selected_props: Vec<(LayerId, Uid)>,
pub selected_keys: Vec<KeyRef>,
@ -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<LayerId> = 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<LayerId> = p.comps().flat_map(|(_, c)| c.layers.iter().map(|l| l.id)).collect();
if let Some(c) = self.active_comp() {
let ids: Vec<LayerId> = 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));
}

View File

@ -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<u64> {
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<J> {
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<u64> = 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(),

View File

@ -151,7 +151,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
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<J, String> {
&& 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);

View File

@ -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,