mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06: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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user