Files
effectcraft/crates/engine/src/cmd/quick_offset.rs
T

145 lines
6.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
),
]
}