diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 650544d17..15a6628d4 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -201,6 +201,11 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// "Pre-comp N" with the lowest N no item already uses. +fn unused_precomp_name(proj: &effectcraft_project::Project) -> String { + (1u32..).map(|n| format!("Pre-comp {n}")).find(|n| !proj.items.values().any(|i| &i.name == n)).unwrap_or_else(|| "Pre-comp".to_string()) +} + /// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition") /// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage, /// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's @@ -212,7 +217,10 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(c, "no layers")); } - let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string(); + let name = match str_p(p, "name") { + Some(n) => n.to_string(), + None => unused_precomp_name(&s.project), + }; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let leave = match str_p(p, "mode").unwrap_or("move") { "move" => false, @@ -280,6 +288,15 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); c.layers.retain(|l| !ids.contains(&l.id)); + // Layers left behind must not keep pointing at layers that moved into the new comp. + for l in &mut c.layers { + if l.parent.is_some_and(|p| ids.contains(&p)) { + l.parent = None; + } + if l.track_matte.as_ref().is_some_and(|m| ids.contains(&m.layer)) { + l.track_matte = None; + } + } let lid = l.id; c.layers.insert(at.min(c.layers.len()), l); st.selected_layers = vec![lid]; diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index d30fc156a..6698cbd0b 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -2035,3 +2035,33 @@ fn pasting_a_mask_onto_its_own_layer_drops_the_source_vertex_selection() { s.execute("edit.clear", json!({})).unwrap(); assert_eq!(layer(&s, a).masks().unwrap().groups().count(), 1, "the pasted mask goes, the source stays"); } + +#[test] +fn precompose_unlinks_layers_left_behind_and_numbers_default_names() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap(); + let mk = |s: &mut Session, name: &str| { + s.execute("layer.newSolid", json!({"name": name, "color": "#fff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap() + }; + let (a, b, c, d) = (mk(&mut s, "A"), mk(&mut s, "B"), mk(&mut s, "C"), mk(&mut s, "D")); + s.execute("layer.setParent", json!({"layers": [c], "parent": a})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layers": [d], "matte": b, "kind": "alpha"})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b]})).unwrap(); + let comp = s.project.comp(cid).unwrap(); + assert_eq!(comp.layers.len(), 3); + for l in &comp.layers { + assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{} has a dangling parent", l.name); + assert!(l.track_matte.as_ref().is_none_or(|m| comp.layer(m.layer).is_some()), "{} has a dangling matte", l.name); + } + s.execute("layer.precompose", json!({"layers": [c, d]})).unwrap(); + let mut names: Vec = s + .project + .items + .values() + .filter(|i| matches!(i.kind, effectcraft_project::ItemKind::Comp(_)) && i.name.starts_with("Pre-comp")) + .map(|i| i.name.clone()) + .collect(); + names.sort(); + assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]); +}