Keyframes: a path pasted onto Position becomes a motion path (fixes #1425)

This commit is contained in:
Marco Arnulfo
2026-10-14 13:30:45 +02:00
parent 549e9d8251
commit 69d17d89af
4 changed files with 153 additions and 2 deletions
+89 -2
View File
@@ -1,8 +1,8 @@
//! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key
//! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs.
use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease};
use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease};
use effectcraft_project::{ItemId, LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if clip.is_empty() {
return Err(bad("keys.paste", "no keyframes have been copied"));
}
// A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425).
if let [c] = clip.as_slice()
&& let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value)
&& let Some(target) = path_target(s, p)?
{
return paste_path(s, p, target, path);
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
@@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path:
/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none.
pub(super) fn path_target(s: &Session, p: &Value) -> Result<Option<(ItemId, LayerId, Uid)>> {
let (cid, targets) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_));
let explicit = targets.first().and_then(|lid| {
let l = comp.layer(*lid)?;
let pr = match p.get("prop").and_then(Value::as_u64) {
Some(u) => l.props.find(u),
None => l.props.prop(str_p(p, "path")?),
}?;
Some((*lid, pr))
});
if p.get("prop").is_some() || p.get("path").is_some() {
return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid)));
}
let selected = s
.state
.selected_props
.iter()
.filter(|(lid, _)| targets.contains(lid))
.find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion));
Ok(selected.map(|(lid, uid)| (cid, *lid, *uid)))
}
/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per
/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first
/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the
/// vertices are pasted as they are (layer-space numbers, not converted to the target's space).
pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
if path.closed && !order.is_empty() {
order.push(0);
}
if order.is_empty() {
return Err(bad("keys.paste", "the copied path has no points"));
}
let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX);
let at = |xy: Option<&[f64; 2]>| {
let [x, y] = xy.copied().unwrap_or_default();
[x, y, 0.0]
};
let last = order.len() - 1;
let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order
.iter()
.enumerate()
.map(|(i, &v)| {
let [x, y, z] = at(path.vertices.get(v));
let value = match target.props.find(uid).map(|pr| &pr.value) {
Some(KValue::Vec2(_)) => KValue::Vec2([x, y]),
_ => KValue::Vec3([x, y, z]),
};
let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX))));
(time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last)
})
.collect();
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
let mut sel = vec![];
for (time, value, spatial_in, spatial_out, roving) in keys {
let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) };
match pr.keys.binary_search_by(|x| x.time.cmp(&time)) {
Ok(i) => pr.keys[i] = k,
Err(i) => pr.keys.insert(i, k),
}
sel.push(time);
}
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
retime_roving(&mut pr.keys, pr.spatial);
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
Ok(json!(st.selected_keys.len()))
})
}
/// Select every keyframe of the selected properties, or of the given / selected layers.
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, mut layers) = layers_p(s, p)?;
+7
View File
@@ -495,6 +495,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(clip) = s.state.link_clipboard.clone() {
return paste_links(s, clip);
}
// A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425).
if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice()
&& let Some(target) = super::anim::path_target(s, p)?
{
let path = path.clone();
return super::anim::paste_path(s, p, target, &path);
}
if !s.state.value_clipboard.is_empty() {
return paste_properties(s, p);
}
+54
View File
@@ -1396,6 +1396,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch()
assert_eq!(vals, vec![10.0, 0.0], "reversed");
}
/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion
/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI,
/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers
/// as they are, so the source layer's and the target parent's transforms don't move them.
#[test]
fn a_copied_path_pastes_onto_position_as_a_motion_path() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
// A moved and scaled source: converting to comp space would give 2v + (200, 100).
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap();
let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]);
s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap();
// B's parent would shift a converted Position by (0, −50).
let parent = solid(&mut s, "#0000ff");
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap();
let b = solid(&mut s, "#00ff00");
s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap();
let motion = |s: &Session, id: u64| {
let l = layer(s, id);
let pr = l.props.prop("transform/position").unwrap().clone();
pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::<Vec<_>>()
};
let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| {
let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]];
assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}");
for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() {
assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}");
assert_eq!(*roving, i != 0 && i != 4, "key {i} roving");
assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}");
}
assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI");
assert!(keys.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent");
};
// A Mask Path keyframe, pasted at 1 s.
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
check(motion(&s, b), 1.0);
// The same path as an unanimated mask, pasted at 5 s onto an unparented layer.
s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let c = solid(&mut s, "#ffffff");
s.execute("time.set", json!({"time": 5.0})).unwrap();
s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
check(motion(&s, c), 5.0);
}
#[test]
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
let mut s = comp();
+3
View File
@@ -746,6 +746,9 @@ them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel
way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks.
- **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste
Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties).
A Mask Path or shape Path (its value or a key) pasted onto Position becomes a motion path: a
key per vertex with the path's tangents, over 2 s from the CTI, the inner keys roving, the
vertices pasted as they are (layer space, as in After Effects) (#1425).
- **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected
key's property, time and value.