Files
effectcraft/crates/engine/src/tests_menu_cmds.rs
T
bflatasticandClaude Opus 5.5 daed03305a M5.11: Graph Editor: drags move every selected key, Shift keeps an axis, Ctrl / Ctrl+Alt clicks; drags and the transform box never merge or lose keys
- `keys.transform {fromStart}`: each step of a merged drag gives the whole transform since the
  drag started and applies to the keys as they were then. Keys squeezed onto one frame on the
  way (Alt-drag scaling, the transform box) are no longer merged for good, and a slow
  transform-box move, whose per-frame offsets rounded to nothing, now moves the keys.
- Dragging a key in the Graph Editor moves every selected key in time and value (it moved only
  the grabbed one), by how far the pointer went from where the button went down (the key no
  longer jumps under the pointer); Shift keeps the drag to the time or the value axis.
- Shift+click toggles a key in or out of the selection; Ctrl+click switches Linear ↔ Auto
  Bezier and Ctrl+Alt+click toggles Hold, as in the Timeline.
- The transform box and the Timeline's Alt-drag scale send whole-gesture values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:52:20 +07:00

960 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tests for the menu-parity command families (Layer / Edit / Animation / File / Composition /
//! View menus), including undo.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling};
use serde_json::{Value, json};
use crate::{EngineError, Event, Session};
fn comp() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s
}
fn solid(s: &mut Session, color: &str) -> u64 {
s.execute("layer.newSolid", json!({"color": color, "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
}
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-menu-tests-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
fn frontend_events(s: &mut Session) -> Vec<(String, Value)> {
s.drain_events()
.into_iter()
.filter_map(|e| match e {
Event::Frontend { command, params } => Some((command, params)),
_ => None,
})
.collect()
}
#[test]
fn layer_quality_sampling_frame_blending_and_undo() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.quality", json!({"quality": "wireframe"})).unwrap();
s.execute("layer.sampling", json!({"sampling": "bicubic"})).unwrap();
s.execute("layer.frameBlending", json!({"mode": "pixelMotion"})).unwrap();
let l = layer(&s, a);
assert_eq!((l.switches.quality, l.switches.sampling, l.switches.frame_blend), (Quality::Wireframe, Sampling::Bicubic, FrameBlend::PixelMotion));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, a).switches.frame_blend, FrameBlend::Off);
assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err());
}
#[test]
fn layer_switches_menu() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("layer.hideOtherVideo", json!({})).unwrap();
assert!(!layer(&s, a).switches.video && layer(&s, b).switches.video);
s.execute("layer.showAllVideo", json!({})).unwrap();
assert!(layer(&s, a).switches.video);
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "lock"})).unwrap();
assert!(layer(&s, a).switches.locked);
s.execute("layer.unlockAll", json!({})).unwrap();
assert!(!layer(&s, a).switches.locked && !layer(&s, b).switches.locked);
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, a).switches.locked);
// Expressions on / off.
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
s.execute("layer.expressions", json!({"layers": [a], "enabled": false})).unwrap();
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().enabled);
s.execute("layer.expressions", json!({"layers": [a], "enabled": true})).unwrap();
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().has_expression());
}
#[test]
fn transform_dialog_values_and_center_anchor() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.setTransform", json!({"prop": "position", "value": [100, 50]})).unwrap();
s.execute("layer.setTransform", json!({"prop": "rotation", "value": 90})).unwrap();
s.execute("layer.setTransform", json!({"prop": "opacity", "value": 25})).unwrap();
let l = layer(&s, a);
assert_eq!(l.props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 50.0, 0.0]));
assert_eq!(l.props.prop("transform/opacity").unwrap().value.as_f64(), 25.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
assert!(s.execute("layer.setTransform", json!({"prop": "skew", "value": 1})).is_err());
// Move the anchor off-centre, then Center Anchor Point keeps the layer in place.
s.execute("layer.setTransform", json!({"prop": "anchor", "value": [0, 0]})).unwrap();
s.execute("layer.setTransform", json!({"prop": "rotation", "value": 0})).unwrap();
let before = layer(&s, a).props.prop("transform/position").unwrap().value.as_vec3();
s.execute("layer.centerAnchor", json!({})).unwrap();
let l = layer(&s, a);
assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), [100.0, 50.0]);
let after = l.props.prop("transform/position").unwrap().value.as_vec3();
assert!((after[0] - before[0] - 100.0).abs() < 1e-6 && (after[1] - before[1] - 50.0).abs() < 1e-6, "{before:?} → {after:?}");
s.execute("layer.autoOrient", json!({"mode": "alongPath"})).unwrap();
assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath);
}
#[test]
fn mask_menu_on_existing_masks() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
assert!(!s.is_enabled("layer.mask.reset"));
s.execute("layer.addMask", json!({})).unwrap();
s.execute("layer.addMask", json!({"shape": "ellipse"})).unwrap();
assert!(s.is_enabled("layer.mask.reset"));
let masks = |s: &Session| layer(s, a).masks().unwrap().groups().cloned().collect::<Vec<_>>();
s.execute("layer.mask.set", json!({"field": "feather", "value": 12, "mask": 1})).unwrap();
assert_eq!(masks(&s)[0].get("feather").unwrap().value, KV::Vec2([12.0, 12.0]));
s.execute("layer.mask.set", json!({"field": "opacity", "value": 40})).unwrap();
assert!(masks(&s).iter().all(|m| m.get("opacity").unwrap().value.as_f64() == 40.0));
s.execute("layer.mask.invert", json!({"mask": 2})).unwrap();
s.execute("layer.mask.mode", json!({"mode": "Subtract", "mask": 2})).unwrap();
s.execute("layer.mask.lock", json!({"mask": 1})).unwrap();
let kinds: Vec<_> = masks(&s).iter().map(|m| m.kind.clone()).collect();
assert!(matches!(kinds[1], effectcraft_project::GroupKind::Mask { inverted: true, mode: effectcraft_project::MaskMode::Subtract, .. }));
assert!(matches!(kinds[0], effectcraft_project::GroupKind::Mask { locked: true, .. }));
s.execute("layer.mask.unlockAll", json!({})).unwrap();
assert!(matches!(masks(&s)[0].kind, effectcraft_project::GroupKind::Mask { locked: false, .. }));
s.execute("layer.mask.lockOthers", json!({"mask": 1})).unwrap();
assert!(matches!(masks(&s)[1].kind, effectcraft_project::GroupKind::Mask { locked: true, .. }));
s.execute("layer.mask.reset", json!({"mask": 1})).unwrap();
assert_eq!(masks(&s)[0].get("opacity").unwrap().value.as_f64(), 100.0);
s.execute("layer.mask.shape", json!({"mask": 1, "rect": [10, 10, 50, 40], "shape": "ellipse"})).unwrap();
s.execute("layer.mask.remove", json!({"mask": 2})).unwrap();
assert_eq!(masks(&s).len(), 1);
s.execute("layer.mask.removeAll", json!({})).unwrap();
assert!(masks(&s).is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(masks(&s).len(), 1);
}
#[test]
fn layer_markers_lock_and_delete() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("layer.addMarker", json!({"comment": "hit"})).unwrap();
s.execute("layer.addMarker", json!({"time": 2.0})).unwrap();
assert_eq!(layer(&s, a).markers.len(), 2);
s.execute("layer.markersLock", json!({})).unwrap();
assert!(layer(&s, a).markers_locked);
s.execute("layer.deleteAllMarkers", json!({})).unwrap();
assert_eq!(layer(&s, a).markers.len(), 2, "locked markers survive");
s.execute("layer.markersLock", json!({"value": false})).unwrap();
s.execute("layer.deleteAllMarkers", json!({})).unwrap();
assert!(layer(&s, a).markers.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, a).markers.len(), 2);
}
#[test]
fn track_matte_menu() {
let mut s = comp();
let below = solid(&mut s, "#ff0000");
let top = solid(&mut s, "#00ff00");
s.execute("layer.select", json!({"layers": [below]})).unwrap();
s.execute("layer.trackMatte", json!({"op": "luma"})).unwrap();
let m = layer(&s, below).track_matte.unwrap();
assert_eq!((m.layer.0, m.kind), (top, MatteKind::Luma));
assert!(!layer(&s, top).switches.video);
s.execute("layer.trackMatte", json!({"op": "alphaInverted"})).unwrap();
assert_eq!(layer(&s, below).track_matte.unwrap().kind, MatteKind::AlphaInverted);
s.execute("layer.trackMatte", json!({"op": "none"})).unwrap();
assert!(layer(&s, below).track_matte.is_none());
s.execute("layer.select", json!({"layers": [top]})).unwrap();
assert!(s.execute("layer.trackMatte", json!({"op": "above"})).is_err());
s.execute("layer.trackMatte", json!({"op": "below"})).unwrap();
assert_eq!(layer(&s, top).track_matte.unwrap().layer.0, below);
}
#[test]
fn keyframe_clipboard_paste_and_paste_reversed() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
assert_eq!(s.state.key_clipboard.iter().map(|c| c.keys.len()).sum::<usize>(), 2);
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let keys = layer(&s, b).props.prop("transform/opacity").unwrap().keys.clone();
assert_eq!(keys.len(), 2);
assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[0].value.as_f64() == 100.0);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.pasteReversedKeyframes", json!({})).unwrap();
let keys = layer(&s, b).props.prop("transform/opacity").unwrap().keys.clone();
assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0));
}
#[test]
fn property_links_and_expression_only() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap();
s.execute("edit.copyWithPropertyLinks", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let e = layer(&s, b).props.prop("transform/position").unwrap().expr.clone().unwrap();
let name = layer(&s, a).name;
assert_eq!(e.text, format!("comp(\"Main\").layer({}).transform(\"Position\")", serde_json::to_string(&name).unwrap()));
// Relative links use thisComp.
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("edit.copyWithRelativePropertyLinks", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert!(layer(&s, b).props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text.starts_with("thisComp.layer("));
// Copy Expression Only.
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "transform/rotation"})).unwrap();
s.execute("edit.copyExpressionOnly", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().expr.as_ref().unwrap().text, "time * 90");
// Layers with property links: the pasted copy follows the original.
s.state.selected_props.clear();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("edit.copyWithPropertyLinks", json!({})).unwrap();
let new = s.execute("edit.paste", json!({})).unwrap();
let nid = new[0].as_u64().unwrap();
assert!(layer(&s, nid).props.prop("transform/scale").unwrap().has_expression());
}
#[test]
fn lift_and_extract_work_area() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("comp.workArea", json!({"start": 2.0, "end": 4.0})).unwrap();
s.execute("edit.liftWorkArea", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert_eq!(c.layers.len(), 2, "split around the gap");
let mut spans: Vec<(f64, f64)> = c.layers.iter().map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect();
spans.sort_by(|x, y| x.0.total_cmp(&y.0));
assert!((spans[0].1 - 2.0).abs() < 1e-6 && (spans[1].0 - 4.0).abs() < 1e-6);
s.execute("edit.undo", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("edit.extractWorkArea", json!({})).unwrap();
let c = s.active_comp().unwrap();
let mut spans: Vec<(f64, f64)> = c.layers.iter().map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect();
spans.sort_by(|x, y| x.0.total_cmp(&y.0));
assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}");
}
#[test]
fn label_group_purge_and_edit_original() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let _c = solid(&mut s, "#0000ff");
s.execute("edit.label", json!({"layers": [a, b], "label": "Cyan"})).unwrap();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("edit.selectLabelGroup", json!({})).unwrap();
assert_eq!(s.state.selected_layers.len(), 2);
// A footage source that counts purges (the media pool drops its decoded frames).
struct Counting(std::sync::atomic::AtomicUsize);
impl crate::render::FootageSource for Counting {
fn frame(
&self,
_: effectcraft_project::ItemId,
_: &effectcraft_project::Footage,
_: effectcraft_time::Tick,
) -> Option<std::sync::Arc<crate::render::Image>> {
None
}
fn purge(&self) {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
let src = std::sync::Arc::new(Counting(Default::default()));
s.footage = src.clone();
s.drain_events();
s.execute("edit.purge", json!({"what": "memory"})).unwrap();
assert!(s.drain_events().contains(&Event::PurgeCaches));
assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1, "decoded footage frames are dropped");
// The disk cache and the snapshot purge alone: the RAM preview stays.
for what in ["disk", "snapshot"] {
s.execute("edit.purge", json!({"what": what})).unwrap();
assert!(!s.drain_events().contains(&Event::PurgeCaches), "{what}");
}
assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1);
s.execute("edit.purge", json!({"what": "3d"})).unwrap();
assert!(s.drain_events().contains(&Event::PurgeCaches));
assert!(!s.is_enabled("edit.editOriginal"));
}
#[test]
fn animation_menu_keys_presets_text() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
// Add Keyframe on selected properties.
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
s.execute("anim.addKeyframe", json!({})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [400, 400]})).unwrap();
assert_eq!(layer(&s, a).props.prop("transform/scale").unwrap().keys.len(), 2);
// Exponential Scale: a key per frame, geometric in between.
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
s.execute("keys.exponentialScale", json!({})).unwrap();
let keys = layer(&s, a).props.prop("transform/scale").unwrap().keys.clone();
assert_eq!(keys.len(), 31);
assert!((keys[15].value.as_vec3()[0] - 200.0).abs() < 1e-6, "{:?}", keys[15].value);
s.execute("edit.undo", json!({})).unwrap();
// Time-Reverse Keyframes.
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
s.execute("keys.timeReverse", json!({})).unwrap();
let keys = layer(&s, a).props.prop("transform/scale").unwrap().keys.clone();
assert_eq!((keys[0].value.as_vec3()[0], keys[1].value.as_vec3()[0]), (400.0, 100.0));
// Presets round-trip through a file onto another layer at the CTI.
let path = tmp("scale.ecpreset");
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
s.execute("anim.savePreset", json!({"path": path})).unwrap();
let b = solid(&mut s, "#00ff00");
s.execute("time.set", json!({"time": 3.0})).unwrap();
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [b]})).unwrap();
assert_eq!(r["applied"], 1);
let keys = layer(&s, b).props.prop("transform/scale").unwrap().keys.clone();
assert_eq!(keys.len(), 2);
assert!((keys[0].time.seconds() - 3.0).abs() < 1e-6);
// Text: animate, add selector, reveal, remove all.
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addTextAnimator", json!({"properties": ["anchor", "position", "scale", "skew", "rotation", "opacity"]})).unwrap();
s.execute("text.addSelector", json!({"kind": "range"})).unwrap();
let anim = layer(&s, t).props.group("text/animators").unwrap().groups().next().unwrap().clone();
assert_eq!(anim.sub("selectors").unwrap().children.len(), 2);
// Skew brings Skew Axis along.
assert_eq!(anim.sub("properties").unwrap().children.len(), 7);
s.execute("text.addSelector", json!({"kind": "wiggly"})).unwrap();
assert!(s.execute("text.addSelector", json!({"kind": "other"})).is_err());
s.execute("text.removeAllAnimators", json!({})).unwrap();
assert!(layer(&s, t).props.group("text/animators").unwrap().children.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, t).props.group("text/animators").unwrap().children.len(), 1);
}
#[test]
fn reveal_properties() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/rotation"})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [50, 50]})).unwrap();
s.drain_events();
let n = |v: &Value| v["props"].as_array().unwrap().len();
assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "keyframes"})).unwrap()), 1);
assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "animation"})).unwrap()), 2);
assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "modified"})).unwrap()), 3);
assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps"));
}
#[test]
fn sequence_layers() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let c = solid(&mut s, "#0000ff");
for (id, out) in [(a, 2.0), (b, 3.0), (c, 1.0)] {
s.execute("layer.timing", json!({"layers": [id], "out": out})).unwrap();
}
s.execute("layer.sequence", json!({"layers": [a, b, c], "overlap": true, "duration": 0.5, "transition": "dissolveFront"})).unwrap();
assert!((layer(&s, b).in_point.seconds() - 1.5).abs() < 1e-6);
assert!((layer(&s, c).in_point.seconds() - 4.0).abs() < 1e-6);
assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2);
}
#[test]
fn file_menu_project_ops() {
let mut s = comp();
let f = s.execute("project.newFolder", json!({"name": "Stuff"})).unwrap()["item"].as_u64().unwrap();
assert!(s.project.item(effectcraft_project::ItemId(f)).unwrap().is_folder());
let p1 = s.execute("file.importPlaceholder", json!({"name": "Shot 1", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap();
let p2 = s.execute("file.importPlaceholder", json!({"name": "Unused"})).unwrap()["item"].as_u64().unwrap();
s.execute("file.importSolid", json!({"name": "Grey"})).unwrap();
s.state.project_selection = vec![effectcraft_project::ItemId(p1)];
let r = s.execute("file.newCompFromSelection", json!({})).unwrap();
let nc = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
assert_eq!((s.project.comp(nc).unwrap().width, s.project.comp(nc).unwrap().layers.len()), (320, 1));
// Missing footage = the placeholders.
let miss = s.execute("file.findMissing", json!({"what": "footage"})).unwrap();
assert_eq!(miss["items"].as_array().unwrap().len(), 2);
assert!(s.execute("file.findMissing", json!({"what": "fonts"})).is_ok());
// Interpretation.
s.state.project_selection = vec![effectcraft_project::ItemId(p1)];
s.execute("file.interpretFootage", json!({"frameRate": 24, "alpha": "premultiplied", "loop": 3})).unwrap();
s.execute("file.rememberInterpretation", json!({})).unwrap();
s.state.project_selection = vec![effectcraft_project::ItemId(p2)];
s.execute("file.applyInterpretation", json!({})).unwrap();
match &s.project.item(effectcraft_project::ItemId(p2)).unwrap().kind {
ItemKind::Footage(f) => assert_eq!((f.alpha, f.loop_count, f.frame_rate.as_f64().round()), (effectcraft_project::AlphaMode::Premultiplied, 3, 24.0)),
_ => panic!(),
}
// Remove Unused Footage drops the unused placeholder and the unused solid.
let before = s.project.items.len();
s.execute("file.removeUnusedFootage", json!({})).unwrap();
assert_eq!(s.project.items.len(), before - 2);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.items.len(), before);
// Replace with a solid retargets the layer.
s.state.project_selection = vec![effectcraft_project::ItemId(p1)];
s.execute("file.replaceWithSolid", json!({"color": "#336699"})).unwrap();
assert!(matches!(s.project.comp(nc).unwrap().layers[0].source, effectcraft_project::LayerSource::Solid { .. }));
// Reduce Project keeps only the selected comp and what it uses (+ folders of kept items).
s.state.project_selection = vec![nc];
s.execute("file.reduceProject", json!({})).unwrap();
assert!(s.project.items.len() <= 2, "{:?}", s.project.items.values().map(|i| &i.name).collect::<Vec<_>>());
// Save a copy leaves the session path alone.
let path = tmp("copy.ecproj");
s.execute("file.saveCopy", json!({"path": path})).unwrap();
assert!(s.path.is_none() && std::fs::metadata(&path).is_ok());
s.execute("file.closeProject", json!({})).unwrap();
assert!(s.project.items.is_empty());
}
#[test]
fn consolidate_duplicate_footage() {
let mut s = comp();
let f = |s: &mut Session| s.execute("file.importPlaceholder", json!({})).unwrap()["item"].as_u64().unwrap();
let a = f(&mut s);
let b = f(&mut s);
// Give both the same file path.
let mut p = (*s.project).clone();
for id in [a, b] {
if let Some(ItemKind::Footage(ft)) = p.item_mut(effectcraft_project::ItemId(id)).map(|i| &mut i.kind) {
ft.path = "/media/shot.mov".into();
}
}
s.project = std::sync::Arc::new(p);
s.execute("layer.addItem", json!({"item": b})).unwrap();
assert_eq!(s.execute("file.consolidateFootage", json!({})).unwrap()["removed"], 1);
let c = s.active_comp().unwrap();
assert_eq!(c.layers[0].source.item().unwrap().0, a);
}
#[test]
fn run_script_steps() {
let mut s = comp();
let path = tmp("script.jsonl");
std::fs::write(&path, "// make two solids\n{\"command\":\"layer.newSolid\",\"params\":{\"color\":\"#ff0000\"}}\n{\"method\":\"engine.execute\",\"params\":{\"id\":\"layer.newNull\",\"params\":{}}}\n").unwrap();
let r = s.execute("file.runScript", json!({"path": path})).unwrap();
assert_eq!(r["steps"], 2);
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
assert!(s.execute("file.runScript", json!({"steps": [{"command": "nope.nope"}]})).is_err());
}
#[test]
fn crop_comp_and_save_frame() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
// ROI crop moves layers so nothing shifts on screen.
assert!(!s.is_enabled("comp.cropToRegionOfInterest"));
s.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 100]})).unwrap();
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert_eq!((c.width, c.height), (200, 100));
assert_eq!(layer(&s, a).props.prop("transform/position").unwrap().value.as_vec2(), [220.0, 130.0]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().width, 640);
// Crop to the selected layer's bounds (a 200×100 solid centred in 640×360).
s.execute("layer.select", json!({"layers": [a]})).unwrap();
let r = s.execute("comp.cropToLayerBounds", json!({})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)));
assert_eq!(layer(&s, a).props.prop("transform/position").unwrap().value.as_vec2(), [100.0, 50.0]);
// Save Frame As ▸ File… writes a PNG.
let path = tmp("frame.png");
let r = s.execute("comp.saveFrameAs", json!({"path": path, "scale": 0.5})).unwrap();
assert_eq!(r["width"], 100);
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
// Responsive Design — Time.
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("comp.responsiveTime", json!({"op": "intro"})).unwrap();
let m = &s.active_comp().unwrap().markers[0];
assert!(m.protected && (m.duration.seconds() - 2.0).abs() < 1e-6);
}
#[test]
fn guides_add_clear_import_export() {
let mut s = comp();
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap();
s.execute("view.addGuide", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().guides.len(), 2);
assert_eq!(s.active_comp().unwrap().guides[1].position, 320.0);
let path = tmp("guides.json");
s.execute("view.exportGuides", json!({"path": path})).unwrap();
s.execute("view.clearGuides", json!({})).unwrap();
assert!(s.active_comp().unwrap().guides.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().guides.len(), 2);
s.execute("view.clearGuides", json!({})).unwrap();
s.execute("view.importGuides", json!({"path": path})).unwrap();
assert_eq!(s.active_comp().unwrap().guides.len(), 2);
}
#[test]
fn frontend_commands_emit_events_and_stubs_are_disabled() {
let mut s = comp();
s.drain_events();
s.execute("view.zoomIn", json!({})).unwrap();
s.execute("window.panel", json!({"panel": "align"})).unwrap();
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}
}
fn solid_of(s: &Session, l: u64) -> (u64, effectcraft_project::Solid, String) {
let effectcraft_project::LayerSource::Solid { item } = layer(s, l).source else { panic!("not a solid") };
let it = s.project.item(item).unwrap();
let ItemKind::Solid(so) = &it.kind else { panic!("not a solid item") };
(item.0, so.clone(), it.name.clone())
}
#[test]
fn layer_settings_edit_the_solid_or_give_the_layer_its_own() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
let b = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert_eq!(solid_of(&s, a).0, solid_of(&s, b).0, "the duplicate shares the solid");
// Nothing given: nothing recorded.
let steps = s.history.undo.len();
s.execute("layer.settings", json!({"layer": b})).unwrap();
assert_eq!(s.history.undo.len(), steps);
// Affect all off: the duplicate gets its own solid; the original keeps its colour.
let r = s.execute("layer.settings", json!({"layer": b, "color": "#0000ff", "name": "Blue", "affectAll": false})).unwrap();
let (bi, bs, bn) = solid_of(&s, b);
assert_eq!(r, json!({"layer": b, "solid": bi}));
assert_ne!(bi, solid_of(&s, a).0);
assert_eq!((bs.color, bn.as_str(), layer(&s, b).name.as_str()), ([0.0, 0.0, 1.0], "Blue", "Blue"));
assert_eq!(solid_of(&s, a).1.color, [1.0, 0.0, 0.0]);
assert_eq!(
s.project.item(effectcraft_project::ItemId(bi)).unwrap().parent,
s.project.item(effectcraft_project::ItemId(solid_of(&s, a).0)).unwrap().parent,
"same folder"
);
// Affect all (the default) changes the shared solid in place; size and pixel aspect too.
s.execute("layer.select", json!({"layers": [a]})).unwrap();
let c = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
s.execute("layer.settings", json!({"layer": a, "width": 320, "height": 90, "pixelAspect": 2.0})).unwrap();
assert_eq!(solid_of(&s, a).0, solid_of(&s, c).0);
let so = solid_of(&s, c).1;
assert_eq!((so.width, so.height, so.pixel_aspect), (320, 90, 2.0));
assert!(s.execute("layer.settings", json!({"layer": a, "pixelAspect": 0})).is_err());
// One undo step each.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(solid_of(&s, a).1.width, 200);
// A new solid takes a pixel aspect too.
let d = s.execute("layer.newSolid", json!({"color": "#00ff00", "pixelAspect": 0.9091})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(solid_of(&s, d).1.pixel_aspect, 0.9091);
// Text and shape layers have no settings.
assert!(s.is_enabled("layer.settings"));
s.execute("layer.newText", json!({"text": "T"})).unwrap();
assert!(!s.is_enabled("layer.settings"));
}
fn order(s: &Session) -> Vec<u64> {
s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect()
}
fn pos(s: &Session, l: u64) -> [f64; 3] {
let ly = layer(s, l);
let tr = ly.transform().unwrap();
match tr.get("positionX") {
Some(x) => [x.value.as_f64(), tr.get("positionY").unwrap().value.as_f64(), tr.get("positionZ").map(|z| z.value.as_f64()).unwrap_or(0.0)],
None => tr.get("position").unwrap().value.as_vec3(),
}
}
#[test]
fn locked_layers_are_not_selected_deleted_or_moved() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
assert!(layer(&s, b).switches.locked);
s.execute("edit.selectAll", json!({})).unwrap();
assert_eq!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>(), vec![a]);
// Ctrl+Up / Down step over it.
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.selectPrevious", json!({})).unwrap();
assert_eq!(s.state.selected_layers[0].0, a, "nothing above the locked layer to step to");
// Deleting, arranging or transforming only the locked layer is refused; with others, it stays.
let e = s.execute("edit.clear", json!({"layers": [b]})).unwrap_err().to_string();
assert!(e.contains("locked"), "{e}");
assert!(s.execute("layer.arrange", json!({"layers": [b], "to": "back"})).is_err());
assert!(s.execute("layer.transform", json!({"layers": [b], "op": "center"})).is_err());
s.execute("edit.clear", json!({"layers": [a, b]})).unwrap();
assert_eq!(order(&s), vec![b]);
}
#[test]
fn deleting_a_parent_keeps_its_children_in_place() {
let mut s = comp();
let parent = solid(&mut s, "#ff0000");
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [300, 200]})).unwrap();
s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90})).unwrap();
let child = solid(&mut s, "#00ff00");
s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [210, 160]})).unwrap();
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
assert_ne!(pos(&s, child)[..2], [210.0, 160.0], "expressed in the parent's space");
s.execute("edit.clear", json!({"layers": [parent]})).unwrap();
let p = pos(&s, child);
assert!((p[0] - 210.0).abs() < 1e-6 && (p[1] - 160.0).abs() < 1e-6, "{p:?}");
assert_eq!(layer(&s, child).parent, None);
let r = layer(&s, child).transform().unwrap().get("rotation").unwrap().value.as_f64();
assert!(r.abs() < 1e-6, "{r}");
// One undo step restores both.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, child).parent.map(|l| l.0), Some(parent));
}
#[test]
fn bring_forward_and_send_backward_move_each_layer_one_step() {
let mut s = comp();
let ids: Vec<u64> = (0..4).map(|_| solid(&mut s, "#ffffff")).collect();
let top = order(&s);
assert_eq!(top, ids.iter().rev().copied().collect::<Vec<_>>());
let (l0, l1, l2, l3) = (top[0], top[1], top[2], top[3]);
// A non-contiguous selection: each moves down one step.
s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "backward"})).unwrap();
assert_eq!(order(&s), vec![l1, l0, l3, l2]);
// Forward again; a layer already at the top stays there.
s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap();
assert_eq!(order(&s), vec![l0, l1, l2, l3]);
s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap();
assert_eq!(order(&s), vec![l0, l2, l1, l3]);
// Front / Back keep the layers' order.
s.execute("layer.arrange", json!({"layers": [l0, l1], "to": "back"})).unwrap();
assert_eq!(order(&s), vec![l2, l3, l0, l1]);
}
#[test]
fn transform_commands_keep_depth_and_follow_separated_dimensions() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20, -300]})).unwrap();
s.execute("layer.transform", json!({"layers": [a], "op": "center"})).unwrap();
assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]);
s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap();
assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]);
let b = solid(&mut s, "#00ff00");
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap();
s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
s.execute("layer.transform", json!({"layers": [b], "op": "center"})).unwrap();
assert_eq!(pos(&s, b)[..2], [320.0, 180.0]);
s.execute("layer.transform", json!({"layers": [b], "op": "reset"})).unwrap();
assert_eq!(pos(&s, b)[..2], [320.0, 180.0]);
// Center Anchor Point moves separated X / Y Position too, so the layer stays put.
s.execute("prop.set", json!({"layer": b, "path": "transform/anchor", "value": [0, 0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/positionX", "value": 100})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/positionY", "value": 50})).unwrap();
s.execute("layer.centerAnchor", json!({"layers": [b]})).unwrap();
assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre");
}
#[test]
fn duplicated_and_pasted_layers_keep_their_links() {
let mut s = comp();
let parent = solid(&mut s, "#ff0000");
let matte = solid(&mut s, "#ffffff");
let child = solid(&mut s, "#00ff00");
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": child, "matte": matte, "kind": "alpha"})).unwrap();
let m = layer(&s, child).track_matte.unwrap().layer;
assert_eq!(m.0, matte);
// Duplicating the child alone keeps the original parent and matte.
let d = s.execute("edit.duplicate", json!({"layers": [child]})).unwrap()[0].as_u64().unwrap();
assert_eq!((layer(&s, d).parent.map(|l| l.0), layer(&s, d).track_matte.map(|t| t.layer)), (Some(parent), Some(m)));
// Duplicating parent and child together: the copy follows the copied parent.
let r = s.execute("edit.duplicate", json!({"layers": [parent, child]})).unwrap();
let (p2, c2) = (r[0].as_u64().unwrap(), r[1].as_u64().unwrap());
assert_eq!(layer(&s, c2).parent.map(|l| l.0), Some(p2));
// Copy / paste in the same composition keeps the links; in another one they are dropped.
s.execute("layer.select", json!({"layers": [child]})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let pasted = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap();
assert_eq!(layer(&s, pasted).parent.map(|l| l.0), Some(parent));
assert_eq!(layer(&s, pasted).track_matte.map(|t| t.layer), Some(m));
s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64})).unwrap();
let other = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap();
assert_eq!((layer(&s, other).parent, layer(&s, other).track_matte), (None, None));
}
#[test]
fn precompose_takes_the_compositions_settings() {
let mut s = Session::default();
s.execute(
"comp.new",
json!({"name": "Main", "width": 640, "height": 360, "pixelAspect": 2.0, "shutterAngle": 90, "motionBlurSamples": 8, "renderer": "advanced3D", "background": [0.2, 0.3, 0.4]}),
)
.unwrap();
let a = solid(&mut s, "#ff0000");
let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap();
assert_eq!((inner.pixel_aspect, inner.shutter_angle, inner.motion_blur_samples), (2.0, 90.0, 8));
assert_eq!((inner.renderer, inner.background), (effectcraft_project::Renderer::Advanced3D, [0.2, 0.3, 0.4]));
}
#[test]
fn new_comp_from_selection_is_one_undo_step_with_the_dialog_options() {
let mut s = Session::default();
let still = |s: &mut Session, name: &str, w: u32, par: f64| {
let f = effectcraft_project::Footage {
kind: effectcraft_project::FootageKind::Still,
width: w,
height: 100,
pixel_aspect: par,
has_video: true,
..Default::default()
};
std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, ItemKind::Footage(f))
};
let a = still(&mut s, "a.png", 200, 1.0);
let b = still(&mut s, "b.png", 300, 2.0);
s.state.project_selection = vec![a, b];
// Multiple compositions (the default): one per item, its size and pixel aspect; one undo step.
let steps = s.history.undo.len();
let r = s.execute("file.newCompFromSelection", json!({"duration": 4})).unwrap();
let comps: Vec<u64> = r["comps"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(comps.len(), 2);
let cb = s.project.comp(effectcraft_project::ItemId(comps[1])).unwrap();
assert_eq!((cb.width, cb.pixel_aspect, cb.duration.seconds()), (300, 2.0, 4.0));
assert_eq!(s.history.undo.len(), steps + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.comps().count(), 0);
// One composition holding both, sized like the second, sequenced with a 1 s overlap, queued.
s.state.project_selection = vec![a, b];
let r = s
.execute(
"file.newCompFromSelection",
json!({"single": true, "dimensionsFrom": 1, "duration": 3, "sequence": true, "overlap": true, "overlapDuration": 1, "addToRenderQueue": true}),
)
.unwrap();
let c = s.project.comp(effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
assert_eq!((c.width, c.pixel_aspect, c.duration.seconds()), (300, 2.0, 5.0));
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["a.png", "b.png"], "first selected on top");
// Stills have no rate: the comp is 29.97 fps and the second layer starts on the frame nearest 2 s.
assert_eq!(c.layers[0].in_point.seconds(), 0.0);
assert!((c.layers[1].in_point.seconds() - 2.0).abs() < 1.0 / 29.97, "{}", c.layers[1].in_point.seconds());
assert_eq!(s.project.render_queue.len(), 1);
assert_eq!(s.history.undo.len(), steps + 1);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.render_queue.is_empty() && s.project.comps().count() == 0);
s.state.project_selection = vec![a, b];
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 2})).is_err());
}
#[test]
fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unreadable() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let path = tmp("Safe.ecproj");
s.execute("file.saveAs", json!({"path": path})).unwrap();
let saved = std::fs::read_to_string(&path).unwrap();
assert_eq!(effectcraft_project::saved_by(&saved).as_deref(), Some(effectcraft_project::APP_VERSION));
// Save a Copy and Increment and Save keep an XML project XML.
let copy = tmp("Copy.ecprojx");
s.execute("file.saveCopy", json!({"path": copy})).unwrap();
assert!(std::fs::read_to_string(&copy).unwrap().starts_with("<?xml"));
s.execute("file.saveAs", json!({"path": tmp("Xml.ecprojx")})).unwrap();
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
let inc = r["path"].as_str().unwrap().to_string();
assert!(inc.ends_with("Xml 2.ecprojx"), "{inc}");
assert!(std::fs::read_to_string(&inc).unwrap().starts_with("<?xml"));
let r = s.execute("file.open", json!({"path": inc})).unwrap();
assert_eq!(r["savedBy"], json!(effectcraft_project::APP_VERSION));
// A project from a newer version opens with a warning.
let newer = tmp("Newer.ecproj");
std::fs::write(&newer, saved.replacen(effectcraft_project::APP_VERSION, "999.0.0", 1)).unwrap();
s.drain_events();
let r = s.execute("file.open", json!({"path": newer})).unwrap();
assert_eq!(r["savedBy"], json!("999.0.0"));
let warned = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("newer than this version")));
assert!(warned);
// A value a project file can't hold: Save fails and the file on disk is left as it was.
s.execute("file.open", json!({"path": path})).unwrap();
let cid = s.state.active_comp.unwrap();
std::sync::Arc::make_mut(&mut s.project)
.comp_mut(cid)
.unwrap()
.layer_mut(effectcraft_project::LayerId(a))
.unwrap()
.props
.prop_mut("transform/opacity")
.unwrap()
.value = KV::Scalar(f64::NAN);
let e = s.execute("file.save", json!({})).unwrap_err().to_string();
assert!(e.contains("can't be saved"), "{e}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), saved);
// Not a project: a clear message.
let junk = tmp("Junk.ecproj");
std::fs::write(&junk, "{ not json").unwrap();
let e = s.execute("file.open", json!({"path": junk})).unwrap_err().to_string();
assert!(e.contains("is not a project EffectCraft can open"), "{e}");
}
#[test]
fn comp_settings_preserve_frame_rate_and_resolution() {
let mut s = comp();
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(false), json!(false)));
s.execute_checked("comp.settings", json!({"preserveFrameRate": true, "preserveResolution": true})).unwrap();
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(true), json!(true)));
// Pre-compose keeps them; one undo step clears them.
let a = solid(&mut s, "#ff0000");
let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap();
assert!(inner.preserve_frame_rate && inner.preserve_resolution);
// Undo Pre-compose and the solid; then one more step undoes the settings.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().preserve_frame_rate);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.active_comp().unwrap().preserve_frame_rate && !s.active_comp().unwrap().preserve_resolution);
}
#[test]
fn new_comp_from_selection_survives_hostile_sequence_numbers() {
let mut s = Session::default();
let still = |s: &mut Session, name: &str| {
let f = effectcraft_project::Footage {
kind: effectcraft_project::FootageKind::Still,
width: 64,
height: 64,
pixel_aspect: 1.0,
has_video: true,
..Default::default()
};
std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, ItemKind::Footage(f))
};
let a = still(&mut s, "a.png");
let b = still(&mut s, "b.png");
let c = still(&mut s, "c.png");
for overlap in [json!(1e30), json!(-5), json!(f64::MAX)] {
s.state.project_selection = vec![a, b, c];
// A huge overlap overflowed the duration arithmetic (a panic with overflow checks, a
// wrapped, too long comp without them).
let r = s
.execute("file.newCompFromSelection", json!({"single": true, "sequence": true, "overlap": true, "overlapDuration": overlap, "duration": 2}))
.unwrap();
let c = s.project.comp(effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
assert!(c.duration.seconds() <= 6.0 && c.duration > effectcraft_time::Tick::ZERO, "{overlap}: {}", c.duration.seconds());
}
s.state.project_selection = vec![a, b];
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err());
}
fn key_times(s: &Session, l: u64) -> Vec<f64> {
layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
}
fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) {
for (i, t) in times.iter().enumerate() {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap();
}
}
#[test]
fn dragging_a_key_past_another_keeps_it() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[1.0, 2.0]);
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
// One drag, frame by frame, from 1 s over the 2 s key to 3 s.
for _ in 0..60 {
s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap();
}
assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there");
// Dropped exactly on a key: it replaces it, as in After Effects.
s.history.merge_key = None;
s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap();
assert_eq!(key_times(&s, a), vec![2.0]);
// The whole drag was one undo step.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(key_times(&s, a), vec![1.0, 2.0]);
}
#[test]
fn keys_of_a_stretched_layer_move_with_the_pointer() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[0.0, 1.0]);
s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap();
assert_eq!(layer(&s, a).stretch, 200.0);
let comp_time = |s: &Session, i: usize| {
let l = layer(s, a);
l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds()
};
let before = comp_time(&s, 1);
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1));
s.execute("keys.nudge", json!({"frames": 3})).unwrap();
assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames");
}
#[test]
fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[0.0, 1.0]);
let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t});
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap();
assert_eq!(s.state.selected_keys.len(), 1, "toggled out");
// Hold off returns an eased key to Bezier.
s.execute("keys.easyEase", json!({})).unwrap();
s.execute("keys.toggleHold", json!({})).unwrap();
s.execute("keys.toggleHold", json!({})).unwrap();
let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone();
assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier);
// Locked: its keys can't be selected.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap();
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
assert!(s.state.selected_keys.is_empty());
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
assert!(s.state.selected_keys.is_empty());
}
#[test]
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]);
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
// Squeezed onto one frame on the way, then back: the three keys survive.
for k in [0.5, 0.01, 0.0001, 1.0] {
s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap();
}
assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]);
// A slow move in tiny steps still arrives (each step is the whole offset).
s.history.merge_key = None;
for i in 1..=30 {
s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap();
}
assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]);
}