mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
# Conflicts: # crates/engine/src/tests_menu_cmds.rs # docs/effects-parity.md # docs/gaps.md # docs/ui-parity.md
1608 lines
85 KiB
Rust
1608 lines
85 KiB
Rust
//! 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 effectcraft_time::Tick;
|
||
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());
|
||
}
|
||
|
||
/// `layer.sampling {toggle}` (Alt+B, Alt+Shift+B): layers that all have the sampling already get
|
||
/// the other one; without `toggle` it is only set.
|
||
#[test]
|
||
fn layer_sampling_toggles_when_every_layer_has_it() {
|
||
let mut s = comp();
|
||
let a = solid(&mut s, "#ff0000");
|
||
let b = solid(&mut s, "#00ff00");
|
||
let sampling = |s: &Session| [layer(s, a).switches.sampling, layer(s, b).switches.sampling];
|
||
let bicubic = json!({"layers": [a, b], "sampling": "bicubic", "toggle": true});
|
||
s.execute("layer.sampling", bicubic.clone()).unwrap();
|
||
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
|
||
s.execute("layer.sampling", bicubic.clone()).unwrap();
|
||
assert_eq!(sampling(&s), [Sampling::Bilinear; 2]);
|
||
s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bilinear", "toggle": true})).unwrap();
|
||
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
|
||
// Mixed layers all get the one asked for.
|
||
s.execute("layer.sampling", json!({"layers": [a], "sampling": "bilinear"})).unwrap();
|
||
s.execute("layer.sampling", bicubic).unwrap();
|
||
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
|
||
// Without `toggle` it stays.
|
||
s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bicubic"})).unwrap();
|
||
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
|
||
}
|
||
|
||
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the
|
||
/// other layers come back the next time.
|
||
#[test]
|
||
fn set_switch_others() {
|
||
let mut s = comp();
|
||
let a = solid(&mut s, "#ff0000");
|
||
let b = solid(&mut s, "#00ff00");
|
||
let c = solid(&mut s, "#0000ff");
|
||
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, b, c].map(|l| f(&layer(s, l)));
|
||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "value": false})).unwrap();
|
||
// B is hidden: it is shown, alone; then the others come back; one undo step each.
|
||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
|
||
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
|
||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
|
||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
|
||
// Solo.
|
||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "solo", "value": true})).unwrap();
|
||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "solo", "others": true})).unwrap();
|
||
assert_eq!(get(&s, |l| l.switches.solo), [false, false, true]);
|
||
// Lock: the layer stays unlocked and the others lock, then unlock.
|
||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
|
||
assert_eq!(get(&s, |l| l.switches.locked), [false, true, true]);
|
||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
|
||
assert_eq!(get(&s, |l| l.switches.locked), [false; 3]);
|
||
// A track matte keeps its video off.
|
||
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
|
||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "value": false})).unwrap();
|
||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
|
||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
|
||
assert_eq!(get(&s, |l| l.switches.video), [false, true, true]);
|
||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).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);
|
||
}
|
||
|
||
/// Inclusive bounds of samples whose alpha is above one half, plus the frame size and the count.
|
||
fn opaque_bounds(s: &Session, t: Tick) -> (u32, u32, i64, i64, i64, i64, u32) {
|
||
let img = s.render(s.active_comp_id().unwrap(), t, Default::default());
|
||
let mut n = 0u32;
|
||
let mut x0 = i64::MAX;
|
||
let mut y0 = i64::MAX;
|
||
let mut x1 = i64::MIN;
|
||
let mut y1 = i64::MIN;
|
||
for y in 0..img.height as i64 {
|
||
for x in 0..img.width as i64 {
|
||
if img.get(x, y)[3] > 0.5 {
|
||
n += 1;
|
||
x0 = x0.min(x);
|
||
y0 = y0.min(y);
|
||
x1 = x1.max(x);
|
||
y1 = y1.max(y);
|
||
}
|
||
}
|
||
}
|
||
(img.width, img.height, x0, y0, x1, y1, n)
|
||
}
|
||
|
||
#[test]
|
||
fn separated_position_follows_canvas_resize_and_crop() {
|
||
let square = |separated: bool| {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Test", "width": 100, "height": 100, "frameRate": 10, "duration": 2})).unwrap();
|
||
let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [40, 50]})).unwrap();
|
||
if separated {
|
||
s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap();
|
||
}
|
||
(s, a)
|
||
};
|
||
let placed = |s: &Session, a: u64, t: f64, x: f64, y: f64| {
|
||
let l = layer(s, a);
|
||
let tr = l.transform().unwrap();
|
||
let at = Tick::from_seconds_f64(t);
|
||
if tr.get("positionX").is_some() {
|
||
assert!((tr.get("positionX").unwrap().value_at(at).as_f64() - x).abs() < 1e-6);
|
||
assert!((tr.get("positionY").unwrap().value_at(at).as_f64() - y).abs() < 1e-6);
|
||
}
|
||
let p = tr.get("position").unwrap().value_at(at).as_vec3();
|
||
assert!((p[0] - x).abs() < 1e-6 && (p[1] - y).abs() < 1e-6, "combined {p:?}");
|
||
};
|
||
|
||
let (mut combined, a) = square(false);
|
||
let (mut separated, b) = square(true);
|
||
assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO));
|
||
combined.execute("comp.settings", json!({"width": 200})).unwrap();
|
||
separated.execute("comp.settings", json!({"width": 200})).unwrap();
|
||
placed(&separated, b, 0.0, 90.0, 50.0);
|
||
placed(&combined, a, 0.0, 90.0, 50.0);
|
||
assert_eq!(opaque_bounds(&separated, Tick::ZERO), (200, 100, 85, 45, 94, 54, 100));
|
||
assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO));
|
||
|
||
let (mut separated, b) = square(true);
|
||
separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
|
||
separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||
placed(&separated, b, 0.0, 30.0, 30.0);
|
||
assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100));
|
||
|
||
let (mut separated, b) = square(true);
|
||
separated.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||
separated.execute("comp.cropToLayerBounds", json!({})).unwrap();
|
||
let bounds = opaque_bounds(&separated, Tick::ZERO);
|
||
assert_eq!((bounds.0, bounds.1, bounds.6), (10, 10, 100));
|
||
assert!(bounds.2 == 0 && bounds.3 == 0, "the square fills the cropped frame: {bounds:?}");
|
||
|
||
// Two Position keys keep their spacing after the same canvas offset.
|
||
let (mut separated, b) = square(true);
|
||
separated.execute("edit.undo", json!({})).unwrap();
|
||
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap();
|
||
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap();
|
||
separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
|
||
separated.execute("comp.settings", json!({"width": 200})).unwrap();
|
||
placed(&separated, b, 0.0, 90.0, 50.0);
|
||
placed(&separated, b, 1.0, 110.0, 50.0);
|
||
assert_eq!(opaque_bounds(&separated, Tick::ZERO).2, 85);
|
||
assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)).2, 105);
|
||
|
||
let (mut separated, b) = square(true);
|
||
separated.execute("edit.undo", json!({})).unwrap();
|
||
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap();
|
||
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap();
|
||
separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
|
||
separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
|
||
separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||
placed(&separated, b, 0.0, 30.0, 30.0);
|
||
placed(&separated, b, 1.0, 50.0, 30.0);
|
||
assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100));
|
||
assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100));
|
||
}
|
||
|
||
/// Pixel-centre of the opaque samples, so a 10×10 solid centred on (11, 22) reports (11, 22).
|
||
fn opaque_centroid(s: &Session) -> (f64, f64, u32) {
|
||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||
let mut n = 0u32;
|
||
let mut sx = 0.0;
|
||
let mut sy = 0.0;
|
||
for y in 0..img.height {
|
||
for x in 0..img.width {
|
||
if img.get(x as i64, y as i64)[3] > 0.5 {
|
||
n += 1;
|
||
sx += x as f64 + 0.5;
|
||
sy += y as f64 + 0.5;
|
||
}
|
||
}
|
||
}
|
||
assert!(n > 0, "the layer rendered nothing");
|
||
(sx / n as f64, sy / n as f64, n)
|
||
}
|
||
|
||
#[test]
|
||
fn separated_position_set_transform_moves_the_layer() {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Scene", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||
let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
|
||
let (x, y, n) = opaque_centroid(&s);
|
||
assert_eq!(n, 100);
|
||
assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "starts centred, centroid ({x}, {y})");
|
||
|
||
s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap();
|
||
s.execute("layer.setTransform", json!({"layers": [a], "prop": "position", "value": [11, 22]})).unwrap();
|
||
let l = layer(&s, a);
|
||
let tr = l.transform().unwrap();
|
||
assert!((tr.get("positionX").unwrap().value.as_f64() - 11.0).abs() < 1e-6);
|
||
assert!((tr.get("positionY").unwrap().value.as_f64() - 22.0).abs() < 1e-6);
|
||
assert_eq!(tr.get("position").unwrap().value, KV::Vec3([11.0, 22.0, 0.0]));
|
||
let (x, y, n) = opaque_centroid(&s);
|
||
assert_eq!(n, 100);
|
||
assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "separated position renders at ({x}, {y})");
|
||
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
let (x, y, _) = opaque_centroid(&s);
|
||
assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "undo puts it back at ({x}, {y})");
|
||
|
||
// Combined Position, the control, still moves.
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert!(layer(&s, a).transform().unwrap().get("positionX").is_none());
|
||
s.execute("layer.setTransform", json!({"prop": "position", "value": [11, 22]})).unwrap();
|
||
let (x, y, n) = opaque_centroid(&s);
|
||
assert_eq!(n, 100);
|
||
assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "combined position renders at ({x}, {y})");
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
|
||
/// #493: Edit ▸ Copy with a property selected copies that property (its value when it isn't
|
||
/// animated, its keys when it is), and Paste sets the same property of the selected layer, as
|
||
/// in After Effects, instead of copying the whole layer.
|
||
#[test]
|
||
fn copy_a_selected_property_pastes_onto_the_same_property() {
|
||
let mut s = comp();
|
||
let a = solid(&mut s, "#ff0000");
|
||
let b = solid(&mut s, "#00ff00");
|
||
let n_layers = s.active_comp().unwrap().layers.len();
|
||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20]})).unwrap();
|
||
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
|
||
s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
assert!(s.state.clipboard.is_empty(), "the layer isn't copied");
|
||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||
let undo = s.history.undo.len();
|
||
s.execute("edit.paste", json!({})).unwrap();
|
||
assert_eq!(s.active_comp().unwrap().layers.len(), n_layers);
|
||
assert_eq!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]);
|
||
assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().value.as_f64(), 0.0, "only the copied property");
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_ne!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]);
|
||
assert_eq!(s.history.undo.len(), undo);
|
||
// The Transform group with an animated Opacity: its keys (at the CTI) and the other values,
|
||
// in one undo step.
|
||
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();
|
||
let transform = layer(&s, a).props.sub("transform").unwrap().uid;
|
||
s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||
let undo = s.history.undo.len();
|
||
s.execute("edit.paste", json!({})).unwrap();
|
||
assert_eq!(s.history.undo.len(), undo + 1);
|
||
let l = layer(&s, b);
|
||
let keys = &l.props.prop("transform/opacity").unwrap().keys;
|
||
assert_eq!(keys.len(), 2);
|
||
assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[1].value.as_f64() == 0.0);
|
||
assert_eq!(l.props.prop("transform/rotation").unwrap().value.as_f64(), 30.0);
|
||
assert_eq!(s.active_comp().unwrap().layers.len(), n_layers);
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
let l = layer(&s, b);
|
||
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty() && l.props.prop("transform/rotation").unwrap().value.as_f64() == 0.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:?}");
|
||
}
|
||
|
||
/// A project saved with an inverted work area (by an older build) is edited without a panic:
|
||
/// `clamp` with crossed bounds would abort.
|
||
#[test]
|
||
fn an_inverted_saved_work_area_takes_an_end_edit() {
|
||
let mut s = comp();
|
||
let cid = s.active_comp_id().unwrap();
|
||
std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().work_area = (Tick::from_seconds_f64(9.0), Tick::from_seconds_f64(2.0));
|
||
s.execute("comp.workArea", json!({"end": 1.0})).unwrap();
|
||
s.execute("comp.workArea", json!({"start": 50.0})).unwrap();
|
||
let c = s.active_comp().unwrap();
|
||
assert!(c.work_area.0 >= Tick::ZERO && c.work_area.0 + c.frame_duration() <= c.work_area.1 && c.work_area.1 <= c.duration);
|
||
}
|
||
|
||
#[test]
|
||
fn work_area_stays_ordered_when_the_start_passes_the_end() {
|
||
let ordered = |s: &Session| {
|
||
let c = s.active_comp().unwrap();
|
||
let fd = c.frame_duration();
|
||
assert!(c.duration >= fd, "duration {}", c.duration.seconds());
|
||
assert!(c.work_area.0 >= Tick::ZERO);
|
||
assert!(c.work_area.0 + fd <= c.work_area.1, "start {} end {}", c.work_area.0.seconds(), c.work_area.1.seconds());
|
||
assert!(c.work_area.1 <= c.duration);
|
||
(c.duration, c.work_area)
|
||
};
|
||
let one_frame_at_two = |s: &Session| {
|
||
let end = Tick::from_seconds_f64(2.0);
|
||
(end - s.active_comp().unwrap().frame_duration(), end)
|
||
};
|
||
|
||
let mut s = comp();
|
||
s.execute("comp.workArea", json!({"end": 2.0})).unwrap();
|
||
s.execute("comp.workArea", json!({"start": 8.0})).unwrap();
|
||
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(ordered(&s).1, (Tick::ZERO, Tick::from_seconds_f64(2.0)));
|
||
|
||
s.execute("comp.workArea", json!({"start": 0.0, "end": 2.0})).unwrap();
|
||
s.execute("comp.trimToWorkArea", json!({})).unwrap();
|
||
assert_eq!(ordered(&s), (Tick::from_seconds_f64(2.0), (Tick::ZERO, Tick::from_seconds_f64(2.0))));
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(ordered(&s).0, Tick::from_seconds_f64(10.0));
|
||
|
||
let mut s = comp();
|
||
s.execute("comp.workArea", json!({"start": 9.0, "end": 10.0})).unwrap();
|
||
let before = ordered(&s);
|
||
s.execute("comp.settings", json!({"duration": 2.0})).unwrap();
|
||
let (dur, wa) = ordered(&s);
|
||
assert_eq!(dur, Tick::from_seconds_f64(2.0));
|
||
assert_eq!(wa, one_frame_at_two(&s));
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(ordered(&s), before);
|
||
|
||
let mut s = comp();
|
||
s.execute("comp.workArea", json!({"end": 2.0})).unwrap();
|
||
s.execute("time.set", json!({"time": 8.0})).unwrap();
|
||
s.execute("comp.workArea", json!({"set": "begin"})).unwrap();
|
||
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
|
||
let path = tmp("WorkArea.ecproj");
|
||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||
s.execute("file.open", json!({"path": path})).unwrap();
|
||
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
|
||
|
||
// A project that already has an inverted interval must not trim into a negative duration.
|
||
let mut s = comp();
|
||
let cid = s.state.active_comp.unwrap();
|
||
{
|
||
let c = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap();
|
||
c.work_area = (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0));
|
||
}
|
||
let err = s.execute("comp.trimToWorkArea", json!({})).unwrap_err();
|
||
assert!(err.to_string().contains("work area"), "{err}");
|
||
let c = s.active_comp().unwrap();
|
||
assert_eq!(c.duration, Tick::from_seconds_f64(10.0));
|
||
assert_eq!(c.work_area, (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn label_group_purge_and_edit_original() {
|
||
crate::tests_roto::hold_roto_cache_test_lock();
|
||
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 comps_made_from_the_same_footage_get_numbered_names_as_duplicate_does() {
|
||
let mut s = Session::default();
|
||
let add = |s: &mut Session, name: &str, kind: ItemKind| std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, kind);
|
||
let still = || {
|
||
ItemKind::Footage(effectcraft_project::Footage {
|
||
kind: effectcraft_project::FootageKind::Still,
|
||
width: 200,
|
||
height: 100,
|
||
has_video: true,
|
||
..Default::default()
|
||
})
|
||
};
|
||
let clip = add(&mut s, "clip.png", still());
|
||
let other = add(&mut s, "other.png", still());
|
||
let make = |s: &mut Session, sel: Vec<effectcraft_project::ItemId>, p: Value| -> Vec<String> {
|
||
s.state.project_selection = sel;
|
||
let r = s.execute("file.newCompFromSelection", p).unwrap();
|
||
r["comps"].as_array().unwrap().iter().map(|c| s.project.item(effectcraft_project::ItemId(c.as_u64().unwrap())).unwrap().name.clone()).collect()
|
||
};
|
||
// The same footage again and again: "clip", "clip 2", "clip 3".
|
||
assert_eq!(make(&mut s, vec![clip], json!({})), ["clip"]);
|
||
assert_eq!(make(&mut s, vec![clip], json!({})), ["clip 2"]);
|
||
assert_eq!(make(&mut s, vec![clip], json!({})), ["clip 3"]);
|
||
// Several items at once (one comp each): every new comp has a name of its own.
|
||
assert_eq!(make(&mut s, vec![clip, other, clip], json!({})), ["clip 4", "other", "clip 5"]);
|
||
// One comp for all, named after the item it takes its settings from.
|
||
assert_eq!(make(&mut s, vec![clip, other], json!({"single": true})), ["clip 6"]);
|
||
assert_eq!(make(&mut s, vec![clip, other], json!({"single": true, "dimensionsFrom": 1})), ["other 2"]);
|
||
// Undo removes the latest comp and frees its name for the next one.
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert!(s.project.find_by_name("other 2").is_none());
|
||
assert_eq!(make(&mut s, vec![other], json!({})), ["other 2"]);
|
||
// A solid or extensionless footage still names its own first comp; the second is numbered.
|
||
let red = add(&mut s, "Red Solid 1", ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 0.0, 0.0], width: 100, height: 100, pixel_aspect: 1.0 }));
|
||
assert_eq!(make(&mut s, vec![red], json!({})), ["Red Solid 1"]);
|
||
assert_eq!(make(&mut s, vec![red], json!({})), ["Red Solid 2"]);
|
||
// Duplicate numbers its copies the same way, from the same helper.
|
||
let first = s.project.find_by_name("clip").unwrap().id;
|
||
s.state.project_selection = vec![first];
|
||
let r = s.execute("project.duplicate", json!({})).unwrap();
|
||
assert_eq!(s.project.item(effectcraft_project::ItemId(r["items"][0].as_u64().unwrap())).unwrap().name, "clip 7");
|
||
// A comp made from a comp doesn't take its source comp's name.
|
||
assert_eq!(make(&mut s, vec![first], json!({})), ["clip 8"]);
|
||
// Several comps at once are one undo step; undoing frees all their names.
|
||
assert_eq!(make(&mut s, vec![clip, other], json!({})), ["clip 9", "other 3"]);
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert!(s.project.find_by_name("clip 9").is_none() && s.project.find_by_name("other 3").is_none());
|
||
assert_eq!(make(&mut s, vec![other, clip], json!({})), ["other 3", "clip 9"]);
|
||
}
|
||
|
||
#[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(©).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]);
|
||
}
|
||
|
||
#[test]
|
||
fn select_equal_previous_and_following_keyframes() {
|
||
let mut s = comp();
|
||
let a = solid(&mut s, "#ff0000");
|
||
for (t, v) in [(0.0, 20.0), (1.0, 50.0), (2.0, 20.0), (3.0, 70.0)] {
|
||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||
}
|
||
let pick = |s: &mut Session, t: f64| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": t}]})).unwrap();
|
||
let times = |s: &Session| {
|
||
let mut v: Vec<f64> = s.state.selected_keys.iter().map(|k| k.time.seconds()).collect();
|
||
v.sort_by(f64::total_cmp);
|
||
v
|
||
};
|
||
pick(&mut s, 0.0);
|
||
s.execute("keys.selectEqual", json!({})).unwrap();
|
||
assert_eq!(times(&s), vec![0.0, 2.0]);
|
||
pick(&mut s, 2.0);
|
||
s.execute("keys.selectPrevious", json!({})).unwrap();
|
||
assert_eq!(times(&s), vec![0.0, 1.0, 2.0]);
|
||
pick(&mut s, 1.0);
|
||
s.execute("keys.selectFollowing", json!({})).unwrap();
|
||
assert_eq!(times(&s), vec![1.0, 2.0, 3.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() {
|
||
let mut s = comp();
|
||
let a = solid(&mut s, "#ff0000");
|
||
let b = solid(&mut s, "#00ff00");
|
||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||
s.execute("prop.addKey", json!({"layer": b, "path": "transform/opacity", "time": 0.0, "value": 77})).unwrap();
|
||
let c = solid(&mut s, "#0000ff");
|
||
let d = solid(&mut s, "#ffffff");
|
||
// Copy A's and B's Opacity keys, paste onto C and D: A → C, B → D (in order).
|
||
s.execute("keys.selectAll", json!({"layers": [a, b]})).unwrap();
|
||
s.execute("keys.copy", json!({})).unwrap();
|
||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||
s.execute("layer.select", json!({"layers": [c, d]})).unwrap();
|
||
s.execute("keys.paste", json!({})).unwrap();
|
||
assert_eq!(key_times(&s, c), vec![2.0, 3.0], "A's two keys");
|
||
assert_eq!(key_times(&s, d), vec![2.0], "B's one key");
|
||
// Paste Reversed onto a layer stretched to 200 %: the reversed keys land 2 s apart there too.
|
||
let e = solid(&mut s, "#808080");
|
||
s.execute_checked("layer.timeStretch", json!({"layers": [e], "percent": 200})).unwrap();
|
||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||
s.execute("keys.copy", json!({})).unwrap();
|
||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||
s.execute("layer.select", json!({"layers": [e]})).unwrap();
|
||
s.execute("edit.pasteReversedKeyframes", json!({})).unwrap();
|
||
let pr = layer(&s, e).props.prop("transform/opacity").unwrap().clone();
|
||
let l = layer(&s, e);
|
||
let ct: Vec<f64> = pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect();
|
||
let vals: Vec<f64> = pr.keys.iter().map(|k| k.value.as_f64()).collect();
|
||
assert!(ct.len() == 2 && (ct[1] - ct[0] - 1.0).abs() < 1e-6, "keys 1 s apart in comp time: {ct:?}");
|
||
assert_eq!(vals, vec![10.0, 0.0], "reversed");
|
||
}
|
||
|
||
#[test]
|
||
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
|
||
let mut s = comp();
|
||
let a = solid(&mut s, "#ff0000");
|
||
opacity_keys(&mut s, a, &[2.0]);
|
||
s.execute("comp.workArea", json!({"start": 1.0, "end": 3.0})).unwrap();
|
||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||
let mut stops = vec![];
|
||
for _ in 0..4 {
|
||
s.execute("time.nextKey", json!({})).unwrap();
|
||
stops.push((s.time().seconds() * 30.0).round() / 30.0);
|
||
}
|
||
let last = (3.0 * 30.0 - 1.0) / 30.0;
|
||
assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() {
|
||
let mut s = comp();
|
||
let a = solid(&mut s, "#ff0000");
|
||
opacity_keys(&mut s, a, &[1.0]);
|
||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
|
||
let op = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
|
||
// Only Opacity is revealed: K skips the hidden Rotation key at 2 s, stopping at 1 s and then
|
||
// at the work area's end.
|
||
let visible = json!([{"layer": a, "prop": op}]);
|
||
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
|
||
assert_eq!(s.time().seconds(), 1.0);
|
||
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
|
||
assert!(s.time().seconds() > 3.9, "work area end, not the hidden key: {}", s.time().seconds());
|
||
// Without `visible` (agents), every key of the layers counts, as before.
|
||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||
s.execute("time.nextKey", json!({})).unwrap();
|
||
assert_eq!(s.time().seconds(), 2.0);
|
||
// Select All Keyframes: the shown properties' keys only.
|
||
s.execute_checked("keys.selectAll", json!({"visible": visible})).unwrap();
|
||
assert_eq!(s.state.selected_keys.len(), 1);
|
||
assert_eq!(s.state.selected_keys[0].prop, op);
|
||
}
|
||
|
||
/// A shape layer's Contents names, top to bottom.
|
||
fn contents_names(s: &Session, id: u64) -> Vec<String> {
|
||
layer(s, id).props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect()
|
||
}
|
||
|
||
/// The uid of the shape item `name` at the top level of a shape layer's Contents.
|
||
fn contents_item(s: &Session, id: u64, name: &str) -> u64 {
|
||
layer(s, id).props.sub("contents").unwrap().groups().find(|g| g.name == name).unwrap().uid
|
||
}
|
||
|
||
/// Edit ▸ Copy / Cut / Paste with shape items selected copy the items, not their layer: Paste
|
||
/// puts them into the selected shape layer, above its selected item or on top, and Cut + Paste
|
||
/// moves them (#227).
|
||
#[test]
|
||
fn shape_contents_copy_cut_and_paste_between_shape_layers() {
|
||
let mut s = comp();
|
||
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("shape.newShape", json!({"layer": a, "kind": "ellipse"})).unwrap();
|
||
let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||
let n = s.active_comp().unwrap().layers.len();
|
||
// Copy Rectangle 1 of A; paste into B (nothing of B's selected): on top of its Contents.
|
||
let rect = contents_item(&s, a, "Rectangle 1");
|
||
s.execute("prop.select", json!({"layer": a, "prop": rect})).unwrap();
|
||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["contents"], json!(1));
|
||
assert!(s.state.clipboard.is_empty(), "not the layer");
|
||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]);
|
||
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
|
||
let pasted = r["contents"][0].as_u64().unwrap();
|
||
assert_ne!(pasted, rect);
|
||
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(b), pasted)]);
|
||
// Again, with the pasted item selected: above it, with a unique name.
|
||
s.execute("edit.paste", json!({})).unwrap();
|
||
assert_eq!(contents_names(&s, b), ["Rectangle 2", "Rectangle 1", "Polystar 1"]);
|
||
s.undo();
|
||
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]);
|
||
assert_eq!(contents_names(&s, a), ["Ellipse 1", "Rectangle 1"], "the original stays");
|
||
// Cut Ellipse 1 from A and paste it above B's Polystar 1: it moves.
|
||
s.execute("prop.select", json!({"layer": a, "prop": contents_item(&s, a, "Ellipse 1")})).unwrap();
|
||
s.execute("edit.cut", json!({})).unwrap();
|
||
assert_eq!(contents_names(&s, a), ["Rectangle 1"]);
|
||
assert_eq!(s.active_comp().unwrap().layers.len(), n, "the layer stays");
|
||
s.execute("prop.select", json!({"layer": b, "prop": contents_item(&s, b, "Polystar 1")})).unwrap();
|
||
s.execute("edit.paste", json!({})).unwrap();
|
||
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Ellipse 1", "Polystar 1"]);
|
||
s.undo();
|
||
s.undo();
|
||
assert_eq!(contents_names(&s, a), ["Ellipse 1", "Rectangle 1"]);
|
||
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]);
|
||
// Only shape layers take shape items.
|
||
let sol = solid(&mut s, "#ffffff");
|
||
s.execute("layer.select", json!({"layers": [sol]})).unwrap();
|
||
assert!(s.execute("edit.paste", json!({})).is_err());
|
||
}
|
||
|
||
/// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the
|
||
/// original and named "Rectangle 2", not the layer (#227).
|
||
#[test]
|
||
fn duplicate_with_shape_items_selected_duplicates_them_in_place() {
|
||
let mut s = comp();
|
||
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||
let n = s.active_comp().unwrap().layers.len();
|
||
let rect = contents_item(&s, a, "Rectangle 1");
|
||
s.execute("prop.select", json!({"layer": a, "prop": rect})).unwrap();
|
||
let r = s.execute("edit.duplicate", json!({})).unwrap();
|
||
assert_eq!(contents_names(&s, a), ["Rectangle 2", "Rectangle 1"]);
|
||
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
|
||
let copy = r["contents"][0].as_u64().unwrap();
|
||
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(a), copy)]);
|
||
let l = layer(&s, a);
|
||
let path_uid = |g: u64| l.props.find_group(g).unwrap().sub("contents").unwrap().groups().next().unwrap().uid;
|
||
assert_ne!(path_uid(copy), path_uid(rect), "the copy has its own properties");
|
||
// Again, with the copy selected: Rectangle 3, above it.
|
||
s.execute("edit.duplicate", json!({})).unwrap();
|
||
assert_eq!(contents_names(&s, a), ["Rectangle 3", "Rectangle 2", "Rectangle 1"]);
|
||
s.undo();
|
||
s.undo();
|
||
assert_eq!(contents_names(&s, a), ["Rectangle 1"]);
|
||
// An item inside a group is duplicated in that group.
|
||
let fill = layer(&s, a).props.find_group(rect).unwrap().sub("contents").unwrap().groups().find(|g| g.match_id == "fill").unwrap().uid;
|
||
s.execute("prop.select", json!({"layer": a, "prop": fill})).unwrap();
|
||
s.execute("edit.duplicate", json!({})).unwrap();
|
||
let inner: Vec<String> = layer(&s, a).props.find_group(rect).unwrap().sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
|
||
assert_eq!(inner, ["Rectangle Path 1", "Fill 2", "Fill 1"]);
|
||
// With only the layer selected, the layer is duplicated as before.
|
||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||
s.execute("edit.duplicate", json!({})).unwrap();
|
||
assert_eq!(s.active_comp().unwrap().layers.len(), n + 1);
|
||
}
|
||
|
||
/// The Interpret Footage form sends "Loop (times)" as a float (3.0); it must still be applied (#909).
|
||
#[test]
|
||
fn interpret_footage_loop_from_form_float() {
|
||
let mut s = comp();
|
||
let p = s.execute("file.importPlaceholder", json!({"name": "Shot", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap();
|
||
s.state.project_selection = vec![effectcraft_project::ItemId(p)];
|
||
s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap();
|
||
match &s.project.item(effectcraft_project::ItemId(p)).unwrap().kind {
|
||
ItemKind::Footage(f) => assert_eq!(f.loop_count, 3),
|
||
_ => panic!(),
|
||
}
|
||
}
|
||
|
||
/// Looped video footage lasts its loops: a 2 s clip looped 3 times is a 6 s layer, and a layer
|
||
/// that ran to the clip's end is re-timed when the loop count changes (#1045).
|
||
#[test]
|
||
fn interpret_footage_loop_extends_video_layers() {
|
||
let mut s = comp();
|
||
let p = s.execute("file.importPlaceholder", json!({"name": "Clip", "width": 320, "height": 240, "duration": 2})).unwrap()["item"].as_u64().unwrap();
|
||
let before = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap();
|
||
s.state.project_selection = vec![effectcraft_project::ItemId(p)];
|
||
s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap();
|
||
let after = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap();
|
||
let out = |s: &Session, l: u64| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().out_point.seconds();
|
||
assert_eq!(out(&s, after), 6.0, "a new layer plays every loop");
|
||
assert_eq!(out(&s, before), 6.0, "a layer that ran to the clip's end still does");
|
||
assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn percentage_guides_follow_the_comp_size() {
|
||
let mut s = comp();
|
||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 25, "percent": true})).unwrap();
|
||
s.execute("view.addPercentGuide", json!({"orientation": "horizontal"})).unwrap();
|
||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 100})).unwrap();
|
||
let c = s.active_comp().unwrap().clone();
|
||
assert!(c.guides[0].percent && c.guides[1].percent && !c.guides[2].percent);
|
||
assert_eq!((c.guides[0].px(c.width, c.height), c.guides[1].px(c.width, c.height)), (160.0, 180.0));
|
||
// A comp resize moves percentage guides with it and leaves pixel guides alone.
|
||
s.execute("comp.settings", json!({"width": 1280, "height": 720})).unwrap();
|
||
let c = s.active_comp().unwrap().clone();
|
||
assert_eq!((c.guides[0].px(c.width, c.height), c.guides[1].px(c.width, c.height), c.guides[2].px(c.width, c.height)), (320.0, 360.0, 100.0));
|
||
// Dragging keeps the unit: the position arrives in comp pixels.
|
||
s.execute("view.moveGuide", json!({"index": 0, "position": 640})).unwrap();
|
||
assert_eq!(s.active_comp().unwrap().guides[0].position, 50.0);
|
||
// Snapping sees the pixel position.
|
||
let c = s.active_comp().unwrap().clone();
|
||
assert!(c.guides.iter().any(|g| g.percent && g.vertical && g.px(c.width, c.height) == 640.0));
|
||
// Converting keeps the guide where it is, and undoes in one step.
|
||
s.execute("view.convertGuides", json!({"to": "pixels"})).unwrap();
|
||
let g = s.active_comp().unwrap().guides.clone();
|
||
assert!(g.iter().all(|g| !g.percent));
|
||
assert_eq!((g[0].position, g[1].position), (640.0, 360.0));
|
||
s.execute("view.convertGuides", json!({"to": "percent", "index": 2})).unwrap();
|
||
assert_eq!(s.active_comp().unwrap().guides[2].position, 100.0 / 1280.0 * 100.0);
|
||
s.undo();
|
||
assert!(!s.active_comp().unwrap().guides[2].percent);
|
||
assert!(s.execute("view.convertGuides", json!({"to": "furlongs"})).is_err());
|
||
assert!(s.execute("view.convertGuides", json!({"index": 9})).is_err());
|
||
// A crop keeps percentage guides where they were on the image.
|
||
s.execute("view.convertGuides", json!({"to": "percent"})).unwrap();
|
||
s.execute("view.setRegionOfInterest", json!({"rect": [320, 0, 640, 720]})).unwrap();
|
||
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||
let c = s.active_comp().unwrap();
|
||
assert!((c.guides[0].px(c.width, c.height) - 320.0).abs() < 1e-9, "640 - 320 crop offset");
|
||
}
|
||
|
||
#[test]
|
||
fn old_guides_load_as_pixel_guides_and_percent_round_trips() {
|
||
let old: effectcraft_project::Guide = serde_json::from_str(r#"{"vertical":true,"position":12.5}"#).unwrap();
|
||
assert!(!old.percent);
|
||
assert!(!serde_json::to_string(&old).unwrap().contains("percent"), "pixel guides stay in the old shape");
|
||
let g = effectcraft_project::Guide { vertical: false, position: 10.0, percent: true };
|
||
let back: effectcraft_project::Guide = serde_json::from_str(&serde_json::to_string(&g).unwrap()).unwrap();
|
||
assert_eq!(back, g);
|
||
// A zero-sized comp never divides by zero.
|
||
assert!(g.px(0, 0).is_finite());
|
||
}
|