mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
@@ -405,6 +405,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"),
|
||||
("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"),
|
||||
("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.",
|
||||
"マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。",
|
||||
@@ -840,6 +841,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"),
|
||||
("Select a layer to see its effects", "选择图层以查看其效果"),
|
||||
("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.",
|
||||
"请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。",
|
||||
@@ -1364,6 +1366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"),
|
||||
("Select a layer to see its effects", "選取圖層以查看其效果"),
|
||||
("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.",
|
||||
"請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。",
|
||||
@@ -1882,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."),
|
||||
("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"),
|
||||
("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.",
|
||||
"Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.",
|
||||
|
||||
@@ -180,7 +180,7 @@ fn key_navigator(
|
||||
match what {
|
||||
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "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);
|
||||
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() {
|
||||
@@ -273,8 +273,11 @@ fn prop_row(
|
||||
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);
|
||||
let merge = format!("ec-{uid}");
|
||||
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})));
|
||||
// With several layers selected the edit applies to the same effect property of each (#491).
|
||||
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 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));
|
||||
|
||||
@@ -72,6 +72,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut y = top;
|
||||
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
|
||||
if let Some(tr) = layer.transform().cloned() {
|
||||
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 {
|
||||
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "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 });
|
||||
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
|
||||
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);
|
||||
let vx = r.min.x + (r.width() * 0.5).max(130.0);
|
||||
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 is_scale = pr.name == "Scale";
|
||||
let pct = is_scale || pr.name == "Opacity";
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
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");
|
||||
}
|
||||
@@ -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})));
|
||||
}
|
||||
} 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) {
|
||||
// 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;
|
||||
@@ -3510,8 +3511,11 @@ fn value_editor(
|
||||
let uid = prop.uid;
|
||||
let mut dims = vec![];
|
||||
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| {
|
||||
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 mut x = at.x;
|
||||
|
||||
@@ -84,12 +84,17 @@ enum Gesture {
|
||||
/// Layer pixels → comp as the drag began, and its inverse.
|
||||
l2c: 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 {
|
||||
layer: LayerId,
|
||||
center: Pos2,
|
||||
start_angle: f64,
|
||||
start_rot: f64,
|
||||
/// The other selected layers and their rotations: they turn by the same angle.
|
||||
others: Vec<(LayerId, f64)>,
|
||||
},
|
||||
Anchor {
|
||||
layer: LayerId,
|
||||
@@ -258,6 +263,16 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
|
||||
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
|
||||
/// 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).
|
||||
@@ -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::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 center = layer
|
||||
.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]]))
|
||||
.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);
|
||||
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| {
|
||||
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),
|
||||
l2c,
|
||||
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) {
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
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 uniform = mods.shift && axes == [true, true];
|
||||
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),
|
||||
// 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 v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]];
|
||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge}));
|
||||
for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
|
||||
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 mut r = start_rot + (ang - start_angle);
|
||||
if mods.shift {
|
||||
r = (r / 45.0).round() * 45.0;
|
||||
}
|
||||
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 } => {
|
||||
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
|
||||
|
||||
Reference in New Issue
Block a user