Refactor engine for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-28 06:09:21 +00:00
parent d01871516a
commit f20d22bf36
35 changed files with 323 additions and 197 deletions
+71 -45
View File
@@ -3,7 +3,7 @@
use effectcraft_effects::camera_tracker as ct;
use effectcraft_geom::vec3;
use effectcraft_project::LayerId;
use effectcraft_project::{Comp, Layer, LayerId, PropGroup};
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::camera::active_camera;
@@ -11,6 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3};
use serde_json::{Value, json};
use crate::Session;
use crate::camera_track::Placed;
use crate::tests::track::{H, W, frame_time, setup, texture};
const F: f64 = 260.0;
@@ -77,10 +78,9 @@ fn status(s: &mut Session) -> Value {
s.execute("camera.solveStatus", json!({})).unwrap()
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let st = status(&mut s);
/// Solved with a sane error and focal length; returns the Camera Tracker effect group.
fn assert_solved(s: &mut Session, clip: LayerId) -> PropGroup {
let st = status(s);
assert_eq!(st["solved"], true, "{st}");
let err = st["averageError"].as_f64().unwrap();
assert!(err < 0.5, "average error {err}");
@@ -90,9 +90,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
// Method Used / Average Error are reported in the effect.
let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty()));
g
}
// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
// ones seen below the horizon at the bottom of the frame).
/// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
/// ones seen below the horizon at the bottom of the frame).
fn select_floor_points(s: &mut Session) -> Vec<u64> {
s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap();
let pts = s.execute("camera.points", json!({})).unwrap();
let list = pts["points"].as_array().unwrap();
@@ -101,32 +104,19 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert!(floor.len() >= 3, "{floor:?}");
s.execute("camera.selectPoints", json!({"points": floor})).unwrap();
assert_eq!(s.state.camera_points.len(), floor.len());
floor
}
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
// Every solved point projects through the created camera exactly where the solve puts it,
// and onto its tracked position (the footage fills the comp, so layer pixels are comp
// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
// features on occlusion edges), 0.5 px on average.
/// Every solved point projects through the created camera exactly where the solve puts it,
/// and onto its tracked position (the footage fills the comp, so layer pixels are comp
/// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
/// features on occlusion edges), 0.5 px on average.
fn assert_reprojection(s: &Session, comp: &Comp, pl: &Placed, g: &PropGroup) {
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(g)).unwrap();
let mut errs = vec![];
for k in [0u32, 6, 12, 18, 24] {
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k));
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(k));
let camst = active_camera(&ctx);
for sp in pl.solve.visible(k as usize) {
let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue };
@@ -142,34 +132,31 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
let p95 = errs[errs.len() * 95 / 100];
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px");
}
// The solid lies on the selected points' plane.
/// The solid lies on the selected points' plane and faces the camera.
fn assert_solid_on_plane(s: &Session, comp: &Comp, sl: &Layer, pl: &Placed, size: f64) {
let cid = s.active_comp_id().unwrap();
let tr = sl.props.sub("transform").unwrap();
let pos = tr.get("position").unwrap().value.as_vec3();
let ori = tr.get("orientation").unwrap().value.as_vec3();
let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]);
let size = r["target"]["size"].as_f64().unwrap();
for id in &s.state.camera_points {
let w = pl.to_world(pl.solve.point(*id).unwrap().pos);
let d = linalg::dot(linalg::sub(w, pos), normal).abs();
assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane");
}
// The solid faces the camera.
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12));
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(12));
let eye = active_camera(&ctx).eye;
assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0);
}
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
// A second create reuses the camera: Create Multiple Nulls and Camera.
/// A second create reuses the camera (Create Multiple Nulls and Camera), then Text and
/// Shadow Catcher (+ light).
fn assert_create_kinds(s: &mut Session, cam: LayerId, n_floor: usize) {
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap();
assert_eq!(r["camera"].as_u64().unwrap(), cam.0);
assert_eq!(r["layers"].as_array().unwrap().len(), floor.len());
assert_eq!(r["layers"].as_array().unwrap().len(), n_floor);
// Text and Shadow Catcher (+ light).
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 1);
@@ -180,11 +167,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2);
assert!(comp.layer(ids[1]).unwrap().is_light());
assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err());
}
// Set Ground Plane and Origin: the target becomes the origin; a null created there sits at
// (0, 0, 0) lying on the X-Z plane (its front facing up, −y).
/// Set Ground Plane and Origin, then an explicit target.
fn assert_ground_and_target(s: &mut Session) {
s.execute_checked("camera.setGroundPlane", json!({})).unwrap();
assert_eq!(status(&mut s)["groundPlane"], true);
assert_eq!(status(s)["groundPlane"], true);
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap();
let null = LayerId(r["layers"][0].as_u64().unwrap());
let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone();
@@ -203,6 +191,44 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}");
let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}");
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let g = assert_solved(&mut s, clip);
let floor = select_floor_points(&mut s);
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
assert_reprojection(&s, &comp, &pl, &g);
assert_solid_on_plane(&s, &comp, sl, &pl, r["target"]["size"].as_f64().unwrap());
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
assert_create_kinds(&mut s, cam, floor.len());
assert_ground_and_target(&mut s);
// Serde keeps the analysis.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
+23 -19
View File
@@ -239,6 +239,28 @@ fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value)
s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap();
}
/// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
fn assert_crop_framing(s: &mut Session, clip: LayerId) {
set_param(s, clip, "borders/framing", json!(2));
let st = s.execute("warp.status", json!({})).unwrap();
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
for f in 0..25 {
assert_eq!(transparent(&render(s, f)), 0, "frame {f}");
}
set_param(s, clip, "borders/framing", json!(1));
let st = s.execute("warp.status", json!({})).unwrap();
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
for f in [0, 9, 24] {
let img = render(s, f);
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
}
}
assert!(img.get(0, 0)[3] < 0.01);
}
}
#[test]
fn warp_stabilizer_analyzes_and_stabilizes() {
let (mut s, clip, solid) = setup(shaky);
@@ -289,25 +311,7 @@ fn warp_stabilizer_analyzes_and_stabilizes() {
}
set_param(&mut s, clip, "stabilization/result", json!(0));
// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
set_param(&mut s, clip, "borders/framing", json!(2));
let st = s.execute("warp.status", json!({})).unwrap();
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
for f in 0..25 {
assert_eq!(transparent(&render(&s, f)), 0, "frame {f}");
}
set_param(&mut s, clip, "borders/framing", json!(1));
let st = s.execute("warp.status", json!({})).unwrap();
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
for f in [0, 9, 24] {
let img = render(&s, f);
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
}
}
assert!(img.get(0, 0)[3] < 0.01);
}
assert_crop_framing(&mut s, clip);
// Synthesize Edges fills most of the borders from neighbouring frames.
set_param(&mut s, clip, "borders/framing", json!(3));
let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
+43 -33
View File
@@ -49,45 +49,38 @@ fn camera(s: &mut Session) -> u64 {
s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn stereo_rig_builds_eye_comps_and_glasses() {
let mut s = session();
let src = cid(&s);
let cam = camera(&mut s);
let items_before = s.project.items.len();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
assert_eq!(r["master"].as_u64(), Some(cam));
// Controls on a hidden null in the source comp.
let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap());
/// Controls on a hidden null in the source comp.
fn assert_stereo_controls(ctl: &Layer) {
assert_eq!(ctl.name, "Stereo 3D Controls");
assert!(!ctl.switches.video);
let fx = ctl.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS));
assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0));
// Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp.
let d = 0.04 * 640.0;
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
let ec = ItemId(r[key].as_u64().unwrap());
assert_eq!(s.project.item(ec).unwrap().name, name);
let c = s.project.comp(ec).unwrap();
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.layers.len(), 2);
let cam = &c.layers[0];
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
// Parallel eyes (no convergence): each looks straight ahead.
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
}
let nest = &c.layers[1];
assert_eq!(nest.source, LayerSource::Comp { item: src });
assert!(nest.switches.collapse && nest.switches.three_d);
}
/// An eye comp: an eye camera (two-node, linked by expressions) at `x` and the collapsed source comp.
fn assert_eye_comp(s: &Session, src: ItemId, ec: ItemId, name: &str, x: f64) {
assert_eq!(s.project.item(ec).unwrap().name, name);
let c = s.project.comp(ec).unwrap();
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.layers.len(), 2);
let cam = &c.layers[0];
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
// Parallel eyes (no convergence): each looks straight ahead.
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
}
// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
let nest = &c.layers[1];
assert_eq!(nest.source, LayerSource::Comp { item: src });
assert!(nest.switches.collapse && nest.switches.three_d);
}
/// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
fn assert_stereo_comp(s: &Session, r: &serde_json::Value) {
let oc = ItemId(r["stereoComp"].as_u64().unwrap());
let out = s.project.comp(oc).unwrap();
assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D");
@@ -98,6 +91,23 @@ fn stereo_rig_builds_eye_comps_and_glasses() {
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses"));
assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l)));
assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr)));
}
#[test]
fn stereo_rig_builds_eye_comps_and_glasses() {
let mut s = session();
let src = cid(&s);
let cam = camera(&mut s);
let items_before = s.project.items.len();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
assert_eq!(r["master"].as_u64(), Some(cam));
assert_stereo_controls(&layer_in(&s, src, r["controls"].as_u64().unwrap()));
let d = 0.04 * 640.0;
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
assert_eye_comp(&s, src, ItemId(r[key].as_u64().unwrap()), name, x);
}
assert_stereo_comp(&s, &r);
// One undo step; redo; serde.
let json = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&json).unwrap();
+40 -25
View File
@@ -2,7 +2,7 @@
use effectcraft_color::BlendMode;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemKind, LayerSource, MaskMode};
use effectcraft_project::{Comp, ItemKind, Layer, LayerSource, MaskMode};
use effectcraft_psd::write::*;
use effectcraft_psd::{DValue, Descriptor, Rect};
use serde_json::json;
@@ -74,22 +74,8 @@ pub(crate) fn sample_psd() -> Vec<u8> {
write(&d)
}
#[test]
fn psd_imports_as_composition() {
let path = tmp("sample.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"})).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));
assert_eq!(s.project.item(cid).unwrap().name, "sample");
let c = s.project.comp(cid).unwrap().clone();
assert_eq!((c.width, c.height), (120, 80));
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]);
// Text layer: editable text, positioned at the type origin.
let title = &c.layers[0];
/// Text layer: editable text, positioned at the type origin.
fn assert_title_layer(title: &Layer) {
assert!(matches!(title.source, LayerSource::Text));
let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") };
assert_eq!(doc.text, "Hello PSD");
@@ -97,14 +83,18 @@ fn psd_imports_as_composition() {
assert_eq!(doc.font, "Inter");
assert_eq!(doc.style, "Bold");
assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]);
// Adjustment layer with Hue/Saturation.
let hue = &c.layers[1];
}
/// Adjustment layer with Hue/Saturation.
fn assert_hue_layer(hue: &Layer) {
assert!(hue.switches.adjustment);
let fx = hue.effects().unwrap().groups().next().unwrap();
assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0);
assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0);
// Vector mask → mask; Stroke style.
let vm = &c.layers[2];
}
/// Vector mask → mask; Stroke style.
fn assert_vector_mask_layer(vm: &Layer) {
let m = vm.masks().unwrap().groups().next().unwrap();
assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. }));
let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") };
@@ -113,19 +103,23 @@ fn psd_imports_as_composition() {
let st = vm.props.group("layerStyles/stroke").unwrap();
assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0);
assert_eq!(st.get("position").unwrap().value, KV::Enum(1));
// Clipped → preserve transparency; opacity and blend mode.
}
/// Clipped → preserve transparency (opacity, blend mode); drop shadow.
fn assert_clipped_and_shadow(c: &Comp) {
let clipped = &c.layers[3];
assert!(clipped.preserve_transparency);
assert_eq!(clipped.blend_mode, BlendMode::Screen);
assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9);
// Drop shadow.
let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap();
assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0);
assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0);
assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false));
assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0);
// Group → collapsed precomp (pass through) with its layers.
let g = &c.layers[5];
}
/// Group → collapsed precomp (pass through) with its layers; footage items name their Photoshop layer.
fn assert_group_precomp(s: &Session, g: &Layer) {
assert!(g.switches.collapse);
let LayerSource::Comp { item } = g.source else { panic!("precomp") };
let inner = s.project.comp(item).unwrap();
@@ -140,6 +134,27 @@ fn psd_imports_as_composition() {
assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false));
assert_eq!((f.width, f.height), (120, 80));
assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd");
}
#[test]
fn psd_imports_as_composition() {
let path = tmp("sample.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"})).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));
assert_eq!(s.project.item(cid).unwrap().name, "sample");
let c = s.project.comp(cid).unwrap().clone();
assert_eq!((c.width, c.height), (120, 80));
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]);
assert_title_layer(&c.layers[0]);
assert_hue_layer(&c.layers[1]);
assert_vector_mask_layer(&c.layers[2]);
assert_clipped_and_shadow(&c);
assert_group_precomp(&s, &c.layers[5]);
// One undo step removes everything.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(cid).is_none());