mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
753de070a0
commit
89653f37de
8 files changed
+381
-88
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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})));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user