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:
Brandon Thomas
2026-10-12 03:20:51 -04:00
7 changed files with 166 additions and 16 deletions
+1 -1
View File
@@ -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;
+47 -10
View File
@@ -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
),
+63
View File
@@ -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);
}