Merge main into #2268; import both layers_or and popup_options, drop main's now-unreachable post-duplicate end_edit (the PR ends the edit first), adapt tests to main's kept empty Type-tool layer (f262e39c6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 10:23:03 +07:00
co-authored by Claude Opus 5.5
542 changed files with 37155 additions and 3140 deletions
+33 -2
View File
@@ -78,7 +78,7 @@ fn transition_preset_by_name_and_bad_input() {
#[test]
fn preset_catalogue_is_unique_and_categorised() {
let all = crate::animation_presets();
let known = ["Transitions", "Backgrounds"];
let known = ["Transitions", "Backgrounds", "Behaviors"];
let mut ids = std::collections::HashSet::new();
let mut names = std::collections::HashSet::new();
for (id, name, cat) in &all {
@@ -90,7 +90,7 @@ fn preset_catalogue_is_unique_and_categorised() {
assert!(!ids.contains(&id.to_lowercase()) && !names.contains(&name.to_lowercase()), "text preset {id} clashes");
}
let n = |c: &str| all.iter().filter(|(_, _, k)| k == c).count();
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20);
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20 && n("Behaviors") >= 20);
}
#[test]
@@ -126,3 +126,34 @@ fn preset_expressions_and_point_fractions() {
assert_eq!(ramp.prop("end").unwrap().value, effectcraft_keyframe::Value::Vec2([80.0, 90.0]));
assert_eq!(ramp.prop("startColor").unwrap().keys.len(), 2);
}
/// #1510: Behaviors presets add no effects; each puts an enabled expression on a transform
/// property, and undo removes it again.
#[test]
fn every_behavior_preset_applies_and_undoes() {
let (mut s, solid) = session();
let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap();
let list = list.as_array().unwrap().clone();
assert!(list.len() >= 20, "{}", list.len());
let driven = |s: &Session| {
let l = &s.active_comp().unwrap().layers[0];
["transform/position", "transform/scale", "transform/rotation", "transform/opacity"]
.iter()
.filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression()))
.copied()
.collect::<Vec<_>>()
};
for p in &list {
let id = p["id"].as_str().unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies");
assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects");
let applied = driven(&s);
assert_eq!(applied.len(), 1, "{id}: exactly one property is driven");
s.execute("edit.undo", json!({})).unwrap();
assert!(driven(&s).is_empty(), "{id}: undo removes the expression");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(driven(&s), applied, "{id}: redo restores the expression");
s.execute("edit.undo", json!({})).unwrap();
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Synthetic camera interchange: projection, timing, DOF and hostile inputs. Optional public
//! Blender-API oracle: EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture.
use effectcraft_effects::camera_tracker as ct;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, ItemKind, LayerId, LayerSource, build};
use effectcraft_time::{FrameRate, Tick};
use effectcraft_track::camtrack::{CameraSolve, Distortion, Ground, ShotType, SolveMethod, SolvedFrame, SolvedPoint};
use serde_json::{Value, json};
use crate::Session;
fn path(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("effectcraft-blender-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().into_owned()
}
fn fixture(ground: bool) -> (Session, LayerId, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Shot '\\ — câmera", "width": 640, "height": 360, "duration": 6})).unwrap();
let cid = s.active_comp_id().unwrap();
let rate = FrameRate::new(30000, 1001);
let lid = s
.edit("fixture", None, |p, _| {
p.comp_mut(cid).unwrap().frame_rate = rate;
let item = p.add_item(
"Plate",
Default::default(),
None,
ItemKind::Footage(Footage {
path: std::env::var("EFFECTCRAFT_BLENDER_PLATE").unwrap_or_else(|_| path("plate '\\ — vídeo.mp4")),
width: 640,
height: 360,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(6.0),
kind: FootageKind::Video,
has_video: true,
..Default::default()
}),
);
let comp = p.comp(cid).unwrap().clone();
let mut layer = build::layer(p, &comp, "Tracked Video", LayerSource::Footage { item }, (640, 360), None);
layer.in_point = rate.tick_of(60);
layer.out_point = rate.tick_of(63);
layer.start_time = rate.tick_of(30);
layer.stretch = 200.0;
let id = layer.id;
p.comp_mut(cid).unwrap().layers.push(layer);
Ok(id)
})
.unwrap();
let uid = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0]})).unwrap()["effects"][0].as_u64().unwrap();
let solve = CameraSolve {
version: 1,
size: [640.0, 360.0],
start: rate.tick_of(30).seconds() / 2.0,
frame_duration: rate.frame_duration().seconds() / 2.0,
shot: ShotType::VariableZoom,
method_used: SolveMethod::Typical,
average_error: 0.12,
frames: (0..3)
.map(|i| SolvedFrame {
rot: [0.02 * i as f64, -0.03 * i as f64, 0.01 * i as f64],
center: [0.1 * i as f64, -0.01 * i as f64, 0.02 * i as f64],
focal: 600.0 + i as f64 * 25.0,
solved: true,
})
.collect(),
points: vec![
SolvedPoint { id: 7, pos: [0.1, 0.2, 2.0], error: 0.13, first: 0, last: 2 },
SolvedPoint { id: 9, pos: [-0.3, -0.1, 1.2], error: 0.11, first: 0, last: 1 },
],
ground: ground.then_some(Ground { origin: [0.1, 0.2, 2.0], normal: [0.0, -1.0, 0.0], x_axis: [1.0, 0.0, 0.0] }),
distortion: ground.then_some(Distortion { k1: 0.01, k2: -0.002, radius: 320.0_f64.hypot(180.0) }),
};
// Directly install a deterministic solve; no analysis or decoder is needed by these tests.
s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
s.camera_pending.clear();
s.execute("layer.select", json!({"layers": [lid.0]})).unwrap();
(s, lid, uid)
}
fn data(script: &str) -> Value {
let line = script.lines().find_map(|l| l.strip_prefix("DATA = json.loads(")).unwrap().strip_suffix(')').unwrap();
let encoded: String = serde_json::from_str(line).unwrap();
serde_json::from_str(&encoded).unwrap()
}
#[test]
fn blender_export_bakes_projection_focus_and_retimed_background() {
let (mut s, lid, uid) = fixture(true);
let result = s.execute("camera.create", json!({"layer": lid.0, "effect": uid})).unwrap();
let camera = result["camera"].as_u64().unwrap();
s.execute("layer.cameraSettings", json!({"layer": camera, "focusDistance": 800.0, "dof": true, "fStop": 4.0, "lockToZoom": false})).unwrap();
for (frame, value) in [(60, 800.0), (62, 1000.0)] {
let time = FrameRate::new(30000, 1001).tick_of(frame).seconds();
s.execute("prop.addKey", json!({"layer": camera, "path": "cameraOptions/focusDistance", "time": time, "value": value})).unwrap();
}
let camera_layer = s.active_comp().unwrap().layer(LayerId(camera)).unwrap();
assert!(camera_layer.props.prop("cameraOptions/dof").unwrap().value.as_bool());
let output = path("oracle-scene.py");
let before = s.project.to_json();
let r = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "effect": uid, "path": output, "startFrame": 101})).unwrap();
assert_eq!(s.project.to_json(), before, "export must not edit the document");
assert_eq!(r["frames"], 3);
assert_eq!(r["points"], 2);
let script = std::fs::read_to_string(&output).unwrap();
let d = data(&script);
assert_eq!(d["resolution"], json!([640.0, 360.0]));
assert_eq!(d["fpsNumerator"], 30000);
assert_eq!(d["fpsDenominator"], 1001);
assert_eq!(d["frames"][0]["sourceFrame"], 15, "trim + 200% stretch, not scene frame 101");
assert_eq!(d["frames"][2]["sourceFrame"], 16);
assert_eq!(d["frames"][0]["focus"], 8.0);
assert_eq!(d["frames"][2]["focus"], 10.0);
assert_eq!(d["frames"][0]["dof"], true);
assert!((d["frames"][0]["fstop"].as_f64().unwrap() - 4.0).abs() < 1e-6);
assert_eq!(d["frames"][0]["lens"], 33.75);
assert_eq!(d["frames"][2]["lens"], 36.5625);
assert_eq!(d["ground"], true);
assert_eq!(d["solve"]["distortion"]["k1"], 0.01);
assert_eq!(d["points"][0]["location"][2], 0.0, "ground point lies on Blender XY plane");
// Execute the actual exported artifact in a separately installed Blender, when requested.
if let Ok(blender) = std::env::var("EFFECTCRAFT_BLENDER") {
let oracle = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blender_scene_oracle.py");
let output = std::process::Command::new(blender)
.args(["--background", "--factory-startup", "--python-exit-code", "1", "--python"])
.arg(oracle)
.arg("--")
.arg(&output)
.output()
.unwrap();
assert!(output.status.success(), "Blender oracle failed:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).contains("EFFECTCRAFT_BLENDER_ORACLE_OK"));
}
}
#[test]
fn blender_export_bakes_reverse_time_remap_and_footage_loops() {
let (mut s, lid, _) = fixture(false);
let cid = s.active_comp_id().unwrap();
let rate = FrameRate::new(30000, 1001);
s.edit("retimed fixture", None, |p, _| {
let layer = p.comp(cid).unwrap().layer(lid).unwrap();
let item = layer.source.item().unwrap();
let keys = [(60, 8.0), (61, 6.0), (62, -1.0)]
.map(|(frame, time)| effectcraft_keyframe::Keyframe::new(layer.layer_time(rate.tick_of(frame)), KV::Scalar(time)));
let mut remap = build::Ids(&mut p.next_id).prop("timeRemap", "Time Remap", KV::Scalar(8.0));
remap.keys = keys.to_vec();
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.children.push(remap.into());
if let ItemKind::Footage(f) = &mut p.item_mut(item).unwrap().kind {
f.loop_count = 2;
}
Ok(())
})
.unwrap();
let output = path("remap.py");
s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output})).unwrap();
let d = data(&std::fs::read_to_string(output).unwrap());
let indices: Vec<i64> = d["frames"].as_array().unwrap().iter().map(|f| f["sourceFrame"].as_i64().unwrap()).collect();
assert_eq!(indices, vec![60, 0, 0], "reverse remap wraps the 6-second video and clamps negative source time, as the media pool does");
}
#[test]
fn blender_export_defaults_and_script_escaping() {
let (mut s, lid, _) = fixture(false);
let output = path("escaped.py");
let r = s
.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output, "includePoints": false, "sensorWidth": 24.0, "unitScale": 0.1}))
.unwrap();
assert_eq!(r["points"], 0);
let script = std::fs::read_to_string(&output).unwrap();
let d = data(&script);
assert!(d["name"].as_str().unwrap().contains("'\\ — câmera"));
assert_eq!(d["frames"][0]["lens"], 22.5);
assert_eq!(d["frames"][0]["dof"], false);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("cannot be recovered")));
match std::process::Command::new("python3").args(["-c", "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())", &output]).status() {
Ok(status) => assert!(status.success(), "generated script must parse even with quoted/Unicode paths"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => eprintln!("Python AST oracle skipped: python3 is not installed"),
Err(e) => panic!("Python AST oracle failed: {e}"),
}
}
#[test]
fn blender_export_rejects_invalid_arguments_and_write_failures() {
let (mut s, lid, _) = fixture(false);
for params in [
json!({"sensorWidth": 0}),
json!({"sensorWidth": "wide"}),
json!({"unitScale": -1}),
json!({"startFrame": -1}),
json!({"startFrame": 1.5}),
json!({"startFrame": 1048574}),
json!({"includePoints": "yes"}),
json!({"bogus": true}),
json!({"path": path("bad.blend")}),
] {
let mut p = json!({"layer": lid.0, "path": path("invalid.py")});
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
assert!(s.execute_checked("camera.exportBlender", p.clone()).is_err(), "must reject {p}");
}
let blocker = path("blocker");
std::fs::write(&blocker, b"not a directory").unwrap();
let p = format!("{blocker}/scene.py");
assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": p})).unwrap_err().to_string().contains("cannot write"));
}
#[test]
fn blender_export_rejects_malformed_solves_without_panicking() {
for variant in ["empty", "unsolved", "zero-focal", "bad-ground", "huge-resolution", "mismatched-range"] {
let (mut s, lid, uid) = fixture(false);
let cid = s.active_comp_id().unwrap();
let mut solve = ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap()))
.unwrap()
.as_ref()
.clone();
match variant {
"empty" => solve.frames.clear(),
"unsolved" => solve.frames[0].solved = false,
"zero-focal" => solve.frames[0].focal = 0.0,
"bad-ground" => solve.ground = Some(Ground::default()),
"huge-resolution" => solve.size[0] = 1e10,
_ => {
solve.frames.pop();
}
}
s.edit("corrupt solve", None, |p, _| {
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.find_group_mut(uid).unwrap().prop_mut(ct::SOLVE).unwrap().value = KV::Str(solve.to_json());
Ok(())
})
.unwrap();
s.camera_pending.clear();
let result = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("corrupt.py")}));
assert!(result.is_err(), "must reject {variant}");
}
}
#[test]
fn blender_export_respects_script_write_permission() {
let (mut s, lid, _) = fixture(false);
let output = path("script-permissions.py");
let params = json!({"layer": lid.0, "path": output});
for (from_script, allowed) in [(true, false), (true, true), (false, false), (false, true)] {
std::fs::write(&output, b"existing file").unwrap();
s.prefs.scripting.allow_scripts_write_files = allowed;
let result =
if from_script { s.execute_for_script("camera.exportBlender", params.clone()) } else { s.execute_checked("camera.exportBlender", params.clone()) };
assert!(!s.running_for_script(), "script origin must be restored after success or error");
let contents = std::fs::read_to_string(&output).unwrap();
if from_script && !allowed {
assert!(result.unwrap_err().to_string().contains("Allow Scripts to Write Files"));
assert_eq!(contents, "existing file", "blocked scripts must not replace an existing file");
} else {
assert_eq!(result.unwrap()["frames"], 3);
assert!(contents.contains("def import_scene():"));
}
}
}
+10 -1
View File
@@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
let layer = &comp.layers[0];
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
let (px, py) = buf.to_px([x, y]);
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
+59
View File
@@ -136,3 +136,62 @@ fn footage_changes_change_the_salt() {
assert_ne!(k3, k2);
assert_ne!(set_proxy(&mut s, false), k3);
}
/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to
/// render again, #1083); an expression that reads markers makes them count.
#[test]
fn markers_keep_the_content_key_unless_an_expression_reads_them() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
let cid = s.active_comp_id().unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
let k0 = key(&s);
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap();
assert_eq!(key(&s), k0, "markers");
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap();
let k1 = key(&s);
assert_ne!(k1, k0);
s.execute("comp.addMarker", json!({"time": 1.0})).unwrap();
assert_ne!(key(&s), k1, "a marker read by an expression");
}
/// A comp marker alone (no layer marker beside it) keeps the key too.
#[test]
fn a_lone_comp_marker_keeps_the_content_key() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
let cid = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
let k0 = key(&s);
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
assert_eq!(key(&s), k0);
}
/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so
/// changing it changes the key of the comp that nests it; a plain marker beside it does not.
#[test]
fn protected_markers_with_a_duration_change_the_content_key() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap();
let outer = s.active_comp_id().unwrap();
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer);
let k0 = key(&s);
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap();
assert_eq!(key(&s), k0, "a plain marker");
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap();
let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap();
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap();
let k1 = key(&s);
assert_ne!(k1, k0, "a protected marker with a duration");
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap();
assert_ne!(key(&s), k1, "its duration");
}
+127 -1
View File
@@ -550,7 +550,7 @@ fn opening_an_old_project_upgrades_effect_instances() {
effectcraft_project::ItemKind::Comp(c) => c,
_ => panic!("not a composition"),
});
let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap();
let l = comp.layers.find_mut(|l| l.id == LayerId(a)).unwrap();
for n in &mut l.props.sub_mut("effects").unwrap().children {
let Node::Group(g) = n else { continue };
if g.match_id == "ec.blur.gaussian" {
@@ -772,6 +772,132 @@ fn preset_round_trips_effect_properties_by_match_name() {
}
}
/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a
/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value
/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the
/// old id applies to the new one.
#[test]
fn applying_an_old_preset_upgrades_its_effects_and_property_paths() {
let (mut s, a, b) = comp_with_two_solids();
apply(&mut s, a, "Change to Color");
let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone();
old.children.retain(|c| c.match_id() != "toleranceGroup");
old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into());
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]});
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap();
assert_eq!(r["applied"], 1, "{r}");
let l = layer(&s, b);
let g = l.effects().unwrap().groups().next().unwrap();
assert!(g.get("tolerance").is_none(), "the old Tolerance is still there");
let tol = g.sub("toleranceGroup").expect("no Tolerance group");
assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0));
assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0));
assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0));
let path = "effects/ec.color.changetocolor/tolerance";
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []});
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap();
assert_eq!(r["applied"], 1, "{r}");
assert_eq!(r["skipped"], json!([]), "{r}");
let l = layer(&s, a);
let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone();
assert_eq!(hue, KV::Scalar(30.0));
}
/// Save the selected effects and properties as a preset, then apply it to `target` at `at`.
fn save_and_apply_preset(s: &mut Session, name: &str, selected: Vec<(LayerId, u64)>, target: u64, at: f64) -> serde_json::Value {
let path = std::env::temp_dir().join(format!("ec-1971-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string();
s.state.selected_props = selected;
s.execute("anim.savePreset", json!({"path": path})).unwrap();
s.execute("time.set", json!({"time": at})).unwrap();
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [target]}));
let _ = std::fs::remove_file(&path);
r.unwrap()
}
/// The comp times of the keys at `path` on `lid`'s `n`th effect (0-based), or on the layer when
/// `n` is `None`.
fn key_times(s: &Session, lid: u64, n: Option<usize>, path: &str) -> Vec<f64> {
let l = layer(s, lid);
let group = match n {
Some(n) => l.effects().unwrap().groups().nth(n).unwrap().clone(),
None => l.props.clone(),
};
group.prop(path).unwrap().keys.iter().map(|k| l.comp_time(k.time).seconds()).collect()
}
/// #1971: a saved effect's keys start at the playhead of the layer it's applied to, as a saved
/// property's do, nested controls included, an effect without keys still applies, and the whole
/// apply is one undo step.
#[test]
fn preset_effect_keys_start_at_the_playhead() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
let fractal = apply(&mut s, a, "Fractal");
let slider = apply(&mut s, a, "Slider Control");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (5.5, 30.0), (6.0, 10.0)]);
animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(5.5, 1.25), (7.0, 2.5)]);
let before = layer(&s, b);
let undo = s.history.undo.len();
let r = save_and_apply_preset(&mut s, "effects", vec![(LayerId(a), blur), (LayerId(a), fractal), (LayerId(a), slider)], b, 0.5);
assert_eq!(r["applied"], 3, "{r}");
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.0, 1.5]);
assert_eq!(key_times(&s, b, Some(1), "mandelbrot/mandelbrotX"), vec![1.0, 2.5]);
assert_eq!(s.history.undo.len(), undo + 1, "the whole apply is one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, b), before);
}
/// #1971: effects and properties saved together keep their relative timing: the earliest key of
/// either starts at the playhead.
#[test]
fn preset_effects_and_properties_share_one_anchor() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
for (time, value) in [(4.0, -12.0), (7.0, 12.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
}
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
save_and_apply_preset(&mut s, "mixed", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 1.0);
assert_eq!(key_times(&s, b, None, "transform/rotation"), vec![1.0, 4.0]);
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![2.0, 3.0]);
}
/// #1971: on a stretched layer a saved effect's keys land where a saved property's keys do.
#[test]
fn preset_effect_keys_follow_a_stretched_layer_like_properties() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
for (time, value) in [(5.0, -12.0), (6.0, 12.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
}
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
set_layer_clock(&mut s, b, 1.0, 200.0);
save_and_apply_preset(&mut s, "stretched", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 2.0);
let property = key_times(&s, b, None, "transform/rotation");
assert_eq!(property.first(), Some(&2.0), "the property's keys start at the playhead");
assert_eq!(key_times(&s, b, Some(0), "blurriness"), property, "and the effect's keys land with them");
}
/// #1971: a preset saved before effect keys were stored relative to the earliest key (version 1)
/// has its effects' keys at the source layer's times; they also start at the playhead.
#[test]
fn old_preset_effect_keys_start_at_the_playhead() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
let old = json!({
"effectcraftAnimationPreset": 1,
"name": "shake",
"properties": [],
"effects": [serde_json::to_value(effect_group(&s, a, blur)).unwrap()],
});
s.execute("time.set", json!({"time": 0.5})).unwrap();
s.execute("anim.applyPreset", json!({"preset": old, "layers": [b]})).unwrap();
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.5]);
}
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
/// `essential.list` reports.
#[test]
+122
View File
@@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() {
assert!(center(&s, main)[0] > 0.99);
}
/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested
/// layers past the precomp's frame instead of falling back to a flattened, clipped frame.
#[test]
fn collapsed_precomp_applies_property_overrides_without_clipping() {
struct AddY;
impl effectcraft_render::ExprHost for AddY {
fn eval(
&self,
_: &effectcraft_render::EvalCtx,
_: &effectcraft_project::Layer,
_: &effectcraft_project::Property,
value: &effectcraft_keyframe::Value,
) -> Result<effectcraft_keyframe::Value, String> {
Ok(match value {
effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]),
_ => value.clone(),
})
}
}
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
// The source property's expression still runs after the instance override.
s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap();
let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap();
s.expr = Some(std::sync::Arc::new(AddY));
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [other]})).unwrap();
s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
// The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to
// outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above
// the instance's frame (150..449), where a flattened precomp would stop. The second maps to
// 470 and spans 420..519, past the bottom.
assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100));
assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds");
assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500));
assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances");
}
/// #1747: two collapsed instances of the same comp keep their own source-affecting property
/// overrides in the layer cache (the nested layers' ids and static values are identical).
#[test]
fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
let card = s.active_comp_id().unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let main = s.active_comp_id().unwrap();
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let img = s.render(main, Tick::ZERO, RenderOpts::default());
let left = img.get(60, 50);
let right = img.get(140, 50);
assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}");
assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}");
}
/// #1747 with Media Replacement: the replacement footage and the position override both apply
/// while the precomp stays collapsed, so the box is blue and still draws above the frame.
#[test]
fn collapsed_precomp_applies_media_replacement_without_clipping() {
struct Colors;
impl crate::render::FootageSource for Colors {
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c)))
}
}
let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() };
let footage = |path: &str| effectcraft_project::Footage {
path: path.into(),
kind: effectcraft_project::FootageKind::Still,
width: 100,
height: 100,
has_video: true,
..Default::default()
};
let (red, blue) = s
.edit("test", None, |proj, _| {
let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png")));
let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png")));
Ok((red, blue))
})
.unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap();
let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap();
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
let above = img.get(200, 100);
assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}");
assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame");
}
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
/// original speed; the unprotected parts absorb the stretch.
#[test]
+31
View File
@@ -2,6 +2,7 @@
use effectcraft_keyframe::Value as KV;
use effectcraft_project::essential::{self, ControlType};
use effectcraft_time::Tick;
use serde_json::json;
use crate::{Event, Session};
@@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() {
);
assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A");
}
/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source
/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides`
/// does on the flattened path, instead of one entry replacing the other.
#[test]
fn collapsed_instance_merges_text_and_font_overrides() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let title = s.active_comp_id().unwrap();
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let main = s.active_comp_id().unwrap();
let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap();
// Evaluate the nested text in the collapsed instance's context, as the renderer does.
let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default());
let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO);
let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap();
let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses");
let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context");
let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap();
match nctx.value(src, src.props.prop("text/sourceText").unwrap()) {
KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)),
o => panic!("{o:?}"),
}
}
+66
View File
@@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
}
#[test]
fn save_frame_as_png_preserves_alpha_channel() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap();
let dir = out_dir("frame-png-alpha");
let path = dir.join("alpha.png").to_string_lossy().to_string();
s.execute("comp.saveFrameAs", json!({"path": path})).unwrap();
let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8();
assert_eq!((img.width(), img.height()), (32, 32));
let solid_px = img.get_pixel(8, 8);
assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}");
assert_eq!(solid_px[0], 255);
let empty_px = img.get_pixel(24, 24);
assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}");
let flat_path = dir.join("flat.png").to_string_lossy().to_string();
s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap();
let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8();
let flat_empty_px = flat_img.get_pixel(24, 24);
assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}");
}
#[test]
fn save_frame_as_queued_respects_frame_default_template() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
// Default Still template is "PNG Sequence" (Channels::Rgb)
let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
assert_eq!(r["queued"], true);
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.name, "PNG Sequence");
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
// Test undo and redo of queuing a frame
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.render_queue.is_empty());
s.execute("edit.redo", json!({})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.name, "PNG Sequence");
// Explicit transparent: false forces Channels::Rgb
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
// When Frame Default is set to an RGBA template, it is respected
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap();
s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.name, "PNG Sequence with Alpha");
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba);
// Passing transparent: false forces Channels::Rgb even with an RGBA template
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
// With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap();
assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok());
}
+257
View File
@@ -0,0 +1,257 @@
//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack
//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its
//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde).
use std::sync::Arc;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project};
use effectcraft_render::{FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::{Importer, Session};
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
s
}
fn cid(s: &Session) -> ItemId {
s.active_comp_id().unwrap()
}
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
}
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[(y * img.width + x) as usize]
}
fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool {
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
}
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
}
}
/// A red triangle in the Z = 0 plane (buffer embedded as a data URI).
const TRIANGLE: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
]
}"#;
fn triangle_model(s: &mut Session) -> u64 {
let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Triangle.gltf");
std::fs::write(&path, TRIANGLE).unwrap();
s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn instance3d_creates_a_2d_copy_of_the_model() {
let mut s = session();
let model = triangle_model(&mut s);
// The model renders through the comp's camera.
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180));
assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4));
// Create 3D Layer Instance.
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
let comp = cid(&s);
let l = layer_in(&s, comp, solid);
assert_eq!(l.name, "Triangle Instance");
assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack");
// A 3D Layer Instance effect referencing the 3D layer.
let g = l.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind);
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
// The model is switched off, the copy selected.
assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does");
assert_eq!(s.state.selected_layers, vec![LayerId(solid)]);
// The comp renders the same through the copy: a 2D copy of the 3D layer, transparent
// around it.
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4));
// Undo removes the copy and switches the model back on; redo returns it.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none());
assert!(layer_in(&s, comp, model).switches.video);
s.execute("edit.redo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some());
assert!(!layer_in(&s, comp, model).switches.video);
// Serde round-trips the layer parameter.
let j = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&j).unwrap();
assert_eq!(back, *s.project);
// 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue
// recolours the triangle.
s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap();
s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default());
let p = px(&with_fx, 320, 180);
assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}");
}
#[test]
fn instance3d_also_takes_3d_primitives() {
let mut s = session();
let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap();
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
let l = layer_in(&s, cid(&s), solid);
assert_eq!(l.name, "Cube Instance");
assert!(!layer_in(&s, cid(&s), prim).switches.video);
let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
assert!(close(&before, &after, 2e-2), "the copy shows the cube");
}
#[test]
fn instance3d_needs_a_3d_layer() {
let mut s = session();
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]}));
assert!(err.is_err());
let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
// A 3D-switched text layer is not a model layer either (it has 2D pixels of its own).
s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap();
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
}
#[test]
fn instance3d_creates_every_selected_copy_in_one_undo() {
let mut s = session();
let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap();
let copies = r["layers"].as_array().unwrap();
assert_eq!(copies.len(), 2);
let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap());
let comp = cid(&s);
assert_ne!(ia, ib);
assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own");
// Both sources are off, both copies selected, above their sources in the stack.
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
let mut sel = s.state.selected_layers.clone();
let mut want = vec![LayerId(ia), LayerId(ib)];
sel.sort_by_key(|l| l.0);
want.sort_by_key(|l| l.0);
assert_eq!(sel, want);
let c = s.active_comp().unwrap();
let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap();
assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources");
// One undo removes both copies and switches both models back on; one redo returns them.
s.execute("edit.undo", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance");
assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video);
s.execute("edit.redo", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance");
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
}
#[test]
fn instance3d_in_a_mixed_selection_only_takes_the_models() {
let mut s = session();
let model = triangle_model(&mut s);
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap();
let copies = r["layers"].as_array().unwrap();
assert_eq!(copies.len(), 1, "only the model gets a copy: {r}");
let copy = copies[0].as_u64().unwrap();
let comp = cid(&s);
assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off");
assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched");
let skipped = r["skipped"].as_array().unwrap();
assert_eq!(skipped.len(), 1);
assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}");
// The copy references the model, not the solid.
let l = layer_in(&s, comp, copy);
let g = l.effects().unwrap().groups().next().unwrap().clone();
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
}
#[test]
fn instance3d_shows_nothing_outside_the_source_layer_range() {
let mut s = session();
let model = triangle_model(&mut s);
// The model's range ends at 1 s; the comp runs to 2 s.
let comp = cid(&s);
s.edit("trim", None, |p, _| {
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0);
Ok(())
})
.unwrap();
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let _copy = r["layers"][0].as_u64().unwrap();
// Inside the model's range the copy shows it, transparent around it.
let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default());
assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180));
// After its out point the source has nothing to read: the copy is transparent.
let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default());
assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180));
}
#[test]
fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() {
let mut s = session();
let model = triangle_model(&mut s);
let comp = cid(&s);
// Solo the model, then create the instance: any_solo hides non-soloed layers and the
// source's video goes off, so the copy has to take the Solo over.
s.edit("solo", None, |p, _| {
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true;
Ok(())
})
.unwrap();
let before = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180));
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch");
assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off");
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180));
assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
}
+482
View File
@@ -0,0 +1,482 @@
//! Copied keyframes as After Effects keyframe text for other applications (#1908).
use effectcraft_project::{LayerId, Uid};
use effectcraft_time::Tick;
use serde_json::json;
use crate::{KeyRef, Session};
/// A 1920×1080 23.976 fps comp with a 200×100 solid.
fn session() -> (Session, LayerId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap();
let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
(s, LayerId(id))
}
fn frame(s: &Session, n: i64) -> Tick {
Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n)
}
fn uid(s: &Session, lid: LayerId, path: &str) -> Uid {
s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid
}
fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
let cid = s.active_comp_id().unwrap();
f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap());
}
fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) {
s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap();
}
#[test]
fn copied_keys_are_after_effects_text_in_comp_frames() {
let (mut s, l) = session();
// The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19.
let start = frame(&s, 10);
edit_layer(&mut s, l, |l| l.start_time = start);
for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] {
let t = frame(&s, n);
add_key(&mut s, l, "transform/position", t, json!(v));
}
let t = frame(&s, 5);
add_key(&mut s, l, "transform/rotation", t, json!(-45));
let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation"));
// A subset: two of the three Position keys and the Rotation key.
s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec();
s.execute("keys.copy", json!({})).unwrap();
assert_eq!(
s.state.keyframe_text.as_deref(),
Some(
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\
Layer\n\
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\
Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\
\n\
End of Keyframe Data\n"
)
);
// The internal clipboard still has the keys themselves, relative to the earliest.
let times: Vec<Vec<Tick>> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect();
assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]);
}
#[test]
fn a_stretched_layer_writes_the_frames_its_keys_show_at() {
let (mut s, l) = session();
edit_layer(&mut s, l, |l| l.stretch = 200.0);
let t = frame(&s, 6);
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0));
add_key(&mut s, l, "transform/opacity", t, json!(100));
s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let text = s.state.keyframe_text.clone().unwrap();
assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}");
}
#[test]
fn copied_static_values_have_no_frame() {
let (mut s, l) = session();
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let text = s.state.keyframe_text.clone().unwrap();
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}");
assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value");
}
#[test]
fn a_copied_transform_group_lists_what_the_layer_shows() {
let (mut s, l) = session();
let t = frame(&s, 24);
add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100]));
add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100]));
let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid;
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let text = s.state.keyframe_text.clone().unwrap();
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation");
assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}");
// 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three
// values sit under one label, as After Effects writes them.
edit_layer(&mut s, l, |l| l.switches.three_d = true);
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let text = s.state.keyframe_text.clone().unwrap();
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]);
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}");
}
fn copy_transform(s: &mut Session, lid: LayerId) -> String {
let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid;
s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.state.keyframe_text.clone().unwrap()
}
fn names(text: &str) -> Vec<&str> {
text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect()
}
#[test]
fn properties_the_layer_does_not_use_are_left_out() {
// Separate Dimensions: the old Position no longer moves the layer.
let (mut s, l) = session();
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap();
let text = copy_transform(&mut s, l);
assert!(!names(&text).contains(&"Position"), "{text}");
// A one-node camera has no Point of Interest; a two-node one does.
let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap());
let text = copy_transform(&mut s, cam);
assert!(!names(&text).contains(&"Point of Interest"), "{text}");
s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap();
let text = copy_transform(&mut s, cam);
assert!(names(&text).contains(&"Point of Interest"), "{text}");
}
#[test]
fn separated_position_writes_x_y_and_z_position() {
let (mut s, l) = session();
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
let t = frame(&s, 12);
add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100));
add_key(&mut s, l, "transform/positionX", t, json!(321));
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap();
let text = copy_transform(&mut s, l);
// After Effects labels their single column with no unit.
assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}");
assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}");
edit_layer(&mut s, l, |l| l.switches.three_d = true);
let text = copy_transform(&mut s, l);
assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}");
}
#[test]
fn time_remap_writes_source_seconds_at_comp_frames() {
let (mut s, l) = session();
let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap();
let l = LayerId(r["layer"].as_u64().unwrap());
s.execute("layer.select", json!({"layers": [l.0]})).unwrap();
s.execute("layer.enableTimeRemap", json!({})).unwrap();
let start = frame(&s, 10);
edit_layer(&mut s, l, |l| l.start_time = start);
let t = frame(&s, 6);
add_key(&mut s, l, "timeRemap", t, json!(2.5));
s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let text = s.state.keyframe_text.clone().unwrap();
// As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16.
assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}");
}
/// Selects the layer's properties at `paths` and copies them.
fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String {
for (i, path) in paths.iter().enumerate() {
s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap();
}
s.execute("edit.copy", json!({})).unwrap();
s.state.keyframe_text.clone().unwrap()
}
#[test]
fn effect_properties_are_written_as_after_effects_does() {
// The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown
// Menu Control: that block follows its other popups, written 1-based with no unit).
let (mut s, l) = session();
for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] {
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
}
let t = frame(&s, 12);
add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1]));
add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1]));
add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true));
add_key(&mut s, l, "effects/#2/checkbox", t, json!(false));
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap();
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap();
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap();
// After Effects writes a renamed effect under the effect's own name.
let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid;
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap();
let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]);
assert!(
text.contains(
"Layer\n\
Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\
Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\
Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\
Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\
Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n"
),
"{text}"
);
}
#[test]
fn effect_properties_have_after_effects_indices() {
// After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9,
// Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a
// group takes an index where it opens and closes, and its properties are written without
// it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for
// its source choice.
let (mut s, l) = session();
for fx in ["Fractal Noise", "Set Matte"] {
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
}
let text = copy_props(
&mut s,
l,
&[
"effects/#1/transform/rotation",
"effects/#1/complexity",
"effects/#1/evolution",
"effects/#1/opacity",
"effects/#1/blendingMode",
"effects/#2/takeMatteFrom",
],
);
for header in [
"Effects\tFractal Noise #1\tRotation #9\n",
"Effects\tFractal Noise #1\tComplexity #17\n",
"Effects\tFractal Noise #1\tEvolution #25\n",
"Effects\tFractal Noise #1\tOpacity #31\n",
"Effects\tFractal Noise #1\tBlending Mode #32\n",
"Effects\tSet Matte #2\tUse For Matte #3\n",
] {
assert!(text.contains(header), "{header}in\n{text}");
}
}
#[test]
fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() {
// After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no
// unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color
// sliders start at #10 and are percent.
let (mut s, l) = session();
for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] {
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
}
let text = copy_props(
&mut s,
l,
&["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"],
);
for block in [
"Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n",
"Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n",
"Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n",
"Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n",
"Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n",
] {
assert!(text.contains(block), "{block}in\n{text}");
}
}
#[test]
fn mask_properties_are_written_as_after_effects_does() {
// The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not
// name, and Mask Path is left out.
let (mut s, l) = session();
for _ in 0..2 {
s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
}
let t = frame(&s, 12);
for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] {
add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a);
add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b);
}
s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap();
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid;
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap();
let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]);
assert!(
text.contains(
"Layer\n\
Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\
Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\
Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\
Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\
\nEnd of Keyframe Data\n"
),
"{text}"
);
}
#[test]
fn text_properties_are_written_as_after_effects_does() {
// The blocks After Effects 2019 wrote for the same text properties and values.
let mut s = Session::default();
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap();
let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap();
s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap();
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid;
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap();
let t = frame(&s, 12);
add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello"));
add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0));
add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50));
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0));
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100));
add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0]));
add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0]));
for (path, v) in [
("text/animators/#1/selectors/#1/end", json!(80)),
("text/animators/#1/selectors/#1/offset", json!(-10)),
("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])),
("text/moreOptions/groupingAlignment", json!([10, -20])),
] {
s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap();
}
let text = copy_props(
&mut s,
l,
&[
"text/sourceText",
"text/pathOptions/firstMargin",
"text/animators/#1/selectors/#1/start",
"text/animators/#1/selectors/#1/end",
"text/animators/#1/selectors/#1/offset",
"text/animators/#1/selectors/#1/advanced/amount",
"text/animators/#1/properties/position",
"text/animators/#1/properties/fillColor",
"text/animators/#2/properties/opacity",
"text/moreOptions/groupingAlignment",
],
);
for block in [
"Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n",
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n",
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n",
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n",
"Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n",
"Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n",
"Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n",
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n",
"Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n",
] {
assert!(text.contains(block), "{block}\nin\n{text}");
}
assert!(!text.contains("Source Text"), "{text}");
}
#[test]
fn a_2d_layer_writes_the_z_it_shows() {
// Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it.
let (mut s, l) = session();
let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
edit_layer(&mut s, src, |l| l.switches.three_d = true);
add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300]));
add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70]));
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec();
s.execute("keys.copy", json!({})).unwrap();
s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap();
let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components();
assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z");
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec();
s.execute("keys.copy", json!({})).unwrap();
let text = s.state.keyframe_text.clone().unwrap();
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}");
assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}");
}
#[test]
fn the_header_has_the_source_pixel_aspect() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap();
let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap());
let text = copy_transform(&mut s, solid);
assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}");
// A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect.
let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap());
let text = copy_transform(&mut s, t);
assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}");
// A shape layer has no source of its own: the comp's size.
let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap());
let text = copy_transform(&mut s, shape);
assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}");
}
#[test]
fn keys_from_two_layers_have_no_text() {
let (mut s, a) = session();
let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
for l in [a, b] {
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50));
}
s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec();
s.execute("keys.copy", json!({})).unwrap();
assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text");
assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both");
// A copied layer leaves no text behind.
s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }];
s.execute("keys.copy", json!({})).unwrap();
assert!(s.state.keyframe_text.is_some());
s.state.selected_keys.clear();
s.execute("edit.copy", json!({"layers": [a.0]})).unwrap();
assert_eq!(s.state.keyframe_text, None);
}
#[test]
fn shape_contents_are_written_as_after_effects_does() {
// Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and
// place in their group, and a Size has no unit (`X `).
let (mut s, _) = session();
let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap());
for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] {
let mut p = json!({"layer": sh.0, "kind": kind});
if let Some(g) = group {
p["group"] = json!(g);
}
s.execute("layer.addShapeItem", p).unwrap();
}
let text = copy_props(
&mut s,
sh,
&[
"contents/#1/contents/#1/size",
"contents/#1/contents/#2/color",
"contents/#1/transform/position",
"contents/#2/points",
"contents/#3/transform/startOpacity",
],
);
for block in [
"Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n",
"Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n",
"Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n",
"Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n",
"Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n",
] {
assert!(text.contains(block), "{block}in\n{text}");
}
}
#[test]
fn camera_light_and_material_options_are_written_as_after_effects_does() {
// Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow
// Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0.
let (mut s, l) = session();
let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap());
let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]);
assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}");
assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}");
let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap());
let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]);
assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}");
assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}");
edit_layer(&mut s, l, |l| l.switches.three_d = true);
let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]);
for block in [
"Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n",
"Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n",
"Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n",
] {
assert!(text.contains(block), "{block}in\n{text}");
}
}
+122
View File
@@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() {
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();
@@ -1423,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
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();
+44
View File
@@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() {
s.execute("footage.open", json!({"item": item.0})).unwrap();
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]);
}
/// A proxy in use that can't be decoded leaves its item without a picture although the item's
/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the
/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off.
#[test]
fn a_proxy_in_use_that_cannot_be_decoded_reports_why() {
struct BrokenProxies;
impl FootageSource for BrokenProxies {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn problem(&self, f: &Footage) -> Option<String> {
f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec))
}
}
let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() };
let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() };
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still));
let proxy = Footage {
kind: FootageKind::Video,
path: "plate.avi".into(),
codec: "MPEG-4 Part 2".into(),
width: 32,
height: 18,
has_video: true,
..Default::default()
};
let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder");
let problem = |s: &mut Session, id: u64| {
let sum = s.execute("project.summary", json!({})).unwrap();
sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned()
};
for id in [item.0, comp] {
let p = std::sync::Arc::make_mut(&mut s.project);
p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true }));
assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}");
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false;
assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine");
}
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true;
s.execute("footage.open", json!({"item": item.0})).unwrap();
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want);
}
+290 -7
View File
@@ -1,6 +1,6 @@
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
//! a synthetic footage source.
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings),
//! rendered through a synthetic footage source.
use std::sync::Arc;
@@ -14,7 +14,8 @@ use crate::Session;
use crate::render::{FootageSource, RenderOpts};
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from
/// the second frame), `matte_black.png` / `matte_white.png` (a
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
/// (white at 25 % alpha), `wide.png` (10×10 white).
struct Synthetic;
@@ -35,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
"half.png" => [1.0, 1.0, 1.0, 0.25],
// A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands
// them out (code / 1023).
"scan.dpx" | "plate.dpx" => {
let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0;
[v, v, v, 1.0]
}
_ => {
let _ = x;
[1.0, 1.0, 1.0, 1.0]
@@ -46,19 +53,29 @@ fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4),
_ => (10, 10),
}
}
impl FootageSource for Synthetic {
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let (w, h) = size_of(&f.path);
let mut img = Image::new(w, h);
// `flash.mov` is red for its first frame and blue from its second (0.1 s) on.
if f.path.ends_with("flash.mov") {
let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
return Some(Arc::new(Image::filled(w, h, c)));
}
for y in 0..h {
for x in 0..w {
// Decoders hand out premultiplied pixels (straight file colour × alpha).
let p = file_px(&f.path, x, y);
let mut p = file_px(&f.path, x, y);
// A log scan sequence: code 95 on even frames, 685 on odd ones.
if f.path.ends_with("frames.dpx") {
let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0;
p = [v, v, v, 1.0];
}
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
}
}
@@ -70,7 +87,7 @@ struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let (w, h) = size_of(path);
let video = path.ends_with(".mov");
let video = path.ends_with(".mov") || path.ends_with("frames.dpx");
Ok(Footage {
path: path.into(),
kind: if video { FootageKind::Video } else { FootageKind::Still },
@@ -171,6 +188,118 @@ fn comp_proxy_and_create_proxy() {
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
}
/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer).
fn nested(s: &mut Session) -> (ItemId, ItemId) {
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
(inner, outer)
}
/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a
/// composition proxy draws, as they do for the same file used as footage.
#[test]
fn comp_proxy_draws_with_its_interpretation() {
let mut s = session();
let (inner, outer) = nested(&mut s);
let o = RenderOpts::default();
// Invert Alpha: the 25 % proxy covers 75 %.
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap();
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap();
let p = px(&s, outer, 0.0, o, 1, 1);
assert!((p[3] - 0.75).abs() < 0.01, "{p:?}");
// Interpret As Linear Light: the proxy draws as the file does as footage with that setting.
let f = import(&mut s, "/m/matte_black.png");
s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap();
s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
let r = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": f.0})).unwrap();
let want = px(&s, r, 0.0, o, 1, 1);
assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}");
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap();
s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap();
let p = px(&s, outer, 0.0, o, 1, 1);
assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}");
}
/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame
/// at twice the rate, as footage does.
#[test]
fn comp_proxy_separates_fields() {
let mut s = session();
let (inner, outer) = nested(&mut s);
let o = RenderOpts::default();
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap();
assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive");
s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap();
// At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated.
for y in 0..3 {
assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}");
}
}
/// Frame Blending on a precomp layer blends the frames of the composition's proxy.
#[test]
fn comp_proxy_frames_blend() {
let mut s = session();
let (inner, outer) = nested(&mut s);
let o = RenderOpts::default();
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap();
assert!(red(px(&s, outer, 0.05, o, 1, 1)));
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
// Halfway between the red first frame and the blue second one.
let p = px(&s, outer, 0.05, o, 1, 1);
assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}");
assert!(blue(px(&s, outer, 0.1, o, 1, 1)));
}
#[test]
fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() {
struct Interpreted(std::sync::atomic::AtomicUsize);
impl FootageSource for Interpreted {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let pixel = match f.alpha {
AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5],
AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5],
AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0],
};
Some(Arc::new(Image::filled(4, 4, pixel)))
}
}
for mode in ["frameMix", "pixelMotion"] {
let mut s = session();
let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0)));
s.footage = footage.clone();
let (inner, outer) = nested(&mut s);
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap();
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap();
s.execute("layer.frameBlending", json!({"mode": mode})).unwrap();
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
let opts = RenderOpts::default();
let first = px(&s, outer, 0.05, opts, 1, 1);
let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first);
assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls);
for params in [
json!({"item": inner.0, "alpha": "ignore"}),
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}),
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}),
] {
s.execute("file.interpretProxy", params.clone()).unwrap();
let mut fresh = s.renderer(opts);
fresh.cache = None;
let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1);
let actual = px(&s, outer, 0.05, opts, 1, 1);
assert_eq!(actual, want, "{mode} after {params}");
}
}
}
#[test]
fn interpret_proxy_settings_apply_to_the_proxy() {
let mut s = session();
@@ -187,6 +316,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() {
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
}
/// Agents and the control channel run commands through `execute_checked`, which refuses keys a
/// params doc does not list. The Interpret Footage commands list every key they read.
#[test]
fn interpret_commands_accept_their_keys_when_checked() {
let mut s = session();
let mov = import(&mut s, "/m/lines.mov");
let b = import(&mut s, "/m/red16.png");
s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap();
s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap();
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap();
let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage;
assert!(px.preserve_rgb);
assert_eq!(px.start_timecode, Some(25));
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap();
assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None);
s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap();
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied);
s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap();
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]);
// `item` names one footage item, as `items` names several.
s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap();
s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap();
s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap();
let f = match &s.project.item(mov).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
assert!(f.invert_alpha);
assert_eq!(f.premul_color, [0.0, 1.0, 0.0]);
assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1);
}
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
/// interpolated; the dominant field comes first.
#[test]
@@ -281,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() {
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).preserve_rgb);
}
/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the
/// Current Gamma between the converted points (off, they show as stored); the converted points
/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX
/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings.
#[test]
fn cineon_settings_convert_dpx_code_values() {
let mut s = session();
let d = import(&mut s, "/m/scan.dpx");
let other = import(&mut s, "/m/plate.dpx");
let png = import(&mut s, "/m/red16.png");
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0];
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
// Off: the code values as stored.
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
// The checked path (control channel, MCP) accepts every Cineon parameter the doc lists.
let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}");
assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1));
assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2));
s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap();
assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2));
// Converted points, in 8-bit units.
let out = s
.execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685}))
.unwrap();
assert_eq!(out["items"][0]["cineon"]["units"], "8bit");
assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}");
assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1));
// The form sends the points in the units it showed them in (`pointUnits`) with a new Units.
s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap();
let f = foot(&s, d);
assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float);
assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon);
// Refused: a black point at or above the white point, values out of range, and footage
// that is not DPX or Cineon. Nothing changes.
let before = foot(&s, d).cineon;
for bad in [
json!({"item": d.0, "tenBitBlack": 700}),
json!({"item": d.0, "tenBitWhite": 2000}),
json!({"item": d.0, "currentGamma": 0}),
json!({"item": d.0, "highlightRolloff": -1}),
json!({"item": d.0, "convertedWhite": 1.5}),
json!({"item": d.0, "cineonUnits": "12bit"}),
json!({"item": png.0, "logConversion": true}),
] {
assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}");
}
assert_eq!(foot(&s, d).cineon, before);
assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default());
// With DPX and other footage selected, only the DPX file takes the settings.
s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap();
assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion);
s.execute("edit.undo", json!({})).unwrap();
assert!(!foot(&s, other).cineon.log_conversion);
// Remember / Apply Interpretation.
s.state.project_selection = vec![d];
s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap();
assert_eq!(foot(&s, other).cineon, before);
assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default());
// Off again: as stored.
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap();
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
// Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at
// Current Gamma 1 is 0.5, not the conversion of the blended code values.
let seq = import(&mut s, "/m/frames.dpx");
s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap();
s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap();
let bc = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": seq.0})).unwrap();
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0];
assert!(near(mid, 0.5), "frame-mixed log frames: {mid}");
// A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in.
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap();
let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0];
assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1));
s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1));
}
/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX
/// footage with its Cineon Settings, as a composition renders it.
#[test]
fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() {
let mut s = session();
let d = import(&mut s, "/m/scan.dpx");
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap();
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0];
assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}");
let f = match &s.project.item(d).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0];
assert!(near(panel, rendered), "Footage panel: {panel}");
let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap();
assert_eq!(w, 4);
let thumb = rgba[8] as f32 / 255.0;
assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}");
let comp = s.project.comp(c).unwrap();
let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default());
let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap();
assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]);
}
+606
View File
@@ -0,0 +1,606 @@
//! Rendering never replaces a file the project reads (#1743). Ordinary-file replacement
//! behavior stays with the exporter and its confirmation API.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use effectcraft_color::Label;
use effectcraft_project::render_queue::{OutputFormat, RenderStatus, sequence_path};
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::{ExportJob, ExportResult, Exporter, Session};
const SOURCE: &[u8] = b"SOURCE FOOTAGE";
const EARLIER: &[u8] = b"AN EARLIER RENDER";
const RENDERED: &[u8] = b"RENDERED";
/// An exporter that writes like the real one: `RENDERED` at the job's path, or at the path of
/// every frame for a sequence (leaving the frames that exist alone when the item skips them).
#[derive(Default)]
struct Writer {
jobs: Mutex<Vec<String>>,
}
impl Exporter for Writer {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
self.jobs.lock().unwrap().push(job.path.to_string());
let comp = job.project.comp(job.item.comp).ok_or("no comp")?;
let paths: Vec<String> = if job.item.output.format.is_sequence() {
let first = job.item.settings.first_frame(comp);
(0..job.item.settings.frame_count(comp)).map(|k| sequence_path(job.path, first + k as i64)).collect()
} else {
vec![job.path.to_string()]
};
for path in &paths {
if job.item.settings.skip_existing && Path::new(path).exists() {
continue;
}
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(path, RENDERED).map_err(|e| e.to_string())?;
}
Ok(ExportResult { path: paths.first().cloned().unwrap_or_default(), ..Default::default() })
}
}
/// A session whose project is saved in a fresh folder (the footage goes there too), with an
/// exporter that writes real files.
struct Fixture {
s: Session,
writer: Arc<Writer>,
dir: PathBuf,
}
impl Fixture {
fn new(name: &str) -> Fixture {
let dir = std::env::temp_dir().join("effectcraft-render-overwrite").join(format!("{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
let writer = Arc::new(Writer::default());
s.exporter = Some(writer.clone());
s.execute("file.saveAs", json!({"path": dir.join("Project.ecproj").to_string_lossy()})).unwrap();
Fixture { s, writer, dir }
}
fn path(&self, file: &str) -> PathBuf {
self.dir.join(file)
}
fn text(&self, file: &str) -> String {
self.path(file).to_string_lossy().into_owned()
}
fn read(&self, file: &str) -> Vec<u8> {
std::fs::read(self.path(file)).unwrap()
}
fn write(&self, file: &str, bytes: &[u8]) {
std::fs::write(self.path(file), bytes).unwrap();
}
/// A video file in the project's folder, imported.
fn footage(&mut self, file: &str) -> ItemId {
self.write(file, SOURCE);
let f = Footage {
path: self.text(file),
kind: FootageKind::Video,
width: 64,
height: 36,
has_video: true,
duration: Tick::from_seconds_f64(1.0),
..Default::default()
};
Arc::make_mut(&mut self.s.project).add_item(file, Label::None, None, ItemKind::Footage(f))
}
/// Numbered stills in the project's folder, imported as one image sequence.
fn sequence(&mut self, name: &str, frames: std::ops::Range<u32>) -> ItemId {
let files: Vec<String> = frames
.map(|n| {
let file = format!("{name}_{n:05}.png");
self.write(&file, SOURCE);
self.text(&file)
})
.collect();
let f = Footage {
path: files[0].clone(),
kind: FootageKind::Sequence,
sequence: files,
width: 16,
height: 16,
has_video: true,
duration: Tick::from_seconds_f64(0.5),
..Default::default()
};
Arc::make_mut(&mut self.s.project).add_item(&format!("{name}_[00000-00004].png"), Label::None, None, ItemKind::Footage(f))
}
/// A five-frame composition.
fn comp(&mut self, name: &str) {
self.s.execute("comp.new", json!({"name": name, "width": 16, "height": 16, "frameRate": 10, "duration": 0.5})).unwrap();
}
/// The output path of queue item `index` (0-based).
fn output(&self, index: usize) -> PathBuf {
PathBuf::from(self.s.resolve_output(&self.s.project.render_queue[index]).unwrap())
}
/// The paths the exporter was started for.
fn exported(&self) -> Vec<String> {
self.writer.jobs.lock().unwrap().clone()
}
fn render(&mut self, params: Value) -> crate::Result<Value> {
self.s.execute("renderQueue.render", params)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn link(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
return std::os::unix::fs::symlink(target, link);
#[cfg(windows)]
return std::os::windows::fs::symlink_file(target, link);
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err(std::io::ErrorKind::Unsupported.into())
}
}
/// A folder that leads to `target`: a symbolic link, or on Windows, where that takes a privilege
/// most accounts lack, a junction.
fn link_dir(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
return std::os::unix::fs::symlink(target, link);
#[cfg(windows)]
return std::os::windows::fs::symlink_dir(target, link).or_else(|_| {
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output()?;
if made.status.success() { Ok(()) } else { Err(std::io::Error::other(String::from_utf8_lossy(&made.stdout).into_owned())) }
});
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err(std::io::ErrorKind::Unsupported.into())
}
}
/// The report: save a new project beside `footage.mp4`, import it, make a composition from it
/// (it is named after the file), add that to the Render Queue and click Render. The composition's
/// default output is `<project folder>/footage.mp4`, the footage itself.
#[test]
fn a_comp_made_from_footage_does_not_render_over_it() {
let mut f = Fixture::new("comp-from-footage");
let footage = f.footage("footage.mp4");
f.s.state.project_selection = vec![footage];
f.s.execute("file.newCompFromSelection", json!({"addToRenderQueue": true})).unwrap();
assert_eq!(f.s.project.render_queue.len(), 1);
f.render(json!({})).unwrap();
assert_eq!(f.read("footage.mp4"), SOURCE, "the footage is still the footage");
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
// The default name stepped aside: the render sits next to it.
assert_eq!(f.output(0), f.path("footage_1.mp4"));
assert_eq!(f.read("footage_1.mp4"), RENDERED);
// Another item for the same composition takes the next free number.
f.s.execute("renderQueue.add", json!({})).unwrap();
assert_eq!(f.output(1), f.path("footage_2.mp4"));
}
#[test]
fn an_output_that_is_the_footage_is_refused_with_or_without_overwrite() {
for (n, params) in [json!({}), json!({"overwrite": true})].into_iter().enumerate() {
let mut f = Fixture::new(&format!("explicit-footage-{n}"));
f.footage("clip.mov");
f.comp("Main");
let clip = f.text("clip.mov");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
let e = f.render(params).unwrap_err().to_string();
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert!(f.exported().is_empty(), "the exporter was not started");
let RenderStatus::Failed(why) = &f.s.project.render_queue[0].status else { panic!("{:?}", f.s.project.render_queue[0].status) };
assert!(why.contains("clip.mov"), "{why}");
}
}
/// An item that would replace the footage fails on its own; the rest of the queue renders.
#[test]
fn the_rest_of_the_queue_renders_when_one_item_would_replace_footage() {
let mut f = Fixture::new("one-refused");
f.footage("clip.mov");
f.comp("Main");
let clip = f.text("clip.mov");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
let ok = f.text("main.mp4");
f.s.execute("renderQueue.add", json!({"output": ok})).unwrap();
let r = f.render(json!({})).unwrap();
assert_eq!(f.read("clip.mov"), SOURCE);
assert_eq!(f.read("main.mp4"), RENDERED);
assert!(matches!(f.s.project.render_queue[0].status, RenderStatus::Failed(_)));
assert_eq!(f.s.project.render_queue[1].status, RenderStatus::Done);
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"].get("Failed").is_some()), "the reply includes the refused item: {r}");
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"] == "Done"), "{r}");
}
#[test]
fn the_project_file_is_never_an_output() {
let mut f = Fixture::new("project-file");
// A project saved under a name a movie could have.
let name = f.text("final.mp4");
f.s.execute("file.saveAs", json!({"path": name})).unwrap();
f.comp("final");
f.s.execute("renderQueue.add", json!({})).unwrap();
assert_eq!(f.output(0), f.path("final_1.mp4"), "the default name moves aside");
f.s.execute("renderQueue.remove", json!({"index": 1})).unwrap();
f.s.execute("renderQueue.add", json!({"output": name})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("final.mp4") && e.contains("project"), "{e}");
assert!(std::fs::read_to_string(f.path("final.mp4")).unwrap().contains("\"schema\""), "the project file is still a project");
}
#[test]
fn a_proxy_is_not_an_output_either() {
let mut f = Fixture::new("proxy");
f.footage("clip.mov");
f.write("proxy.mov", SOURCE);
f.comp("Main");
let proxy = Footage { path: f.text("proxy.mov"), kind: FootageKind::Video, has_video: true, ..Default::default() };
let comp = f.s.active_comp_id().unwrap();
Arc::make_mut(&mut f.s.project).item_mut(comp).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true }));
let target = f.text("proxy.mov");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": target})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("proxy"), "{e}");
assert_eq!(f.read("proxy.mov"), SOURCE);
}
/// Recreating a default proxy writes a new file and attaches it, without replacing the proxy
/// the project is currently reading. Explicitly choosing that protected path is still refused.
#[test]
fn creating_a_proxy_again_chooses_a_new_default_and_attaches_it() {
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !Path::new(path).is_file() {
return Err("proxy file missing".into());
}
Ok(Footage {
path: path.into(),
kind: if path.ends_with(".png") { FootageKind::Still } else { FootageKind::Video },
width: 16,
height: 16,
has_video: true,
..Default::default()
})
}
}
for kind in ["movie", "still"] {
let mut f = Fixture::new(&format!("recreate-proxy-{kind}"));
f.s.importer = Some(Arc::new(Probe));
f.comp("Main");
let comp = f.s.active_comp_id().unwrap();
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
f.render(json!({})).unwrap();
let first = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
f.render(json!({})).unwrap();
let second = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
assert_ne!(second, first, "the replacement proxy gets its own file");
assert_eq!(std::fs::read(&second).unwrap(), RENDERED);
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
assert_eq!(f.exported().len(), 2);
// For a still proxy, an explicit output is the sequence pattern, not its first
// rendered frame (a bare PNG name would have another frame number appended).
let protected_output = f.output(1);
f.s.execute("file.createProxy", json!({"kind": kind, "path": protected_output})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("never replaces a proxy"), "{error}");
assert_eq!(f.exported().len(), 2);
}
}
/// A pre-render becomes imported footage. Its old output stays protected even from the
/// same queue item; issuing Pre-render again supplies a fresh default output instead.
#[test]
fn refreshing_a_pre_render_uses_a_new_default_without_exempting_imported_footage() {
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !Path::new(path).is_file() {
return Err("pre-render missing".into());
}
Ok(Footage { path: path.into(), kind: FootageKind::Video, width: 16, height: 16, has_video: true, ..Default::default() })
}
}
let mut f = Fixture::new("refresh-pre-render");
f.s.importer = Some(Arc::new(Probe));
f.comp("Main");
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
let first = f.output(0);
f.render(json!({})).unwrap();
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("footage"), "{error}");
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
assert_eq!(f.exported().len(), 1);
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": false})).unwrap();
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
let second = f.output(1);
assert_ne!(first, second);
f.render(json!({})).unwrap();
assert_eq!(std::fs::read(first).unwrap(), RENDERED);
assert_eq!(std::fs::read(second).unwrap(), RENDERED);
assert_eq!(f.exported().len(), 2);
}
/// Exercise the engine's import and project serialization paths, rather than injecting a
/// Footage item directly. The importer reads dimensions from a real, generated PNG.
#[test]
fn imported_footage_stays_protected_after_the_project_is_reopened() {
struct PngProbe;
impl crate::Importer for PngProbe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?;
Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height, has_video: true, ..Default::default() })
}
}
let mut f = Fixture::new("import-reopen");
f.s.importer = Some(Arc::new(PngProbe));
let path = f.path("plate_00002.png");
image::RgbaImage::from_pixel(4, 4, image::Rgba([40, 80, 120, 255])).save(&path).unwrap();
let original = std::fs::read(&path).unwrap();
let result = f.s.execute("file.import", json!({"paths": [path]})).unwrap();
assert_eq!(result["items"].as_array().unwrap().len(), 1);
f.comp("Main");
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("plate_[#####].png")})).unwrap();
f.s.execute("file.save", json!({})).unwrap();
f.s.execute("file.open", json!({"path": f.text("Project.ecproj")})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("plate_00002.png") && error.contains("footage"), "{error}");
assert_eq!(std::fs::read(&path).unwrap(), original);
assert!(f.exported().is_empty());
}
#[test]
fn a_protected_frame_past_the_probe_limit_is_not_rendered() {
struct LastFrame(PathBuf);
impl Exporter for LastFrame {
fn formats(&self) -> Vec<OutputFormat> {
vec![OutputFormat::PngSequence]
}
fn export(&self, _: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
// A synthetic exporter writes only the vulnerable late frame, so a broken guard
// can be reproduced without creating 100,001 output directories.
std::fs::write(&self.0, RENDERED).map_err(|e| e.to_string())?;
Ok(ExportResult { path: self.0.to_string_lossy().into_owned(), ..Default::default() })
}
}
let mut f = Fixture::new("late-frame");
std::fs::create_dir_all(f.path("f100000")).unwrap();
f.footage("f100000/plate.png");
f.comp("Main");
f.s.execute("comp.settings", json!({"duration": 10000.1})).unwrap();
f.s.exporter = Some(Arc::new(LastFrame(f.path("f100000/plate.png"))));
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("f[######]/plate.png")})).unwrap();
let error = f.render(json!({})).unwrap_err().to_string();
assert!(error.contains("cannot safely check"), "{error}");
assert_eq!(f.read("f100000/plate.png"), SOURCE);
}
/// A numbered sequence is footage frame by frame: one colliding frame is enough, and nothing of
/// the item is rendered.
#[test]
fn a_sequence_never_replaces_one_of_its_footage_frames() {
let mut f = Fixture::new("sequence");
f.sequence("shot", 0..5);
f.comp("shot");
// The default name steps aside as a whole: shot_1_00000.png …
f.s.execute("renderQueue.add", json!({"format": "png"})).unwrap();
assert_eq!(f.output(0), f.path("shot_1_[#####].png"));
f.render(json!({})).unwrap();
for n in 0..5 {
assert_eq!(f.read(&format!("shot_{n:05}.png")), SOURCE, "frame {n}");
assert_eq!(f.read(&format!("shot_1_{n:05}.png")), RENDERED, "frame {n}");
}
// Named by hand, one frame of five is a footage file.
let mut f = Fixture::new("sequence-one-frame");
f.write("shot_00002.png", SOURCE);
let still = Footage { path: f.text("shot_00002.png"), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() };
Arc::make_mut(&mut f.s.project).add_item("shot_00002.png", Label::None, None, ItemKind::Footage(still));
f.comp("shot");
let pattern = f.text("shot_[#####].png");
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("shot_00002.png"), "{e}");
assert_eq!(f.read("shot_00002.png"), SOURCE);
assert!(!f.path("shot_00000.png").exists(), "the frames before it were not rendered either");
assert!(f.exported().is_empty());
}
#[test]
fn ordinary_existing_outputs_keep_the_exporters_replacement_policy() {
let mut f = Fixture::new("earlier-render");
f.comp("Main");
f.s.execute("renderQueue.add", json!({"output": f.text("render.mp4")})).unwrap();
f.write("render.mp4", EARLIER);
f.render(json!({})).unwrap();
assert_eq!(f.read("render.mp4"), RENDERED);
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
}
#[test]
fn a_render_with_nothing_in_the_way_needs_no_flag() {
let mut f = Fixture::new("free");
f.comp("Main");
f.s.execute("renderQueue.add", json!({})).unwrap();
assert_eq!(f.output(0), f.path("Main.mp4"), "no footage in the way: the comp's own name");
f.render(json!({})).unwrap();
assert_eq!(f.read("Main.mp4"), RENDERED);
}
/// Skip Existing Files leaves the frames that exist alone, so they need no answer.
#[test]
fn a_sequence_that_skips_existing_frames_needs_no_flag() {
let mut f = Fixture::new("skip-existing");
f.comp("Main");
let pattern = f.text("render_[#####].png");
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern, "skipExisting": true})).unwrap();
for n in 0..3 {
f.write(&format!("render_{n:05}.png"), EARLIER);
}
f.render(json!({})).unwrap();
assert_eq!(f.read("render_00002.png"), EARLIER);
assert_eq!(f.read("render_00003.png"), RENDERED);
// Ordinary files still follow the exporter's replacement policy when skipping is off.
f.s.execute("renderQueue.setRenderSettings", json!({"index": 1, "skipExisting": false})).unwrap();
f.render(json!({})).unwrap();
assert_eq!(f.read("render_00002.png"), RENDERED);
}
/// A link is another name for the file it points to.
#[test]
fn a_link_to_the_footage_is_the_footage() {
let mut f = Fixture::new("link");
f.footage("clip.mov");
f.comp("Main");
let alias = f.path("render.mov");
if link(&f.path("clip.mov"), &alias).is_err() {
eprintln!("this account cannot make symbolic links: skipped");
return;
}
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert!(std::fs::symlink_metadata(&alias).unwrap().file_type().is_symlink(), "the link was not replaced either");
}
/// A hard link to the footage under another name is the same file on disk: replacing it would truncate the footage.
#[test]
fn a_hard_link_to_the_footage_is_the_footage() {
let mut f = Fixture::new("hardlink");
f.footage("clip.mov");
f.comp("Main");
if std::fs::hard_link(f.path("clip.mov"), f.path("render.mov")).is_err() {
eprintln!("this volume cannot make hard links: skipped");
return;
}
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
// not even with overwrite
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert_eq!(f.read("render.mov"), SOURCE, "the other name still leads to the footage");
assert!(f.exported().is_empty(), "nothing was rendered");
}
/// A copy of the footage has its size and bytes, but it is another file: an earlier render may replace it.
#[test]
fn a_copy_of_the_footage_is_not_the_footage() {
let mut f = Fixture::new("copy");
f.footage("clip.mov");
f.comp("Main");
std::fs::copy(f.path("clip.mov"), f.path("copy.mov")).unwrap();
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("copy.mov")})).unwrap();
f.render(json!({})).unwrap();
assert_eq!(f.read("copy.mov"), RENDERED);
assert_eq!(f.read("clip.mov"), SOURCE, "the footage is as it was");
}
/// Footage imported through a linked folder is the same file as the one in the real folder.
#[test]
fn footage_reached_through_a_linked_folder_is_still_footage() {
let mut f = Fixture::new("linked-folder");
std::fs::create_dir_all(f.path("real")).unwrap();
std::fs::write(f.path("real").join("clip.mov"), SOURCE).unwrap();
if link_dir(&f.path("real"), &f.path("linked")).is_err() {
eprintln!("this account cannot link folders: skipped");
return;
}
let via_link =
Footage { path: f.path("linked").join("clip.mov").to_string_lossy().into_owned(), kind: FootageKind::Video, has_video: true, ..Default::default() };
Arc::make_mut(&mut f.s.project).add_item("clip.mov", Label::None, None, ItemKind::Footage(via_link));
f.comp("Main");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.path("real").join("clip.mov").to_string_lossy()})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("footage"), "{e}");
assert_eq!(std::fs::read(f.path("real").join("clip.mov")).unwrap(), SOURCE);
}
/// Where the volume ignores case, `CLIP.MOV` is `clip.mov`.
#[test]
fn another_spelling_of_the_footage_name_is_the_footage_where_the_volume_ignores_case() {
let mut f = Fixture::new("case");
f.footage("clip.mov");
f.comp("Main");
if !f.path("CLIP.MOV").exists() {
eprintln!("this volume tells the two names apart: skipped");
return;
}
let shouting = f.text("CLIP.MOV");
f.s.execute("renderQueue.add", json!({"format": "prores", "output": shouting})).unwrap();
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
assert!(e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
}
#[test]
fn save_current_preview_never_replaces_footage() {
let mut f = Fixture::new("preview");
f.footage("clip.mov");
f.comp("Main");
let clip = f.text("clip.mov");
let e = f.s.execute("render.saveCurrentPreview", json!({"path": clip})).unwrap_err().to_string();
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
assert_eq!(f.read("clip.mov"), SOURCE);
assert!(f.exported().is_empty());
// Anywhere else it renders.
let elsewhere = f.text("preview.mp4");
f.s.execute("render.saveCurrentPreview", json!({"path": elsewhere})).unwrap();
assert_eq!(f.read("preview.mp4"), RENDERED);
}
#[test]
fn save_current_preview_checks_sequence_frames_as_well_as_the_pattern() {
let mut f = Fixture::new("preview-sequence");
f.sequence("plate", 0..5);
f.comp("Main");
let error = f.s.execute("render.saveCurrentPreview", json!({"path": f.text("plate_[#####].png")})).unwrap_err().to_string();
assert!(error.contains("plate_000") && error.contains("footage"), "{error}");
for frame in 0..5 {
assert_eq!(f.read(&format!("plate_{frame:05}.png")), SOURCE);
}
assert!(f.exported().is_empty());
}
/// A watched folder renders unattended; delete the status file to render a project again.
#[test]
fn a_watched_folder_still_renders_a_project_again_over_its_outputs() {
let mut f = Fixture::new("watch");
f.comp("Main");
let out = f.text("Main.mp4");
f.s.execute("renderQueue.add", json!({"output": out})).unwrap();
f.s.execute("file.save", json!({})).unwrap();
f.write("Main.mp4", EARLIER);
let folder = f.dir.to_string_lossy().into_owned();
let done = crate::commands::watch_folder::poll(&mut f.s, &folder).unwrap();
assert_eq!(done.len(), 1, "{done:?}");
assert_eq!(done[0]["state"], "done", "{:?}", done[0]);
assert_eq!(f.read("Main.mp4"), RENDERED);
}
+183 -2
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use effectcraft_geom::{Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::three_d::camera::layer_frame;
use effectcraft_render::three_d::camera::{is_two_node, layer_frame};
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
(e, f)
}
@@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() {
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
}
/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of
/// Interest are re-expressed in the null's space, so the frame does not move.
#[test]
fn parented_camera_keeps_its_frame_under_an_offset_null() {
let mut s = session();
let cam = camera(&mut s);
let (eye0, f0) = frame(&s, cam);
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
let (eye1, f1) = frame(&s, cam);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
// The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp.
let c = layer_in(&s, cid(&s), cam);
assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position"));
assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi"));
// Moving the null carries the camera and keeps its aim.
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap();
let (eye2, f2) = frame(&s, cam);
assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}");
assert!((f2 - f0).length() < 1e-6, "{f2:?}");
}
/// World (eye, forward, down) of a layer in the active comp at time `t`.
fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
layer_frame(&ctx, comp.layer(LayerId(id)).unwrap())
}
/// Whether two frames agree on eye, forward and down.
fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool {
(a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6
}
/// A 3D null off-centre in the comp, with an Orientation and a Z rotation.
fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 {
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap();
nul
}
/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is
/// re-expressed against the new aim, so Up does not drift.
#[test]
fn two_node_camera_under_rotated_null_keeps_its_roll() {
let mut s = session();
let cam = camera(&mut s);
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap();
assert!(is_two_node(&layer_in(&s, cid(&s), cam)));
let before = frame_at(&s, cam, Tick::ZERO);
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
let after = frame_at(&s, cam, Tick::ZERO);
assert!(frames_match(before, after), "{before:?} {after:?}");
}
#[test]
fn parented_point_light_keeps_its_direction_under_a_rotated_null() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
let (eye0, f0) = frame(&s, light);
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let (eye1, f1) = frame(&s, light);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}");
}
/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at
/// every time, between the keys too.
#[test]
fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| frame_at(&s, light, t));
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
for (t, b) in times.into_iter().zip(before) {
assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction");
}
}
/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are
/// added between them: the light keeps its direction within a small angle at every time.
#[test]
fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() {
{
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64);
let before = times.map(|t| frame_at(&s, light, t));
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
for (t, b) in times.into_iter().zip(before) {
let a = frame_at(&s, light, t);
// 0.05 degrees is about 9e-4 of a radian on a unit vector.
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
}
assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two");
}
}
/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and
/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is
/// compensated rather than reversing its frame (a half-compensated light would point backwards).
#[test]
fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
let before = layer_in(&s, cid(&s), light);
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0);
s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap();
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let after = layer_in(&s, cid(&s), light);
assert_eq!(after.parent, Some(LayerId(nul)));
assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position"));
assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation"));
}
/// The cap on keys added by baking is for the whole Orientation property, not for each segment:
/// many strongly eased segments under a tilted parent stay within it.
#[test]
fn baked_orientation_keys_are_capped_across_the_whole_property() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
let count = 120;
for i in 0..count {
let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) };
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap();
}
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len();
assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment");
// the segments the cap left unchecked keep their authored easing instead of turning into straight blends
let orientation = layer_in(&s, cid(&s), light);
let keys = &orientation.props.prop("transform/orientation").unwrap().keys;
let last = keys.iter().rev().nth(1).expect("the penultimate key");
assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing");
}
// ---------------------------------------------------------------- from 3D model
/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points.
#[test]
fn eased_orientation_under_x_parent_keeps_motion_between_keys() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect();
let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect();
let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap();
for (t, b) in times.into_iter().zip(before) {
let a = frame_at(&s, light, t);
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
}
}
struct DiskModels;
impl FootageSource for DiskModels {
+27
View File
@@ -164,6 +164,33 @@ fn alpha_at(s: &Session, f: u32) -> Vec<f32> {
img.data.iter().map(|p| p[3]).collect()
}
/// Green, then Alt (red) over part of the object, then green there again: the last stroke wins,
/// in the render and through Undo / Redo (#1646).
#[test]
fn roto_brush_strokes_correct_earlier_ones() {
let (mut s, clip, _) = painted(center, "disk-1646.mov");
let c = center(0);
let spot = [c[0], c[1] + 25.0];
let i = spot[1] as usize * W as usize + spot[0] as usize;
let paint = |s: &mut Session, kind: &str| {
s.execute_checked(
"roto.stroke",
json!({"layer": clip.0, "kind": kind, "points": line([spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]]), "radius": 7}),
)
.unwrap();
};
assert!(alpha_at(&s, 0)[i] > 0.5, "the spot starts inside the matte");
paint(&mut s, "bg");
assert!(alpha_at(&s, 0)[i] < 0.5, "Alt removes it");
paint(&mut s, "fg");
assert!(alpha_at(&s, 0)[i] > 0.5, "green over the Alt stroke restores it");
assert!(iou_at(&mut s, clip, 0, c) > 0.95);
assert!(s.undo());
assert!(alpha_at(&s, 0)[i] < 0.5, "undo brings the removal back");
assert!(s.redo());
assert!(alpha_at(&s, 0)[i] > 0.5, "redo restores it again");
}
#[test]
fn roto_brush_segments_and_propagates() {
let (mut s, clip, uid) = painted(center, "disk-a.mov");
+287
View File
@@ -0,0 +1,287 @@
//! Scale Composition (`comp.scale`, #2208): the comp and everything in it grow or shrink by one
//! factor, so the frame looks the same at the new size.
use effectcraft_project::{Layer, LayerId};
use effectcraft_time::Tick;
use serde_json::json;
use crate::{EngineError, Session};
fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": w, "height": h, "frameRate": 10, "duration": 2})).unwrap();
s
}
fn solid(s: &mut Session, color: &str, w: u32, h: u32) -> u64 {
s.execute("layer.newSolid", json!({"color": color, "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap()
}
fn layer(s: &Session, id: u64) -> Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
fn v3(s: &Session, id: u64, path: &str, t: f64) -> [f64; 3] {
layer(s, id).props.prop(path).unwrap().value_at(Tick::from_seconds_f64(t)).as_vec3()
}
/// Opaque pixels of `color_channel` (0 = red, 1 = green) at `t`: (x0, y0, x1, y1, count).
fn bounds(s: &Session, t: f64, channel: usize) -> (i64, i64, i64, i64, u32) {
let img = s.render(s.active_comp_id().unwrap(), Tick::from_seconds_f64(t), Default::default());
let (mut x0, mut y0, mut x1, mut y1, mut n) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN, 0);
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
let p = img.get(x, y);
if p[3] > 0.5 && p[channel] > 0.5 {
n += 1;
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
(x0, y0, x1, y1, n)
}
/// Twice as large on every side, from a pixel-exact frame.
fn doubled(b: (i64, i64, i64, i64, u32)) -> (i64, i64, i64, i64, u32) {
(b.0 * 2, b.1 * 2, b.2 * 2 + 1, b.3 * 2 + 1, b.4 * 4)
}
#[test]
fn scale_comp_doubles_the_frame_with_keys_and_parented_layers() {
let mut s = comp(200, 100);
// A red solid moving right (two Position keys), and a green solid parented to a null.
let red = solid(&mut s, "#ff0000", 20, 10);
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 0, "value": [30, 20]})).unwrap();
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 1, "value": [70, 20]})).unwrap();
let green = solid(&mut s, "#00ff00", 10, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [150, 60]})).unwrap();
s.execute("layer.setParent", json!({"layers": [green], "parent": rig})).unwrap();
s.execute("prop.set", json!({"layer": green, "path": "transform/position", "value": [10, 15]})).unwrap();
let before = s.project.clone();
let (red0, red1, green0) = (bounds(&s, 0.0, 0), bounds(&s, 1.0, 0), bounds(&s, 0.0, 1));
assert_eq!(red0, (20, 15, 39, 24, 200));
let r = s.execute("comp.scale", json!({"factor": 2})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(400), Some(200)));
let c = s.active_comp().unwrap();
assert_eq!((c.width, c.height), (400, 200));
// Every key moves, not only the value at the current time.
assert_eq!(bounds(&s, 0.0, 0), doubled(red0));
assert_eq!(bounds(&s, 1.0, 0), doubled(red1));
assert_eq!(v3(&s, red, "transform/scale", 1.0), [200.0; 3]);
// The child keeps its own transform: its scaled parent carries it.
assert_eq!(bounds(&s, 0.0, 1), doubled(green0));
assert_eq!(v3(&s, green, "transform/position", 0.0)[..2], [10.0, 15.0]);
assert_eq!(v3(&s, green, "transform/scale", 0.0), [100.0; 3]);
let after = s.project.clone();
// One undo step, and one redo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project == before, "undo restores the comp and every layer");
s.execute("edit.redo", json!({})).unwrap();
assert!(s.project == after, "redo scales the comp and every layer again");
}
#[test]
fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() {
let mut s = comp(640, 360);
let card = solid(&mut s, "#ff0000", 100, 100);
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [100, 50, 200]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 160})).unwrap();
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 25, "percent": true})).unwrap();
let zoom = |s: &Session| layer(s, cam).props.prop("cameraOptions/zoom").unwrap().value.as_f64();
let (zoom0, cam0, poi0) = (zoom(&s), v3(&s, cam, "transform/position", 0.0), v3(&s, cam, "transform/poi", 0.0));
let seen0 = bounds(&s, 0.0, 0);
s.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
let r = s.execute("comp.scale", json!({"width": 960})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["factor"].as_f64()), (Some(960), Some(540), Some(1.5)));
assert_eq!(v3(&s, card, "transform/position", 0.0), [150.0, 75.0, 300.0]);
assert_eq!(v3(&s, card, "transform/scale", 0.0), [150.0; 3]);
assert!((zoom(&s) - zoom0 * 1.5).abs() < 1e-9, "the camera sees the same frame");
assert_eq!(v3(&s, cam, "transform/position", 0.0), cam0.map(|v| v * 1.5));
assert_eq!(v3(&s, cam, "transform/poi", 0.0), poi0.map(|v| v * 1.5));
// Through the camera the card covers the same part of the frame.
let seen = bounds(&s, 0.0, 0);
let near = |a: i64, b: i64| (a as f64 - b as f64 * 1.5).abs() <= 2.0;
assert!(near(seen.0, seen0.0) && near(seen.1, seen0.1) && near(seen.2, seen0.2) && near(seen.3, seen0.3), "{seen0:?} → {seen:?}");
let c = s.active_comp().unwrap();
assert_eq!(c.guides[0].px(c.width, c.height), 240.0);
assert_eq!(c.guides[1].position, 25.0, "a percentage guide keeps its percent");
assert_eq!(s.state.region_of_interest, Some([15.0, 30.0, 105.0, 105.0]), "the region of interest covers the same part");
// A height alone works the same way.
let r = s.execute("comp.scale", json!({"height": 270})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(480), Some(270)));
}
#[test]
fn scale_comp_scales_depth_under_a_2d_parent_and_children_of_a_camera() {
let mut s = comp(200, 100);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
// A 3D card under a 2D null: the null's Scale carries X and Y but not Z.
let card = solid(&mut s, "#ff0000", 20, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [100, 50]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap();
// A 3D card parented to the camera, in front of it.
let held = solid(&mut s, "#00ff00", 10, 10);
s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap();
s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap();
let (card0, held0) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
let (held_pos0, held_scale0) = (v3(&s, held, "transform/position", 0.0), v3(&s, held, "transform/scale", 0.0));
assert!(card0.4 > 0 && held0.4 > 0, "both cards are in view: {card0:?} {held0:?}");
s.execute("comp.scale", json!({"factor": 2})).unwrap();
let near = |a: (i64, i64, i64, i64, u32), b: (i64, i64, i64, i64, u32)| {
[(a.0, b.0 * 2), (a.1, b.1 * 2), (a.2, b.2 * 2 + 1), (a.3, b.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2)
};
let (card1, held1) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
assert!(near(card1, card0), "the card under the 2D null doubles: {card0:?} → {card1:?}");
assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 1000.0]);
assert!(near(held1, held0), "the card held by the camera doubles: {held0:?} → {held1:?}");
assert_eq!(v3(&s, held, "transform/position", 0.0), held_pos0.map(|v| v * 2.0));
assert_eq!(v3(&s, held, "transform/scale", 0.0), held_scale0.map(|v| v * 2.0));
}
#[test]
fn scale_comp_keeps_the_ease_of_depth_moves_under_a_2d_parent() {
let mut s = comp(200, 100);
let card = solid(&mut s, "#ff0000", 20, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 0, "value": [0, 0, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 1, "value": [0, 0, 100]})).unwrap();
s.execute("keys.setEase", json!({"layer": card, "path": "transform/position", "time": 0, "side": "out", "speed": 100, "influence": 50})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap();
s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap();
let (pos0, scale0) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25));
s.execute("comp.scale", json!({"factor": 2})).unwrap();
// Twice the distance in the same time: the same ease, every frame twice as deep.
let (pos, scale) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25));
assert!((pos[2] - pos0[2] * 2.0).abs() < 1e-6, "{pos0:?} → {pos:?}");
assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}");
assert_eq!(scale[..2], scale0[..2]);
}
#[test]
fn scale_comp_gives_z_its_own_speed_when_a_scale_key_has_one_ease() {
let mut s = comp(200, 100);
let card = solid(&mut s, "#ff0000", 20, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap();
s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap();
s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap();
// One ease for every dimension, as a project file may store it.
let cid = s.active_comp_id().unwrap();
let key =
&mut std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(card)).unwrap().props.prop_mut("transform/scale").unwrap().keys
[0];
key.out_ease.truncate(1);
let scale0 = v3(&s, card, "transform/scale", 0.25);
s.execute("comp.scale", json!({"factor": 2})).unwrap();
let scale = v3(&s, card, "transform/scale", 0.25);
assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}");
assert_eq!(scale[..2], scale0[..2], "X and Y keep the shared ease");
}
#[test]
fn scale_comp_scales_depth_once_through_a_3d_grandparent() {
let mut s = comp(200, 100);
s.execute("layer.newCamera", json!({})).unwrap();
// card (3D) → A (2D null) → B (3D null): B's Scale already carries the depth.
let card = solid(&mut s, "#ff0000", 20, 10);
let a = s.execute("layer.newNull", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [card, b], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [100, 50, 0]})).unwrap();
s.execute("layer.setParent", json!({"layers": [a], "parent": b})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [0, 0]})).unwrap();
s.execute("layer.setParent", json!({"layers": [card], "parent": a})).unwrap();
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap();
let card0 = bounds(&s, 0.0, 0);
assert!(card0.4 > 0, "the card is in view");
s.execute("comp.scale", json!({"factor": 2})).unwrap();
assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 500.0]);
let card1 = bounds(&s, 0.0, 0);
let near =
[(card1.0, card0.0 * 2), (card1.1, card0.1 * 2), (card1.2, card0.2 * 2 + 1), (card1.3, card0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2);
assert!(near, "{card0:?} → {card1:?}");
}
#[test]
fn scale_comp_scales_once_through_a_camera_under_a_scaled_null() {
let mut s = comp(200, 100);
// solid → camera → B (3D null): B's Scale reaches the solid through the camera.
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [cam], "parent": b})).unwrap();
let held = solid(&mut s, "#00ff00", 10, 10);
s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap();
s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap();
let held0 = bounds(&s, 0.0, 1);
assert!(held0.4 > 0, "the solid is in view: {held0:?}");
s.execute("comp.scale", json!({"factor": 2})).unwrap();
assert_eq!(v3(&s, held, "transform/position", 0.0), [30.0, 0.0, 500.0]);
assert_eq!(v3(&s, held, "transform/scale", 0.0), [100.0; 3]);
let held1 = bounds(&s, 0.0, 1);
let near =
[(held1.0, held0.0 * 2), (held1.1, held0.1 * 2), (held1.2, held0.2 * 2 + 1), (held1.3, held0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2);
assert!(near, "{held0:?} → {held1:?}");
}
#[test]
fn scale_comp_scales_the_point_of_interest_depth_of_a_camera_under_a_2d_null() {
let mut s = comp(200, 100);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setParent", json!({"layers": [cam], "parent": rig})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [10, 20, -1000]})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [10, 20, 200]})).unwrap();
s.execute("comp.scale", json!({"factor": 2})).unwrap();
// The 2D null carries X and Y; the camera's own Z doubles at both ends, so it looks the same way.
assert_eq!(v3(&s, cam, "transform/position", 0.0), [10.0, 20.0, -2000.0]);
assert_eq!(v3(&s, cam, "transform/poi", 0.0), [10.0, 20.0, 400.0]);
}
#[test]
fn scale_comp_refuses_a_bad_factor_and_changes_nothing() {
let mut s = comp(640, 360);
solid(&mut s, "#ff0000", 100, 100);
let before = s.project.clone();
let cases = [
(json!({}), "pass `factor`"),
(json!({"factor": 0}), "4 to 30,000 px"),
(json!({"factor": -2}), "4 to 30,000 px"),
(json!({"width": 0}), "4 to 30,000 px"),
(json!({"factor": 100}), "4 to 30,000 px"),
(json!({"factor": 0.001}), "4 to 30,000 px"),
(json!({"factor": 2, "width": 960}), "only one of"),
(json!({"width": 960, "height": 360}), "only one of"),
];
for (p, why) in cases {
match s.execute("comp.scale", p.clone()) {
Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "comp.scale" && msg.contains(why), "{p}: {msg}"),
other => panic!("{p} is refused as bad parameters, got {other:?}"),
}
assert!(s.project == before, "{p} leaves the project alone");
}
s.execute("comp.scale", json!({"factor": 1})).unwrap();
}
+51 -6
View File
@@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() {
let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence));
// Scripts and agents naming one frame without `sequence` still get its run.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap();
let (_, f) = footage(&s, &r["items"][0]);
assert_eq!(f.kind, FootageKind::Sequence);
// #2018: scripts and agents naming one frame without `sequence` get that still, as
// `ImportOptions.sequence` defaults to false in After Effects.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still));
}
#[test]
@@ -181,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() {
}
#[test]
fn replace_footage_and_reload_keep_sequences() {
fn replace_footage_keeps_a_still_a_still() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone();
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
// Replacing with a numbered still does not silently turn the item into a sequence (#2073).
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4));
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
// JSON null is unspecified, not "on": the still stays a still.
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
// Alphabetical is a sequence modifier, not a request to make one.
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
// Asking for the run still brings it.
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
}
#[test]
fn replace_footage_keeps_a_one_frame_run_a_sequence() {
let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
// A numbered file with no other frame in its run is still the replacement for a sequence.
s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1));
// A single-file source keeps the sequence kind too.
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1));
// An explicit `sequence: false` still asks for one frame.
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap();
let (_, f) = footage(&s, &item);
assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0));
}
#[test]
fn replace_footage_and_reload_keep_sequences() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
// Reload finds a new frame and keeps the interpretation.
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap();
std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap();
+44 -1
View File
@@ -86,7 +86,7 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() {
s.execute("file.import", params).unwrap();
let comp = s.active_comp().unwrap();
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["a.png", "b.png", "Bg"]);
for l in &comp.layers[..2] {
for l in comp.layers.iter().take(2) {
assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0])));
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0));
}
@@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() {
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
}
/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what
/// the import made, first file on top, at once or when the background job lands; an import that
/// made nothing makes no comp.
#[test]
fn new_comp_from_footage_makes_a_comp_of_what_was_imported() {
use effectcraft_project::{Footage, FootageKind, ItemKind};
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
if path.ends_with(".xyz") {
return Err("unsupported file".into());
}
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
}
}
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count();
let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
// At once.
s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap();
assert_eq!(comps(&s), 1);
assert_eq!(layers(&s), ["a.png"]);
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32));
// In the background: the job's own items, whatever is selected or active meanwhile; it takes
// precedence over `addToComp`.
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap();
s.state.project_selection.clear();
s.execute("jobs.wait", json!({})).unwrap();
assert_eq!(comps(&s), 2);
assert_eq!(layers(&s), ["c.png", "d.png"]);
// Nothing imported: no comp, and the failure is reported.
s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap();
s.execute("jobs.wait", json!({})).unwrap();
assert_eq!(comps(&s), 2);
assert!(s.job_log.last().unwrap().message.contains("unsupported file"));
let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap();
assert_eq!(comps(&s), 2);
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1));
}
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
#[test]
fn audio_lock_and_shy_keep_cached_layers() {
@@ -476,6 +516,9 @@ fn set_text_paragraph_fill_and_leading() {
s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap();
let d = doc(&s);
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap();
assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel);
assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err());
}
#[test]
+25
View File
@@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn project_files_are_indented_unless_compact_is_on() {
let d = tmp("compact-project-files");
let path = d.join("p.ecproj");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let pretty = std::fs::read_to_string(&path).unwrap();
assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default");
s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap();
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let compact = std::fs::read_to_string(&path).unwrap();
assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on");
let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap();
assert_eq!(open(&compact), open(&pretty), "both read back as the same project");
let _ = std::fs::remove_dir_all(d);
}
// ---------------------------------------------------------------- shortcuts
#[test]
@@ -1072,6 +1089,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
cs.load_settings();
assert_eq!(cs.prefs.general.language, "cs");
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
// Polish, likewise.
assert!(crate::prefs::LANGUAGES.contains(&("Polski", "pl")));
s.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap();
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pl"));
let mut pl = Session { config: Some(store.clone()), ..Default::default() };
pl.load_settings();
assert_eq!(pl.prefs.general.language, "pl");
assert_eq!(Prefs::from_json(r#"{"general":{"language":"pl"}}"#).general.language, "pl");
// Georgian too.
assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka")));
s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
+42
View File
@@ -0,0 +1,42 @@
//! An edit copies only the layer it changes: the others stay shared with the undo state, so an
//! edit's cost doesn't grow with the size of the comp (scripts setting many keys went quadratic).
use std::sync::Arc;
use serde_json::json;
use crate::Session;
#[test]
fn a_key_on_one_layer_leaves_the_other_layers_shared_with_undo() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Big", "duration": 10, "frameRate": 30})).unwrap();
let ids: Vec<u64> = (0..20).map(|i| s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap()["layer"].as_u64().unwrap()).collect();
let cid = s.active_comp_id().unwrap();
let before = s.project.clone();
s.execute("prop.addKey", json!({"layer": ids[7], "path": "transform/position", "time": 1.0, "value": [5, 5]})).unwrap();
let (old, new) = (before.comp(cid).unwrap(), s.project.comp(cid).unwrap());
assert_eq!(old.layers.len(), new.layers.len());
let edited = new.layers.iter().position(|l| l.id.0 == ids[7]).unwrap();
for i in 0..new.layers.len() {
assert_eq!(new.layers.same_at(&old.layers, i), i != edited, "layer {i} shared: {}", new.layers.same_at(&old.layers, i));
}
// Undo brings back the very same state, and the edited layer is the only one that differed.
s.execute("edit.undo", json!({})).unwrap();
assert!(Arc::ptr_eq(&s.project, &before));
}
#[test]
fn layers_save_and_load_as_a_plain_list() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Round trip", "duration": 5, "frameRate": 25})).unwrap();
for i in 0..3 {
s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap();
}
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let text = serde_json::to_value(&comp.layers).unwrap();
assert_eq!(text.as_array().map(Vec::len), Some(3), "a JSON array of layers, as before");
let back: effectcraft_project::Layers = serde_json::from_value(text).unwrap();
assert_eq!(&back, &comp.layers);
}
+22
View File
@@ -184,6 +184,28 @@ fn in_and_out_points_stretch_to_the_current_time() {
assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err());
}
#[test]
fn trimming_out_to_the_playhead_keeps_its_frame_visible() {
let (mut s, ids) = session([(1.0, 9.0); 3]);
let frame = 1.0 / 30.0;
// Alt+] at 4 s: the Out edge lands one frame after the playhead, so 4 s is the layer's last
// visible frame — it must not be cut away at the playhead (as in After Effects).
s.execute("time.set", json!({"time": 4.0})).unwrap();
s.execute("layer.timing", json!({"layers": [ids[0]], "op": "trimOutToTime"})).unwrap();
assert!((span(&s, ids[0]).1 - (4.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[0]));
// Alt+[ at 2 s: the playhead's frame becomes the layer's first visible one.
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("layer.timing", json!({"layers": [ids[1]], "op": "trimInToTime"})).unwrap();
assert!((span(&s, ids[1]).0 - 2.0).abs() < 1e-9, "{:?}", span(&s, ids[1]));
// Trimmed before the In point, the Out point keeps one frame of layer.
s.execute("time.set", json!({"time": 0.5})).unwrap();
s.execute("layer.timing", json!({"layers": [ids[2]], "op": "trimOutToTime"})).unwrap();
assert!((span(&s, ids[2]).1 - (1.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[2]));
// One undo step back to the original span.
assert!(s.undo());
assert!((span(&s, ids[2]).1 - 9.0).abs() < 1e-9);
}
#[test]
fn masks_are_stepped_through_and_wrap() {
let (mut s, ids) = session([(0.0, 10.0); 3]);
+86 -2
View File
@@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
};
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
// A 120 x 80 rectangle is its four corners, joined by straight edges.
// A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right
// corner clockwise as After Effects draws it.
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]);
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
// Undo brings the parametric shape back.
s.execute("edit.undo", json!({})).unwrap();
@@ -583,3 +584,86 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
}
/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so
/// the editable path is the shape that was on screen.
#[test]
fn convert_to_bezier_honours_per_corner_roundness() {
let mut s = comp();
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let (uid, _) = first_shape(&s, l);
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
let per_corner = uid_of(&s, "perCorner");
let top_left = uid_of(&s, "topLeftRoundness");
let bottom_right = uid_of(&s, "bottomRightRoundness");
for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] {
s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap();
}
s.state.selected_props = vec![(LayerId(l), uid)];
s.execute("path.convertToBezier", json!({})).unwrap();
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path");
let path = shape_path_of(&s, l, new_uid);
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap();
assert_eq!(path, expected, "the converted path has the per-corner geometry");
}
/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven
/// Per-Corner checkbox and radius convert to the geometry on screen.
#[test]
fn convert_to_bezier_evaluates_corner_expressions() {
struct Corners;
impl effectcraft_render::ExprHost for Corners {
fn eval(
&self,
_: &effectcraft_render::EvalCtx,
_: &effectcraft_project::Layer,
prop: &effectcraft_project::Property,
value: &KV,
) -> std::result::Result<KV, String> {
Ok(match prop.match_id.as_str() {
"perCorner" => KV::Bool(true),
"topLeftRoundness" => KV::Scalar(20.0),
_ => value.clone(),
})
}
}
let mut s = comp();
s.expr = Some(Arc::new(Corners));
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let (uid, _) = first_shape(&s, l);
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] {
let u = uid_of(&s, id);
s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap();
}
s.state.selected_props = vec![(LayerId(l), uid)];
s.execute("path.convertToBezier", json!({})).unwrap();
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path");
let path = shape_path_of(&s, l, new_uid);
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap();
assert_eq!(path, expected, "the expression-evaluated corner is converted");
}
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
/// shared merge key; one undo must revert the whole drag, not the last frame.
#[test]
fn free_transform_drag_undoes_in_one_step() {
let mut s = comp();
let (l, m) = masked(&mut s);
s.state.selected_vertices.clear();
let before = mask_path(&s, l, m);
for k in [90, 90, 90, 90] {
s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap();
}
let after = mask_path(&s, l, m);
assert_ne!(after, before);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag");
}
+48
View File
@@ -117,3 +117,51 @@ fn styles_render_and_round_trip() {
assert_eq!(back, *s.project);
assert_eq!(back.comp(cid).unwrap().global_light.angle.value.as_f64(), 33.0);
}
/// #1389: Blending Options on a layer with no styles. `layer.style.add` with `blendingOptions`
/// creates the Layer Styles group with Blending Options only; with the same merge key as the
/// value set right after it, both are one undo step, and undoing it leaves no group behind.
#[test]
fn blending_options_alone_create_the_group_in_one_undo_step() {
let (mut s, a, b) = session();
let undo = s.history.undo.len();
let r = s.execute("layer.style.add", json!({"layers": [a], "style": "blendingOptions", "merge": "bo"})).unwrap();
assert_eq!(r["styles"][0]["style"], "blendingOptions", "{r}");
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().clone();
let g = layer.layer_styles().expect("the group is created");
assert!(g.sub("blendingOptions").is_some());
assert!(styles_of(&s, a).is_empty(), "no style is added with it");
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/blendingOptions/advancedBlending/fillOpacity", "value": 40, "merge": "bo"})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/blendingOptions/advancedBlending/fillOpacity", "value": 30, "merge": "bo"})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1, "creating the group and setting the value are one step");
// The other layer is untouched, and Global Light is the comp's.
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(b)).unwrap().layer_styles().is_none());
let gl = s.execute("prop.get", json!({"layer": a, "path": "layerStyles/blendingOptions/globalLightAngle"})).unwrap();
assert!(gl.to_string().contains("120"), "{gl}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().layer_styles().is_none(), "one undo removes the group");
// By display name too.
s.execute("layer.style.add", json!({"layers": [a], "style": "Blending Options"})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().layer_styles().is_some());
}
/// Removing the last style keeps a Layer Styles group whose Blending Options were changed (a
/// group with Blending Options only is a state of its own since #1389); at their defaults the
/// group goes, as before.
#[test]
fn removing_the_last_style_keeps_changed_blending_options() {
let (mut s, a, b) = session();
let fill = "layerStyles/blendingOptions/advancedBlending/fillOpacity";
s.execute("layer.style.add", json!({"layers": [a], "style": "blendingOptions"})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": fill, "value": 40})).unwrap();
s.execute("layer.style.dropShadow", json!({"layers": [a]})).unwrap();
s.execute("layer.style.remove", json!({"layer": a, "style": "dropShadow"})).unwrap();
let v = s.execute("prop.get", json!({"layer": a, "path": fill})).unwrap();
assert_eq!(v["value"].as_f64(), Some(40.0), "{v}");
assert!(styles_of(&s, a).is_empty());
// At the defaults (here on the other layer, through the same command) the group goes.
s.execute("layer.style.add", json!({"layers": [b], "style": "blendingOptions"})).unwrap();
s.execute("layer.style.dropShadow", json!({"layers": [b]})).unwrap();
s.execute("layer.style.remove", json!({"layer": b, "style": "dropShadow"})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(b)).unwrap().layer_styles().is_none());
}
+64
View File
@@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() {
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
}
/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean
/// errors, and a depth-of-field frame renders right after the command.
#[test]
fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() {
let mut s = comp(400, 300);
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
// Behind the camera (which now sits at z = −500 looking towards +z).
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap();
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
assert!(e.contains("behind the camera"), "{e}");
// Exactly at the camera: depth 0, still a clean error.
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap();
assert!(s.execute("camera.setFocusToLayer", json!({})).is_err());
// In front: the command works, and the depth-of-field frame renders with finite pixels.
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap();
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
assert!((d - 900.0).abs() < 1e-6, "{d}");
// An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover
// ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels.
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap();
let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap();
let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap();
s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer");
// The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs.
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap();
let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred");
}
/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number
/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so
/// storing it would leave a project that can't be saved.
#[test]
fn camera_set_focus_to_layer_rejects_non_finite_depth() {
let mut s = comp(400, 300);
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
assert!(e.contains("out of range"), "{e}");
let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}");
// The project still round-trips through serde (a stored infinity would come back as null).
let back = roundtrip(&s);
let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64();
assert!((f - before).abs() < 1e-9, "serde focus distance: {f}");
}
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
#[test]
fn numeric_string_resolves_as_id() {
+39 -26
View File
@@ -200,6 +200,20 @@ fn typing_deleting_and_caret_movement() {
assert!(s.state.text_edit.is_none());
}
/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends
/// (#2226), as in After Effects — typing must not keep editing an unselected layer.
#[test]
fn duplicate_during_a_text_edit_ends_the_edit() {
let (mut s, t) = session("Hello");
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap();
assert_ne!(copy, t, "the duplicate is created");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected");
assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)");
assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text");
assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it");
}
#[test]
fn caret_style_applies_to_the_next_typed_text() {
let (mut s, t) = session("ab");
@@ -261,25 +275,25 @@ fn clipboard_and_paste_formatting() {
}
#[test]
fn type_tool_layers_point_box_and_empty_cleanup() {
fn type_tool_layers_point_box_and_empty_layer_is_kept() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t));
// Leaving an untouched Type-tool layer deletes it.
// Leaving an untouched Type-tool layer keeps it, as After Effects does.
s.execute("text.endEdit", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty());
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE");
let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.insert", json!({"text": "Boxed"})).unwrap();
let d = doc(&s, b);
assert_eq!(d.box_size, Some([300.0, 120.0]));
assert_eq!(d.box_pos, [-150.0, -60.0]);
let l = s.active_comp().unwrap().layers[0].clone();
let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone();
assert_eq!(l.name, "Boxed", "named after its text");
let pos = l.props.prop("transform/position").unwrap().value.as_vec3();
assert_eq!([pos[0], pos[1]], [190.0, 110.0]);
s.execute("text.endEdit", json!({})).unwrap();
assert_eq!(layer_count(&s), 1);
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included");
// Resizing the box (layer space) is an attribute too.
s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap();
assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0]));
@@ -929,11 +943,19 @@ fn fonts_lists_bundled_and_installed_families() {
let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap();
assert_eq!(r["families"][0]["family"], "Noto Serif");
// The bundled face is Regular; the OS can also provide Bold, Italic, or other faces.
// Compare against the independent directory inventory rather than assuming an empty OS.
let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]);
expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone()));
// Model `add_source()` on the independent directory inventory: a system face whose family
// and primary style duplicate an earlier face is skipped, with its named instances.
let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]);
let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]);
for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) {
if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) {
expected_styles.insert(face.style.to_ascii_lowercase());
expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase()));
}
}
let styles: Vec<String> = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect();
assert_eq!(styles.iter().cloned().collect::<std::collections::BTreeSet<_>>(), expected_styles);
// `families()` keeps the first spelling of a style that differs only in case.
assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::<std::collections::BTreeSet<_>>(), expected_styles);
assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates");
assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser")));
// The style menus offer a family's own styles.
@@ -1203,7 +1225,7 @@ fn deselect_all_ends_the_open_edit() {
}
#[test]
fn deselect_all_discards_an_empty_type_tool_layer() {
fn deselect_all_keeps_an_empty_type_tool_layer() {
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
@@ -1214,9 +1236,7 @@ fn deselect_all_discards_an_empty_type_tool_layer() {
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(layer_count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
assert_eq!(layer_count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
#[test]
@@ -1386,24 +1406,17 @@ fn duplicate_ends_the_open_edit_and_selects_the_copy() {
}
#[test]
fn duplicate_of_an_empty_type_tool_layer_discards_it_as_endedit_does() {
fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
use effectcraft_project::LayerId;
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let before = layer_count(&s);
s.execute("edit.duplicate", json!({})).unwrap();
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
assert_eq!(layer_count(&s), before - 1, "a Type-tool layer left empty is discarded, not duplicated");
assert!(s.state.selected_layers.is_empty(), "nothing is selected: the layer that would be copied is gone");
// The edit is committed before Duplicate resolves its layers, so naming the very layer
// the empty edit was on addresses nothing: the commit still happened, the duplicate errors.
let new2 = s.execute("layer.newText", json!({"text": "", "position": [90.0, 90.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("edit.duplicate", json!({"layers": [new2]})).is_err(), "the named layer is gone once the edit commits");
assert!(s.state.text_edit.is_none(), "and the edit ended all the same");
assert_eq!(layer_count(&s), before - 1, "with the empty layer discarded, nothing left to duplicate");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
}
+80 -1
View File
@@ -1,6 +1,6 @@
//! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp.
use effectcraft_project::{AutoOrient, LayerSource, LightKind};
use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer};
use effectcraft_render::three_d::View3D;
use serde_json::json;
@@ -149,6 +149,85 @@ fn deleting_a_source_layer_clears_the_reference() {
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some());
}
/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are
/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches
/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does.
#[test]
fn environment_lights_switch_classic_comps_to_advanced_3d() {
let toasts = |s: &mut Session| -> Vec<String> {
s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect()
};
let mut s = session();
let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
toasts(&mut s);
let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
assert_eq!(r["advanced3d"], true);
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
// As with Composition Settings: the text layer gets Geometry Options.
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some());
assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")));
// A second Environment light leaves the comp (and the user) alone.
s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
assert!(toasts(&mut s).is_empty());
// Undoing the creations takes the lights and the switch back together.
s.undo();
s.undo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert_eq!(layer_count(&s), 1);
// Changing a light to Environment switches too, and undoes with the type.
let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
s.undo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot });
// Other light types never switch it.
s.execute("layer.newLight", json!({"kind": "Point"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
// Switching back to Classic 3D with an Environment light in the comp explains itself.
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
toasts(&mut s);
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained");
// Editing that Environment light (no type change) leaves the renderer the user chose.
s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch");
assert!(toasts(&mut s).is_empty(), "and doesn't toast either");
}
/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every
/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no
/// Falloff, which an environment light shining from every direction has no use for (#2436).
#[test]
fn environment_light_takes_shadow_options_but_no_falloff() {
let mut s = session();
let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"]
.as_u64()
.unwrap();
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0);
assert!(
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(),
"an environment light has no Falloff"
);
// The shadow values carry over when the type changes, both ways.
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap();
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0);
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap();
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again");
s.undo();
assert!(
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(),
"back to Spot: it has a Cone Angle again"
);
}
#[test]
fn three_d_switch_undo_redo() {
let mut s = session();
+53
View File
@@ -765,6 +765,59 @@ fn pick_whip_generates_ae_reference_expressions() {
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
}
/// #1072: an Alt pick whip (`byIndex`) selects an effect's properties by their index,
/// `effect("Color Control")(1)`, its place among its group's children (nested groups too). The
/// effect keeps its name; other properties, dimensions, insertion into an expression and match
/// names (Compact English off) work as before. (`effectcraft-host`'s `pick_whip` tests check the
/// references read the right values.)
#[test]
fn pick_whip_by_index_selects_effect_properties_by_their_index() {
let (mut s, l) = setup();
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
for fx in ["Color Control", "Fractal Noise", "Point Control"] {
s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap();
}
// Fractal Noise ▸ Transform ▸ Scale, by its places in the property tree.
let fx = s.active_comp().unwrap().layer(effectcraft_project::LayerId(o)).unwrap().props.sub("effects").unwrap().groups().nth(1).unwrap().clone();
let group = fx.children.iter().position(|c| c.match_id() == "transform").unwrap() + 1;
let scale = fx.sub("transform").unwrap().children.iter().position(|c| c.match_id() == "scale").unwrap() + 1;
let whip = |s: &mut Session, path: &str, target: Value, extra: Value| {
let mut p = json!({"layer": l, "path": path, "target": target, "byIndex": true});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("prop.pickWhip", p).unwrap()
};
let other = "thisComp.layer(\"Other\")";
let undo = s.history.undo.len();
assert_eq!(
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#1/color"}), json!({})),
json!(format!("{other}.effect(\"Color Control\")(1)[0]")),
"a colour into one value: its first"
);
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
let nested = format!("{other}.effect(\"Fractal Noise\")({group})({scale})");
assert_eq!(whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})), json!(nested));
assert_eq!(
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "effects/#3/point", "dimension": 1}), json!({})),
json!(format!("{other}.effect(\"Point Control\")(1)[1]"))
);
assert_eq!(
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({"expression": "1 + ", "range": [4, 4]})),
json!(format!("1 + {nested}")),
"inserted at the cursor"
);
assert_eq!(
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "transform/opacity"}), json!({})),
json!(format!("{other}.transform.opacity")),
"only an effect's properties go by index"
);
// Match names (Compact English off): the effect by its match name, its properties by index.
s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap();
let e = whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({}));
assert!(e.as_str().unwrap().ends_with(&format!("({group})({scale})")), "{e}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/rotation").expr.unwrap().text, format!("1 + {nested}"), "one undo per pick");
}
/// #407: Reset without a value gives a property its own default (a Transform property's, an
/// effect parameter's) besides removing its keyframes and expression.
#[test]
+31
View File
@@ -160,6 +160,37 @@ fn psd_imports_as_composition() {
assert!(s.project.comp(cid).is_none());
}
/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that
/// composition; it isn't wrapped in another.
#[test]
fn psd_imported_as_composition_with_new_comp_is_the_new_comp() {
let path = tmp("newcomp.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
assert_eq!(s.state.active_comp, Some(cid));
let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count();
assert_eq!(roots, 1, "no wrapper comp");
}
/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds
/// its composition once, not its folder too.
#[test]
fn psd_imported_as_composition_into_a_comp_adds_one_layer() {
let path = tmp("dropped.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let psd = r["comps"][0].as_u64().unwrap();
let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers;
assert_eq!(layers.len(), 1, "{layers:?}");
assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd));
}
#[test]
fn psd_retain_layer_sizes() {
let path = tmp("retain.psd");
+164 -1
View File
@@ -419,7 +419,7 @@ fn shape_tool_options_paint_new_shapes() {
fn masks_of_every_shape_tool_kind() {
let mut s = comp();
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] {
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] {
let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap();
let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
assert!(path.closed, "{kind}");
@@ -697,3 +697,166 @@ fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() {
assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1));
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0));
}
#[test]
fn polystar_expression_routes_synchronize_controls_in_the_same_undo_step() {
for explicit in [false, true] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
let uid = before.get("type").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), uid)];
let params = if explicit { json!({"layer": lid, "prop": uid, "expression": "1"}) } else { json!({"expression": "1"}) };
s.execute("prop.setExpression", params).unwrap();
let animated = group(&s);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.execute("prop.setExpression", json!({"layer": lid, "prop": uid, "expression": ""})).unwrap();
assert!(group(&s).get("innerRadius").is_none());
assert!(s.state.selected_props.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert!(group(&s).get("innerRadius").is_none());
}
}
#[test]
fn polystar_animation_menu_adds_inner_controls_and_undoes() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
s.state.selected_props = vec![(LayerId(lid), before.get("type").unwrap().uid)];
s.execute("anim.addKeyframe", json!({})).unwrap();
let animated = group(&s);
assert_eq!(animated.get("type").unwrap().keys.len(), 1);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
}
#[test]
fn deleting_the_last_polystar_type_key_removes_stale_controls_and_selection() {
for command in ["keys.delete", "edit.clear"] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = group(&s).get("type").unwrap().uid;
s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap();
let animated = group(&s);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: type_uid, time: Tick::ZERO }];
s.execute(command, json!({})).unwrap();
let polygon = group(&s);
assert!(polygon.get("innerRadius").is_none() && polygon.get("innerRoundness").is_none());
assert!(s.state.selected_props.is_empty() && s.state.selected_keys.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert_eq!(group(&s), polygon);
}
}
#[test]
fn polystar_sync_cleans_the_edited_compositions_parked_selection() {
let mut s = comp();
let first = s.active_comp_id().unwrap();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let path = "contents/group/contents/star";
let type_uid = layer(&s, lid).props.group(path).unwrap().get("type").unwrap().uid;
s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap();
let before = layer(&s, lid).props.group(path).unwrap().clone();
let inner = before.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }];
s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap();
let second = s.active_comp_id().unwrap();
let other = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
let other_prop = layer(&s, other).props.prop("transform/rotation").unwrap().uid;
s.execute("prop.addKey", json!({"layer": other, "prop": other_prop, "time": 0, "value": 15})).unwrap();
s.state.selected_props = vec![(LayerId(other), other_prop)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(other), prop: other_prop, time: Tick::ZERO }];
let live = (s.state.selected_props.clone(), s.state.selected_keys.clone());
s.execute("prop.toggleAnimation", json!({"comp": first.0, "layer": lid, "prop": type_uid, "value": false})).unwrap();
assert_eq!(s.active_comp_id(), Some(second));
assert_eq!((s.state.selected_props.clone(), s.state.selected_keys.clone()), live);
let parked = s.state.comp_selections.get(&first).unwrap();
assert!(parked.props.is_empty() && parked.keys.is_empty(), "the inactive composition must not keep removed control references");
let after = s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap().clone();
assert!(after.get("innerRadius").is_none());
assert!(s.undo());
assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &before);
assert!(s.redo());
assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &after);
}
#[test]
fn polystar_multi_property_expression_clear_resolves_targets_before_removing_controls() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = group(&s).get("type").unwrap().uid;
s.execute("prop.setExpression", json!({"layer": lid, "prop": type_uid, "expression": "1"})).unwrap();
let inner = group(&s).get("innerRadius").unwrap().uid;
s.execute("prop.setExpression", json!({"layer": lid, "prop": inner, "expression": "37"})).unwrap();
let before = group(&s);
s.state.selected_props = vec![(LayerId(lid), type_uid), (LayerId(lid), inner)];
assert_eq!(s.execute("prop.setExpression", json!({"expression": ""})).unwrap(), json!([null, null]));
let after = group(&s);
assert!(after.get("type").unwrap().expr.is_none());
assert!(after.get("innerRadius").is_none());
assert_eq!(s.state.selected_props, vec![(LayerId(lid), type_uid)]);
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), after);
}
/// Undo must discard removed controls before their UIDs can name newly allocated properties.
#[test]
fn polystar_undo_sanitizes_live_and_parked_property_selections_before_uid_reuse() {
for parked in [false, true] {
let mut s = comp();
let cid = s.active_comp_id().unwrap();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let polygon = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = polygon.get("type").unwrap().uid;
s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap();
let other = s.active_comp_id().unwrap();
s.activate_comp(cid);
s.state.selected_props = vec![(LayerId(lid), type_uid)];
s.execute("anim.addKeyframe", json!({})).unwrap();
let inner = layer(&s, lid).props.group("contents/group/contents/star").unwrap().get("innerRadius").unwrap().uid;
let valid = vec![(LayerId(lid), type_uid), (LayerId(lid), polygon.uid)];
s.state.selected_props = valid.iter().copied().chain([(LayerId(lid), inner)]).collect();
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }];
if parked {
s.activate_comp(other);
}
assert!(s.undo());
let (props, keys) = if parked {
let selection = s.state.comp_selections.get(&cid).unwrap();
(&selection.props, &selection.keys)
} else {
(&s.state.selected_props, &s.state.selected_keys)
};
assert_eq!(props, &valid, "remove stale leaves while preserving property and group selections");
assert!(keys.is_empty());
s.activate_comp(cid);
s.execute("prop.separateDimensions", json!({"layer": lid, "value": true})).unwrap();
let position_z = layer(&s, lid).props.prop("transform/positionZ").unwrap().clone();
assert_eq!(position_z.uid, inner, "the removed control's UID was actually reused");
s.execute("prop.setExpression", json!({"expression": "1"})).unwrap();
assert_eq!(layer(&s, lid).props.prop("transform/positionZ").unwrap(), &position_z, "the reused UID must not become an implicit expression target");
}
}