mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
Composition viewer interaction parity with After Effects:
- Snapping (View > Snapping, the Tools bar checkbox, Cmd/Ctrl inverts while
dragging): layer edges, centres and anchor points, mask and shape-path
vertices, comp edges/centre, guides (Snap to Guides) and grid (Snap to
Grid), with highlighted target box / line / cross feedback. Pure maths in
engine::viewer (nearest target within tolerance, priority ties).
- Rulers (Cmd+R) with live pointer markers and a draggable zero point;
guides dragged out of the rulers, moved in the viewer, dragged off to
remove (view.moveGuide / view.removeGuide), lock respected; proportional
grid.
- Show Channel (RGB/Red/Green/Blue/Alpha/RGB Straight, Colorize), exposure
(+/- stops in linear light, reset), Take Snapshot (Shift+F5) with a stored
render and Show Snapshot (F5 held / button held), Fast Previews (Off,
Adaptive Resolution, Draft, Fast Draft, Wireframe), Resolution > Custom.
- Region of interest: drawn in the viewer; RenderOpts.roi renders only that
rectangle (crop of the full render, tested), Crop Comp to ROI uses it.
- Bottom bar in AE 2026 order ("136.1 %", "(Full)").
- Shape Pen (SHP-1): Pen on a shape layer / nothing selected draws Shape n
groups (Path 1, Stroke 1, Fill 1) via shape.newPath; the G slot cycles
Add / Delete / Convert Vertex and Mask Feather; mask.* vertex commands
work on shape paths; mask.insertVertex / mask.convertVertex; marquee
vertex selection, Alt-drag/click converts, double-click free transform
box (path.freeTransform on masks and shape paths).
- Motion paths (ANM-3): keyframe dots and spatial tangent handles, dragging
keys (keys.set) and tangents (keys.setSpatialTangents), parented layers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
7.3 KiB
Rust
154 lines
7.3 KiB
Rust
//! Keyframe geometry edits from the viewer and the Graph Editor: the Graph Editor transform box
|
|
//! (scale / move selected keys in time and value; Alt-drag of a key group in the timeline) and
|
|
//! spatial Bezier tangents dragged on a motion path.
|
|
|
|
use effectcraft_keyframe::Keyframe;
|
|
use effectcraft_project::{LayerId, Uid};
|
|
use effectcraft_time::Tick;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::prop::prop_ref;
|
|
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, merge_p};
|
|
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
|
|
|
fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
|
let a = v?.as_array()?;
|
|
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
|
|
}
|
|
|
|
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
|
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
|
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
|
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
|
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
|
let fr = comp.frame_rate;
|
|
let sel = s.state.selected_keys.clone();
|
|
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
|
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
|
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
|
let voff = f_p(p, "valueOffset").unwrap_or(0.0);
|
|
let va = f_p(p, "valueAnchor").unwrap_or(0.0);
|
|
// `dim` (one dimension) or `dims` (several); none = every dimension.
|
|
let dims: Option<Vec<usize>> = match (p.get("dim").and_then(Value::as_u64), p.get("dims").and_then(Value::as_array)) {
|
|
(Some(d), _) => Some(vec![d as usize]),
|
|
(None, Some(a)) => Some(a.iter().filter_map(Value::as_u64).map(|d| d as usize).collect()),
|
|
_ => None,
|
|
};
|
|
if !ts.is_finite() || ts <= 0.0 {
|
|
return Err(bad("keys.transform", "`timeScale` must be positive"));
|
|
}
|
|
// Default time anchor: the earliest selected key (comp time).
|
|
let first = sel.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time))).min().unwrap_or(Tick::ZERO);
|
|
let ta = f_p(p, "timeAnchor").map(Tick::from_seconds_f64).unwrap_or(first);
|
|
let map_t = |ct: Tick| -> Tick {
|
|
let x = ta.seconds() + (ct.seconds() - ta.seconds()) * ts + toff;
|
|
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
|
};
|
|
let touch_values = vs != 1.0 || voff != 0.0;
|
|
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
|
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
|
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
|
for k in &sel {
|
|
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
|
}
|
|
let mut new_sel = vec![];
|
|
for ((lid, uid), times) in groups {
|
|
let Some(layer) = comp.layer_mut(lid) else { continue };
|
|
let (start, stretch) = (layer.start_time, layer.stretch);
|
|
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
|
let to_layer = |ct: Tick| {
|
|
let d = ct - start;
|
|
if (stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / stretch) as i64) }
|
|
};
|
|
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
|
let (mut moved, mut kept): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
|
for k in &mut moved {
|
|
k.time = to_layer(map_t(to_comp(k.time)));
|
|
if touch_values {
|
|
let mut c = k.value.components();
|
|
for (d, v) in c.iter_mut().enumerate() {
|
|
if dims.as_ref().is_none_or(|x| x.contains(&d)) {
|
|
*v = va + (*v - va) * vs + voff;
|
|
}
|
|
}
|
|
k.value = k.value.with_components(&c);
|
|
}
|
|
}
|
|
// Transformed keys win over unselected keys at the same time.
|
|
kept.retain(|k| !moved.iter().any(|m| m.time == k.time));
|
|
moved.sort_by_key(|k| k.time);
|
|
moved.dedup_by_key(|k| k.time);
|
|
for k in &moved {
|
|
new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
|
}
|
|
kept.extend(moved);
|
|
kept.sort_by_key(|k| k.time);
|
|
pr.keys = kept;
|
|
effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial);
|
|
}
|
|
st.selected_keys = new_sel;
|
|
Ok(json!({"keys": st.selected_keys.len()}))
|
|
})
|
|
}
|
|
|
|
/// Spatial tangents of one key of a spatial property (motion path handles), relative to the key
|
|
/// value. Setting one side only mirrors it to the other unless `break` is true; the key stops
|
|
/// being auto Bezier.
|
|
fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (cid, lid, uid) = prop_ref(s, p, "keys.setSpatialTangents")?;
|
|
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setSpatialTangents", "missing `time` (layer s)"))?);
|
|
let (tin, tout) = (v3(p.get("in")), v3(p.get("out")));
|
|
if tin.is_none() && tout.is_none() {
|
|
return Err(bad("keys.setSpatialTangents", "pass `in` and/or `out` [dx, dy, dz?]"));
|
|
}
|
|
let brk = b_p(p, "break").unwrap_or(false);
|
|
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
|
let l = super::layer_mut(proj, cid, lid)?;
|
|
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
|
if !pr.spatial {
|
|
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
|
}
|
|
let i = pr.keys.iter().position(|k| k.time == t).or_else(|| pr.keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i));
|
|
let i = i.ok_or_else(|| bad("keys.setSpatialTangents", "the property has no keyframes"))?;
|
|
let (cur_in, cur_out) = effectcraft_keyframe::spatial_tangents(&pr.keys, i);
|
|
let k = &mut pr.keys[i];
|
|
let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]];
|
|
let (ni, no) = match (tin, tout) {
|
|
(Some(a), Some(b)) => (a, b),
|
|
(Some(a), None) => (a, if brk { cur_out } else { neg(a) }),
|
|
(None, Some(b)) => (if brk { cur_in } else { neg(b) }, b),
|
|
(None, None) => (cur_in, cur_out),
|
|
};
|
|
k.spatial_auto = false;
|
|
k.spatial_continuous = !brk && tin.is_some() != tout.is_some();
|
|
k.spatial_in = ni;
|
|
k.spatial_out = no;
|
|
effectcraft_keyframe::retime_roving(&mut pr.keys, true);
|
|
Ok(json!({"in": ni, "out": no}))
|
|
})
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"keys.transform",
|
|
"Transform Keyframes",
|
|
[],
|
|
None,
|
|
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
|
has_keys,
|
|
transform
|
|
),
|
|
cmd!(
|
|
"keys.setSpatialTangents",
|
|
"Edit Spatial Tangents",
|
|
[],
|
|
None,
|
|
"{layer?, path|prop, time (layer s), in?: [dx,dy,dz?], out?, break?, merge?}",
|
|
has_comp,
|
|
spatial_tangents
|
|
),
|
|
]
|
|
}
|