effectcraft/crates/engine/src/tests_menu_cmds.rs

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]);
}