Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 14:04:46 +07:00
109 changed files with 4958 additions and 519 deletions
+74
View File
@@ -9,6 +9,80 @@ fn select(s: &mut crate::Session, layer: u64, path: &str) {
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
}
fn selection_command_skips_locked_keys(command: &str, path: &str, values: [serde_json::Value; 3], mut params: serde_json::Value) {
let (mut s, locked) = setup();
let unlocked = s.execute("layer.newSolid", json!({"name": "Editable", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
for layer in [locked, unlocked] {
animate(&mut s, layer, path, &[(0.0, values[0].clone()), (0.5, values[1].clone()), (1.0, values[2].clone())]);
}
let before = prop(&s, locked, path).keys;
let selected: Vec<_> = [locked, unlocked]
.into_iter()
.flat_map(|layer| {
let pr = prop(&s, layer, path);
pr.keys.into_iter().map(move |key| crate::KeyRef { layer: effectcraft_project::LayerId(layer), prop: pr.uid, time: key.time })
})
.collect();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = selected.clone();
if params.get("keys").is_some() {
params["keys"] = serde_json::to_value(&selected).unwrap();
}
s.execute_checked(command, params.clone()).unwrap();
assert_eq!(prop(&s, locked, path).keys, before, "{command} changed a locked layer");
let after = prop(&s, unlocked, path).keys;
assert_ne!(after, before, "{command} must still edit unlocked keys");
let after_selection = s.state.selected_keys.clone();
assert!(selected.iter().filter(|k| k.layer.0 == locked).all(|key| after_selection.contains(key)));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, unlocked, path).keys, before);
assert!(selected.iter().filter(|key| key.layer.0 == locked).all(|key| s.state.selected_keys.contains(key)));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, unlocked, path).keys, after);
assert_eq!(s.state.selected_keys, after_selection);
// All-locked input must preserve the selection and document as well.
s.state.selected_keys.retain(|k| k.layer.0 == locked);
if params.get("keys").is_some() {
params["keys"] = serde_json::to_value(&s.state.selected_keys).unwrap();
}
let locked_selection = s.state.selected_keys.clone();
let project = serde_json::to_value(&*s.project).unwrap();
let _ = s.execute_checked(command, params);
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
assert_eq!(s.state.selected_keys, locked_selection);
}
#[test]
fn wiggler_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys(
"keys.wiggle",
"transform/opacity",
[json!(10), json!(90), json!(20)],
json!({"apply": "temporal", "frequency": 8, "magnitude": 10}),
);
}
#[test]
fn smoother_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys("keys.smooth", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"tolerance": 1}));
}
#[test]
fn exponential_scale_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys(
"keys.exponentialScale",
"transform/scale",
[json!([100, 100, 100]), json!([150, 150, 150]), json!([400, 400, 400])],
json!({}),
);
}
#[test]
fn edit_label_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red"}));
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red", "keys": true}));
}
#[test]
fn wiggle_adds_keys_at_frequency_within_magnitude() {
let (mut s, l) = setup();
+204
View File
@@ -43,3 +43,207 @@ fn delete_keys_reports_zero_when_no_selected_key_exists() {
assert_eq!(removed, json!(0));
assert_eq!(keys_of(&s, layer, prop), keys);
}
/// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone.
#[test]
fn a_locked_layers_keys_cannot_be_deleted_or_edited() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
s.state.selected_keys = keys.clone();
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
assert_eq!(removed, json!(0));
assert_eq!(keys_of(&s, layer, prop), keys);
assert_eq!(s.state.selected_keys, keys, "the selection stays");
let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string();
assert!(e.contains("locked"), "{e}");
let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64();
assert_eq!(value, 0.0);
// Unlocked again, both work.
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap();
assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3));
}
/// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them
/// selected; unlocked, they change them.
#[test]
fn selection_based_key_edits_skip_a_locked_layer() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
let before = stored(&s);
s.state.selected_keys = keys.clone();
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] {
s.execute_checked(command, json!({})).unwrap();
assert_eq!(stored(&s), before, "{command} changed a locked layer's keys");
assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys");
}
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
s.execute_checked("keys.easyEase", json!({})).unwrap();
assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys");
}
#[test]
fn spatial_handles_cannot_edit_a_locked_layer() {
let (mut s, layer, _) = setup();
for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] {
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap();
}
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let before = s.project.to_json();
let selected = s.state.selected_keys.clone();
let undo_len = s.history.undo.len();
let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]});
let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string();
assert!(error.contains("locked"), "{error}");
assert_eq!(s.project.to_json(), before);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo_len);
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
let unlocked = s.project.to_json();
s.execute("keys.setSpatialTangents", params).unwrap();
let edited = s.project.to_json();
assert_ne!(edited, unlocked);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), unlocked);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), edited);
}
#[test]
fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() {
for path_clip in [false, true] {
for explicit in [false, true] {
let (mut s, layer, opacity) = setup();
let target_path = if path_clip { "transform/position" } else { "transform/opacity" };
let source = if path_clip {
s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap();
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid
} else {
opacity
};
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) };
let before = s.project.to_json();
let selected = s.state.selected_keys.clone();
let undo_len = s.history.undo.len();
let result = s.execute("keys.paste", params.clone());
assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}");
assert_eq!(s.project.to_json(), before);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo_len);
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
let unlocked = s.project.to_json();
s.execute("keys.paste", params).unwrap();
let pasted = s.project.to_json();
assert_ne!(pasted, unlocked);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), unlocked);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
}
}
#[test]
fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
let (mut s, locked, locked_prop) = setup();
let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0, 100), (1, 80), (2, 60)] {
s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap();
}
let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid;
let locked_keys = keys_of(&s, locked, locked_prop);
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect();
s.execute("keys.copy", json!({})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted");
assert_eq!(keys_of(&s, locked, locked_prop), locked_keys);
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 6);
let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys;
assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::<Vec<_>>(), [100.0, 80.0, 60.0]);
let pasted = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
#[test]
fn motion_path_paste_preserves_locked_source_selection() {
for explicit in [false, true] {
let (mut s, source, _) = setup();
s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap();
let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
let locked_keys = s.state.selected_keys.clone();
let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = locked_keys.clone();
let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) };
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3));
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 4);
assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3);
let pasted = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
}
#[test]
fn key_transform_preserves_locked_keys_in_a_mixed_selection() {
let (mut s, locked, locked_prop) = setup();
let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for t in 0..3 {
s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
}
let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid;
let locked_keys = keys_of(&s, locked, locked_prop);
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
let before = stored(&s, locked, locked_prop);
let undo = s.history.undo.len();
for offset in [0.5, 1.0] {
s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(s.state.selected_keys.len(), 6);
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times");
assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys");
let moved = stored(&s, unlocked, unlocked_prop);
for (i, key) in moved.iter().enumerate() {
assert_eq!(key.time.seconds(), i as f64 + offset);
assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0);
}
}
assert_eq!(s.history.undo.len(), undo + 1, "one merged drag");
let after = stored(&s, unlocked, unlocked_prop);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(stored(&s, unlocked, unlocked_prop), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(stored(&s, unlocked, unlocked_prop), after);
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap();
s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap();
assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again");
}
+4 -1
View File
@@ -58,7 +58,8 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
let before = get(&s);
assert_eq!((before.start_time, before.in_point.seconds(), before.out_point.seconds()), (Tick::ZERO, 1.0, 3.0));
// Slip the source 0.5 s later: in/out stay, the source (start) moves.
s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap();
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap();
assert_eq!(r["slipped"][0]["clamped"], json!(false));
let l = get(&s);
assert_eq!((l.start_time.seconds(), l.in_point, l.out_point), (0.5, before.in_point, before.out_point));
// The source time at comp 2 s is now 1.5 s.
@@ -67,6 +68,8 @@ 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));
// The response says the 5 s asked for was cut short (#2371).
assert_eq!((r["requested"].clone(), r["slipped"][0]["clamped"].clone()), (json!(5.0), json!(true)));
// 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);
+45
View File
@@ -184,6 +184,51 @@ fn mask_interpolation_creates_matching_keys() {
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
}
#[test]
fn mask_interpolation_skips_locked_selection_and_rejects_explicit_locked_targets() {
let (mut s, locked, _) = setup(patch_frames(rotating_pose));
let unlocked = effectcraft_project::LayerId(s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap());
let a = ShapePath::rect([20.0, 20.0], 10.0, 10.0);
let b = ShapePath::rect([40.0, 30.0], 20.0, 20.0);
let cid = s.active_comp_id().unwrap();
let mut selected = vec![];
let mut masks = vec![];
for layer in [locked, unlocked] {
let uid = new_mask(&mut s, layer, &a);
masks.push(uid);
s.edit("keys", None, |project, _| {
let pr = project.comp_mut(cid).unwrap().layer_mut(layer).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
pr.keys = vec![Keyframe::new(frame_time(0), KV::Path(a.clone())), Keyframe::new(frame_time(20), KV::Path(b.clone()))];
selected.extend(pr.keys.iter().map(|key| crate::KeyRef { layer, prop: pr.uid, time: key.time }));
Ok(())
})
.unwrap();
}
s.execute("layer.setSwitch", json!({"layers": [locked.0], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = selected.clone();
let before = mask_path(&s, locked, masks[0]).keys;
let options = serde_json::to_value(&s.state.mask_interp).unwrap();
let project = serde_json::to_value(&*s.project).unwrap();
let error = s.execute_checked("mask.interpolate", json!({"layer": locked.0, "mask": masks[0], "times": [0.0, 0.8], "quality": 75})).unwrap_err();
assert!(error.to_string().contains("locked"), "{error}");
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
assert_eq!(serde_json::to_value(&s.state.mask_interp).unwrap(), options);
assert_eq!(s.state.selected_keys, selected);
s.execute_checked("mask.interpolate", json!({})).unwrap();
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
assert_eq!(s.state.selected_keys, selected);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 2);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
s.state.selected_keys.retain(|key| key.layer == locked);
let selection = s.state.selected_keys.clone();
assert!(s.execute_checked("mask.interpolate", json!({})).is_err());
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
assert_eq!(s.state.selected_keys, selection);
}
// ---------------------------------------------------------------- Warp Stabilizer
/// Camera offset on frame `f`: a slow pan plus jitter.
+39
View File
@@ -308,6 +308,45 @@ fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() {
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err());
}
/// Editing a parameter of one of several selected pins edits the same parameter of the others,
/// in one undo step; unselected pins keep theirs (#1751).
#[test]
fn editing_one_selected_pin_edits_the_other_selected_pins() {
let (mut s, id) = setup();
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": id, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
let (a, b, c) = (pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90));
let position = |s: &Session, pin: u64| layer(s, id).props.find_group(pin).unwrap().get("position").unwrap().clone();
let (pa, pb) = (position(&s, a).uid, position(&s, b).uid);
let at = |s: &Session, pin: u64| position(s, pin).value_at(Tick::ZERO).components();
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.select", json!({"layer": id, "prop": b, "selectKeys": false, "add": true})).unwrap();
// A typed value lands on both selected pins, and one Undo takes it back.
let steps = s.history.undo.len();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [20, 40], "selected": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![20.0, 40.0], vec![20.0, 40.0], vec![90.0, 30.0]));
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
s.undo();
assert_eq!((at(&s, a), at(&s, b)), (vec![10.0, 30.0], vec![50.0, 30.0]));
// A scrub moves each selected pin by the same change from its own value.
s.execute("prop.set", json!({"layer": id, "prop": pb, "value": [55, 25], "selected": true, "offset": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![15.0, 25.0], vec![55.0, 25.0], vec![90.0, 30.0]));
// The stopwatch follows on both (a new pin starts keyed, so the toggle turns it off).
let animated = |s: &Session| [a, b, c].map(|p| position(s, p).is_animated());
assert_eq!(animated(&s), [true; 3]);
s.execute("prop.toggleAnimation", json!({"layer": id, "prop": pa, "selected": true})).unwrap();
assert_eq!(animated(&s), [false, false, true]);
// Without `selected`, or with only one pin selected, the edit stays on its own pin.
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [0, 0]})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [5, 5], "selected": true})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
}
fn expr_of(s: &Session, layer: u64, uid: u64) -> String {
let l = layer_of(s, layer);
l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default()
+9
View File
@@ -79,6 +79,15 @@ fn prefs_migrate_old_disk_cache_default() {
assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100);
}
#[test]
fn prefs_migrate_old_split_layers_default() {
// Settings saved before #2083 kept "below"; they move to After Effects' "above" once (#2545).
let above = |doc: &str| Prefs::from_json(doc).general.create_split_layers_above;
assert!(above(r#"{"version": 3, "general": {"createSplitLayersAbove": false}}"#));
assert!(above(r#"{"version": 2, "general": {"createSplitLayersAbove": false}}"#));
assert!(!above(r#"{"version": 4, "general": {"createSplitLayersAbove": false}}"#), "a choice made since stays");
}
#[test]
fn prefs_set_validates_keys_types_and_ranges() {
let mut p = Prefs::default();
+182 -3
View File
@@ -7,6 +7,7 @@ use serde_json::json;
use crate::Session;
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
use crate::tests::three_d::layer_count;
fn session(text: &str) -> (Session, u64) {
let mut s = Session::default();
@@ -1231,10 +1232,188 @@ fn deselect_all_keeps_an_empty_type_tool_layer() {
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
let before = count(&s);
let before = layer_count(&s);
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
assert_eq!(layer_count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
#[test]
fn duplicate_ends_an_edit_on_already_committed_text() {
// The engine route (MCP, control channel, script) with text that was committed before the
// edit opened and nothing typed inside it: the edit still ends and the copy is selected.
let (mut s, t) = editing("Hello", 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the open edit ends on a direct edit.duplicate");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and the copy is selected");
assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original");
assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removes the copy");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() {
use effectcraft_project::LayerId;
// Opening the editor by command, as a Type-tool click does, keeps the property selection, so
// an effect, shape item or text animator can still be selected while the user types.
for what in ["effect", "shape item", "text animator"] {
let (mut s, t) = session("Hello");
let (owner, path) = match what {
"effect" => {
s.execute("effect.apply", json!({"layers": [t], "effect": "Gaussian Blur"})).unwrap();
(t, "effects")
}
"shape item" => (s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(), "contents"),
_ => {
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["opacity"]})).unwrap();
(t, "text/animators")
}
};
let items =
|s: &Session| s.active_comp().unwrap().layer(LayerId(owner)).unwrap().props.group(path).unwrap().groups().map(|g| g.uid).collect::<Vec<_>>();
let item = items(&s)[0];
let n = items(&s).len();
let before = layer_count(&s);
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
begin_edit_at(&mut s, t, 5);
let r = s.execute("edit.duplicate", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "{what}: Duplicate ends the open edit");
assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated");
assert_eq!(layer_count(&s), before + 1, "{what}: the edited layer is");
assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected");
// The selection the edit passed over goes with it: the next Duplicate copies the selected
// copy rather than reaching back to the original's {what}.
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n, "{what}: a second Duplicate leaves the original's {what} alone");
assert_eq!(layer_count(&s), before + 2, "{what}: and copies the copy");
// With no edit open, the selected item keeps its precedence over the layers.
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}");
assert_eq!(layer_count(&s), before + 2, "{what}: and no layer");
}
}
#[test]
fn duplicate_during_an_edit_leaves_no_key_or_vertex_selected_on_the_original() {
use effectcraft_project::LayerId;
// Opening the editor keeps the keyframe and mask-vertex selection too. Once the copy takes the
// selection, Clear deletes the copy, not a key or a vertex of the original.
for what in ["keyframe", "mask vertex"] {
let (mut s, t) = session("Hello");
let mask = match what {
"keyframe" => {
for time in [0.0, 1.0] {
s.execute("prop.addKey", json!({"layer": t, "path": "transform/opacity", "time": time, "value": 50})).unwrap();
}
s.execute("keys.select", json!({"keys": [{"layer": t, "path": "transform/opacity", "time": 0.0}]})).unwrap();
None
}
// The Pen leaves the new mask's last vertex selected.
_ => Some(s.execute("mask.new", json!({"layer": t, "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true})).unwrap()["mask"].as_u64().unwrap()),
};
let original = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap();
match mask {
None => l.props.prop("transform/opacity").unwrap().keys.len(),
Some(m) => l.props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(),
}
};
let (n, before) = (original(&s), layer_count(&s));
begin_edit_at(&mut s, t, 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "{what}: the copy is selected");
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "{what}: Clear deletes the selected copy");
assert_eq!(original(&s), n, "{what}: and leaves the original's {what} alone");
}
}
#[test]
fn duplicate_during_an_edit_copies_the_edited_layer_after_another_is_selected() {
use effectcraft_project::LayerId;
// An engine route (MCP, control channel, script) can select another layer while the edit
// stays open. The implicit Duplicate still means the edited layer, as Clear does; a named
// target keeps its meaning.
let (mut s, t) = session("Hello");
let other = s.execute("layer.newText", json!({"text": "Other"})).unwrap()["layer"].as_u64().unwrap();
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.layer == LayerId(t)), "selecting another layer leaves the edit open");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(layer_count(&s), before + 1, "one layer is copied");
assert_eq!(doc(&s, copy).text, "Hello", "the edited layer is copied, not the newly selected one");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and its copy is selected");
// Null parameters name no layer, as everywhere else layers are read.
for unnamed in [json!({"layers": null}), json!({"layer": null})] {
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
let copy = s.execute("edit.duplicate", unnamed.clone()).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, copy).text, "Hello", "{unnamed}: the edited layer is copied");
}
begin_edit_at(&mut s, t, 5);
let named = s.execute("edit.duplicate", json!({"layer": other})).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, named).text, "Other", "a named layer is copied as named");
}
#[test]
fn duplicate_during_an_edit_with_nothing_selected_copies_the_edited_layer() {
use effectcraft_project::LayerId;
// An engine route can also clear the selection while the edit stays open. Duplicate stays
// available, since its unnamed target is the edited layer, not the selection.
let (mut s, _) = editing("Hello", 5);
s.execute("layer.select", json!({"layers": []})).unwrap();
assert!(s.state.selected_layers.is_empty() && s.state.text_edit.is_some(), "the edit is open with nothing selected");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(doc(&s, copy).text, "Hello", "and copies the edited layer");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "which is selected");
// With no edit open, nothing selected still means nothing to duplicate.
s.execute("layer.select", json!({"layers": []})).unwrap();
let before = layer_count(&s);
assert!(s.execute("edit.duplicate", json!({})).is_err(), "outside an edit Duplicate needs a selection");
assert_eq!(layer_count(&s), before, "and copies nothing");
}
#[test]
fn duplicate_ends_the_open_edit_and_selects_the_copy() {
let (mut s, t) = editing("Hello", 5);
s.execute("text.insert", json!({"text": "!"})).unwrap();
assert_eq!(doc(&s, t).text, "Hello!");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
// A deselected layer never keeps an open text edit: Duplicate commits the edit through the
// same route as text.endEdit before the selection moves, so every route (menu, shortcut,
// palette, MCP, script) leaves one final state.
assert!(s.state.text_edit.is_none(), "Duplicate ends the open text edit, as Deselect All does");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and selects the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the original keeps the typed text");
assert_eq!(doc(&s, copy).text, "Hello!", "the copy carries it too");
// The edit ending is no undoable step of its own (as with text.endEdit): undo takes the
// duplicate, it does not reopen the edit or undo the typing.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removed the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
// With no open edit Duplicate is untouched by all of this: same behavior, no extra steps.
s.execute("edit.duplicate", json!({"layers": [t]})).unwrap();
assert_eq!(layer_count(&s), 2, "a plain duplicate still duplicates");
assert!(s.state.text_edit.is_none());
}
#[test]
fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
use effectcraft_project::LayerId;
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)), "the Type tool opens the edit on its new, empty layer");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
}
+2 -1
View File
@@ -26,7 +26,8 @@ fn auto_orient_command() {
assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0]));
}
fn layer_count(s: &Session) -> usize {
/// Layers in the active comp.
pub(crate) fn layer_count(s: &Session) -> usize {
s.active_comp().unwrap().layers.len()
}
+73
View File
@@ -298,6 +298,25 @@ fn interpolation_dialog_temporal_spatial_and_roving() {
assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
}
/// Editing another key's value or time keeps the roving key at its share of the path (#1284: the
/// roving key went back to where it was after edits to other keys).
#[test]
fn roving_key_follows_value_and_time_edits_to_other_keys() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]);
let uid = prop(&s, l, "transform/position").uid;
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap();
s.execute("keys.interpolation", json!({"roving": true})).unwrap();
let roving_at = |s: &Session| prop(s, l, "transform/position").keys[1].time.seconds();
assert!((roving_at(&s) - 1.5).abs() < 0.01, "start: {}", roving_at(&s));
// The last key's value moves to 800 px: the path is 300 + 500 px, so the roving key is at 300 / 800.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "value": [800, 0]})).unwrap();
assert!((roving_at(&s) - 0.75).abs() < 0.02, "value edit: {}", roving_at(&s));
// The last key moves to 3 s: the same 300 / 800 share of 3 s.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 3.0})).unwrap();
assert!((roving_at(&s) - 1.125).abs() < 0.02, "time edit: {}", roving_at(&s));
}
#[test]
fn graph_editor_key_and_handle_edits() {
let (mut s, l) = setup();
@@ -325,6 +344,34 @@ fn graph_editor_key_and_handle_edits() {
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
}
#[test]
fn graph_editor_ease_edits_reject_a_locked_layer() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": true})).unwrap();
let before = prop(&s, l, "transform/opacity");
let selected = s.state.selected_keys.clone();
let undo = s.history.undo.len();
for side in ["in", "out"] {
let error =
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": side, "speed": 200, "influence": 60})).unwrap_err();
assert!(error.to_string().contains("locked"), "{error}");
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo);
}
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": false})).unwrap();
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": "out", "speed": 200, "influence": 60})).unwrap();
let after = prop(&s, l, "transform/opacity");
assert_eq!(after.keys[1].out_interp, Interp::Bezier);
assert_eq!(after.keys[1].out_ease[0].speed, 200.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, after.keys);
}
#[test]
fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() {
let (mut s, l) = setup();
@@ -1483,3 +1530,29 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
}
/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and
/// its Right moves by its own change, so every layer keeps its own stereo balance.
#[test]
fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
let a = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": a, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "audio/levels", "value": [-20.0, -30.0]})).unwrap();
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let uid = prop(&s, a, "audio/levels").uid;
let undo = s.history.undo.len();
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [-3.0, -9.0], "selected": true, "dims": [0], "follow": [1]})).unwrap();
assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]);
assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
// Keyed at a time other than the CTI: B's change is measured on its key there.
animate(&mut s, b, "audio/levels", &[(0.0, json!([-20.0, -30.0])), (1.0, json!([-10.0, -40.0]))]);
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [-4.0, -10.0], "time": 1.0, "selected": true, "dims": [0], "follow": [1]})).unwrap();
let keys: Vec<Vec<f64>> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels");
}
@@ -52,6 +52,14 @@ fn export_and_reimport_timeline_commands() {
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": tmp("NoExt"), "format": "otio", "prerender": "none"})).unwrap();
assert!(r["path"].as_str().unwrap().ends_with("NoExt.otio"));
assert!(s.execute_checked("file.exportTimeline", json!({"path": tmp("x.xml"), "prerender": "sometimes"})).is_err());
// In the background (the export dialog, #2269): a job, then the same file and result.
let path = tmp("Background.xml");
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": path, "background": true})).unwrap();
assert!(r["job"].is_string(), "{r}");
s.wait_jobs();
let done = s.job_log.last().unwrap();
assert_eq!((done.status.as_str(), done.result["path"].as_str()), ("done", Some(path.as_str())));
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
assert!(s.execute_checked("file.importTimeline", json!({"path": "/nope/a.prproj"})).is_err());
let formats = s.execute("file.timelineFormats", json!({})).unwrap();
assert_eq!(formats.as_array().unwrap().len(), 6);