refactor(engine): move commands/ to cmd/, keeping commands as an alias

This commit is contained in:
aiongg
2026-10-26 21:49:10 +00:00
parent 887af7f2c6
commit a3ea645fc6
78 changed files with 4 additions and 3 deletions
+144
View File
@@ -0,0 +1,144 @@
//! Quick Offset (After Effects 25.4): Ctrl/Cmd+Alt dragging selected layers or keyframes in the
//! Timeline staggers them in the order they were selected. The first stays, the last moves by the
//! whole drag and the ones between spread evenly, so they land on subframes; each layer's
//! selected keys move together.
use std::collections::HashMap;
use effectcraft_project::LayerId;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde_json::{Value, json};
use super::prop::{key_gesture, layer_shift, move_key_refs};
use super::{CommandSpec, b_p, bad, f_p, has_keys, has_layers, layers_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
/// `delta` (comp seconds) in ticks; out of range is an error, not a clamped drag.
fn delta_p(p: &Value, name: &str) -> Result<Tick> {
let d = f_p(p, "delta").ok_or_else(|| bad(name, "missing `delta` (comp seconds)"))?;
let t = (d * TICKS_PER_SECOND as f64).round();
if !t.is_finite() || t.abs() > Tick::MAX.0 as f64 {
return Err(bad(name, "`delta` is out of range"));
}
Ok(Tick(t as i64))
}
/// How far each of `n` layers moves, in order, when the last moves by `total`: `total × i / (n − 1)`
/// to the nearest tick, so the steps between them are equal.
fn shares(total: Tick, n: usize) -> Vec<Tick> {
let last = n.saturating_sub(1).max(1) as i128;
(0..n)
.map(|i| {
let x = total.0 as i128 * i as i128;
Tick(((x + x.signum() * (last / 2)) / last) as i64)
})
.collect()
}
/// `t + d`, or an error (`what` names the time) when that leaves the range of times.
fn shifted(t: Tick, d: Tick, name: &str, what: &str) -> Result<Tick> {
let v = t.0 as i128 + d.0 as i128;
if v < Tick::MIN.0 as i128 || v > Tick::MAX.0 as i128 {
return Err(bad(name, format!("{what} would move out of range")));
}
Ok(Tick(v as i64))
}
/// The layers to stagger, in order: unlocked, each once. At least two.
fn order(name: &str, layers: impl IntoIterator<Item = LayerId>, unlocked: impl Fn(LayerId) -> bool) -> Result<Vec<LayerId>> {
let mut out: Vec<LayerId> = vec![];
for l in layers {
if unlocked(l) && !out.contains(&l) {
out.push(l);
}
}
if out.len() < 2 {
return Err(bad(name, "select at least two unlocked layers (or keys on two layers), in the order to offset them"));
}
Ok(out)
}
/// `layer.quickOffset`: move the layers (`layers`, else the selection, in that order) by their
/// share of `delta`, the last layer's move. With `merge` and `fromStart`, `delta` is the whole drag
/// and every step applies to the layers as they were before it.
fn layers(s: &mut Session, p: &Value) -> Result<Value> {
const NAME: &str = "layer.quickOffset";
let d = delta_p(p, NAME)?;
let (cid, ids) = layers_p(s, p)?;
let merge = merge_p(p);
let gesture = b_p(p, "fromStart").unwrap_or(false) && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m));
let base = s.history.undo.last().filter(|_| gesture).map(|(_, b)| b.clone());
let start = base.clone().unwrap_or_else(|| s.project.clone());
let comp = start.comp(cid).ok_or(EngineError::NoComp)?;
let order = order(NAME, ids, |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?;
let moves: Vec<(LayerId, Tick)> = order.iter().copied().zip(shares(d, order.len())).collect();
s.edit("Quick Offset", merge, |proj, _| {
if let Some(b) = &base {
*proj = (**b).clone();
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for (id, d) in &moves {
let l = comp.layer_mut(*id).ok_or(EngineError::NoComp)?;
let what = format!("layer \"{}\"", l.name);
let times = (shifted(l.start_time, *d, NAME, &what)?, shifted(l.in_point, *d, NAME, &what)?, shifted(l.out_point, *d, NAME, &what)?);
(l.start_time, l.in_point, l.out_point) = times;
}
Ok(json!({"layers": moves.iter().map(|(id, d)| json!({"layer": id.0, "offset": d.seconds()})).collect::<Vec<_>>()}))
})
}
/// `keys.quickOffset`: the selected keys of each layer move together by the layer's share of
/// `delta` (comp seconds, the last layer's move), the layers in the order their first key was
/// selected. Like `keys.move`, steps sharing `merge` are one drag; with `fromStart`, `delta` is the
/// whole drag.
fn keys(s: &mut Session, p: &Value) -> Result<Value> {
const NAME: &str = "keys.quickOffset";
let d = delta_p(p, NAME)?;
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let merge = merge_p(p);
let (from, total, base) = key_gesture(s, merge, d, b_p(p, "fromStart").unwrap_or(false));
let start = base.clone().unwrap_or_else(|| s.project.clone());
let comp = start.comp(cid).ok_or(EngineError::NoComp)?;
let order = order(NAME, from.iter().map(|k| k.layer), |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?;
let by_layer: HashMap<LayerId, Tick> = order.iter().copied().zip(shares(total, order.len())).collect();
let next = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
let moved = s.edit("Quick Offset Keyframes", merge, |proj, st| {
if let Some(b) = &base {
*proj = (**b).clone();
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
st.selected_keys = move_key_refs(comp, &from, |l, t| {
let Some(d) = by_layer.get(&l.id) else { return Ok(t) };
let what = format!("a key of \"{}\"", l.name);
shifted(l.start_time, *d, NAME, &what)?;
shifted(t, layer_shift(l, *d), NAME, &what)
})?;
Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::<Vec<_>>()}))
})?;
// Only a step that applied counts towards the drag (a refused one is as if never sent).
s.key_move = next;
Ok(moved)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"layer.quickOffset",
"Quick Offset",
[],
None,
"{delta (comp s: the last layer's move), layers? (in order; default the selection), merge?, fromStart?: bool (with merge: delta is the whole drag)}",
has_layers,
layers
),
cmd!(
"keys.quickOffset",
"Quick Offset Keyframes",
[],
None,
"{delta (comp s: the last layer's keys' move), merge?, fromStart?: bool (with merge: delta is the whole drag)}",
has_keys,
keys
),
]
}