M3.11: layer commands respect locks, keep children in place on delete, arrange step by step, keep depth and separated dimensions, keep links on duplicate/paste

- Locked layers can't be selected (Timeline click, Select All, Ctrl+Up/Down; shy layers while
  hidden too) and are left alone by Clear/Delete, Arrange, Layer ▸ Transform and Center Anchor
  Point, which fail when every target is locked (`commands::unlocked`, `commands::selectable`).
- Deleting a parent unparents its children where they are, in the same undo step: the
  pick-whip compensation moved out of `layer.setParent` into `layer::parent_fixes` /
  `ParentFix::apply`, which now also handles separated X/Y Position.
- Bring Forward / Send Backward move each selected layer one step (a non-contiguous selection
  was squeezed into one block).
- Center In View / Fit to Comp keep a 3D layer's Z (and Fit keeps Scale/Anchor Z); Reset /
  Center / Fit / Center Anchor Point work with separated dimensions (`layer::update_position`).
- Duplicate and paste keep parents and track mattes, following the copies when both were
  copied; paste dropped them always.
- Docs: agents, parity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-04 23:33:54 +07:00
1 parent 753de070a0
commit 89653f37de
8 files changed
+381 -88

No files matched your search

+29 -4
View File
@@ -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<Value> {
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<Value> {
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<LayerId> = 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<Value> {
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<Value> {
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<LayerId> = 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<Value> {
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)
+159 -73
View File
@@ -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<Value> {
}
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<Value> {
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<f64>, Vec<f64>) = (
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<LayerId>) -> Vec<ParentFix> {
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<LayerId>| -> 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<Value> {
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<Value> {
// 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<LayerId>| -> 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<Value> {
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
};
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<Layer> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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]);
+18 -9
View File
@@ -209,6 +209,7 @@ fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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(())
+20
View File
@@ -497,6 +497,26 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)>
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<LayerId>, cmd: &str) -> Result<Vec<LayerId>> {
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") {
+129
View File
@@ -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<u64> {
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<_>>(), 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<u64> = (0..4).map(|_| solid(&mut s, "#ffffff")).collect();
let top = order(&s);
assert_eq!(top, ids.iter().rev().copied().collect::<Vec<_>>());
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));
}
+4 -2
View File
@@ -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<u64> =
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})));
}
+4
View File
@@ -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
+18
View File
@@ -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