Layers: with several layers selected, value edits, stopwatches and viewer Scale / Rotate change them all (#355, #491)

With several layers selected, After Effects applies a property edit to the same property of
every selected layer; we changed only the layer whose row was edited (the Properties panel
showed only the first selected layer), so a stopwatch switched on one layer and later moves
of the others had nothing to keyframe (#355), and scaling or rotating a selection changed
one layer (#491).

- Engine: `prop.set`, `prop.toggleAnimation` and `prop.toggleKey` take `selected: true`:
  the change also goes to the same property (by match path, `transform/scale`,
  `effects/<effect>#n/<param>`) of every other selected, unlocked layer whose value has the
  same kind, in one undo step (a scrub keeps folding into it through its `merge` key). With
  `offset: true` (a scrub) each layer changes by the same amount from its own value; a typed
  value sets them all. Position with Separate Dimensions is handled per layer.
- The Timeline, Properties and Effect Controls panels pass `selected` (and `offset` while a
  value is dragged). The Properties panel heads "(N) Selected Objects" (zh-CN / zh-TW / ja /
  uk rows).
- Viewer: a bounding-box handle drag scales every selected layer by the same ratio about its
  own anchor point; the Rotation tool turns them all by the same angle (one undo step).

Tests: engine test (stopwatch, scrub offset, typed value, keyframe button, one undo step,
unselected layers untouched); kittests: the Properties panel with three layers selected
(header, stopwatch on all three undone in one step, a scrub moving all three by the same
amount) and a handle drag scaling two selected layers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 21:23:36 +00:00
1 parent 03598229ba
commit 657f34d40b
9 files changed
+342 -20

No files matched your search

+143 -4
View File
@@ -165,6 +165,145 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va
} }
} }
// ---------------------------------------------------------------- several selected layers
/// With `selected: true` (the Timeline, Properties and Effect Controls panels pass it): the same
/// property (by match path, `transform/position`, `effects/<effect>#n/<param>`) of every other
/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty
/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491).
fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
if b_p(p, "selected") != Some(true) {
return Ok(vec![]);
}
let (cid, lid, uid) = prop_ref(s, p, cmd)?;
let Some(comp) = s.project.comp(cid) else { return Ok(vec![]) };
let Some(layer) = comp.layer(lid) else { return Ok(vec![]) };
let (Some(path), Some(pr)) = (layer.props.match_path_of(uid), layer.props.find(uid)) else { return Ok(vec![]) };
if !s.state.selected_layers.contains(&lid) {
return Ok(vec![]);
}
let kind = (pr.value.kind_name(), pr.value.components().len());
Ok(s.state
.selected_layers
.iter()
.filter(|o| **o != lid)
.filter_map(|o| comp.layer(*o))
.filter(|l| !l.switches.locked)
.filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid)))
.collect())
}
/// `p` aimed at property `uid` of layer `lid` instead.
fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.remove("path");
o.remove("selected");
o.remove("offset");
}
q["layer"] = json!(lid.0);
q["prop"] = json!(uid);
q
}
/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate
/// Dimensions: its X / Y / Z Position).
fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Vec<f64>> {
let l = s.project.comp(cid)?.layer(lid)?;
let lt = l.layer_time(s.time_of(cid));
if let Some(tr) = l.transform()
&& tr.get("position").is_some_and(|q| q.uid == uid)
&& tr.get("positionX").is_some()
{
return Some(["positionX", "positionY", "positionZ"].iter().filter_map(|m| tr.get(m)).map(|q| q.value_at(lt).as_f64()).collect());
}
Some(l.props.find(uid)?.value_at(lt).components())
}
/// A JSON number or array of numbers.
fn json_numbers(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Number(n) => Some(vec![n.as_f64()?]),
Value::Array(a) => a.iter().map(Value::as_f64).collect(),
_ => None,
}
}
/// `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`,
/// 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")?;
if twins.is_empty() {
return set(s, p);
}
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
let new = p.get("value").and_then(json_numbers);
let delta = match (b_p(p, "offset"), &new, current_components(s, cid, lid, uid)) {
(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,
};
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) };
}
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
let _ = set(s, &q);
}
Ok(out)
};
if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) }
}
/// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows.
fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result<Value> {
let twins = selected_twins(s, p, "prop.toggleAnimation")?;
if twins.is_empty() {
return toggle_anim(s, p);
}
let run = |s: &mut Session| -> Result<Value> {
let on = toggle_anim(s, p)?;
for (olid, ouid) in twins {
let mut q = twin_params(p, olid, ouid);
q["value"] = on.clone();
let _ = toggle_anim(s, &q);
}
Ok(on)
};
// (a stopwatch drag over several rows keeps folding into the step its `merge` names)
if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Toggle Animation", run) }
}
/// `prop.toggleKey`; with `selected`, the other selected layers' same property gets (or loses)
/// its keyframe at the current time too.
fn toggle_key_selected(s: &mut Session, p: &Value) -> Result<Value> {
let twins = selected_twins(s, p, "prop.toggleKey")?;
if twins.is_empty() {
return toggle_key(s, p);
}
let cid = prop_ref(s, p, "prop.toggleKey")?.0;
super::app_more::grouped(s, "Add/Remove Keyframe", |s| {
let added = toggle_key(s, p)?;
let want = added.as_bool().unwrap_or(true);
for (olid, ouid) in twins {
let _ = with_prop(s, "Add/Remove Keyframe", None, cid, olid, ouid, |pr, lt| {
if key_at(&pr.keys, lt).is_some() != want {
toggle_key_at(pr, lt);
}
Ok(())
});
}
Ok(added)
})
}
fn set(s: &mut Session, p: &Value) -> Result<Value> { fn set(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
// Position with Separate Dimensions: write the X/Y/Z Position properties instead. // Position with Separate Dimensions: write the X/Y/Z Position properties instead.
@@ -1001,13 +1140,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value", "Set Property Value",
[], [],
None, None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}", "{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)}",
has_layers, has_layers,
set set_selected
), ),
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim), cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?, selected?}", has_layers, toggle_anim_selected),
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key), cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key),
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key), cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop, selected?}", has_layers, toggle_key_selected),
cmd!( cmd!(
"keys.toggleTransform", "keys.toggleTransform",
"Add or Remove Transform Keyframe", "Add or Remove Transform Keyframe",
+48
View File
@@ -831,3 +831,51 @@ fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotatio
} }
assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err()); assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err());
} }
/// #355, #491: with several layers selected, the stopwatch, the keyframe button and value edits
/// (`selected: true`, as the panels send them) apply to every selected layer in one undo step; a
/// scrub (`offset`) moves each layer by the same amount, a typed value sets them all.
#[test]
fn property_edits_apply_to_every_selected_layer() {
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/position", "value": [10, 20]})).unwrap();
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value_at(effectcraft_time::Tick::ZERO).components();
let uid = prop(&s, a, "transform/position").uid;
// Stopwatch on all selected layers, one undo step; the unselected layer is untouched.
let undo = s.history.undo.len();
s.execute("prop.toggleAnimation", json!({"layer": a, "prop": uid, "selected": true})).unwrap();
assert!(prop(&s, a, "transform/position").is_animated() && prop(&s, b, "transform/position").is_animated());
assert!(!prop(&s, c, "transform/position").is_animated());
assert_eq!(s.history.undo.len(), undo + 1);
s.execute("edit.undo", json!({})).unwrap();
assert!(!prop(&s, a, "transform/position").is_animated() && !prop(&s, b, "transform/position").is_animated());
// A scrub moves both by the same change (merged into one step while it continues).
let (pa, pb) = (pos(&s, a), pos(&s, b));
for k in 1..=3 {
let v = [pa[0] + 5.0 * k as f64, pa[1]];
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "merge": "scrub-1"})).unwrap();
}
assert_eq!(pos(&s, a)[..2], [pa[0] + 15.0, pa[1]]);
assert_eq!(pos(&s, b)[..2], [pb[0] + 15.0, pb[1]]);
assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub");
// A typed value sets them all.
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 40, "selected": true})).unwrap();
assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 40.0);
assert_eq!(prop(&s, c, "transform/opacity").value.as_f64(), 100.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, a, "transform/opacity").value.as_f64(), 100.0);
assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 100.0);
// The keyframe button adds a key on each selected layer.
s.execute("prop.toggleKey", json!({"layer": a, "path": "transform/scale", "selected": true})).unwrap();
assert_eq!(prop(&s, a, "transform/scale").keys.len(), 1);
assert_eq!(prop(&s, b, "transform/scale").keys.len(), 1);
// Without `selected` only the given layer changes.
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);
}
+4
View File
@@ -405,6 +405,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"), ("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"),
("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"), ("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"),
("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"), ("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"),
("({}) Selected Objects", "({}) 個の選択されたオブジェクト"),
( (
"Select a layer with a mask or shape path (or the path property), or Puppet pins.", "Select a layer with a mask or shape path (or the path property), or Puppet pins.",
"マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。", "マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。",
@@ -840,6 +841,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"), ("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"),
("Select a layer to see its effects", "选择图层以查看其效果"), ("Select a layer to see its effects", "选择图层以查看其效果"),
("Select a layer to see its properties", "选择图层以查看其属性"), ("Select a layer to see its properties", "选择图层以查看其属性"),
("({}) Selected Objects", "({}) 个选定对象"),
( (
"Select a layer with a mask or shape path (or the path property), or Puppet pins.", "Select a layer with a mask or shape path (or the path property), or Puppet pins.",
"请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。", "请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。",
@@ -1364,6 +1366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"), ("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"),
("Select a layer to see its effects", "選取圖層以查看其效果"), ("Select a layer to see its effects", "選取圖層以查看其效果"),
("Select a layer to see its properties", "選取圖層以查看其屬性"), ("Select a layer to see its properties", "選取圖層以查看其屬性"),
("({}) Selected Objects", "({}) 個選取的物件"),
( (
"Select a layer with a mask or shape path (or the path property), or Puppet pins.", "Select a layer with a mask or shape path (or the path property), or Puppet pins.",
"請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。", "請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。",
@@ -1882,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."), ("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."),
("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"), ("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"),
("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"), ("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"),
("({}) Selected Objects", "Вибрано об’єктів: ({})"),
( (
"Select a layer with a mask or shape path (or the path property), or Puppet pins.", "Select a layer with a mask or shape path (or the path property), or Puppet pins.",
"Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.", "Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.",
+7 -4
View File
@@ -180,7 +180,7 @@ fn key_navigator(
match what { match what {
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
_ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))), _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))),
} }
} }
} }
@@ -241,7 +241,7 @@ fn prop_row(
); );
app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name); app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name);
if resp.clicked() { if resp.clicked() {
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
} }
} }
if prop.is_animated() { if prop.is_animated() {
@@ -273,8 +273,11 @@ fn prop_row(
ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
let merge = format!("ec-{uid}"); let merge = format!("ec-{uid}");
let set = // With several layers selected the edit applies to the same effect property of each (#491).
|actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))); let scrub = ui.ctx().dragged_id().is_some();
let set = |actions: &mut Actions, v: serde_json::Value| {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
};
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
let open = app.ui.fx_slider_open.contains(&uid); let open = app.ui.fx_slider_open.contains(&uid);
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
+19 -3
View File
@@ -72,6 +72,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut y = top; let mut y = top;
let mut actions: Actions = vec![]; let mut actions: Actions = vec![];
// Several layers selected: "(3) Selected Objects", as in After Effects. The rows show the
// first layer; the stopwatch, keyframe button and value edits apply to all of them (#355).
let selected = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some()).count();
if selected > 1 {
let n = selected.to_string();
let label = crate::i18n::tr_args("({}) Selected Objects", &[&n]);
p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, &label, Tokens::semibold(12.0), t.text);
app.auto.add("properties.selectedObjects", Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &label);
y += ROW_H + 4.0;
}
// ---------------------------------------------------------------- Layer Transform // ---------------------------------------------------------------- Layer Transform
if let Some(tr) = layer.transform().cloned() { if let Some(tr) = layer.transform().cloned() {
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform"); section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
@@ -218,7 +229,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
match what { match what {
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
_ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))), _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid, "selected": true}))),
} }
} }
} }
@@ -228,13 +239,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim }); icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name); app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
if resp.clicked() { if resp.clicked() {
actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid}))); actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid, "selected": true})));
} }
} }
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
let vx = r.min.x + (r.width() * 0.5).max(130.0); let vx = r.min.x + (r.width() * 0.5).max(130.0);
let merge = format!("props-{uid}"); let merge = format!("props-{uid}");
let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge}))); // With several layers selected the edit applies to all: a scrub by the same change, a typed
// value as it is.
let scrub = ui.ctx().dragged_id().is_some();
let set = |actions: &mut Actions, v: serde_json::Value| {
actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
};
let value = ectx.value(layer, pr); let value = ectx.value(layer, pr);
let is_scale = pr.name == "Scale"; let is_scale = pr.name == "Scale";
let pct = is_scale || pr.name == "Opacity"; let pct = is_scale || pr.name == "Opacity";
+8 -4
View File
@@ -2403,7 +2403,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time))); ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
} }
if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() { if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() {
actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))); actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
} }
app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe"); app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
} }
@@ -2426,10 +2426,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid}))); actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
} }
} else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) { } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge}))); actions
.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge, "selected": true})));
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
// Keyboard or accessibility activation. // Keyboard or accessibility activation.
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
} }
} }
let name_x = indent + 12.0; let name_x = indent + 12.0;
@@ -3510,8 +3511,11 @@ fn value_editor(
let uid = prop.uid; let uid = prop.uid;
let mut dims = vec![]; let mut dims = vec![];
let merge = format!("scrub-{uid}"); let merge = format!("scrub-{uid}");
// 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();
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { 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}))) actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
}; };
let is_3d = layer.is_3d(); let is_3d = layer.is_3d();
let mut x = at.x; let mut x = at.x;
+37 -5
View File
@@ -84,12 +84,17 @@ enum Gesture {
/// Layer pixels → comp as the drag began, and its inverse. /// Layer pixels → comp as the drag began, and its inverse.
l2c: Mat3, l2c: Mat3,
inv: Mat3, inv: Mat3,
/// The other selected layers and their scales as the drag began: they scale by the same
/// ratio about their own anchor points, as in After Effects (#491).
others: Vec<(LayerId, [f64; 3])>,
}, },
Rotate { Rotate {
layer: LayerId, layer: LayerId,
center: Pos2, center: Pos2,
start_angle: f64, start_angle: f64,
start_rot: f64, start_rot: f64,
/// The other selected layers and their rotations: they turn by the same angle.
others: Vec<(LayerId, f64)>,
}, },
Anchor { Anchor {
layer: LayerId, layer: LayerId,
@@ -258,6 +263,16 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
from_effect_space(ctx, layer, l2c(ctx, layer).0) from_effect_space(ctx, layer, l2c(ctx, layer).0)
} }
/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a
/// handle or Rotation tool drag on one changes them all.
fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> {
let sel = &app.session.state.selected_layers;
if !sel.contains(&layer) {
return vec![];
}
sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect()
}
/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 /// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1
/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the /// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the
/// camera would flip through infinity and draw long lines across the viewer (#572). /// camera would flip through infinity and draw long lines across the viewer (#572).
@@ -1232,6 +1247,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} }
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay), Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| { Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
let layer = comp.layer(l).cloned(); let layer = comp.layer(l).cloned();
let center = layer let center = layer
.as_ref() .as_ref()
@@ -1239,7 +1255,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.map(|p| map.to_screen([p[0], p[1]])) .map(|p| map.to_screen([p[0], p[1]]))
.unwrap_or(pos); .unwrap_or(pos);
let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0); let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0);
Gesture::Rotate { layer: l, center, start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), start_rot: rot } Gesture::Rotate {
layer: l,
center,
start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(),
start_rot: rot,
others,
}
}), }),
Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| { Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
let layer = comp.layer(l)?; let layer = comp.layer(l)?;
@@ -1349,6 +1371,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
axes: handle_axes(hi), axes: handle_axes(hi),
l2c, l2c,
inv, inv,
others: selected_others(app, &comp, lid)
.into_iter()
.filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3]))))
.collect(),
}) })
}) })
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
@@ -1417,23 +1443,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
} }
} }
Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => { Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv, others } => {
let lp = inv.apply(gv2(cpt[0], cpt[1])); let lp = inv.apply(gv2(cpt[0], cpt[1]));
let uniform = mods.shift && axes == [true, true]; let uniform = mods.shift && axes == [true, true];
let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform); let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
// The handle snaps (to the comp's and other layers' edges, corners and centres), // The handle snaps (to the comp's and other layers' edges, corners and centres),
// so a layer scales exactly to them, e.g. to the comp's size. // so a layer scales exactly to them, e.g. to the comp's size.
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods)); let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]]; for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge})); let v = [s0[0] * f[0], s0[1] * f[1], s0[2]];
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
}
} }
Gesture::Rotate { layer, center, start_angle, start_rot } => { Gesture::Rotate { layer, center, start_angle, start_rot, others } => {
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
let mut r = start_rot + (ang - start_angle); let mut r = start_rot + (ang - start_angle);
if mods.shift { if mods.shift {
r = (r / 45.0).round() * 45.0; r = (r / 45.0).round() * 45.0;
} }
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
for (lid, r0) in others {
let _ =
app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge}));
}
} }
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => {
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features, // Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
+50
View File
@@ -348,3 +348,53 @@ fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() {
click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0));
assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button");
} }
/// #355, #491: with three layers selected the Properties panel heads "(3) Selected Objects"; its
/// stopwatch animates Position on all three (one undo step) and scrubbing X moves all three by
/// the same amount.
#[test]
fn properties_panel_edits_every_selected_layer() {
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 (i, c) in ["#e04020", "#20e040", "#2040e0"].iter().enumerate() {
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": [60.0 + 80.0 * i as f64, 90.0]})).unwrap();
ids.push(l);
}
s.execute("layer.select", json!({"layers": ids})).unwrap();
let first = s.state.selected_layers[0];
let uid = s.active_comp().unwrap().layer(first).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.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Properties);
h.run_steps(4);
assert_eq!(h.state().auto.find("properties.selectedObjects").map(|e| e.label.clone()), Some("(3) Selected Objects".into()));
let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone()
};
let at = |h: &Harness<'_, EffectcraftApp>, id: &str| {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
};
let sw = at(&h, &format!("properties.prop.{uid}.stopwatch"));
h.input_mut().events.push(Event::PointerMoved(sw));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
assert!(ids.iter().all(|l| pos(&h, *l).is_animated()), "every selected layer's stopwatch");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert!(ids.iter().all(|l| !pos(&h, *l).is_animated()), "one undo step");
// Scrub X: each layer moves by the same amount.
let before: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
let x = at(&h, &format!("properties.prop.{uid}.value.0"));
drag(&mut h, x, x + egui::vec2(30.0, 0.0), Default::default());
let after: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
let d = after[0] - before[0];
assert!(d.abs() > 1.0, "the scrub moved X: {before:?} → {after:?}");
for (a, b) in after.iter().zip(&before) {
assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}");
}
}
+26
View File
@@ -1707,6 +1707,32 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() {
assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h));
} }
/// #491: with several layers selected, a handle drag scales all of them by the same ratio, each
/// about its own anchor point, in one undo step.
#[test]
fn handle_drags_scale_every_selected_layer() {
let mut h = harness();
let layers = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::<Vec<_>>();
let (box_id, plate) = (layers[0], layers[1]);
let scale = |h: &Harness<'_, EffectcraftApp>, id: LayerId| {
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
l.props.prop("transform/scale").unwrap().value.as_vec3()
};
h.state_mut().session.execute("prop.set", json!({"layer": plate.0, "path": "transform/scale", "value": [50, 50, 100]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0, plate.0]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.run_steps(2);
let undo0 = h.state().session.history.undo.len();
// The box's bottom-right corner (360, 220) to (400, 260): twice the box's size.
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
let to = screen(&h, [400.0, 260.0]);
drag_path(&mut h, &[corner, to], Default::default());
let (b, p) = (scale(&h, box_id), scale(&h, plate));
assert!((b[0] - 200.0).abs() < 1.0 && (b[1] - 200.0).abs() < 1.0, "{b:?}");
assert!((p[0] - 100.0).abs() < 1.0 && (p[1] - 100.0).abs() < 1.0, "the other layer doubles too: {p:?}");
assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step");
}
/// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other /// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other
/// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands /// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands
/// on the comp's size. /// on the comp's size.