diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c803c27a..009cc59c 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension")); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Ease In-Out")); - assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}"); + // In-Out (the default) on every key: each pair gets the whole curve, as before. + assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}"); assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset")); assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset"); @@ -203,10 +204,298 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { s.execute("edit.deselectAll", json!({})).unwrap(); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Linear")); - assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property"); + assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it"); assert_eq!(s.history.undo.len(), steps); } +/// Select only the keys at `keys` (property path, time in seconds). +fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) { + s.execute("edit.deselectAll", json!({})).unwrap(); + let keys: Vec = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); +} + +/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply. +fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) { + for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) { + set(s, win, name, json!(v.to_string())); + } + set(s, win, "mode", json!(mode)); + click(s, win, "apply"); +} + +fn eased(e: Ease, speed: f64, influence: f64) -> bool { + close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9) +} + +/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both, +/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its +/// neighbours, key times, values or the selection; one undo step restores it. +#[test] +fn one_selected_keyframe_eases_its_in_out_or_both_sides() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + // In-Out is the default. + assert_eq!(control(&mut s, win, "mode")["selection"], json!([2])); + let before = prop(&s, l, "transform/opacity"); + for mode in ["In", "Out", "In-Out"] { + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + let selected = s.state.selected_keys.clone(); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]); + assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve")); + assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step"); + assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change"); + assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}"); + let k = &op.keys[1]; + let (ins, outs) = (mode != "Out", mode != "In"); + assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}"); + assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}"); + if ins { + assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease); + } else { + assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly"); + } + if outs { + assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease); + } else { + assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone"); + } +} + +/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes +/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several +/// properties, each ease on their own. +#[test] +fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] { + select_keys(&mut s, l, &[("transform/opacity", t)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, c); + assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}")); + assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step"); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + } + // In-Out on the first key eases only its out side. + select_keys(&mut s, l, &[("transform/opacity", 0.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier)); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert_eq!(op.keys[1..], before.keys[1..]); + s.execute("edit.undo", json!({})).unwrap(); + // The first and last Opacity keys (not neighbours) and the last Rotation key. + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, "In-Out", c); + assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve"); + assert_eq!(s.history.undo.len(), steps + 1); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease); + assert_eq!(op.keys[1], before.keys[1], "the unselected middle key"); + let rot = prop(&s, l, "transform/rotation"); + assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease); + assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear)); +} + +/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides +/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only +/// out sides. +#[test] +fn a_selected_pair_eases_the_sides_its_mode_names() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6)); + assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease); + assert_eq!(op.keys[2], before.keys[2]); + s.execute("edit.undo", json!({})).unwrap(); + apply_typed(&mut s, win, "Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3)); + assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear)); +} + +/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an +/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too. +#[test] +fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType; + t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD); + t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + t.rotation.setTemporalAutoBezierAtKey(2, true);"#, + ); + let rot = prop(&s, l, "transform/rotation"); + let after: Vec = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect(); + let win = open_panel(&mut s); + set(&mut s, win, "mode", json!("In")); + select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold)); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease); + assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds"); + let eased_rot = prop(&s, l, "transform/rotation"); + assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease); + for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) { + assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}"); + } + assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); +} + +/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set +/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased. +#[test] +fn roving_keyframes_are_skipped_and_reported() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)"); + let pos = prop(&s, l, "transform/position"); + assert!(pos.keys[1].roving); + let roving_at = pos.keys[1].time.seconds(); + let win = open_panel(&mut s); + // Only the roving key: nothing changes, no undo step. + select_keys(&mut s, l, &[("transform/position", roving_at)]); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them"); + assert_eq!(s.history.undo.len(), steps); + assert_eq!(prop(&s, l, "transform/position").keys, pos.keys); + // With an ordinary key: that one is eased, the roving one is counted as skipped. + select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]); + set(&mut s, win, "presets", json!("Linear")); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!( + status(&mut s, win), + "Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them" + ); + let k = &prop(&s, l, "transform/position").keys[1]; + assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}"); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); +} + +/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state. +#[test] +fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType; + r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + r.setTemporalAutoBezierAtKey(2, true);"#, + ); + let before = prop(&s, l, "transform/rotation"); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/rotation", 1.0)]); + apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]); + let after = prop(&s, l, "transform/rotation"); + assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease); + for t in [0.25, 0.5, 0.75] { + assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}"); + } + assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys); +} + +/// On a curved path, a single key's in and out speeds follow the length of the path on each side +/// (unequal lengths and durations), and the side not eased plays as it did. +#[test] +fn a_single_key_on_a_curved_path_eases_along_each_side() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]); + let before = prop(&s, l, "transform/position"); + let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1)); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/position", 1.0)]); + apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]); + let pos = prop(&s, l, "transform/position"); + let k = &pos.keys[1]; + // In speed: twice the incoming segment's average (its path length over 1 s). + assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease); + // The out side (2 s along a longer path) plays as before. + for t in [1.5, 2.0, 2.5] { + let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2()); + assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}"); + } + // Playback on the in side: arriving at twice the average speed, the last tenth of a second + // covers more of the path than a constant speed would (len_in / 10). + let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2()); + let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt(); + assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}"); + assert!(len_out > len_in, "{len_out} {len_in}"); +} + +/// On one key, each side scales to its own segment: per dimension, and along the motion path. +#[test] +fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + // Scale: +100/−50 per second into the key, 0/+50 per second out of it. + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]); + // Position on a straight path: 100 px/s into the key, 50 px/s out of it. + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]); + apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve"); + let k = prop(&s, l, "transform/scale").keys[1].clone(); + assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease); + assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease); + let k = prop(&s, l, "transform/position").keys[1].clone(); + assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease); + assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease); + let pos = prop(&s, l, "transform/position"); + assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays"); +} + #[test] fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() { let store = Arc::new(MemoryConfig::default()); diff --git a/docs/plugins.md b/docs/plugins.md index fc7c0b8f..a688a89b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -103,17 +103,24 @@ name in your Scripts folders takes their place. ### Ease Presets (`extensions/scriptui-panels/Ease Presets.jsx`) -Window ▸ Ease Presets.jsx: easing curves kept by name and applied to pairs of neighbouring -selected keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's +Window ▸ Ease Presets.jsx: easing curves kept by name and applied to the sides of selected +keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's first key and the in side of its second, in the Keyframe Velocity dialog's terms: influence in percent, and speed relative to the segment's average speed (1 = as fast as a straight line, 0 = at rest), so one curve fits any duration and change of value. -* Click a preset to apply it to every pair of neighbouring selected keyframes of each selected - property, in one undo step (per dimension, and along the motion path for spatial properties; - the keys' other sides keep playing as they did). The keys stay selected, so you can try another. +* Click a preset to apply it to the selected keyframes of each selected property, in one undo + step. **Ease** chooses the sides (#1329): **In** eases each key's incoming side with the curve's + in side, **Out** its outgoing side with the curve's out side, **In-Out** (the default) both. A + side is eased when it has a neighbouring keyframe, selected or not (the neighbour doesn't + change), with its speeds scaled to the segment on that side: per dimension, and along the + motion path for spatial properties. So a single keyframe works, a selected pair gets the whole + curve, and the first and last keys ease the side that has a segment. The side not eased keeps + playing as it did (a Hold stays a Hold). Roving keyframes are skipped, and the status says how + many: they pass speed straight through, so an ease on one wouldn't play (turn off Roving to + ease it). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and - click Apply, or click From Keys to read the curve between the first selected pair. + click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename and Delete act on the selected preset of your own. The twelve built-in presets can't change. * User presets are kept with `app.settings` (section `Ease Presets`, key `userPresets`, in diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index a08fdbcc..6f6eadae 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -1,4 +1,4 @@ -// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes. +// Ease Presets: easing curves kept by name and applied to the sides of selected keyframes. // // A ScriptUI panel that ships with EffectCraft as an optional extension (Window ▸ Ease // Presets.jsx). It uses only After Effects' documented scripting API: comp.selectedProperties, @@ -12,6 +12,13 @@ // speeds are scaled to it: per dimension, along the motion path for spatial properties, or as // overall progress for shapes. // +// Applied to selected keyframes, In eases each key's incoming side with the curve's in side, Out +// its outgoing side with the curve's out side, In-Out both: a side is eased when it has a +// neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the +// segment on that side. So a selected pair gets the whole curve, and a single key works too. +// Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease +// set on them wouldn't play. +// // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", // "inSpeed"}}]}. @@ -98,9 +105,8 @@ // ------------------------------------------------------------------ keyframes - // Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's - // index), by property. - function selectedPairs() { + // Selected properties of the active comp whose keys can ease: {prop, keys} (their selected keys). + function selectedKeyed() { var comp = app.project.activeItem; var out = []; if (!(comp instanceof CompItem)) return out; @@ -109,7 +115,17 @@ var p = props[i]; if (p.propertyType !== PropertyType.PROPERTY || p.numKeys < 2 || !p.isInterpolationTypeValid(BEZIER)) continue; var keys = p.selectedKeys; - for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: p, key: keys[k] }); + if (keys.length) out.push({ prop: p, keys: keys }); + } + return out; + } + + // Pairs of neighbouring selected keyframes: {prop, key} (the earlier key's index), by property. + function selectedPairs() { + var keyed = selectedKeyed(), out = []; + for (var i = 0; i < keyed.length; i++) { + var keys = keyed[i].keys; + for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: keyed[i].prop, key: keys[k] }); } return out; } @@ -175,29 +191,43 @@ } } - // Ease every pair of neighbouring selected keyframes with `c` (one undo step). Returns the - // number of pairs eased. - function applyCurve(c) { - var pairs = selectedPairs(), n = 0; - if (!pairs.length) return 0; + // The eases of one side with `speed` (relative to the segment's average) and `influence`. + function eases(s, speed, influence) { + var out = []; + for (var d = 0; d < s.length; d++) out.push(new KeyframeEase(speed * s[d], influence)); + return out; + } + + // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns + // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. + function applyCurve(c, mode) { + var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; + var doIn = mode !== "Out", doOut = mode !== "In"; + // Every side and its eases first: the segments are measured before any key changes. + for (var i = 0; i < keyed.length; i++) { + var p = keyed[i].prop; + for (var j = 0; j < keyed[i].keys.length; j++) { + var k = keyed[i].keys[j], before = sides.length; + if (p.keyRoving(k)) { + roving++; + continue; + } + var s = doIn && k > 1 ? slopes(p, k - 1) : null; + if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + s = doOut && k < p.numKeys ? slopes(p, k) : null; + if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (sides.length > before) keys++; + } + } + if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { - for (var i = 0; i < pairs.length; i++) { - var p = pairs[i].prop, a = pairs[i].key, s = slopes(p, a); - if (!s) continue; - var outE = [], inE = []; - for (var d = 0; d < s.length; d++) { - outE.push(new KeyframeEase(c.outSpeed * s[d], c.outInfluence)); - inE.push(new KeyframeEase(c.inSpeed * s[d], c.inInfluence)); - } - easeSide(p, a, true, outE); - easeSide(p, a + 1, false, inE); - n++; - } + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return n; + return { eased: keys, roving: roving }; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -288,6 +318,13 @@ var inInfluence = field(inRow, "In %", "inInfluence"); var inSpeed = field(inRow, "Speed", "inSpeed"); + var modeRow = ui.add("group"); + modeRow.add("statictext", undefined, "Ease"); + var MODES = ["In", "Out", "In-Out"]; + var mode = modeRow.add("dropdownlist", undefined, MODES, { name: "mode" }); + mode.selection = 2; + mode.helpTip = "The sides of each selected keyframe to ease: incoming, outgoing or both"; + var actions = ui.add("group"); var applyButton = actions.add("button", undefined, "Apply", { name: "apply" }); var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" }); @@ -346,8 +383,12 @@ } function apply(c, what) { - var n = applyCurve(c); - status.text = n > 0 ? "Eased " + n + " keyframe pair(s) with " + what : "Select two or more neighbouring keyframes of a property"; + var m = mode.selection ? mode.selection.text : "In-Out"; + var r = applyCurve(c, m), n = r.eased; + var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; + var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; + if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); + else status.text = skipped ? "Nothing eased: " + skipped : "Select keyframes with another keyframe " + side; return n; }