From 69d17d89afead1ce66d10429c661fb74be5571bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:25:46 +0200 Subject: [PATCH] Keyframes: a path pasted onto Position becomes a motion path (fixes #1425) --- crates/engine/src/commands/anim.rs | 91 +++++++++++++++++++++++++++- crates/engine/src/commands/edit.rs | 7 +++ crates/engine/src/tests_menu_cmds.rs | 54 +++++++++++++++++ docs/target-app-parity.md | 3 + 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3ea27b0d..32584511 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -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 { 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 { }) } +/// 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> { + 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 { + 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 = (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 = 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 { let (cid, mut layers) = layers_p(s, p)?; diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 06ba43b1..10bb10d1 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -495,6 +495,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { 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); } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f..6310e252 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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::>() + }; + 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(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89464bcd..2301fae7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.