mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #774 from vangel76/gap-proportional-scrubbing
#770: Proportional Scrubbing of Timeline values (Ctrl+Alt / Cmd+Opt) # Conflicts: # docs/ui-parity.md
This commit is contained in:
commit
be1d9928e0
7 files changed
+166
-16
No files matched your search
@@ -44,7 +44,7 @@ pub mod panels_cmds;
|
||||
pub(crate) mod path_nulls;
|
||||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
pub(crate) mod prop;
|
||||
mod prop_groups;
|
||||
mod proxy;
|
||||
pub mod puppet;
|
||||
|
||||
@@ -230,8 +230,20 @@ fn json_numbers(v: &Value) -> Option<Vec<f64>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The share of a Proportional Scrubbing change (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub)
|
||||
/// that the layer at `index` of `count` selected layers (in selection order) takes: the first
|
||||
/// none, the last all of it, the ones between even steps.
|
||||
pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
|
||||
match count.checked_sub(1) {
|
||||
Some(last) if last > 0 => index.min(last) as f64 / last as f64,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
|
||||
/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`,
|
||||
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
|
||||
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
|
||||
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
|
||||
/// one that a continuing scrub keeps folding into).
|
||||
fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let twins = selected_twins(s, p, "prop.set")?;
|
||||
@@ -244,16 +256,41 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::<Vec<f64>>()),
|
||||
_ => None,
|
||||
};
|
||||
// Proportional Scrubbing: the edited layer and its twins in selection order.
|
||||
let order: Option<Vec<LayerId>> = (delta.is_some() && b_p(p, "proportional") == Some(true))
|
||||
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
|
||||
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
|
||||
let scalar = p.get("value").is_some_and(Value::is_number);
|
||||
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
|
||||
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
|
||||
if let Some(d) = &delta
|
||||
&& let Some(c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + k * d.get(i).copied().unwrap_or(0.0)).collect();
|
||||
q["value"] = if scalar { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
|
||||
}
|
||||
};
|
||||
let run = |s: &mut Session| -> Result<Value> {
|
||||
let out = set(s, p)?;
|
||||
for (olid, ouid) in twins {
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
if let Some(d) = &delta
|
||||
&& let Some(c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect();
|
||||
q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
|
||||
let out = if order.is_some() {
|
||||
let k = share(lid);
|
||||
if k <= 0.0 {
|
||||
// (the first selected layer keeps its value: no key is added to it)
|
||||
current_components(s, cid, lid, uid).map_or(Value::Null, |c| if scalar { json!(c.first().copied().unwrap_or(0.0)) } else { json!(c) })
|
||||
} else {
|
||||
let mut q = p.clone();
|
||||
shifted(s, &mut q, lid, uid, k);
|
||||
set(s, &q)?
|
||||
}
|
||||
} else {
|
||||
set(s, p)?
|
||||
};
|
||||
for (olid, ouid) in twins {
|
||||
let k = share(olid);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
shifted(s, &mut q, olid, ouid, k);
|
||||
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||
let _ = set(s, &q);
|
||||
}
|
||||
@@ -1140,7 +1177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}",
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
|
||||
has_layers,
|
||||
set_selected
|
||||
),
|
||||
|
||||
@@ -883,3 +883,66 @@ fn property_edits_apply_to_every_selected_layer() {
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0);
|
||||
}
|
||||
|
||||
/// #770: Proportional Scrubbing (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub). The change is
|
||||
/// spread over the selected layers by selection order (not stacking order): the first keeps its
|
||||
/// value, the last takes all of it, the one between half; whichever layer is scrubbed. One undo
|
||||
/// step; a keyframed property gets a key at the current time, the unchanged first layer none.
|
||||
#[test]
|
||||
fn proportional_scrub_spreads_the_change_by_selection_order() {
|
||||
let (mut s, a) = setup();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/rotation", "value": 20})).unwrap();
|
||||
// Selection order c, a, b.
|
||||
s.execute("layer.select", json!({"layers": [c]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a], "add": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b], "toggle": true})).unwrap();
|
||||
let rot = |s: &Session, l: u64| prop(s, l, "transform/rotation").value.as_f64();
|
||||
let uid = prop(&s, a, "transform/rotation").uid;
|
||||
|
||||
// Scrub layer a (the middle one) by +10 a frame, three frames: the UI sends a's shown value
|
||||
// plus the drag.
|
||||
let undo = s.history.undo.len();
|
||||
for _ in 0..3 {
|
||||
let v = rot(&s, a) + 10.0;
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "proportional": true, "merge": "scrub-r"}))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(rot(&s, c), 0.0, "the first selected layer keeps its value");
|
||||
assert_eq!(rot(&s, a), 15.0, "the middle one moves by half");
|
||||
assert_eq!(rot(&s, b), 50.0, "the last one moves by the whole change");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((rot(&s, c), rot(&s, a), rot(&s, b)), (0.0, 0.0, 20.0));
|
||||
|
||||
// Vectors and keyframes: Position animated on every layer, scrubbed on the last layer at 1 s.
|
||||
for l in [a, b, c] {
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [100, 100], "time": 0.0})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "transform/position"})).unwrap();
|
||||
}
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let pos_uid = prop(&s, b, "transform/position").uid;
|
||||
s.execute("prop.set", json!({"layer": b, "prop": pos_uid, "value": [140, 80], "selected": true, "offset": true, "proportional": true})).unwrap();
|
||||
let at1 = |s: &Session, l: u64| {
|
||||
let p = prop(s, l, "transform/position");
|
||||
(p.keys.len(), p.value_at(effectcraft_time::Tick::from_seconds_f64(1.0)).components()[..2].to_vec())
|
||||
};
|
||||
assert_eq!(at1(&s, c), (1, vec![100.0, 100.0]), "no key on the first selected layer");
|
||||
assert_eq!(at1(&s, a), (2, vec![120.0, 90.0]));
|
||||
assert_eq!(at1(&s, b), (2, vec![140.0, 80.0]));
|
||||
|
||||
// Without `proportional` a scrub still moves them all by the same change.
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": 5, "selected": true, "offset": true})).unwrap();
|
||||
assert_eq!((rot(&s, c), rot(&s, a), rot(&s, b)), (5.0, 5.0, 25.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proportional_share_is_linear_in_selection_order() {
|
||||
use crate::commands::prop::proportional_share;
|
||||
assert_eq!((0..5).map(|i| proportional_share(i, 5)).collect::<Vec<_>>(), [0.0, 0.25, 0.5, 0.75, 1.0]);
|
||||
// One layer (or a bad count) takes the whole change; an index past the end the last share.
|
||||
assert_eq!(proportional_share(0, 1), 1.0);
|
||||
assert_eq!(proportional_share(0, 0), 1.0);
|
||||
assert_eq!(proportional_share(usize::MAX, 3), 1.0);
|
||||
}
|
||||
@@ -3546,8 +3546,14 @@ fn value_editor(
|
||||
// With several layers selected the edit applies to all of them (#491): a scrub by the same
|
||||
// change, a typed value as it is.
|
||||
let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
|
||||
// Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
|
||||
// selected layers by selection order, the first unchanged and the last by all of it.
|
||||
let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
|
||||
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||
actions.push((
|
||||
"prop.set".into(),
|
||||
json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub, "proportional": proportional}),
|
||||
))
|
||||
};
|
||||
let is_3d = layer.is_3d();
|
||||
let mut x = at.x;
|
||||
@@ -3602,8 +3608,8 @@ fn value_editor(
|
||||
dims.push(r);
|
||||
if let Some(nv) = nv {
|
||||
let mut nc = c.clone();
|
||||
// Alt edits one value of a linked pair.
|
||||
let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 };
|
||||
// Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
|
||||
let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
|
||||
let from = if shown == 1 { d } else { 0 };
|
||||
for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
|
||||
if let Some(slot) = nc.get_mut(from + e) {
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn hot_number_at(
|
||||
let mult = ui.input(|i| {
|
||||
if i.modifiers.shift {
|
||||
10.0
|
||||
} else if i.modifiers.command {
|
||||
} else if i.modifiers.command && !proportional_scrub(i.modifiers) {
|
||||
0.1
|
||||
} else {
|
||||
1.0
|
||||
@@ -138,6 +138,13 @@ pub fn hot_number_at(
|
||||
(rect, out, resp.drag_stopped())
|
||||
}
|
||||
|
||||
/// Ctrl+Alt (Cmd+Opt on macOS) held while scrubbing hot text: After Effects' Proportional
|
||||
/// Scrubbing, which spreads the change over the selected layers by selection order. Ctrl alone
|
||||
/// still scrubs finely and Shift by tens.
|
||||
pub fn proportional_scrub(m: egui::Modifiers) -> bool {
|
||||
m.command && m.alt
|
||||
}
|
||||
|
||||
/// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every
|
||||
/// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
|
||||
/// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
|
||||
|
||||
@@ -398,3 +398,40 @@ fn properties_panel_edits_every_selected_layer() {
|
||||
assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #770: Proportional Scrubbing (After Effects 26.2). With three layers selected, a Ctrl+Alt
|
||||
/// (Cmd+Opt) scrub of Position X in the Timeline leaves the first selected layer where it is,
|
||||
/// moves the last by the whole change and the middle one by half, whichever layer is scrubbed.
|
||||
#[test]
|
||||
fn ctrl_alt_scrub_in_the_timeline_is_proportional() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for c in ["#e04020", "#20e040", "#2040e0"] {
|
||||
let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [100.0, 90.0]})).unwrap();
|
||||
ids.push(l);
|
||||
}
|
||||
// Selection order: the first created, then the second, then the third.
|
||||
for (i, l) in ids.iter().enumerate() {
|
||||
s.execute("layer.select", json!({"layers": [l], "add": i > 0})).unwrap();
|
||||
}
|
||||
let middle = ids[1];
|
||||
let uid = s.active_comp().unwrap().layer(LayerId(middle)).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.position", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
let x = |h: &Harness<'_, EffectcraftApp>, l: u64| {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().value.components()[0]
|
||||
};
|
||||
let e = h.state().auto.find(&format!("timeline.prop.{uid}.value.0")).cloned().expect("the middle layer's Position X shows");
|
||||
let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
let ctrl_alt = egui::Modifiers { alt: true, ctrl: true, command: true, ..Default::default() };
|
||||
drag(&mut h, from, from + egui::vec2(40.0, 0.0), ctrl_alt);
|
||||
let moved: Vec<f64> = ids.iter().map(|l| x(&h, *l) - 100.0).collect();
|
||||
assert!(moved[2] > 1.0, "the last selected layer moved: {moved:?}");
|
||||
assert!(moved[0].abs() < 1e-9, "the first selected layer stays: {moved:?}");
|
||||
assert!((moved[1] - moved[2] / 2.0).abs() < 1e-6, "the middle one moves by half: {moved:?}");
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|
||||
|---|---|---:|---|
|
||||
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
|
||||
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) |
|
||||
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2) missing |
|
||||
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties |
|
||||
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
|
||||
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) |
|
||||
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) |
|
||||
|
||||
Reference in new issue
Block a user