diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 3ef2b299..10051b42 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -1,7 +1,7 @@ //! Edit menu. use effectcraft_color::Label; -use effectcraft_project::{Expression, ItemKind, Layer, LayerId, LayerSource}; +use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -60,8 +60,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { if let Some(r) = super::puppet::select_all_of_kind(s) { return Ok(r); } + // Locked layers (and shy layers while they are hidden) can't be selected. if let Some(c) = s.active_comp() { - s.state.selected_layers = c.layers.iter().map(|l| l.id).collect(); + s.state.selected_layers = c.layers.iter().filter(|l| super::selectable(c, l)).map(|l| l.id).collect(); } Ok(json!(s.state.selected_layers.len())) } @@ -73,6 +74,17 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { Ok(Value::Null) } +/// Copied layers keep their parent and track matte: the copy of that layer when it was copied +/// too, else the layer itself when it is in `comp`, else none. `map`: (original, copy). +fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) { + let present: Vec = comp.layers.iter().map(|l| l.id).collect(); + let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id)); + for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) { + l.parent = l.parent.and_then(link); + l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m })); + } +} + /// Fresh ids for a copied layer (and its property uids). pub(crate) fn reid(layer: &mut Layer, next: &mut u64) { layer.id = LayerId(*next); @@ -98,14 +110,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; let mut created = vec![]; + let mut map = vec![]; for id in &ids { let Some(i) = comp.layers.iter().position(|l| l.id == *id) else { continue }; let mut l = comp.layers[i].clone(); reid(&mut l, &mut next); l.name = comp.unique_layer_name(&l.name); created.push(l.id); + map.push((*id, l.id)); comp.layers.insert(i, l); } + relink_copies(comp, &map); proj.next_id = next; st.selected_layers = created.clone(); Ok(created) @@ -133,12 +148,20 @@ fn delete(s: &mut Session, p: &Value) -> Result { return s.execute("effect.remove", json!({})); } let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "edit.clear")?; + // Children of deleted layers are unparented where they are (like After Effects). + let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; + let orphans: Vec = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect(); + let fixes = super::layer::parent_fixes(s, cid, &orphans, None); s.edit("Clear", None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; comp.layers.retain(|l| !ids.contains(&l.id)); for l in &mut comp.layers { if l.parent.is_some_and(|p| ids.contains(&p)) { l.parent = None; + if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { + f.apply(l); + } } if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) { l.track_matte = None; @@ -374,14 +397,16 @@ fn paste(s: &mut Session, p: &Value) -> Result { let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0); let mut created = vec![]; + let mut map = vec![]; for (k, mut l) in clip.into_iter().enumerate() { + let original = l.id; reid(&mut l, &mut next); l.name = comp.unique_layer_name(&l.name); - l.parent = None; - l.track_matte = None; created.push(l.id); + map.push((original, l.id)); comp.layers.insert(at + k, l); } + relink_copies(comp, &map); proj.next_id = next; st.selected_layers = created.clone(); Ok(created) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 42ceafe1..8d8daa4e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -9,7 +9,7 @@ use effectcraft_project::{ use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p}; +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p, unlocked}; use crate::{EngineError, Result, Session, cmd}; pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { @@ -315,17 +315,17 @@ fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result { } let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)); let n = comp.layers.len() as i64; - let i = match cur { - Some(i) => (i as i64 + dir).clamp(0, n - 1), - None => { - if dir > 0 { - 0 - } else { - n - 1 - } - } - } as usize; - let id = comp.layers[i].id; + // The next layer that can be selected (locked and hidden shy layers are stepped over); at + // the end of the stack the selection stays. + let start = match cur { + Some(i) => i as i64 + dir, + None if dir > 0 => 0, + None => n - 1, + }; + let next = + std::iter::successors(Some(start), |i| Some(i + dir)).take_while(|i| (0..n).contains(i)).find(|i| super::selectable(comp, &comp.layers[*i as usize])); + let Some(i) = next.or(cur.map(|i| i as i64)) else { return Ok(Value::Null) }; + let id = comp.layers[i as usize].id; let add = b_p(p, "add").unwrap_or(false); if add { if !s.state.selected_layers.contains(&id) { @@ -513,6 +513,101 @@ fn track_matte(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// How a 2D layer's transform changes so it stays where it is when its parent changes. +pub(crate) struct ParentFix { + pub layer: LayerId, + /// Maps Position from the old parent's space into the new one's. + m: effectcraft_geom::Mat3, + /// Added to Rotation. + rotation: f64, + /// Multiply Scale. + scale: [f64; 2], +} + +impl ParentFix { + /// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space. + pub fn apply(&self, l: &mut Layer) { + let Some(tr) = l.props.sub_mut("transform") else { return }; + let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { + pr.value = f(&pr.value); + for key in &mut pr.keys { + key.value = f(&key.value); + } + }; + let m = self.m; + if tr.get("positionX").is_some() { + // Separated dimensions: X and Y move together, key by key at the same times. + // Everything is read before anything is written. + let [x, y] = [tr.get("positionX"), tr.get("positionY")].map(|p| p.map(|p| p.value.as_f64()).unwrap_or(0.0)); + let q = m.apply(effectcraft_geom::vec2(x, y)); + let keys_x: Vec<(Tick, f64)> = tr.get("positionX").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); + let keys_y: Vec<(Tick, f64)> = tr.get("positionY").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default(); + let at = |keys: &[(Tick, f64)], name: &str, t: Tick, tr: &PropGroup| { + keys.iter().find(|k| k.0 == t).map(|k| k.1).unwrap_or_else(|| tr.get(name).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0)) + }; + let (xs, ys): (Vec, Vec) = ( + keys_x.iter().map(|&(t, x)| m.apply(effectcraft_geom::vec2(x, at(&keys_y, "positionY", t, tr))).x).collect(), + keys_y.iter().map(|&(t, y)| m.apply(effectcraft_geom::vec2(at(&keys_x, "positionX", t, tr), y)).y).collect(), + ); + for (name, value, vals) in [("positionX", q.x, xs), ("positionY", q.y, ys)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(value); + for (k, v) in pr.keys.iter_mut().zip(vals) { + k.value = KV::Scalar(v); + } + } + } + } else if let Some(pr) = tr.get_mut("position") { + map(pr, &|v| { + let c = v.as_vec3(); + let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); + KV::Vec3([q.x, q.y, c[2]]) + }); + } + let (dr, k) = (self.rotation, self.scale); + if let Some(pr) = tr.get_mut("rotation") { + map(pr, &|v| KV::Scalar(v.as_f64() + dr)); + } + if let Some(pr) = tr.get_mut("scale") { + map(pr, &|v| { + let c = v.as_vec3(); + KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) + }); + } + } +} + +/// Like the pick-whip, a parent change keeps 2D layers where they are: their transform is +/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current +/// time. +pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { + let Some(comp) = s.project.comp(cid) else { return vec![] }; + let ectx = + effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let space = |id: Option| -> effectcraft_geom::Mat3 { + id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) + }; + let decompose = |m: &effectcraft_geom::Mat3| { + let a = m.0; + let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); + let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; + (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) + }; + let new_m = space(parent); + let Some(inv) = new_m.inverse() else { return vec![] }; + let (rn, sxn, syn) = decompose(&new_m); + comp.layers + .iter() + .filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) + .map(|l| { + let old_m = space(l.parent); + let (ro, sxo, syo) = decompose(&old_m); + let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; + ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale } + }) + .collect() +} + fn set_parent(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -533,59 +628,13 @@ fn set_parent(s: &mut Session, p: &Value) -> Result { // Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed // in the new parent's space (Position, Rotation, Scale), unless `compensate: false`. let compensate = b_p(p, "compensate").unwrap_or(true); - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; - let space = |id: Option| -> effectcraft_geom::Mat3 { - id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) - }; - let decompose = |m: &effectcraft_geom::Mat3| { - let a = m.0; - let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); - let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; - (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) - }; - // Per layer: (id, position transform, rotation delta, scale factors). - let mut fixes: Vec<(LayerId, effectcraft_geom::Mat3, f64, [f64; 2])> = vec![]; - if compensate { - let new_m = space(parent); - for l in comp.layers.iter().filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) { - let old_m = space(l.parent); - let Some(inv) = new_m.inverse() else { continue }; - let (ro, sxo, syo) = decompose(&old_m); - let (rn, sxn, syn) = decompose(&new_m); - let k = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; - fixes.push((l.id, inv * old_m, ro - rn, k)); - } - } + let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { l.parent = parent; - let Some((_, m, dr, k)) = fixes.iter().find(|f| f.0 == l.id) else { continue }; - let Some(tr) = l.props.sub_mut("transform") else { continue }; - let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { - pr.value = f(&pr.value); - for key in &mut pr.keys { - key.value = f(&key.value); - } - }; - if tr.get("positionX").is_none() - && let Some(pr) = tr.get_mut("position") - { - map(pr, &|v| { - let c = v.as_vec3(); - let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); - KV::Vec3([q.x, q.y, c[2]]) - }); - } - if let Some(pr) = tr.get_mut("rotation") { - map(pr, &|v| KV::Scalar(v.as_f64() + dr)); - } - if let Some(pr) = tr.get_mut("scale") { - map(pr, &|v| { - let c = v.as_vec3(); - KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) - }); + if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { + f.apply(l); } } Ok(()) @@ -696,6 +745,7 @@ fn slip(s: &mut Session, p: &Value) -> Result { fn arrange(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, "layer.arrange")?; let how = str_p(p, "to").unwrap_or("front").to_string(); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); // `above`: put the layers right above that one (the timeline's drag to reorder). @@ -712,10 +762,29 @@ fn arrange(s: &mut Session, p: &Value) -> Result { }; s.edit("Arrange Layers", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let n = comp.layers.len(); + // Bring Forward / Send Backward move each layer one step past the next layer that isn't + // moving (a run of selected layers moves as one; one already at the end stays). + if above.is_none() && index.is_none() && matches!(how.as_str(), "forward" | "backward") { + let picked = |l: &Layer| ids.contains(&l.id); + let n = comp.layers.len(); + if how == "forward" { + for i in 1..n { + if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { + let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); + comp.layers[i - 1..run_end].rotate_left(1); + } + } + } else { + for i in (0..n.saturating_sub(1)).rev() { + if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { + let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); + comp.layers[run_start..=i + 1].rotate_right(1); + } + } + } + return Ok(()); + } let mut picked: Vec = Vec::new(); - let first_idx = comp.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); - let last_idx = comp.layers.iter().rposition(|l| ids.contains(&l.id)).unwrap_or(0); comp.layers.retain(|l| { if ids.contains(&l.id) { picked.push(l.clone()); @@ -727,13 +796,9 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let at = match (how.as_str(), index) { _ if above.is_some() => comp.layers.iter().position(|l| Some(l.id) == above).unwrap_or(comp.layers.len()), (_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()), - ("front", _) => 0, ("back", _) => comp.layers.len(), - ("forward", _) => first_idx.saturating_sub(1), - ("backward", _) => (last_idx + 2 - picked.len()).min(comp.layers.len()), _ => 0, }; - let _ = n; for (k, l) in picked.into_iter().enumerate() { comp.layers.insert((at + k).min(comp.layers.len()), l); } @@ -999,8 +1064,26 @@ fn set_text(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions +/// are separated. +pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { + const SEPARATE: [&str; 3] = ["positionX", "positionY", "positionZ"]; + if tr.get("positionX").is_some() { + let v = f(SEPARATE.map(|m| tr.get(m).map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0))); + for (m, x) in SEPARATE.iter().zip(v) { + if let Some(pr) = tr.get_mut(m) { + pr.set_value_at(lt, KV::Scalar(x)); + } + } + } else if let Some(pr) = tr.get_mut("position") { + let v = f(pr.value_at(lt).as_vec3()); + pr.set_value_at(lt, KV::Vec3(v)); + } +} + fn transform_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, "layer.transform")?; let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); @@ -1021,7 +1104,7 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { match op.as_str() { "reset" => { set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); - set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])); + update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]); set(tr, "scale", KV::Vec3([100.0; 3])); set(tr, "rotation", KV::Scalar(0.0)); set(tr, "rotationX", KV::Scalar(0.0)); @@ -1029,16 +1112,19 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { set(tr, "orientation", KV::Vec3([0.0; 3])); set(tr, "opacity", KV::Scalar(100.0)); } - "center" => set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])), + // Centred in the frame; a 3D layer keeps its depth. + "center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]), "fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => { let (sx, sy) = match op.as_str() { "fitWidth" => (cw / w, cw / w), "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; - set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, 100.0])); - set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); + let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); + let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); + set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); + update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); + set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index d32bb970..5ad0c1fc 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -209,6 +209,7 @@ fn set_transform(s: &mut Session, p: &Value) -> Result { fn center_anchor(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "layer.centerAnchor")?; let t = s.time(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t); @@ -234,17 +235,25 @@ fn center_anchor(s: &mut Session, p: &Value) -> Result { if let Some(a) = tr.get_mut("anchor") { a.set_value_at(lt, KV::Vec3([c[0], c[1], anchor[2]])); } - if let Some(pos) = tr.get_mut("position") { - // Shift every key so the layer stays put over its whole animation. - if pos.keys.is_empty() { - let v = pos.value.as_vec3(); - pos.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]); - } else { - for k in &mut pos.keys { - let v = k.value.as_vec3(); - k.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]); + // Shift every key so the layer stays put over its whole animation (X and Y Position + // when the dimensions are separated). + let offset = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { + pr.value = f(&pr.value); + for k in &mut pr.keys { + k.value = f(&k.value); + } + }; + if tr.get("positionX").is_some() { + for (m, d) in [("positionX", shift[0]), ("positionY", shift[1])] { + if let Some(pr) = tr.get_mut(m) { + offset(pr, &|v| KV::Scalar(v.as_f64() + d)); } } + } else if let Some(pr) = tr.get_mut("position") { + offset(pr, &|v| { + let c = v.as_vec3(); + KV::Vec3([c[0] + shift[0], c[1] + shift[1], c[2]]) + }); } } Ok(()) diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4b6cba0e..52c90e3e 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -497,6 +497,26 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) } +/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not +/// locked, and not hidden by the composition's Hide Shy Layers. +pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool { + !(l.switches.locked || (comp.hide_shy && l.switches.shy)) +} + +/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects. +/// An error when every one of them is locked. +pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec, cmd: &str) -> Result> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked); + match ids.iter().find(|id| locked(id)) { + Some(first) if ids.iter().all(locked) => { + let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default(); + Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)"))) + } + _ => Ok(ids.into_iter().filter(|id| !locked(id)).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") { diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 5d2e61ba..545147fa 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -542,3 +542,132 @@ fn layer_settings_edit_the_solid_or_give_the_layer_its_own() { s.execute("layer.newText", json!({"text": "T"})).unwrap(); assert!(!s.is_enabled("layer.settings")); } + +fn order(s: &Session) -> Vec { + s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect() +} + +fn pos(s: &Session, l: u64) -> [f64; 3] { + let ly = layer(s, l); + let tr = ly.transform().unwrap(); + match tr.get("positionX") { + Some(x) => [x.value.as_f64(), tr.get("positionY").unwrap().value.as_f64(), tr.get("positionZ").map(|z| z.value.as_f64()).unwrap_or(0.0)], + None => tr.get("position").unwrap().value.as_vec3(), + } +} + +#[test] +fn locked_layers_are_not_selected_deleted_or_moved() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + assert!(layer(&s, b).switches.locked); + s.execute("edit.selectAll", json!({})).unwrap(); + assert_eq!(s.state.selected_layers.iter().map(|l| l.0).collect::>(), vec![a]); + // Ctrl+Up / Down step over it. + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("layer.selectPrevious", json!({})).unwrap(); + assert_eq!(s.state.selected_layers[0].0, a, "nothing above the locked layer to step to"); + // Deleting, arranging or transforming only the locked layer is refused; with others, it stays. + let e = s.execute("edit.clear", json!({"layers": [b]})).unwrap_err().to_string(); + assert!(e.contains("locked"), "{e}"); + assert!(s.execute("layer.arrange", json!({"layers": [b], "to": "back"})).is_err()); + assert!(s.execute("layer.transform", json!({"layers": [b], "op": "center"})).is_err()); + s.execute("edit.clear", json!({"layers": [a, b]})).unwrap(); + assert_eq!(order(&s), vec![b]); +} + +#[test] +fn deleting_a_parent_keeps_its_children_in_place() { + let mut s = comp(); + let parent = solid(&mut s, "#ff0000"); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [300, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90})).unwrap(); + let child = solid(&mut s, "#00ff00"); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [210, 160]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); + assert_ne!(pos(&s, child)[..2], [210.0, 160.0], "expressed in the parent's space"); + s.execute("edit.clear", json!({"layers": [parent]})).unwrap(); + let p = pos(&s, child); + assert!((p[0] - 210.0).abs() < 1e-6 && (p[1] - 160.0).abs() < 1e-6, "{p:?}"); + assert_eq!(layer(&s, child).parent, None); + let r = layer(&s, child).transform().unwrap().get("rotation").unwrap().value.as_f64(); + assert!(r.abs() < 1e-6, "{r}"); + // One undo step restores both. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, child).parent.map(|l| l.0), Some(parent)); +} + +#[test] +fn bring_forward_and_send_backward_move_each_layer_one_step() { + let mut s = comp(); + let ids: Vec = (0..4).map(|_| solid(&mut s, "#ffffff")).collect(); + let top = order(&s); + assert_eq!(top, ids.iter().rev().copied().collect::>()); + let (l0, l1, l2, l3) = (top[0], top[1], top[2], top[3]); + // A non-contiguous selection: each moves down one step. + s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "backward"})).unwrap(); + assert_eq!(order(&s), vec![l1, l0, l3, l2]); + // Forward again; a layer already at the top stays there. + s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap(); + assert_eq!(order(&s), vec![l0, l1, l2, l3]); + s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap(); + assert_eq!(order(&s), vec![l0, l2, l1, l3]); + // Front / Back keep the layers' order. + s.execute("layer.arrange", json!({"layers": [l0, l1], "to": "back"})).unwrap(); + assert_eq!(order(&s), vec![l2, l3, l0, l1]); +} + +#[test] +fn transform_commands_keep_depth_and_follow_separated_dimensions() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20, -300]})).unwrap(); + s.execute("layer.transform", json!({"layers": [a], "op": "center"})).unwrap(); + assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]); + s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); + assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]); + let b = solid(&mut s, "#00ff00"); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + s.execute("layer.transform", json!({"layers": [b], "op": "center"})).unwrap(); + assert_eq!(pos(&s, b)[..2], [320.0, 180.0]); + s.execute("layer.transform", json!({"layers": [b], "op": "reset"})).unwrap(); + assert_eq!(pos(&s, b)[..2], [320.0, 180.0]); + // Center Anchor Point moves separated X / Y Position too, so the layer stays put. + s.execute("prop.set", json!({"layer": b, "path": "transform/anchor", "value": [0, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/positionX", "value": 100})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/positionY", "value": 50})).unwrap(); + s.execute("layer.centerAnchor", json!({"layers": [b]})).unwrap(); + assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); +} + +#[test] +fn duplicated_and_pasted_layers_keep_their_links() { + let mut s = comp(); + let parent = solid(&mut s, "#ff0000"); + let matte = solid(&mut s, "#ffffff"); + let child = solid(&mut s, "#00ff00"); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": child, "matte": matte, "kind": "alpha"})).unwrap(); + let m = layer(&s, child).track_matte.unwrap().layer; + assert_eq!(m.0, matte); + // Duplicating the child alone keeps the original parent and matte. + let d = s.execute("edit.duplicate", json!({"layers": [child]})).unwrap()[0].as_u64().unwrap(); + assert_eq!((layer(&s, d).parent.map(|l| l.0), layer(&s, d).track_matte.map(|t| t.layer)), (Some(parent), Some(m))); + // Duplicating parent and child together: the copy follows the copied parent. + let r = s.execute("edit.duplicate", json!({"layers": [parent, child]})).unwrap(); + let (p2, c2) = (r[0].as_u64().unwrap(), r[1].as_u64().unwrap()); + assert_eq!(layer(&s, c2).parent.map(|l| l.0), Some(p2)); + // Copy / paste in the same composition keeps the links; in another one they are dropped. + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let pasted = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap(); + assert_eq!(layer(&s, pasted).parent.map(|l| l.0), Some(parent)); + assert_eq!(layer(&s, pasted).track_matte.map(|t| t.layer), Some(m)); + s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64})).unwrap(); + let other = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap(); + assert_eq!((layer(&s, other).parent, layer(&s, other).track_matte), (None, None)); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c29e7a49..090cadbe 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1288,7 +1288,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect())); lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col); } - if row_resp.drag_started() { + // Locked layers can't be selected or moved (unlock them with the lock switch). + let locked = layer.switches.locked; + if row_resp.drag_started() && !locked { // The selected layers move together; an unselected row moves alone. let moving: Vec = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; @@ -1298,7 +1300,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true)); } app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name); - if row_resp.clicked() { + if row_resp.clicked() && !locked { let m = ui.input(|i| i.modifiers); actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command, "add": m.shift}))); } diff --git a/docs/agents.md b/docs/agents.md index 2a389b52..48ab8edd 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -111,6 +111,10 @@ build real projects through these interfaces; they are worked examples of everyt * **Name things when you create them**: `layer.newText`, `layer.newShape`, `layer.newSolid`, `layer.newNull`, `layer.newCamera` and `layer.newLight` take `name`, so later calls can say `"layer": "Title"`. A reference that matches no layer is an error, never a silent no-op. +* **Locked layers** are left alone: `edit.clear`, `layer.arrange`, `layer.transform` and + `layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select + All and `layer.selectNext` / `selectPrevious` step over them. Unlock with + `layer.setSwitch {"layers": [...], "switch": "lock", "value": false}` or `layer.unlockAll`. * **Solid Settings**: `layer.settings {layer, name?, color?, width?, height?, pixelAspect?, affectAll?}` edits a solid or adjustment layer's solid (and renames the layer and the solid). A solid shared by several layers (duplicates share theirs) changes for all of them unless diff --git a/docs/parity.md b/docs/parity.md index ad33fa5e..e83bb462 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -383,6 +383,24 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: M3.11 layer commands + +- **Lock**: locked layers can't be selected (a Timeline click, Select All, Ctrl+Up / Down step + over them, as do shy layers while hidden) and are left alone by Clear / Delete, Arrange, the + Layer ▸ Transform commands and Center Anchor Point (an error when every target is locked). +- **Deleting a parent** unparents its children where they are (their Position, Rotation and + Scale are re-expressed in the composition, like the pick-whip), in the same undo step; they + used to jump. +- **Bring Forward / Send Backward** move each selected layer one step past the next unselected + layer, so a non-contiguous selection keeps its gaps (it was squeezed into one block). +- **Layer ▸ Transform**: Center In View and Fit to Comp keep a 3D layer's depth (Z was reset to + 0) and Fit keeps Scale / Anchor Z; Reset, Center and Fit set X / Y Position when the dimensions + are separated (they changed the hidden combined Position, so nothing moved); Center Anchor + Point keeps layers with separated dimensions in place. +- **Duplicate and paste** keep parents and track mattes: a copy follows the copy of its parent / + matte when both were copied, else the original in the same composition (paste dropped them + always). + ## Update: M3.10 Layer Settings and settings dialogs Layer ▸ Layer Settings on a solid or adjustment layer opens Solid Settings on the layer's solid