Files
effectcraft/crates/engine/src/tests/menu_cmds.rs
T
bb2d2005ce Layer: Un-precompose a selected pre-comp layer back into its parent composition (#3081)
* Layer: Un-precompose a selected pre-comp layer back into its parent composition (#2918)

layer.unprecompose copies the nested composition's layers into the parent at the pre-comp
layer's place with timing and position carried over, and refuses what it can't keep exactly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Un-precompose: refuse a pre-comp layer with Solo or Guide on

A soloed pre-comp layer hides the comp's other layers and a guide layer is
left out of renders; the expanded copies carry neither switch, so the picture
would change. Listed with the other blockers, in the command description and
the agents.md Cookbook entry; the refusal test covers both switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn instead of refusing, only a locked layer stops it

A locked pre-comp layer is still refused, with or without `force`. Every
other former refusal reason is a warning: without `force`,
`layer.unprecompose` returns an error that lists the reasons and changes
nothing. `dryRun` returns `{dryRun, warnings}`. With `force` the layer is
unpacked in one undo step and the result carries `warnings`. Whatever the
pre-comp layer itself carried is dropped, and other layers' parent, track
matte and layer-property links to it are removed.

Layer > Un-precompose in the UI lists the reasons in a prompt with
Unpack Anyway / Cancel (`dialog.unprecompose.force` /
`dialog.unprecompose.cancel`). A layer without reasons unpacks at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn about expressions that refer to the pre-comp layer

An expression on another layer of the comp that names the pre-comp layer
would no longer find it once the layer is unpacked. The expression can
name it in quotes or call `layer(<index>)` with its index, bare or on
thisComp; another comp's comp("…").layer(…) is not counted. Each such
property is now listed once as a warning. Like the other warnings,
`force` unpacks and `dryRun` lists it. The expressions are left as
written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn about lost frame sampling and Position expressions, keep markers in order

Review follow-up (CodeRabbit on #3081):
- A nested comp that preserves a frame rate other than the parent's
  samples its layers on its own frames, and the expanded copies would
  lose that. Motion-blurred nested layers under a different shutter
  would also blur with the parent's shutter. Both are now warnings.
- When the pre-comp layer's Position differs from its Anchor Point, the
  move is added to the stored Position of the unparented nested layers.
  An unparented layer whose Position, or a separated axis, has an
  expression may ignore that move, so it is now a warning.
- Copied comp markers are sorted into time order with the comp's own.
- New tests: a 10 fps nested comp with Preserve Frame Rate in a 30 fps
  comp, a different shutter, a Position expression with and without a
  move, and nested matte and layer-value remapping with shifted markers
  merged in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn when an expression's layer numbers shift; the prompt keeps its target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: a shorter renumbering warning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-07 00:56:54 +01:00

2728 lines
147 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 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());
}
/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking
/// it (and Hide Other Video) takes them along with the rest.
#[test]
fn video_others_takes_cameras_and_lights() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap();
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l)));
// The camera and the light are hidden: Alt-click on the solid's eye brings them back, since
// they stand apart with it, and the next Alt-click hides them with every other layer.
s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
// Hide Other Video takes them too.
s.execute("layer.showAllVideo", json!({})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
}
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
/// hidden (parenting ignores the eye).
#[test]
fn null_video_switch() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video"})).unwrap();
assert!(!layer(&s, n).switches.video);
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, n).switches.video);
// Alt-click on the solid's eye hides the null too; on the null's, the solid.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video", "others": true})).unwrap();
assert!(!layer(&s, a).switches.video && layer(&s, n).switches.video);
s.execute("layer.showAllVideo", json!({})).unwrap();
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
// The hidden null still carries its child.
s.execute("layer.setParent", json!({"layers": [a], "parent": n})).unwrap();
s.execute("prop.set", json!({"layer": n, "path": "transform/position", "value": [420, 180, 0]})).unwrap();
let cid = s.active_comp_id().unwrap();
let c = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &c, Tick::ZERO);
let child = c.layer(effectcraft_project::LayerId(a)).unwrap();
let (m, _) = ctx.layer_to_comp(child);
let centre = m.apply(effectcraft_geom::vec2(100.0, 50.0));
assert!((centre.x - 420.0).abs() < 1e-6 && (centre.y - 180.0).abs() < 1e-6, "{centre:?}");
}
#[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();
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
let transform = layer(&s, a).props.sub("transform").unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": transform, "selectKeys": false})).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 reveal_layer_source_selects_each_layers_source_item() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let ia = layer(&s, a).source.item().unwrap();
let ib = layer(&s, b).source.item().unwrap();
assert_ne!(ia, ib);
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let r = s.execute("layer.revealSource", json!({})).unwrap();
assert_eq!(r["item"], ia.0);
assert_eq!(r["items"], json!([ia.0, ib.0]));
assert_eq!(s.state.project_selection, vec![ia, ib]);
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]);
// A null has no source. Alone it is refused; in a mixed selection only the sources are revealed.
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [n]})).unwrap();
assert!(s.execute("layer.revealSource", json!({})).is_err());
s.execute("layer.select", json!({"layers": [n, a]})).unwrap();
let r = s.execute("layer.revealSource", json!({})).unwrap();
assert_eq!(r["items"], json!([ia.0]));
assert_eq!(s.state.project_selection, vec![ia]);
// Reveal Composition in Project opens the same panel and folders.
let cid = s.active_comp_id().unwrap();
s.drain_events();
let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap();
assert_eq!(r["item"], cid.0);
assert_eq!(s.state.project_selection, vec![cid]);
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]);
}
#[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);
}
/// File ▸ Dependencies ▸ Find Missing Fonts lists a text layer only when no face matches its
/// font by family, PostScript or full name.
#[test]
fn fonts_named_by_their_postscript_or_full_name_are_not_missing() {
let mut s = comp();
// The first three name bundled faces by family, PostScript and full name; no face has the fourth.
let fonts = ["Noto Serif", "Inter-Bold", "Inter SemiBold", "No Such Font Family 123"];
let ids: Vec<u64> = fonts.iter().map(|f| s.execute("layer.newText", json!({"text": "Aa", "font": f})).unwrap()["layer"].as_u64().unwrap()).collect();
let r = s.execute("file.findMissing", json!({"what": "fonts"})).unwrap();
let listed: Vec<(u64, &str)> = r["fonts"].as_array().unwrap().iter().map(|f| (f["layer"].as_u64().unwrap(), f["font"].as_str().unwrap())).collect();
assert_eq!(listed, [(ids[3], fonts[3])], "{r}");
let f = s.execute("text.fontFeatures", json!({"layer": ids[1]})).unwrap();
assert_eq!((&f["family"], &f["style"], &f["missing"]), (&json!("Inter"), &json!("Bold"), &json!(false)), "{f}");
}
#[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");
}
/// The layer's painted bounds mapped to comp space — what the viewer shows.
fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap().clone();
let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap();
let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time());
let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds");
let m = ectx.layer_to_comp(layer).0;
let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]];
let pts: Vec<[f64; 2]> = corners
.iter()
.map(|p| {
let q = m.apply(effectcraft_geom::vec2(p[0], p[1]));
[q.x, q.y]
})
.collect();
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in &pts {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
[x0, y0, x1, y1]
}
#[test]
fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() {
let mut s = comp();
let close = |x: f64, y: f64| (x - y).abs() < 1e-6;
// A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at
// the bounds' centre, so what the viewer shows fills the frame (#2198).
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap();
let seen = seen_bounds(&s, a);
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}");
let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap();
let seen = seen_bounds(&s, t);
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}");
// fitWidth spans the comp's width and centres vertically.
for lid in [a, t] {
s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap();
let seen = seen_bounds(&s, lid);
assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}");
assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}");
}
}
#[test]
fn fit_to_comp_refuses_a_scale_that_overflows() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap();
let before = s.project.clone();
assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused");
assert_eq!(s.project, before, "nothing was written");
}
#[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(&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 muting_keys_skips_them_in_the_timeline_value_and_undoes() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64();
let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
select(&mut s);
let before = value_at(&s, 0.5);
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
// The muted key no longer bends the 0 → 300 segment.
assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6);
assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6);
s.undo();
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
assert!((value_at(&s, 0.5) - before).abs() < 1e-6);
// The same command unmutes.
select(&mut s);
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
s.execute("keys.setMuted", json!({"muted": false})).unwrap();
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
}
/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work.
#[test]
fn mute_and_unmute_keyframes_menu_rows_work_and_undo() {
fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) {
for n in nodes {
match n {
crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())),
crate::menus::MenuNode::Submenu { children, .. } => rows(children, out),
_ => {}
}
}
}
let mut menu = vec![];
rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu);
assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}");
let mut s = comp();
let a = solid(&mut s, "#ff0000");
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted;
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
let run = |s: &mut Session, params: &Value| {
s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap();
};
run(&mut s, &menu[0].1);
assert!(muted(&s));
s.undo();
assert!(!muted(&s));
s.redo();
assert!(muted(&s));
run(&mut s, &menu[1].1);
assert!(!muted(&s));
}
#[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");
}
/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion
/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI,
/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers
/// as they are, so the source layer's and the target parent's transforms don't move them.
#[test]
fn a_copied_path_pastes_onto_position_as_a_motion_path() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
// A moved and scaled source: converting to comp space would give 2v + (200, 100).
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap();
let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]);
s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap();
// B's parent would shift a converted Position by (0, −50).
let parent = solid(&mut s, "#0000ff");
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap();
let b = solid(&mut s, "#00ff00");
s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap();
let motion = |s: &Session, id: u64| {
let l = layer(s, id);
let pr = l.props.prop("transform/position").unwrap().clone();
pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::<Vec<_>>()
};
let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| {
let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]];
assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}");
for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() {
assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}");
assert_eq!(*roving, i != 0 && i != 4, "key {i} roving");
assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}");
}
assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI");
assert!(keys.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent");
};
// A Mask Path keyframe, pasted at 1 s.
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
check(motion(&s, b), 1.0);
// The same path as an unanimated mask, pasted at 5 s onto an unparented layer.
s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let c = solid(&mut s, "#ffffff");
s.execute("time.set", json!({"time": 5.0})).unwrap();
s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
check(motion(&s, c), 5.0);
}
#[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());
}
/// Pasting Trim Paths into another shape layer with nothing selected puts it below the shapes,
/// where it acts on them (it acts on the items above it); on top it would trim nothing (#1681, #1629).
#[test]
fn pasted_path_operator_goes_below_the_shapes_it_acts_on() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let trim = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "trim"})).unwrap()["uid"].as_u64().unwrap();
let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": a, "prop": trim})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Polystar 1", "Trim Paths 1"]);
}
/// Pasting Merge Paths onto a painted layer with nothing selected keeps the paint: the renderer
/// drops the draws above a Merge Paths, so it goes between the path and the paints (#1681).
#[test]
fn pasted_merge_paths_keeps_the_targets_paint() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "none", "name": "A"})).unwrap()["layer"].as_u64().unwrap();
let merge = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "merge"})).unwrap()["uid"].as_u64().unwrap();
let b = s.execute("layer.newShape", json!({"kind": "none", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "rect"})).unwrap();
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "fill"})).unwrap();
let before = opaque_centroid(&s).2;
s.execute("prop.select", json!({"layer": a, "prop": merge})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Rectangle Path 1", "Merge Paths 1", "Fill 1"]);
assert_eq!(opaque_centroid(&s).2, before);
}
/// 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)));
}
/// #1068: the same for an interpreted PNG sequence — the loop count survives interpretation and
/// a freshly dragged layer plays every cycle.
#[test]
fn interpret_footage_loop_extends_sequence_layers() {
let mut s = comp();
let p = s.execute("file.importPlaceholder", json!({"name": "Seq", "width": 320, "height": 240, "duration": 0.4})).unwrap()["item"].as_u64().unwrap();
let id = effectcraft_project::ItemId(p);
// Make it a sequence like the importer's grouped PNGs.
s.edit("Seq", None, |proj, _| {
if let Some(ItemKind::Footage(f)) = proj.item_mut(id).map(|i| &mut i.kind) {
f.kind = effectcraft_project::FootageKind::Sequence;
f.sequence = (0..12).map(|n| format!("/tmp/seq_{n:04}.png")).collect();
f.sync_sequence_duration();
}
Ok(())
})
.unwrap();
s.state.project_selection = vec![id];
s.execute("file.interpretFootage", json!({"loop": 15.0})).unwrap();
let l = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap();
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
let dur = (layer.out_point - layer.in_point).seconds();
assert!((dur - 6.0).abs() < 0.05, "15 loops of a 0.4 s sequence: {dur}");
}
#[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());
}
#[test]
fn copy_paste_masks_between_layers() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let n = s.active_comp().unwrap().layers.len();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
s.execute("layer.addMask", json!({"shape": "ellipse"})).unwrap();
let names = |s: &Session, id: u64| layer(s, id).masks().unwrap().groups().map(|m| m.name.clone()).collect::<Vec<_>>();
let uids = |s: &Session, id: u64| layer(s, id).masks().unwrap().groups().map(|m| m.uid).collect::<Vec<_>>();
let first = uids(&s, a)[0];
s.execute("prop.select", json!({"layer": a, "prop": first})).unwrap();
// Copy the mask: it is the mask that is copied, not its layer.
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["masks"], json!(1));
assert!(s.state.clipboard.is_empty(), "not the layer");
assert!(s.is_enabled("edit.paste"));
s.execute("layer.select", json!({"layers": [b]})).unwrap();
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
assert_eq!(names(&s, b), ["Mask 1"]);
let pasted = r["masks"][0].as_u64().unwrap();
assert_eq!(uids(&s, b), [pasted]);
assert_ne!(pasted, first);
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(b), pasted)]);
assert_eq!(names(&s, a), ["Mask 1", "Mask 2"], "the source keeps its masks");
// Pasting again onto the same layer keeps names unique.
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(names(&s, b), ["Mask 1", "Mask 2"]);
// One undo step per paste.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(names(&s, b), ["Mask 1"]);
}
#[test]
fn paste_masks_skips_locked_layers() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("edit.paste", json!({})).is_err());
assert_eq!(layer(&s, b).masks().unwrap().groups().count(), 0);
}
#[test]
fn copy_with_an_explicit_layer_ignores_the_selected_mask() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
s.execute("edit.copy", json!({"layer": a})).unwrap();
assert_eq!(s.state.clipboard.len(), 1, "the named layer is copied");
assert!(s.state.mask_clipboard.is_empty());
}
#[test]
fn selecting_a_mask_drops_stale_key_selection_so_copy_copies_the_mask() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.state.selected_keys = vec![crate::KeyRef { layer: effectcraft_project::LayerId(a), prop: 1, time: Default::default() }];
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
assert!(s.state.selected_keys.is_empty());
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["masks"], json!(1));
}
#[test]
fn pasting_a_mask_onto_its_own_layer_drops_the_source_vertex_selection() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.addMask", json!({})).unwrap();
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
assert!(!s.state.selected_vertices.is_empty());
s.execute("edit.copy", json!({})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert!(s.state.selected_vertices.is_empty());
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(layer(&s, a).masks().unwrap().groups().count(), 1, "the pasted mask goes, the source stays");
}
#[test]
fn precompose_unlinks_layers_left_behind_and_numbers_default_names() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
let mk = |s: &mut Session, name: &str| {
s.execute("layer.newSolid", json!({"name": name, "color": "#fff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap()
};
let (a, b, c, d) = (mk(&mut s, "A"), mk(&mut s, "B"), mk(&mut s, "C"), mk(&mut s, "D"));
s.execute("layer.setParent", json!({"layers": [c], "parent": a})).unwrap();
s.execute("layer.setTrackMatte", json!({"layers": [d], "matte": b, "kind": "alpha"})).unwrap();
let cid = s.active_comp_id().unwrap();
s.execute("layer.precompose", json!({"layers": [a, b]})).unwrap();
let comp = s.project.comp(cid).unwrap();
assert_eq!(comp.layers.len(), 3);
for l in &comp.layers {
assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{} has a dangling parent", l.name);
assert!(l.track_matte.as_ref().is_none_or(|m| comp.layer(m.layer).is_some()), "{} has a dangling matte", l.name);
}
s.execute("layer.precompose", json!({"layers": [c, d]})).unwrap();
let mut names: Vec<String> = s
.project
.items
.values()
.filter(|i| matches!(i.kind, effectcraft_project::ItemKind::Comp(_)) && i.name.starts_with("Pre-comp"))
.map(|i| i.name.clone())
.collect();
names.sort();
assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]);
}
fn id_named(s: &Session, name: &str) -> u64 {
s.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}")).id.0
}
/// A comp with solids A (keyed Position, parent of B), B and C.
fn precomp_scene() -> Session {
let mut s = comp();
for (name, color, pos) in [("A", "#ff0000", [150.0, 100.0]), ("B", "#00ff00", [350.0, 220.0]), ("C", "#0000ff", [500.0, 100.0])] {
let l = solid(&mut s, color);
s.execute("layer.rename", json!({"layers": [l], "name": name})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap();
}
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0, "value": [150, 100]})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 1, "value": [300, 200]})).unwrap();
s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
s
}
/// Where A, B and C are drawn at a few times.
fn scene_look(s: &mut Session) -> Vec<[f64; 4]> {
let mut out = vec![];
for t in [0.0, 0.5, 1.0, 2.0] {
s.execute("time.set", json!({"time": t})).unwrap();
for n in ["A", "B", "C"] {
let id = id_named(s, n);
out.push(seen_bounds(s, id));
}
}
out
}
/// Un-precompose (#2918) gives the picture back: precompose A and B, expand the pre-comp layer
/// again, and every layer is drawn where it was at every time, parenting and stacking order are
/// kept, as one undo step.
#[test]
fn unprecompose_restores_what_precompose_nested() {
let mut s = precomp_scene();
let before = scene_look(&mut s);
let order: Vec<String> = s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let r = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
let pre = r["layer"].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
let undo = s.history.undo.len();
let r = s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
let after: Vec<String> = s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(after, order, "the layers are back in their order");
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
assert_eq!(layer(&s, b).parent, Some(effectcraft_project::LayerId(a)), "B is still A's child");
for (w, g) in before.iter().zip(scene_look(&mut s)) {
assert!(w.iter().zip(g).all(|(x, y)| (x - y).abs() < 1e-6), "a layer moved: {w:?} → {g:?}");
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "undo brings the pre-comp layer back");
}
/// A pre-comp layer that was moved or started later places the nested layers the same way after it
/// is expanded: its Position (against the Anchor Point) and its Start carry over.
#[test]
fn unprecompose_carries_the_precomp_layers_position_and_start() {
let mut s = precomp_scene();
let before = scene_look(&mut s);
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let pre = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": pre, "path": "transform/position", "value": [360, 160]})).unwrap();
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
// The pre-comp's Anchor Point sat at the comp centre (320, 180): everything moved by (+40, −20).
let after = scene_look(&mut s);
for (i, (w, g)) in before.iter().zip(after).enumerate() {
let n = i % 3;
let (dx, dy) = if n < 2 { (40.0, -20.0) } else { (0.0, 0.0) };
let want = [w[0] + dx, w[1] + dy, w[2] + dx, w[3] + dy];
assert!(want.iter().zip(g).all(|(x, y)| (x - y).abs() < 1e-6), "layer {n}: wanted {want:?}, drawn {g:?}");
}
// Starting later: the nested layers start later by as much, and are trimmed to the span shown.
let mut s = precomp_scene();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let pre = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [pre], "op": "set", "start": 2.0})).unwrap();
let start = layer(&s, pre).start_time;
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
let a = layer(&s, id_named(&s, "A"));
assert_eq!(a.start_time, start, "the nested layer's time 0 sits at the pre-comp layer's Start");
assert!(a.in_point >= start, "and it doesn't show before it");
}
/// A pre-comp scene: the outer comp, the nested comp and the pre-comp layer (A and B nested).
fn unprecompose_scene() -> (Session, effectcraft_project::ItemId, u64, u64) {
let mut s = precomp_scene();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let outer = s.active_comp_id().unwrap();
let r = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
(s, outer, r["comp"].as_u64().unwrap(), r["layer"].as_u64().unwrap())
}
/// Everything un-precompose could change: every comp (layers and markers) and the item count.
fn whole_project(s: &Session) -> (Vec<effectcraft_project::Comp>, usize) {
(s.project.comps().map(|(_, c)| c.clone()).collect(), s.project.items.len())
}
/// Without `force`, a reason is an error naming it that changes nothing (no undo step), and the
/// dry run lists it; with `force` the layer is unpacked anyway in one undo step, the result lists
/// the reason as a warning, and undo restores the exact project.
fn warned_then_forced(s: &mut Session, pre: u64, what: &str) {
let undo = s.history.undo.len();
let before = whole_project(s);
let e = s.execute("layer.unprecompose", json!({"layers": [pre]})).expect_err(what).to_string();
assert!(e.contains(what) && e.contains("force"), "{what}: {e}");
assert!(whole_project(s) == before, "{what}: nothing changed");
assert_eq!(s.history.undo.len(), undo, "{what}: no undo step");
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert!(dry["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(what)), "{what}: {dry}");
assert!(whole_project(s) == before, "{what}: the dry run changed nothing");
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).expect(what);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(what)), "{what}: {r}");
assert!(r["layers"].as_array().unwrap().len() >= 2, "{what}: A and B are back");
assert_eq!(s.history.undo.len(), undo + 1, "{what}: one undo step");
let comp = s.active_comp().unwrap();
assert!(comp.layers.iter().all(|l| l.id.0 != pre), "{what}: the pre-comp layer is gone");
for l in &comp.layers {
assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{what}: {} has a dangling parent", l.name);
assert!(l.track_matte.is_none_or(|m| comp.layer(m.layer).is_some()), "{what}: {} has a dangling matte", l.name);
}
scene_look(s);
s.execute("edit.undo", json!({})).unwrap();
assert!(whole_project(s) == before, "{what}: undo restores the exact project");
}
/// What the pre-comp layer itself adds to the look is a warning (#2918): an error listing it
/// without `force`, unpacked anyway with it.
#[test]
fn unprecompose_warns_about_what_the_precomp_layer_itself_adds() {
let fresh = unprecompose_scene;
let (mut s, _, _, pre) = fresh();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
warned_then_forced(&mut s, pre, "Opacity");
let (mut s, _, _, pre) = fresh();
s.execute("prop.set", json!({"layer": pre, "path": "transform/rotation", "value": 10})).unwrap();
warned_then_forced(&mut s, pre, "Rotation");
let (mut s, _, _, pre) = fresh();
s.execute("prop.addKey", json!({"layer": pre, "path": "transform/scale", "time": 0, "value": [100, 100]})).unwrap();
warned_then_forced(&mut s, pre, "animated");
for switch in ["solo", "guide"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": true})).unwrap();
warned_then_forced(&mut s, pre, "Solo or Guide");
}
for switch in ["video", "audio"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": false})).unwrap();
warned_then_forced(&mut s, pre, "Video or Audio");
}
for switch in ["threeD", "collapse", "adjustment", "preserveTransparency"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": true})).unwrap();
warned_then_forced(&mut s, pre, "3D, collapses");
}
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
s.execute("layer.setParent", json!({"layers": [pre], "parent": c})).unwrap();
warned_then_forced(&mut s, pre, "has a parent");
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
s.execute("layer.setTrackMatte", json!({"layer": pre, "matte": c})).unwrap();
warned_then_forced(&mut s, pre, "has a track matte");
let (mut s, _, _, pre) = fresh();
s.execute("effect.apply", json!({"layers": [pre], "effect": "Gaussian Blur"})).unwrap();
warned_then_forced(&mut s, pre, "effects");
let (mut s, _, _, pre) = fresh();
s.execute("layer.addMask", json!({"layer": pre})).unwrap();
warned_then_forced(&mut s, pre, "masks");
let (mut s, _, _, pre) = fresh();
s.execute("layer.style.add", json!({"layers": [pre], "style": "dropShadow"})).unwrap();
warned_then_forced(&mut s, pre, "layer styles");
let (mut s, _, _, pre) = fresh();
s.execute("layer.setBlendMode", json!({"layers": [pre], "mode": "Multiply"})).unwrap();
warned_then_forced(&mut s, pre, "blending mode");
let (mut s, _, _, pre) = fresh();
s.execute("layer.timeStretch", json!({"layers": [pre], "percent": 200.0})).unwrap();
warned_then_forced(&mut s, pre, "time-stretched");
let (mut s, _, _, pre) = fresh();
s.execute("layer.enableTimeRemap", json!({"layers": [pre], "value": true})).unwrap();
warned_then_forced(&mut s, pre, "time-remapped");
// A layer that is not a pre-comp, and a selection of several, are refused, even forced.
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
assert!(s.execute("layer.unprecompose", json!({"layers": [c], "force": true})).is_err());
assert!(s.execute("layer.unprecompose", json!({"layers": [pre, c], "force": true})).is_err());
}
/// What only holds inside the nested comp (a camera or light, a soloed, 3D, adjustment or
/// Preserve Transparency layer) is a warning too.
#[test]
fn unprecompose_warns_about_what_only_holds_inside_the_nested_comp() {
let inside = |switch: &str, what: &str| {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": switch, "value": true})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, what);
};
inside("solo", "soloed layer");
inside("preserveTransparency", "Preserve Transparency");
inside("adjustment", "adjustment, 3D");
inside("threeD", "adjustment, 3D");
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
s.execute("layer.newLight", json!({})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, "camera or light");
}
/// A locked pre-comp layer is the one hard stop: refused with and without `force`, and by the dry
/// run, changing nothing.
#[test]
fn unprecompose_refuses_a_locked_layer_even_forced() {
let (mut s, _, _, pre) = unprecompose_scene();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
let undo = s.history.undo.len();
let before = whole_project(&s);
for p in [json!({"layers": [pre]}), json!({"layers": [pre], "force": true}), json!({"layers": [pre], "dryRun": true})] {
let e = s.execute("layer.unprecompose", p.clone()).expect_err("locked").to_string();
assert!(e.contains("locked"), "{p}: {e}");
assert!(whole_project(&s) == before, "{p}: nothing changed");
assert_eq!(s.history.undo.len(), undo, "{p}: no undo step");
}
// A clean, locked pre-comp layer is refused too.
let (mut s, _, _, pre) = unprecompose_scene();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap_err().to_string().contains("locked"));
}
/// Layers that use the pre-comp layer as parent, track matte or in a property lose that link when
/// it is unpacked by force; links to other layers stay, and nothing dangles or crashes.
#[test]
fn unprecompose_forced_drops_the_links_other_layers_had_to_it() {
let (mut s, outer, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let d = solid(&mut s, "#ffff00");
s.execute("layer.rename", json!({"layers": [d], "name": "D"})).unwrap();
s.execute("layer.setParent", json!({"layers": [c], "parent": pre})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": d, "matte": pre})).unwrap();
s.execute("effect.apply", json!({"layers": [c], "effect": "Set Matte"})).unwrap();
s.execute("effect.apply", json!({"layers": [d], "effect": "Set Matte"})).unwrap();
{
let proj = std::sync::Arc::make_mut(&mut s.project);
let comp = proj.comp_mut(outer).unwrap();
let set = |comp: &mut effectcraft_project::Comp, on: u64, to: u64| {
let l = comp.layer_mut(effectcraft_project::LayerId(on)).unwrap();
l.props.walk_mut(&mut |p| {
if matches!(p.value, KV::Layer(_)) {
p.value = KV::Layer(Some(to));
}
});
};
set(comp, c, pre);
set(comp, d, c);
}
let refs = |s: &Session, on: u64| {
let mut out = vec![];
layer(s, on).props.walk("", &mut |_, p| {
if let KV::Layer(v) = p.value {
out.push(v);
}
});
out
};
assert_eq!(refs(&s, c), vec![Some(pre)], "the test set C's reference");
let e = s.execute("layer.unprecompose", json!({"layers": [pre]})).expect_err("used by others").to_string();
assert!(e.contains("parent or track matte") && e.contains("refers to it"), "{e}");
let undo = s.history.undo.len();
let before = whole_project(&s);
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
let w = r["warnings"].to_string();
assert!(w.contains("link is removed") && w.contains("reference is cleared"), "{w}");
assert_eq!(layer(&s, c).parent, None, "C lost its parent");
assert!(layer(&s, d).track_matte.is_none(), "D lost its matte");
assert_eq!(refs(&s, c), vec![None], "C's reference to the pre-comp layer is cleared");
assert_eq!(refs(&s, d), vec![Some(c)], "D's reference to C stays");
scene_look(&mut s);
s.execute("edit.undo", json!({})).unwrap();
assert!(whole_project(&s) == before, "undo restores the links");
assert_eq!(s.history.undo.len(), undo);
}
/// Expressions of other layers that name the pre-comp layer (in quotes, or by its index) would no
/// longer find it: one warning per property; an expression naming another layer is not one. Forced,
/// the expressions stay as they are and the comp still renders.
#[test]
fn unprecompose_warns_about_expressions_that_refer_to_it() {
let (mut s, _, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let d = solid(&mut s, "#ffff00");
s.execute("layer.rename", json!({"layers": [d], "name": "D"})).unwrap();
let index = s.active_comp().unwrap().layers.iter().position(|l| l.id.0 == pre).unwrap() + 1;
let set = |s: &mut Session, on: u64, path: &str, text: &str| {
s.execute("prop.setExpression", json!({"layer": on, "path": path, "expression": text})).unwrap();
};
set(&mut s, c, "transform/position", "thisComp.layer(\"Inner\").transform.position");
set(&mut s, c, "transform/rotation", &format!("thisComp.layer( {index} ).transform.rotation"));
// Naming another layer, or another comp's layer at the same index, is no reference to it.
set(&mut s, d, "transform/position", "thisComp.layer(\"C\").transform.position");
set(&mut s, d, "transform/rotation", &format!("comp(\"Other\").layer({index}).transform.rotation"));
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
let exprs: Vec<String> =
dry["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("expression")).map(str::to_string).collect();
assert_eq!(exprs.len(), 2, "{exprs:?}");
assert!(exprs.iter().all(|w| w.contains("layer “C”") && w.contains("“Inner”") && w.contains("no longer find it")), "{exprs:?}");
assert!(exprs.iter().any(|w| w.contains("Position")) && exprs.iter().any(|w| w.contains("Rotation")), "{exprs:?}");
warned_then_forced(&mut s, pre, "has an expression that refers to “Inner”");
// Forced, the expressions are left as written.
s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
let text = layer(&s, c).transform().unwrap().get("position").unwrap().expr.clone().unwrap().text;
assert_eq!(text, "thisComp.layer(\"Inner\").transform.position");
scene_look(&mut s);
}
/// An expression naming a layer by a number below the pre-comp layer points at another layer once
/// the nested comp's two layers replace it: a warning. One naming a number above it does not move.
#[test]
fn unprecompose_warns_when_it_renumbers_the_layers_an_expression_names() {
let (mut s, _, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let index = s.active_comp().unwrap().layers.iter().position(|l| l.id.0 == pre).unwrap() + 1;
let warned = |s: &mut Session, number: usize| {
let text = format!("thisComp.layer({number}).transform.rotation");
s.execute("prop.setExpression", json!({"layer": c, "path": "transform/rotation", "expression": text})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
dry["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).any(|w| w.contains("by number"))
};
assert!(warned(&mut s, index + 1), "a number below it");
assert!(!warned(&mut s, index - 1), "a number above it");
}
/// A nested comp that preserves its own frame rate (10 fps in a 30 fps comp) samples its layers on
/// its own frames; expanded, they would change on every frame of the parent: a warning. Without
/// Preserve Frame Rate the nested layers are sampled at the parent's times already: no warning.
#[test]
fn unprecompose_warns_when_the_nested_comp_preserves_another_frame_rate() {
let (mut s, _, inner, pre) = unprecompose_scene();
s.execute("comp.settings", json!({"comp": inner, "frameRate": 10})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "a different rate alone samples at the parent's times");
s.execute("comp.settings", json!({"comp": inner, "preserveFrameRate": true})).unwrap();
warned_then_forced(&mut s, pre, "preserves a frame rate");
// A motion-blurred nested layer under another shutter would be blurred with the parent's.
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.settings", json!({"comp": inner, "shutterAngle": 90})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "no layer uses motion blur");
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, "motion blur shutter");
}
/// The pre-comp layer's move (Position against Anchor Point) is added to the stored Position of
/// unparented nested layers; one whose Position has an expression may ignore it: a warning, only
/// when there is a move.
#[test]
fn unprecompose_warns_about_position_expressions_it_cannot_move() {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/position", "expression": "[100, 100]"})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "no move, nothing to lose");
s.execute("prop.set", json!({"layer": pre, "path": "transform/position", "value": [360, 160]})).unwrap();
warned_then_forced(&mut s, pre, "layer “A” has a Position expression");
}
/// Expanded copies keep pointing at each other: a track matte and a layer-valued effect property
/// on a nested layer follow the copy of the layer they named, and the nested comp's markers are
/// moved by the pre-comp layer's Start and merged in time order with the comp's own.
#[test]
fn unprecompose_remaps_mattes_layer_values_and_merges_markers_in_order() {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
s.execute("layer.setParent", json!({"layers": [b], "parent": null})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
s.execute("effect.apply", json!({"layers": [b], "effect": "Set Matte"})).unwrap();
{
let proj = std::sync::Arc::make_mut(&mut s.project);
let l = proj.comp_mut(effectcraft_project::ItemId(inner)).unwrap().layer_mut(effectcraft_project::LayerId(b)).unwrap();
l.props.walk_mut(&mut |p| {
if matches!(p.value, KV::Layer(_)) {
p.value = KV::Layer(Some(a));
}
});
}
for t in [2.5, 0.5] {
s.execute("comp.addMarker", json!({"time": t, "comment": format!("in {t}")})).unwrap();
}
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
s.execute("comp.addMarker", json!({"time": 2.0, "comment": "out"})).unwrap();
s.execute("layer.timing", json!({"layers": [pre], "op": "set", "start": 1.0})).unwrap();
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
let (a, b) = (layer(&s, id_named(&s, "A")), layer(&s, id_named(&s, "B")));
assert_eq!(b.track_matte.map(|m| m.layer), Some(a.id), "B's matte is the copy of A");
let mut refs = vec![];
b.props.walk("", &mut |_, p| {
if let KV::Layer(v) = p.value {
refs.push(v);
}
});
assert_eq!(refs, vec![Some(a.id.0)], "Set Matte names the copy of A");
let markers: Vec<(f64, String)> = s.active_comp().unwrap().markers.iter().map(|m| (m.time.seconds(), m.comment.clone())).collect();
let want = [(1.5, "in 0.5"), (2.0, "out"), (3.5, "in 2.5")];
assert_eq!(markers.len(), 3, "{markers:?}");
for ((t, c), (wt, wc)) in markers.iter().zip(want) {
assert!((t - wt).abs() < 1e-9 && c == wc, "{markers:?}");
}
scene_look(&mut s);
}
/// Every reason at once, forced: unpacked without a crash, one undo step, undone exactly.
#[test]
fn unprecompose_forced_with_everything_at_once() {
let (mut s, outer, inner, pre) = unprecompose_scene();
let c = id_named(&s, "C");
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
for sw in ["solo", "threeD", "adjustment", "preserveTransparency"] {
s.execute("layer.setSwitch", json!({"layers": [a], "switch": sw, "value": true})).unwrap();
}
s.execute("layer.newCamera", json!({})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 20})).unwrap();
s.execute("prop.addKey", json!({"layer": pre, "path": "transform/rotation", "time": 0, "value": 30})).unwrap();
s.execute("effect.apply", json!({"layers": [pre], "effect": "Gaussian Blur"})).unwrap();
s.execute("layer.addMask", json!({"layer": pre})).unwrap();
s.execute("layer.style.add", json!({"layers": [pre], "style": "dropShadow"})).unwrap();
s.execute("layer.setBlendMode", json!({"layers": [pre], "mode": "Screen"})).unwrap();
s.execute("layer.enableTimeRemap", json!({"layers": [pre], "value": true})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [pre], "percent": -50.0})).unwrap();
for sw in ["solo", "guide", "threeD", "collapse", "adjustment", "preserveTransparency"] {
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": sw, "value": true})).unwrap();
}
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "video", "value": false})).unwrap();
s.execute("layer.setParent", json!({"layers": [pre], "parent": c})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": pre, "matte": c})).unwrap();
let undo = s.history.undo.len();
let before = whole_project(&s);
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
assert!(r["warnings"].as_array().unwrap().len() >= 10, "{r}");
// The undo history is capped (32 levels by default), so its length may stay; one undo is what
// takes the whole unpack back.
assert!(s.history.undo.len() == undo + 1 || s.history.undo.len() == undo, "one undo step");
assert!(whole_project(&s) != before, "it unpacked");
scene_look(&mut s);
assert!(s.history.redo.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.history.redo.len(), 1, "one undo step took it all back");
assert!(whole_project(&s) == before, "undo restores the exact project");
}
/// Quick Set Anchor (#2967): every one of the nine points moves the Anchor Point of a rotated,
/// scaled layer with keyed Position to that point of its content, and the layer looks the same
/// on every frame (Position and its keys are compensated), in one undo step.
#[test]
fn quick_set_anchor_moves_the_anchor_without_moving_the_layer_over_its_keys() {
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6);
// (name, anchor in the 200×100 solid's layer space)
let points = [
("topLeft", [0.0, 0.0]),
("top", [100.0, 0.0]),
("topRight", [200.0, 0.0]),
("left", [0.0, 50.0]),
("center", [100.0, 50.0]),
("right", [200.0, 50.0]),
("bottomLeft", [0.0, 100.0]),
("bottom", [100.0, 100.0]),
("bottomRight", [200.0, 100.0]),
];
for (name, want) in points {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [150, 80]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0, "value": [100, 80]})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 1, "value": [400, 250]})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 2, "value": [300, 60]})).unwrap();
let look = |s: &mut Session| {
[0.0, 0.4, 1.0, 1.5, 2.0]
.map(|t| {
s.execute("time.set", json!({"time": t})).unwrap();
seen_bounds(s, a)
})
.to_vec()
};
let before = look(&mut s);
let undo = s.history.undo.len();
s.execute("layer.quickSetAnchor", json!({"layers": [a], "point": name})).unwrap();
let anchor = layer(&s, a).transform().unwrap().get("anchor").unwrap().value.as_vec3();
assert!(anchor[0] == want[0] && anchor[1] == want[1], "{name}: anchor {anchor:?}, wanted {want:?}");
for (b, n) in before.iter().zip(look(&mut s)) {
assert!(near(*b, n), "{name}: the layer moved, {b:?} → {n:?}");
}
// The centre is where the anchor already is: nothing changes, so there's no undo step.
assert_eq!(s.history.undo.len(), undo + usize::from(name != "center"), "{name}: one undo step");
if name == "center" {
continue;
}
s.execute("edit.undo", json!({})).unwrap();
let back = layer(&s, a).transform().unwrap().get("anchor").unwrap().value.as_vec3();
assert_eq!(back[..2], [100.0, 50.0], "{name}: undo restores the anchor");
}
}
/// Same as above with Position split into X and Y, each with its own keys: both channels get the
/// shift, so the layer still looks the same on every frame.
#[test]
fn quick_set_anchor_keeps_a_layer_with_separated_keyed_position_in_place() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [150, 80]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap();
for (path, values) in [("transform/positionX", [100.0, 400.0, 300.0]), ("transform/positionY", [80.0, 250.0, 60.0])] {
for (time, value) in [0.0, 1.0, 2.0].into_iter().zip(values) {
s.execute("prop.addKey", json!({"layer": a, "path": path, "time": time, "value": value})).unwrap();
}
}
let look = |s: &mut Session| {
[0.0, 0.4, 1.0, 1.5, 2.0]
.map(|t| {
s.execute("time.set", json!({"time": t})).unwrap();
seen_bounds(s, a)
})
.to_vec()
};
let before = look(&mut s);
s.execute("layer.quickSetAnchor", json!({"layers": [a], "point": "topLeft"})).unwrap();
let anchor = layer(&s, a).transform().unwrap().get("anchor").unwrap().value.as_vec3();
assert_eq!(anchor[..2], [0.0, 0.0], "the anchor moved to the top left");
for (b, n) in before.iter().zip(look(&mut s)) {
assert!(b.iter().zip(n).all(|(x, y)| (x - y).abs() < 1e-6), "the layer moved, {b:?} → {n:?}");
}
}
#[test]
fn quick_set_anchor_skips_locked_and_3d_layers_and_checks_its_point() {
let mut s = comp();
let flat = solid(&mut s, "#00ff00");
let deep = solid(&mut s, "#0000ff");
let locked = solid(&mut s, "#ffffff");
s.execute("layer.setSwitch", json!({"layers": [deep], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "lock", "value": true})).unwrap();
let anchor_of = |s: &Session, l: u64| layer(s, l).transform().unwrap().get("anchor").unwrap().value.as_vec3();
let r = s.execute("layer.quickSetAnchor", json!({"layers": [flat, deep, locked], "point": "topLeft"})).unwrap();
assert_eq!(r["layers"], 1, "only the unlocked 2D layer changed");
assert_eq!(anchor_of(&s, flat)[..2], [0.0, 0.0]);
assert_eq!(anchor_of(&s, deep)[..2], [100.0, 50.0]);
assert_eq!(anchor_of(&s, locked)[..2], [100.0, 50.0]);
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [deep], "point": "top"})).is_err(), "3D only: nothing to do");
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [flat], "point": "middle"})).is_err());
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [flat]})).is_err());
}
/// The anchor is set at the current time and the Position shift comes from the current Anchor
/// Point, Scale and Rotation, so a layer where one of them is keyed would move at other frames:
/// it is skipped, and alone it is an error.
#[test]
fn quick_set_anchor_skips_layers_whose_anchor_scale_or_rotation_is_keyed() {
for path in ["transform/anchor", "transform/scale", "transform/rotation"] {
let mut s = comp();
let flat = solid(&mut s, "#00ff00");
let keyed = solid(&mut s, "#0000ff");
let value = if path.ends_with("rotation") {
json!(0)
} else if path.ends_with("scale") {
json!([100, 100])
} else {
json!([100, 50])
};
let later = if path.ends_with("rotation") {
json!(90)
} else if path.ends_with("scale") {
json!([50, 50])
} else {
json!([20, 10])
};
s.execute("prop.addKey", json!({"layer": keyed, "path": path, "time": 0, "value": value})).unwrap();
s.execute("prop.addKey", json!({"layer": keyed, "path": path, "time": 2, "value": later})).unwrap();
let before = layer(&s, keyed);
let r = s.execute("layer.quickSetAnchor", json!({"layers": [flat, keyed], "point": "topLeft"})).unwrap();
assert_eq!(r["layers"], 1, "{path}: only the layer without keys changed");
assert_eq!(layer(&s, keyed), before, "{path}: the keyed layer is untouched");
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [keyed], "point": "top"})).is_err(), "{path}: nothing to do");
}
}
/// An expression on Position (or on the separated X / Y) would override the shifted value, and
/// Auto-Orient along the path adds a rotation the shift does not know: those layers are skipped.
#[test]
fn quick_set_anchor_skips_position_expressions_and_along_path_layers() {
for case in ["position", "positionX", "alongPath"] {
let mut s = comp();
let flat = solid(&mut s, "#00ff00");
let other = solid(&mut s, "#0000ff");
match case {
"alongPath" => {
s.execute("layer.autoOrient", json!({"layers": [other], "mode": "alongPath"})).unwrap();
}
"position" => {
s.execute("prop.setExpression", json!({"layer": other, "path": "transform/position", "expression": "[100, 50]"})).unwrap();
}
_ => {
s.execute("prop.separateDimensions", json!({"layer": other, "value": true})).unwrap();
s.execute("prop.setExpression", json!({"layer": other, "path": "transform/positionX", "expression": "100"})).unwrap();
}
}
let before = layer(&s, other);
let r = s.execute("layer.quickSetAnchor", json!({"layers": [flat, other], "point": "topLeft"})).unwrap();
assert_eq!(r["layers"], 1, "{case}: only the plain layer changed");
assert_eq!(layer(&s, other), before, "{case}: the layer is untouched");
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [other], "point": "top"})).is_err(), "{case}: nothing to do");
}
}
/// A selection of only empty text layers (no content bounds) is an error, not a silent `{layers: 0}`;
/// `layer.centerAnchor` keeps reporting 0 for it.
#[test]
fn quick_set_anchor_errors_when_no_layer_has_content_bounds() {
let mut s = comp();
let t = s.execute("layer.newText", json!({"text": ""})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [t], "point": "center"})).is_err());
let r = s.execute("layer.centerAnchor", json!({"layers": [t]})).unwrap();
assert_eq!(r["layers"], 0);
}
/// A child of a rotated, scaled parent keeps its place when its anchor moves (Position is in the
/// parent's space).
#[test]
fn quick_set_anchor_keeps_a_parented_layer_in_place() {
let mut s = comp();
let parent = solid(&mut s, "#ff0000");
let child = solid(&mut s, "#00ff00");
s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 25})).unwrap();
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [120, 70]})).unwrap();
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
s.execute("prop.set", json!({"layer": child, "path": "transform/rotation", "value": -40})).unwrap();
s.execute("prop.set", json!({"layer": child, "path": "transform/scale", "value": [60, 90]})).unwrap();
let before = seen_bounds(&s, child);
s.execute("layer.quickSetAnchor", json!({"layers": [child], "point": "bottomRight"})).unwrap();
let after = seen_bounds(&s, child);
assert!(before.iter().zip(after).all(|(a, b)| (a - b).abs() < 1e-6), "{before:?} → {after:?}");
assert_eq!(layer(&s, child).transform().unwrap().get("anchor").unwrap().value.as_vec3()[..2], [200.0, 100.0]);
}