mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
keys.quickOffset and keys.move stored the drag's new total before the edit ran. When the edit refused the step (keys.quickOffset with a key landing past the end of time), the project stayed as it was but the total kept the refused delta, and with incremental steps (merge without fromStart) the next step moved by it too. The drag state is now stored only after the edit applies. Test: a_refused_step_does_not_count_towards_the_drag (+1 s, a refused step, -1 s: the keys end where they started; fails before: the last key ends near the end of time).
145 lines
6.5 KiB
Rust
145 lines
6.5 KiB
Rust
//! 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
|
||
),
|
||
]
|
||
}
|