Shortcuts: layer selection, timing, masks and Casts Shadows

- layer.extendSelectionNext / Previous (Cmd+Shift+Down / Up).
- layer.moveInToStart / moveOutToEnd (Alt+Home / End).
- layer.moveEarlier / moveLater (Alt+PageUp / PageDown) and
  layer.moveEarlier10 / moveLater10 (with Shift). Alt+PageUp / PageDown
  used to slip the source one frame; layer.slipBack / slipForward keep
  working but have no default key.
- layer.stretchInToTime / stretchOutToTime (Cmd+Shift+, / Cmd+Alt+,):
  layer.timeStretch takes to: in|out.
- layer.mask.selectNext / selectPrevious (Alt+` / Alt+Shift+`).
- layer.castsShadows (Alt+Shift+C) for the selected 3D layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pravdomil
2026-10-12 21:02:49 +02:00
co-authored by Claude Opus 5.5
parent cf912d5049
commit bf53ae6094
6 changed files with 246 additions and 8 deletions
+47 -3
View File
@@ -919,6 +919,14 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let d = t - l.out_point;
shift(l, d);
}
"moveInToStart" => {
let d = Tick::ZERO - l.in_point;
shift(l, d);
}
"moveOutToEnd" => {
let d = comp_dur - l.out_point;
shift(l, d);
}
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
_ => {
@@ -951,6 +959,20 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// `p` with `key` set.
fn with(p: &Value, key: &str, v: Value) -> Value {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p[key] = v;
p
}
/// Move the layers `frames` frames in time (Alt+Page Up / Page Down, with Shift ten frames).
fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result<Value> {
let (cid, _) = layers_p(s, p)?;
let delta = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate.tick_of(frames).seconds();
timing(s, &with(p, "delta", json!(delta)))
}
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
@@ -1580,6 +1602,16 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
cmd!("layer.extendSelectionNext", "Extend Selection to Next Layer", [], Some("Cmd+Shift+ArrowDown"), "{}", has_comp, |s, _| select_step(
s,
&json!({"add": true}),
1
)),
cmd!("layer.extendSelectionPrevious", "Extend Selection to Previous Layer", [], Some("Cmd+Shift+ArrowUp"), "{}", has_comp, |s, _| select_step(
s,
&json!({"add": true}),
-1
)),
cmd!(
"layer.setSwitch",
"Layer Switch",
@@ -1607,17 +1639,29 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Timing",
[],
None,
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
"{layers?, op?: moveInToTime|moveOutToTime|moveInToStart|moveOutToEnd|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
has_layers,
timing
),
cmd!("layer.moveInToStart", "Move Layer In Point to Beginning of Comp", [], Some("Alt+Home"), "{layers?}", has_layers, |s, p| timing(
s,
&with(p, "op", json!("moveInToStart"))
)),
cmd!("layer.moveOutToEnd", "Move Layer Out Point to End of Comp", [], Some("Alt+End"), "{layers?}", has_layers, |s, p| timing(
s,
&with(p, "op", json!("moveOutToEnd"))
)),
cmd!("layer.moveEarlier", "Move Layers 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -1)),
cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)),
cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)),
cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)),
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| {
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["frames"] = json!(-1);
slip(s, &p)
}),
cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| {
cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], None, "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["frames"] = json!(1);
slip(s, &p)
+26 -1
View File
@@ -24,6 +24,11 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let pct = f_p(p, "percent");
let dur = f_p(p, "duration");
let hold = str_p(p, "hold").unwrap_or("in").to_string();
// `to`: stretch so the In (or Out) point lands on the current time, the other point held.
let to = str_p(p, "to").map(str::to_string);
if to.as_deref().is_some_and(|t| t != "in" && t != "out") {
return Err(bad("layer.timeStretch", "to: in|out"));
}
let cti = s.time();
let comp_rate = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
let fd = comp_rate.frame_duration();
@@ -39,6 +44,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let span = (l.out_point - l.in_point).seconds();
let (dur, hold) = match to.as_deref() {
Some("in") => (Some((l.out_point - cti).seconds()), "out"),
Some(_) => (Some((cti - l.in_point).seconds()), "in"),
None => (dur, hold.as_str()),
};
// The current time is on the wrong side of the held point: nothing to stretch to.
if to.is_some() && dur.is_some_and(|d| d < fd.seconds()) {
continue;
}
let pct = if to.is_some() { None } else { pct };
let new = match (pct, dur) {
(Some(v), _) => v,
(None, Some(d)) if span > 0.0 => old * d / span,
@@ -48,7 +63,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %"));
}
// The hold point keeps showing the same layer time.
let h = match hold.as_str() {
let h = match hold {
"current" => cti,
"out" => l.out_point,
_ => l.in_point,
@@ -220,6 +235,16 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
time_stretch
),
cmd!("layer.stretchInToTime", "Stretch Layer In Point to Current Time", [], Some("Cmd+Shift+,"), "{layers?}", has_layers, |s, p| {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["to"] = json!("in");
time_stretch(s, &p)
}),
cmd!("layer.stretchOutToTime", "Stretch Layer Out Point to Current Time", [], Some("Cmd+Alt+,"), "{layers?}", has_layers, |s, p| {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["to"] = json!("out");
time_stretch(s, &p)
}),
cmd!("layer.timeReverse", "Time-Reverse Layer", ["Layer", "Time"], Some("Cmd+Alt+R"), "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["op"] = json!("reverse");
+51 -1
View File
@@ -2,11 +2,12 @@
use std::collections::BTreeSet;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemId, LayerSource, Project};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, always, comp_id, has_comp, layers_p};
use super::{CommandSpec, always, bad, comp_id, has_comp, has_layers, layers_p};
use crate::{EngineError, Result, Session, cmd};
/// Ctrl+Alt+B: the work area spans the selected layers (their earliest In to their latest Out
@@ -38,6 +39,52 @@ fn deselect_keys(s: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Alt+` / Alt+Shift+`: select the next (previous) mask of the layer, wrapping around; the first
/// (last) one when none is selected.
fn step_mask(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
let cmd = if dir > 0 { "layer.mask.selectNext" } else { "layer.mask.selectPrevious" };
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let (lid, masks) = ids
.iter()
.filter_map(|id| comp.layer(*id))
.find_map(|l| l.masks().filter(|m| m.groups().next().is_some()).map(|m| (l.id, m)))
.ok_or_else(|| bad(cmd, "the selected layers have no masks"))?;
let groups: Vec<_> = masks.groups().collect();
let n = groups.len() as i64;
// The selected mask: its group, or a property inside it.
let selected = |g: &effectcraft_project::PropGroup| {
s.state.selected_props.iter().any(|(l, uid)| *l == lid && (g.find_group(*uid).is_some() || g.find(*uid).is_some()))
};
let i = match groups.iter().position(|g| selected(g)) {
Some(i) => (i as i64 + dir).rem_euclid(n.max(1)),
None if dir > 0 => 0,
None => n - 1,
};
let uid = groups.get(i as usize).map(|g| g.uid).ok_or_else(|| bad(cmd, "the selected layers have no masks"))?;
s.state.selected_props = vec![(lid, uid)];
s.state.selected_keys.clear();
s.state.selected_vertices.clear();
Ok(json!({"layer": lid.0, "mask": uid, "index": i + 1}))
}
/// Alt+Shift+C: Casts Shadows on or off for the selected 3D layers (all follow the first one).
fn casts_shadows(s: &mut Session, p: &Value) -> Result<Value> {
const PATH: &str = "materialOptions/castsShadows";
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let with: Vec<_> = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.switches.three_d && l.props.prop(PATH).is_some()).collect();
let first = with.first().ok_or_else(|| bad("layer.castsShadows", "select a 3D layer"))?;
let on = match p.get("value").and_then(Value::as_bool) {
Some(v) => v,
None => first.props.prop(PATH).is_none_or(|pr| pr.value == KV::Enum(0)),
};
let layers: Vec<u64> = with.iter().map(|l| l.id.0).collect();
let set = super::find("material.set").ok_or_else(|| bad("layer.castsShadows", "Material Options are not available"))?;
(set.run)(s, &json!({"comp": cid.0, "layers": layers, "castsShadows": if on { "on" } else { "off" }}))?;
Ok(json!({"castsShadows": on, "layers": layers}))
}
/// The comps connected to `from` by nesting, in either direction (its network of nested
/// compositions), `from` included.
fn hierarchy(proj: &Project, from: ItemId) -> BTreeSet<ItemId> {
@@ -93,5 +140,8 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("comp.activateRecent", "Activate Most Recent Composition in Hierarchy", [], Some("Shift+Escape"), "{comp?}", has_comp, activate_recent),
cmd!("keys.deselectAll", "Deselect All Keyframes and Properties", [], ["Shift+F2", "Cmd+Alt+Shift+A"], "{}", always, deselect_keys),
cmd!("layer.mask.selectNext", "Select Next Mask", [], Some("Alt+`"), "{layer?}", has_layers, |s, p| step_mask(s, p, 1)),
cmd!("layer.mask.selectPrevious", "Select Previous Mask", [], Some("Alt+Shift+`"), "{layer?}", has_layers, |s, p| step_mask(s, p, -1)),
cmd!("layer.castsShadows", "Casts Shadows", [], Some("Alt+Shift+C"), "{layers?, value?}", has_layers, casts_shadows),
]
}
+4 -3
View File
@@ -67,7 +67,7 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap();
assert_eq!(get(&s).start_time.seconds(), 1.0);
assert_eq!(r["slipped"][0]["delta"], json!(0.5));
// Alt+PageUp / Alt+PageDown nudge by a frame.
// Slip by a frame.
s.execute("layer.slipBack", json!({"layers": [id]})).unwrap();
assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9);
s.execute("layer.slipForward", json!({"layers": [id]})).unwrap();
@@ -75,8 +75,9 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
// Undo walks back through the slips.
assert!(s.undo() && s.undo() && s.undo() && s.undo());
assert_eq!(get(&s).start_time, Tick::ZERO);
assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), Some("Alt+PageUp"));
assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), Some("Alt+PageDown"));
// Alt+PageUp / Alt+PageDown move the layer; the one-frame slips have no default key.
assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), None);
assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), None);
// Layers without a source duration can't slip.
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
assert!(s.execute("layer.slip", json!({"delta": 0.5})).is_err());
+109
View File
@@ -23,6 +23,12 @@ fn secs(s: &Session) -> f64 {
s.time().seconds()
}
fn span(s: &Session, id: u64) -> (f64, f64) {
let c = s.active_comp().unwrap();
let l = c.layers.iter().find(|l| l.id.0 == id).unwrap();
(l.in_point.seconds(), l.out_point.seconds())
}
#[test]
fn time_goes_to_the_work_area_and_to_in_and_out_points() {
let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]);
@@ -107,3 +113,106 @@ fn keyframes_and_properties_deselect_but_layers_stay() {
assert!(s.state.selected_keys.is_empty() && s.state.selected_props.is_empty());
assert_eq!(s.state.selected_layers.len(), 1);
}
#[test]
fn selection_extends_through_the_stack() {
let (mut s, ids) = session([(0.0, 10.0); 3]);
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
s.execute("layer.extendSelectionNext", json!({})).unwrap();
s.execute("layer.extendSelectionNext", json!({})).unwrap();
let mut sel: Vec<u64> = s.state.selected_layers.iter().map(|l| l.0).collect();
sel.sort();
let mut all = ids.clone();
all.sort();
assert_eq!(sel, all);
// At the end of the stack the selection stays.
s.execute("layer.extendSelectionNext", json!({})).unwrap();
assert_eq!(s.state.selected_layers.len(), 3);
s.execute("layer.select", json!({"layers": [ids[2]]})).unwrap();
s.execute("layer.extendSelectionPrevious", json!({})).unwrap();
assert_eq!(s.state.selected_layers.len(), 2);
assert!(s.state.selected_layers.iter().any(|l| l.0 == ids[1]));
}
#[test]
fn layers_move_to_the_comp_ends_and_by_ten_frames() {
let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]);
s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap();
s.execute("layer.moveInToStart", json!({})).unwrap();
assert_eq!((span(&s, ids[0]), span(&s, ids[1])), ((0.0, 2.0), (0.0, 4.0)));
s.execute("layer.moveOutToEnd", json!({})).unwrap();
assert_eq!((span(&s, ids[0]), span(&s, ids[1])), ((8.0, 10.0), (6.0, 10.0)));
s.execute("layer.moveEarlier10", json!({})).unwrap();
let (a, b) = span(&s, ids[0]);
assert!((a - (8.0 - 10.0 / 30.0)).abs() < 1e-9 && (b - (10.0 - 10.0 / 30.0)).abs() < 1e-9);
s.execute("layer.moveLater10", json!({})).unwrap();
assert_eq!(span(&s, ids[0]), (8.0, 10.0));
s.execute("layer.moveLater", json!({})).unwrap();
s.execute("layer.moveEarlier", json!({})).unwrap();
assert_eq!(span(&s, ids[0]), (8.0, 10.0));
// The unselected layer never moved.
assert_eq!(span(&s, ids[2]), (0.0, 10.0));
for (id, keys) in [
("layer.moveEarlier", "Alt+PageUp"),
("layer.moveLater", "Alt+PageDown"),
("layer.moveEarlier10", "Alt+Shift+PageUp"),
("layer.moveLater10", "Alt+Shift+PageDown"),
] {
assert_eq!(crate::find_command(id).and_then(|c| c.shortcut), Some(keys));
}
}
#[test]
fn in_and_out_points_stretch_to_the_current_time() {
let (mut s, ids) = session([(2.0, 4.0), (0.0, 10.0), (0.0, 10.0)]);
let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == ids[0]).unwrap().stretch;
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
// In point to 1 s: the Out point holds, the layer is 3 s long at 150 %.
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("layer.stretchInToTime", json!({})).unwrap();
assert_eq!(span(&s, ids[0]), (1.0, 4.0));
assert!((stretch(&s) - 150.0).abs() < 1e-6);
// Out point to 7 s: the In point holds, 6 s at 300 %.
s.execute("time.set", json!({"time": 7.0})).unwrap();
s.execute("layer.stretchOutToTime", json!({})).unwrap();
assert_eq!(span(&s, ids[0]), (1.0, 7.0));
assert!((stretch(&s) - 300.0).abs() < 1e-6);
// A current time before the In point can't be the Out point: the layer is left alone.
s.execute("time.set", json!({"time": 0.5})).unwrap();
s.execute("layer.stretchOutToTime", json!({})).unwrap();
assert_eq!(span(&s, ids[0]), (1.0, 7.0));
assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err());
}
#[test]
fn masks_are_stepped_through_and_wrap() {
let (mut s, ids) = session([(0.0, 10.0); 3]);
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
assert!(s.execute("layer.mask.selectNext", json!({})).is_err());
for _ in 0..3 {
s.execute("layer.addMask", json!({"shape": "rect"})).unwrap();
}
s.execute("keys.deselectAll", json!({})).unwrap();
let step = |s: &mut Session, cmd: &str| s.execute(cmd, json!({})).unwrap()["index"].as_u64().unwrap();
assert_eq!(step(&mut s, "layer.mask.selectNext"), 1);
assert_eq!(step(&mut s, "layer.mask.selectNext"), 2);
assert_eq!(step(&mut s, "layer.mask.selectNext"), 3);
assert_eq!(step(&mut s, "layer.mask.selectNext"), 1);
assert_eq!(step(&mut s, "layer.mask.selectPrevious"), 3);
assert_eq!(s.state.selected_props.len(), 1);
s.execute("keys.deselectAll", json!({})).unwrap();
assert_eq!(step(&mut s, "layer.mask.selectPrevious"), 3);
}
#[test]
fn casts_shadows_toggles_on_3d_layers_only() {
let (mut s, ids) = session([(0.0, 10.0); 3]);
s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap();
assert!(s.execute("layer.castsShadows", json!({})).is_err());
s.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "threeD", "value": true})).unwrap();
let before = s.execute("layer.castsShadows", json!({})).unwrap();
assert_eq!(before["layers"], json!([ids[0]]));
let after = s.execute("layer.castsShadows", json!({})).unwrap();
assert_ne!(before["castsShadows"], after["castsShadows"]);
assert_eq!(s.execute("layer.castsShadows", json!({"value": true})).unwrap()["castsShadows"], json!(true));
}
+9
View File
@@ -2131,6 +2131,15 @@ mod tests {
("comp.activateRecent", "Shift+Escape"),
("keys.deselectAll", "Shift+F2"),
("keys.deselectAll", "Cmd+Alt+Shift+A"),
("layer.extendSelectionNext", "Cmd+Shift+ArrowDown"),
("layer.moveInToStart", "Alt+Home"),
("layer.moveEarlier", "Alt+PageUp"),
("layer.moveLater10", "Alt+Shift+PageDown"),
("layer.stretchInToTime", "Cmd+Shift+,"),
("layer.stretchOutToTime", "Cmd+Alt+,"),
("layer.mask.selectNext", "Alt+`"),
("layer.mask.selectPrevious", "Alt+Shift+`"),
("layer.castsShadows", "Alt+Shift+C"),
] {
assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}");
}