diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 98fe0cb4..e7ce5252 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -43,7 +43,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; diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 46804c87..64c166fd 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -230,8 +230,20 @@ fn json_numbers(v: &Value) -> Option> { } } +/// 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 { let twins = selected_twins(s, p, "prop.set")?; @@ -244,16 +256,41 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { (Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::>()), _ => None, }; + // Proportional Scrubbing: the edited layer and its twins in selection order. + let order: Option> = (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 = 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 { - 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 = 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 { "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 ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6a74a23b..29755612 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -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::>(), [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); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 61b2d22d..d21d2c93 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3530,8 +3530,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; @@ -3586,8 +3592,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) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 18c6aedb..fcb4a4bc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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). diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index a7d1d1ae..74352824 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -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 = 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:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fd807ebc..a88a6707 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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) | | 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) |