Files
effectcraft/crates/engine/src/tests_tail.rs
T
Brandon ThomasandClaude Opus 5.5 d2ccf737a8 M13.5: Layer Style dialog
LYR-9: Layer > Layer Styles > Layer Style Options... (layer.style.options)
and choosing a style from that menu open a Layer Style dialog: style list
with check boxes (Blending Options first) and the selected style's controls
built from its layerStyles properties, edited live through prop.set (one
undo step per control); Cancel rolls back everything done since it opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:11:19 -04:00

315 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M13.5 long-tail parity: stroke taper/wave/dashes, and the other small gaps (commands, undo,
//! serde).
use serde_json::{Value, json};
use crate::Session;
use effectcraft_project::{LayerId, Project};
pub(crate) fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Tail", "width": w, "height": h, "frameRate": 30, "duration": 2})).unwrap();
s
}
pub(crate) fn roundtrip(s: &Session) -> Project {
Project::from_json(&s.project.to_json()).unwrap()
}
/// A horizontal open path 160 px long across a 200×100 comp, 10 px red stroke and no fill.
fn line_layer(s: &mut Session) -> (u64, u64) {
let r = s.execute("shape.newPath", json!({"vertices": [[20, 50], [180, 50]], "fill": false, "stroke": [1, 0, 0], "strokeWidth": 10})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
let stroke = l.props.find_group(r["group"].as_u64().unwrap()).unwrap().sub("contents").unwrap().sub("stroke").unwrap().uid;
(lid, stroke)
}
fn column(s: &Session, x: u32) -> f32 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
(0..img.height).map(|y| img.data[(y * img.width + x) as usize][3]).sum()
}
#[test]
fn stroke_taper_wave_commands_render_undo_and_serde() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
assert!((column(&s, 30) - 10.0).abs() < 0.6);
let r = s.execute("shape.stroke.taper", json!({"layer": lid, "startLength": 40, "startWidth": 0})).unwrap();
assert_eq!(r["stroke"], json!(stroke));
assert!((column(&s, 30) - 2.5).abs() < 0.8, "tapered start: {}", column(&s, 30));
// Percent units.
s.execute("shape.stroke.taper", json!({"layer": lid, "units": "percent", "startLength": 25})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8, "25 % of 160 px = 40 px: {}", column(&s, 30));
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!((column(&s, 30) - 10.0).abs() < 0.6, "undo restores the plain stroke");
s.execute("edit.redo", json!({})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8);
// Wave in cycles.
s.execute("shape.stroke.wave", json!({"layer": lid, "amount": 100, "units": "cycles", "cycles": 4})).unwrap();
assert!(column(&s, 120) < 0.6, "trough at 100 px (2.5 cycles): {}", column(&s, 120));
// Values are ordinary animatable properties that survive save/load.
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
let g = l.props.find_group(stroke).unwrap();
assert_eq!(g.prop("wave/cycles").unwrap().value.as_f64(), 4.0);
assert_eq!(g.prop("taper/startLength").unwrap().value.as_f64(), 40.0);
assert!(s.execute("shape.stroke.wave", json!({"layer": lid, "units": "furlongs"})).is_err());
}
#[test]
fn dashes_plus_minus_buttons() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
let add = |s: &mut Session| s.execute("shape.dashes.add", json!({"layer": lid, "prop": stroke})).unwrap()["added"].as_str().unwrap().to_string();
assert_eq!(add(&mut s), "dash");
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash"), "value": 20})).unwrap();
assert_eq!(add(&mut s), "dash2");
assert_eq!(add(&mut s), "dash3");
assert!(s.execute("shape.dashes.add", json!({"layer": lid})).is_err(), "three pairs at most");
let names = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
l.props.find_group(stroke).unwrap().sub("dashes").unwrap().children.iter().map(|n| n.match_id().to_string()).collect::<Vec<_>>()
};
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "dash3", "gap3", "offset"]);
// Dash 20, Gap 20 (= dash), Dash 2 20, Gap 2 20…: set Dash 2 to 5.
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap"), "value": 10})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash2"), "value": 5})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap2"), "value": 15})).unwrap();
s.execute("shape.dashes.remove", json!({"layer": lid})).unwrap();
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "offset"]);
// Pattern 20 on, 10 off, 5 on, 15 off from x = 20 (butt caps).
assert!(column(&s, 30) > 9.0 && column(&s, 45) < 0.1 && column(&s, 52) > 9.0 && column(&s, 60) < 0.1 && column(&s, 80) > 9.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(names(&s).len(), 7);
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
assert_eq!(l.props.find_group(stroke).unwrap().prop("dashes/dash2").unwrap().value.as_f64(), 5.0);
}
fn dash_uid(s: &Session, lid: u64, stroke: u64, m: &str) -> Value {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
json!(l.props.find_group(stroke).unwrap().sub("dashes").unwrap().get(m).unwrap().uid)
}
/// A white 200×200 solid in a 200×200 comp with a 100×100 rectangular mask (50…150).
fn masked_solid(s: &mut Session) -> (u64, u64) {
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("mask.new", json!({"layer": l, "vertices": [[50, 50], [150, 50], [150, 150], [50, 150]], "closed": true})).unwrap();
(l, r["mask"].as_u64().unwrap())
}
fn render_at(s: &Session, t: f64) -> effectcraft_raster::Image {
let cid = s.active_comp_id().unwrap();
s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), Default::default())
}
fn alpha_at(s: &Session, x: u32, y: u32, t: f64) -> f32 {
let img = render_at(s, t);
img.data[(y * img.width + x) as usize][3]
}
#[test]
fn variable_mask_feather_points() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap();
assert!(alpha_at(&s, 100, 45, 0.0) < 0.01);
// Mask Feather tool: click on the path and drag out above the top edge → an outer point of 20 px.
let r = s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 30]})).unwrap();
assert_eq!(r["index"], json!(0));
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
let p0 = &pts["points"][0];
assert_eq!(p0["segment"], json!(0));
assert!((p0["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && (p0["radius"].as_f64().unwrap() - 20.0).abs() < 1e-3, "{pts}");
let soft = alpha_at(&s, 100, 39, 0.0);
assert!((soft - 0.475).abs() < 0.06, "{soft}");
// An inner point dragged inside the bottom edge.
s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 130], "tension": 100})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert!((pts["points"][1]["radius"].as_f64().unwrap() + 20.0).abs() < 1e-3, "{pts}");
assert!((alpha_at(&s, 100, 140, 0.0) - 0.475).abs() < 0.08, "{}", alpha_at(&s, 100, 140, 0.0));
assert!(alpha_at(&s, 100, 152, 0.0) < 0.01);
// Drag the radius; undo; redo.
s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 40})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) > 0.7);
s.execute("edit.undo", json!({})).unwrap();
assert!((alpha_at(&s, 100, 39, 0.0) - soft).abs() < 0.08);
s.execute("edit.redo", json!({})).unwrap();
// Inserting a vertex keeps the points where they were on the path.
let before = alpha_at(&s, 100, 39, 0.0);
s.execute("mask.insertVertex", json!({"layer": l, "mask": m, "segment": 0, "t": 0.25})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert_eq!(pts["points"][0]["segment"], json!(1));
assert!((pts["points"][0]["t"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-6);
assert!((alpha_at(&s, 100, 39, 0.0) - before).abs() < 0.02);
// Serde: the points travel in the Mask Path value.
let back = roundtrip(&s);
let lay = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(l)).unwrap();
let effectcraft_keyframe::Value::Path(sp) = &lay.props.find_group(m).unwrap().get("path").unwrap().value else { panic!() };
assert_eq!(sp.feather.len(), 2);
s.execute("mask.featherPoint.remove", json!({"layer": l, "mask": m, "all": true})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) < 0.01);
assert!(s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 1})).is_err());
}
#[test]
fn mask_motion_blur_ramps_across_the_motion() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
// 30 px in 30 frames: 1 px per frame; with a 360° shutter at frame 15 the right edge sweeps
// 165…166, so pixel 165 is half covered.
let verts: Vec<Value> = (0..4).map(|i| json!({"layer": l, "mask": m, "index": i})).collect();
s.execute("mask.moveVertices", json!({"vertices": verts, "delta": [30, 0]})).unwrap();
let a = alpha_at(&s, 165, 100, 0.5);
assert!(a > 0.3 && a < 0.7, "{a}");
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap();
let hard = alpha_at(&s, 165, 100, 0.5);
assert!(!(0.3..0.7).contains(&hard), "{hard}");
}
#[test]
fn adaptive_sample_limit_drives_layer_samples() {
assert_eq!(effectcraft_render::adaptive_samples(0.5, 16, 128), 16);
assert_eq!(effectcraft_render::adaptive_samples(100.0, 16, 128), 100);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 128), 128);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 9999), 256);
let mut s = comp(400, 100);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
// 100 px per frame (keys 3 frames apart, 300 px).
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [50, 50]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.1, "value": [350, 50]})).unwrap();
let max_step = |s: &Session| {
let img = render_at(s, 1.0 / 30.0);
let row: Vec<f32> = (0..img.width).map(|x| img.data[(50 * img.width + x) as usize][3]).collect();
row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max)
};
s.execute("comp.settings", json!({"adaptiveSampleLimit": 16})).unwrap();
let coarse = max_step(&s);
s.execute("comp.settings", json!({"adaptiveSampleLimit": 256})).unwrap();
let fine = max_step(&s);
assert!(coarse > 0.05, "16 samples over 100 px leave visible steps: {coarse}");
assert!(fine < 0.03, "≈100 samples give a smooth ramp: {fine}");
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 256);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 16);
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 16);
// Older projects without the field read the After Effects default.
fn strip(v: &mut Value) {
match v {
Value::Object(o) => {
o.remove("motion_blur_adaptive_limit");
o.values_mut().for_each(strip);
}
Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
let mut v: Value = serde_json::from_str(&s.project.to_json()).unwrap();
strip(&mut v);
let json = v.to_string();
assert!(!json.contains("motion_blur_adaptive_limit"));
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
}
#[test]
fn keyframe_label_groups() {
let mut s = comp(200, 100);
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
for l in [a, b] {
for t in [0.0, 0.5, 1.0] {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t})).unwrap();
}
}
let uid = |s: &Session, l: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid;
let (ua, ub) = (uid(&s, a), uid(&s, b));
// Label a's first key and b's last key Blue.
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}, {"layer": b, "prop": ub, "time": 1.0}]})).unwrap();
s.execute("keys.setLabel", json!({"label": "Blue"})).unwrap();
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.5}]})).unwrap();
s.execute("keys.setLabel", json!({"label": "Green"})).unwrap();
// On selected layers: from a's first key, only a's Blue key.
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap()["keys"], json!(1));
// On all layers: both Blue keys.
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(2));
assert!(s.state.selected_keys.iter().any(|k| k.layer == LayerId(b) && (k.time.seconds() - 1.0).abs() < 1e-6));
// Visible keyframes: only the revealed properties count.
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [ua]})).unwrap()["keys"], json!(1));
// Unlabelled keys group together too (label None).
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 1.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(3));
// Undo, serde.
let lab = |s: &Session| s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label;
assert_eq!(lab(&s), 9);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(lab(&s), 0);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label, 9);
assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err());
}
#[test]
fn tate_chu_yoko_via_set_text_with_undo_and_serde() {
let mut s = comp(400, 400);
let t = s.execute("layer.newText", json!({"text": "A12B", "size": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setText", json!({"layer": t, "vertical": true})).unwrap();
s.execute("layer.setText", json!({"layer": t, "range": [1, 3], "tateChuYoko": true})).unwrap();
let doc = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert!(doc.style_at(1).tate_chu_yoko && doc.style_at(2).tate_chu_yoko && !doc.style_at(0).tate_chu_yoko);
let lay = effectcraft_text::layout_doc(&doc);
assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row");
let back = roundtrip(&s);
let effectcraft_keyframe::Value::Text(d) =
&back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value
else {
panic!()
};
assert!(d.style_at(1).tate_chu_yoko);
s.execute("edit.undo", json!({})).unwrap();
assert!(!crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(1).tate_chu_yoko);
s.execute("layer.setText", json!({"layer": t, "verticalRomanUpright": true})).unwrap();
assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().vertical_roman_upright);
}
#[test]
fn camera_focus_commands_undo_and_serde() {
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, 150, 500]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
assert!(s.is_enabled("camera.setFocusToLayer"));
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}");
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}");
let back = roundtrip(&s);
assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some());
s.execute("edit.undo", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].is_null());
s.execute("edit.undo", json!({})).unwrap();
let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
assert!((v - before).abs() < 1e-9);
s.execute("edit.redo", json!({})).unwrap();
// A camera alone can't link to a layer, but can link to its point of interest.
s.execute("layer.select", json!({"layers": [cam]})).unwrap();
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
}