use std::time::Instant; use effectcraft_color::Label; use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MaskMode, Project, PropGroup, Solid}; use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer}; use effectcraft_time::{FrameRate, Tick}; use crate::{Expressions, check_syntax, eval_property, eval_standalone}; const ROT: &str = "transform/rotation"; const POS: &str = "transform/position"; const OPA: &str = "transform/opacity"; /// A 200×100, 30 fps, 10 s comp named "Main" with: /// 1. "A": 20×20 red solid at (50, 40), slider effect = 42, rotation keyed 0→90 over 0–1 s /// 2. "B": 40×40 blue solid at (100, 50) /// 3. "Title": text layer "Hello" /// 4. "Shape": shape layer with "Rectangle 1" (60×30 rect path, red fill) /// /// plus an "Other" comp with one layer "Inner". struct Fx { p: Project, cid: ItemId, a: LayerId, b: LayerId, title: LayerId, shape: LayerId, } fn solid(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], w: u32, h: u32) -> Layer { let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None) } fn group_push(p: &mut Project, f: impl FnOnce(&mut Ids) -> PropGroup) -> PropGroup { let mut next = p.next_id; let g = f(&mut Ids(&mut next)); p.next_id = next; g } fn fx() -> Fx { let mut p = Project::default(); let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(10.0)); let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); let mut a = solid(&mut p, &comp, "A", [1.0, 0.0, 0.0], 20, 20); a.props.prop_mut(POS).unwrap().value = Value::Vec3([50.0, 40.0, 0.0]); a.props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(90.0))]; let spec = effectcraft_effects::find("ec.control.slider").unwrap(); let mut slider = group_push(&mut p, |ids| effectcraft_effects::instantiate(spec, ids, "Slider Control", [20.0, 20.0])); slider.prop_mut("slider").unwrap().value = Value::Scalar(42.0); a.props.sub_mut("effects").unwrap().children.push(slider.into()); let mask = group_push(&mut p, |ids| build::mask(ids, "Mask 1", ShapePath::rect([10.0, 10.0], 10.0, 10.0), MaskMode::Add, [255, 0, 0])); a.props.sub_mut("masks").unwrap().children.push(mask.into()); a.markers.push(Marker { time: secs(2.0), comment: "beat".into(), ..Default::default() }); a.markers.push(Marker { time: secs(4.0), comment: "drop".into(), ..Default::default() }); let b = solid(&mut p, &comp, "B", [0.0, 0.0, 1.0], 40, 40); let mut title = build::layer(&mut p, &comp, "Title", LayerSource::Text, (200, 100), None); title.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "Hello".into(), size: 40.0, ..Default::default() })); title.props.prop_mut(POS).unwrap().value = Value::Vec3([20.0, 60.0, 0.0]); let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None); let g = group_push(&mut p, |ids| { let rect = build::shape_rect(ids, [60.0, 30.0], [0.0, 0.0], 0.0); let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); build::shape_group(ids, "Rectangle 1", vec![rect, fill]) }); shape.props.sub_mut("contents").unwrap().children.push(g.into()); let ids = (a.id, b.id, title.id, shape.id); let c = p.comp_mut(cid).unwrap(); c.layers = vec![a, b, title, shape]; c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() }); let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0)); let oid = p.add_item("Other", Label::Sandstone, None, ItemKind::Comp(other.clone().into())); let inner = solid(&mut p, &other, "Inner", [0.0, 1.0, 0.0], 64, 32); p.comp_mut(oid).unwrap().layers.push(inner); Fx { p, cid, a: ids.0, b: ids.1, title: ids.2, shape: ids.3 } } fn secs(s: f64) -> Tick { Tick::from_seconds_f64(s) } impl Fx { fn ev(&self, layer: LayerId, path: &str, text: &str, t: f64) -> Result { eval_standalone(&self.p, self.cid, layer, path, text, t) } /// Scalar result on layer A's rotation. fn num(&self, text: &str, t: f64) -> f64 { match self.ev(self.a, ROT, text, t) { Ok(v) => v.as_f64(), Err(e) => panic!("{text}: {e}"), } } /// Position result on layer A (3 components). fn pos(&self, text: &str, t: f64) -> [f64; 3] { match self.ev(self.a, POS, text, t) { Ok(v) => v.as_vec3(), Err(e) => panic!("{text}: {e}"), } } fn text(&self, text: &str, t: f64) -> String { match self.ev(self.title, "text/sourceText", text, t) { Ok(Value::Text(d)) => d.text, Ok(v) => panic!("{text}: not text: {v:?}"), Err(e) => panic!("{text}: {e}"), } } fn err(&self, text: &str) -> String { match self.ev(self.a, ROT, text, 0.0) { Ok(v) => panic!("{text}: expected an error, got {v:?}"), Err(e) => e, } } fn set_expr(&mut self, layer: LayerId, path: &str, text: &str) { let l = self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap(); l.props.prop_mut(path).unwrap().expr = Some(Expression { text: text.into(), enabled: true }); } fn layer_mut(&mut self, layer: LayerId) -> &mut Layer { self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap() } } fn close(a: f64, b: f64) -> bool { (a - b).abs() < 1e-6 } macro_rules! assert_close { ($a:expr, $b:expr) => {{ let (a, b): (f64, f64) = ($a, $b); assert!((a - b).abs() < 1e-6, "{} = {a}, expected {b}", stringify!($a)); }}; ($a:expr, $b:expr, $eps:expr) => {{ let (a, b): (f64, f64) = ($a, $b); assert!((a - b).abs() < $eps, "{} = {a}, expected {b} ± {}", stringify!($a), $eps); }}; } // ---- basics ----------------------------------------------------------------------------------- #[test] fn time_times_360() { let f = fx(); assert_close!(f.num("time * 360", 0.5), 180.0); assert_close!(f.num("time*360", 2.0), 720.0); } #[test] fn value_is_the_keyframed_value() { let f = fx(); assert_close!(f.num("value", 0.5), 45.0); assert_close!(f.num("value + 10", 1.0), 100.0); } #[test] fn math_object() { let f = fx(); assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); assert_close!(f.num("Math.max(3, 7) - Math.floor(2.7)", 0.0), 5.0); } #[test] fn last_statement_is_the_result() { let f = fx(); assert_close!(f.num("var x = 5;\nvar y = x * 2;\ny", 0.0), 10.0); assert_close!(f.num("x = 3; x + 1", 0.0), 4.0); } #[test] fn if_else_blocks_end_in_a_value() { let f = fx(); assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 2.0), 10.0); assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 0.0), 20.0); assert_close!(f.num("if (time > 1) 1; else if (time > 0.5) 2; else 3", 0.75), 2.0); } #[test] fn functions_and_let_are_reusable_across_evaluations() { let f = fx(); let e = "function sq(x) { return x * x }\nlet k = 2;\nconst j = 1;\nsq(time) * k + j"; assert_close!(f.num(e, 3.0), 19.0); assert_close!(f.num(e, 2.0), 9.0); } #[test] fn comments_are_ignored() { let f = fx(); assert_close!(f.num("// spin\n/* fast */ time * 100 // per second", 1.0), 100.0); } #[test] fn ternary_and_comparison() { let f = fx(); assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 2.0), 2.0); assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 0.0), 9.0); } // ---- array maths ------------------------------------------------------------------------------ #[test] fn array_plus_array() { let f = fx(); assert_eq!(f.pos("value + [10, 0]", 0.0), [60.0, 40.0, 0.0]); assert_eq!(f.pos("[10, 5] + value", 0.0), [60.0, 45.0, 0.0]); } #[test] fn array_times_and_divide_scalar() { let f = fx(); assert_eq!(f.pos("[1, 2] * 2", 0.0), [2.0, 4.0, 0.0]); assert_eq!(f.pos("2 * [1, 2]", 0.0), [2.0, 4.0, 0.0]); assert_eq!(f.pos("[10, 20] / 2", 0.0), [5.0, 10.0, 0.0]); assert_eq!(f.pos("value - [50, 40]", 0.0), [0.0, 0.0, 0.0]); } #[test] fn array_precedence_and_unary_minus() { let f = fx(); assert_eq!(f.pos("[1, 1] + [2, 3] * 2", 0.0), [5.0, 7.0, 0.0]); assert_eq!(f.pos("([1, 1] + [2, 3]) * 2", 0.0), [6.0, 8.0, 0.0]); assert_eq!(f.pos("-value + [100, 100]", 0.0), [50.0, 60.0, 0.0]); } #[test] fn compound_assignment_on_arrays() { let f = fx(); assert_eq!(f.pos("var p = value;\np += [5, 5];\np", 0.0), [55.0, 45.0, 0.0]); assert_eq!(f.pos("var p = [1, 2];\np *= 3;\np", 0.0), [3.0, 6.0, 0.0]); } #[test] fn string_concatenation_still_works() { let f = fx(); assert_eq!(f.text("'Count: ' + 5", 0.0), "Count: 5"); assert_eq!(f.text("'a' + 1 + 2", 0.0), "a12"); assert_eq!(f.text("1 + 2 + 'a'", 0.0), "3a"); assert_eq!(f.text("'pos ' + [1, 2]", 0.0), "pos 1,2"); } #[test] fn vector_helpers() { let f = fx(); assert_eq!(f.pos("add([1, 2], [3, 4])", 0.0), [4.0, 6.0, 0.0]); assert_eq!(f.pos("sub([5, 5], [1, 2])", 0.0), [4.0, 3.0, 0.0]); assert_eq!(f.pos("mul([1, 2], 3)", 0.0), [3.0, 6.0, 0.0]); assert_eq!(f.pos("div([3, 6], 3)", 0.0), [1.0, 2.0, 0.0]); assert_close!(f.num("dot([1, 2, 3], [4, 5, 6])", 0.0), 32.0); assert_eq!(f.pos("cross([1, 0, 0], [0, 1, 0])", 0.0), [0.0, 0.0, 1.0]); assert_close!(f.num("length([3, 4])", 0.0), 5.0); assert_close!(f.num("length([1, 1], [4, 5])", 0.0), 5.0); assert_close!(f.num("normalize([0, 10])[1]", 0.0), 1.0); } #[test] fn clamp_scalar_and_array() { let f = fx(); assert_close!(f.num("clamp(150, 0, 100)", 0.0), 100.0); assert_close!(f.num("clamp(-5, 0, 100)", 0.0), 0.0); assert_eq!(f.pos("clamp([-10, 500], 0, 100)", 0.0), [0.0, 100.0, 0.0]); } #[test] fn angles_and_look_at() { let f = fx(); assert_close!(f.num("radiansToDegrees(Math.PI)", 0.0), 180.0); assert_close!(f.num("degreesToRadians(90)", 0.0), std::f64::consts::FRAC_PI_2); let o = f.ev(f.a, "transform/orientation", "lookAt([0, 0, 0], [100, 0, 100])", 0.0).unwrap().as_vec3(); assert!(close(o[1], 45.0) && close(o[0], 0.0), "{o:?}"); } // ---- interpolation ---------------------------------------------------------------------------- #[test] fn linear_five_args_maps_and_clamps() { let f = fx(); assert_close!(f.num("linear(time, 0, 2, 0, 100)", 1.0), 50.0); assert_close!(f.num("linear(time, 0, 2, 0, 100)", 5.0), 100.0); assert_close!(f.num("linear(time, 1, 2, 0, 100)", 0.0), 0.0); assert_close!(f.num("linear(time, 2, 0, 0, 100)", 0.5), 75.0); } #[test] fn linear_three_args() { let f = fx(); assert_close!(f.num("linear(0.25, 10, 20)", 0.0), 12.5); } #[test] fn ease_curves() { let f = fx(); assert_close!(f.num("ease(time, 0, 1, 0, 100)", 0.5), 50.0); assert!(f.num("ease(time, 0, 1, 0, 100)", 0.25) < 25.0); assert!(f.num("easeIn(time, 0, 1, 0, 100)", 0.25) < 25.0); assert!(f.num("easeOut(time, 0, 1, 0, 100)", 0.25) > 25.0); assert_close!(f.num("easeIn(time, 0, 1, 0, 100)", 1.0), 100.0); assert_close!(f.num("easeOut(0.5, 0, 10)", 0.0) + f.num("easeIn(0.5, 0, 10)", 0.0), 10.0); } #[test] fn interpolation_with_arrays() { let f = fx(); assert_eq!(f.pos("linear(time, 0, 1, [0, 0], [100, 50])", 0.5), [50.0, 25.0, 0.0]); assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]); } // ---- object model ----------------------------------------------------------------------------- #[test] fn comp_attributes() { let f = fx(); assert_close!(f.num("thisComp.width + thisComp.height", 0.0), 300.0); assert_close!(f.num("thisComp.duration", 0.0), 10.0); assert_close!(f.num("thisComp.frameDuration * 30", 0.0), 1.0); assert_close!(f.num("thisComp.numLayers", 0.0), 4.0); assert_eq!(f.text("thisComp.name", 0.0), "Main"); } #[test] fn layer_attributes() { let f = fx(); assert_eq!(f.text("thisLayer.name + ' ' + index", 0.0), "Title 3"); assert_close!(f.num("index", 0.0), 1.0); assert_close!(f.num("inPoint + outPoint + startTime", 0.0), 10.0); assert_close!(f.num("width * 1000 + height", 0.0), 20_020.0); assert_close!(f.num("thisLayer.hasParent ? 1 : 0", 0.0), 0.0); } #[test] fn layer_by_name_and_index() { let f = fx(); assert_close!(f.num("thisComp.layer(\"B\").transform.position[0]", 0.0), 100.0); assert_eq!(f.text("thisComp.layer(2).name", 0.0), "B"); assert_eq!(f.text("thisComp.layer(thisLayer, 1).name", 0.0), "Shape"); assert_eq!(f.text("thisComp.layer(thisLayer, -2).name", 0.0), "A"); } #[test] fn cross_layer_position_reference() { let f = fx(); assert_eq!(f.pos("thisComp.layer(\"B\").transform.position", 0.0), [100.0, 50.0, 0.0]); assert_eq!(f.pos("thisComp.layer(\"B\").position + [0, 10]", 0.0), [100.0, 60.0, 0.0]); assert_close!(f.num("thisComp.layer('B').transform.scale[1]", 0.0), 100.0); assert_close!(f.num("thisComp.layer('B').transform.opacity", 0.0), 100.0); } #[test] fn layer_shorthand_attributes() { let f = fx(); assert_close!(f.num("position[0] + anchorPoint[0]", 0.0), 60.0); assert_close!(f.num("scale[0] + opacity", 0.0), 200.0); assert_close!(f.num("transform.anchorPoint[1]", 0.0), 10.0); } #[test] fn reading_a_property_that_has_an_expression() { let mut f = fx(); f.set_expr(f.b, ROT, "time * 10"); assert_close!(f.num("thisComp.layer('B').transform.rotation + 1", 2.0), 21.0); // Chains: B.opacity reads B.rotation (expression), A reads B.opacity. f.set_expr(f.b, OPA, "transform.rotation * 2"); assert_close!(f.num("thisComp.layer('B').transform.opacity", 1.0), 20.0); } #[test] fn own_property_reads_pre_expression_value() { let f = fx(); assert_close!(f.num("transform.rotation + 1", 0.5), 46.0); assert_close!(f.num("thisProperty.value * 2", 1.0), 180.0); } #[test] fn circular_references_terminate() { let mut f = fx(); f.set_expr(f.a, OPA, "thisComp.layer('B').transform.opacity"); f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity"); let start = Instant::now(); let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0); assert!(start.elapsed().as_secs_f64() < 10.0); // The cycle bottoms out at the depth limit and falls back to keyframed values. assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}"); } #[test] fn errors_and_property_reads_keep_the_runtime_usable() { // Errors thrown inside helpers, and the aborts every property read makes until the host // answers, must not use up the thread's runtime (it used to fail every expression after a // few hundred with "reached the maximum stack size"). let f = fx(); for _ in 0..3000 { assert!(f.ev(f.b, ROT, "thisComp.layer('Ghost').position", 0.0).is_err()); } for i in 0..1000 { let t = i as f64 / 1000.0; assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation", t).unwrap().as_f64(), 90.0 * t); } assert_close!(f.num("time * 2", 1.0), 2.0); } #[test] fn effect_slider_reference() { let f = fx(); assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0); assert_close!(f.num("effect('Slider Control')('Slider') * 2", 0.0), 84.0); assert_close!(f.num("thisLayer.effect('Slider Control').param('Slider')", 0.0), 42.0); assert_close!(f.num("effect(1).param(1) + effect(1)(1)", 0.0), 84.0); assert_close!(f.num("thisComp.layer('A').effect('Slider Control')('Slider').value", 0.0), 42.0); assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 0.0).unwrap().as_f64(), 42.0); } #[test] fn effect_slider_with_expression_drives_another_layer() { let mut f = fx(); f.set_expr(f.a, "effects/#1/slider", "time * 5"); assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 2.0).unwrap().as_f64(), 10.0); } #[test] fn missing_effect_is_an_error() { let f = fx(); let e = f.err("effect('Nope')('Slider')"); assert!(e.contains("Nope"), "{e}"); } #[test] fn group_names_and_counts() { let f = fx(); assert_eq!(f.text("thisComp.layer('A').effect(1).name", 0.0), "Slider Control"); assert_close!(f.ev(f.title, ROT, "thisComp.layer('A').transform.numProperties", 0.0).unwrap().as_f64(), 8.0); } #[test] fn mask_properties() { let f = fx(); assert_close!(f.num("mask('Mask 1').maskOpacity", 0.0), 100.0); assert_close!(f.num("mask('Mask 1').maskExpansion + 1", 0.0), 1.0); } #[test] fn shape_content_paths() { let f = fx(); let v = f.ev(f.shape, ROT, "content('Rectangle 1').content('Rectangle Path 1').size[0]", 0.0).unwrap(); assert_close!(v.as_f64(), 60.0); let c = f.ev(f.shape, ROT, "content('Rectangle 1').content('Fill 1').color[0] * 10", 0.0).unwrap(); assert_close!(c.as_f64(), 10.0); } #[test] fn shape_fill_color_expression() { let f = fx(); let path = "contents/#1/contents/fill/color"; let v = f.ev(f.shape, path, "[0, 1, 0]", 0.0).unwrap(); assert_eq!(v, Value::Color([0.0, 1.0, 0.0, 1.0])); } #[test] fn comp_by_name() { let f = fx(); assert_eq!(f.text("comp('Other').layer(1).name", 0.0), "Inner"); assert_close!(f.num("comp('Other').width", 0.0), 64.0); assert!(f.err("comp('Missing').width").contains("Missing")); } #[test] fn parent_attributes() { let mut f = fx(); let b = f.b; f.layer_mut(f.a).parent = Some(b); assert_eq!(f.text("thisComp.layer('A').parent.name", 0.0), "B"); assert_close!(f.num("hasParent ? parent.transform.position[0] : -1", 0.0), 100.0); } #[test] fn markers() { let f = fx(); assert_close!(f.num("marker.numKeys", 0.0), 2.0); assert_close!(f.num("marker.key(2).time", 0.0), 4.0); assert_eq!(f.text("thisComp.layer('A').marker.key(1).comment", 0.0), "beat"); assert_close!(f.num("marker.key('drop').time", 0.0), 4.0); assert_close!(f.num("marker.nearestKey(time).index", 3.5), 2.0); assert_close!(f.num("thisComp.marker.key(1).time", 0.0), 1.5); } #[test] fn layer_markers_follow_start_time() { let mut f = fx(); f.layer_mut(f.a).start_time = secs(1.0); assert_close!(f.num("marker.key(1).time", 0.0), 3.0); } // ---- keyframes, time --------------------------------------------------------------------------- #[test] fn value_at_time() { let f = fx(); assert_close!(f.num("valueAtTime(0.5)", 0.0), 45.0); assert_close!(f.num("thisProperty.valueAtTime(time - 0.5)", 1.0), 45.0); assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation.valueAtTime(0.25)", 0.0).unwrap().as_f64(), 22.5); } #[test] fn value_at_time_respects_layer_start_time() { let mut f = fx(); f.layer_mut(f.a).start_time = secs(1.0); // Keys at layer time 0 and 1 → comp time 1 and 2. assert_close!(f.num("valueAtTime(1.5)", 0.0), 45.0); assert_close!(f.num("key(1).time", 0.0), 1.0); } #[test] fn velocity_and_speed() { let f = fx(); assert_close!(f.num("velocityAtTime(0.5)", 0.0), 90.0, 1e-3); assert_close!(f.num("velocity", 0.5), 90.0, 1e-3); assert_close!(f.num("speedAtTime(0.5)", 0.0), 90.0, 1e-3); assert_close!(f.num("thisComp.layer('B').transform.position.speed", 0.5), 0.0, 1e-6); } #[test] fn keys_and_nearest_key() { let f = fx(); assert_close!(f.num("numKeys", 0.0), 2.0); assert_close!(f.num("key(2).time * 1000 + key(2).value", 0.0), 1090.0); assert_close!(f.num("nearestKey(0.8).index", 0.0), 2.0); assert_close!(f.num("nearestKey(0.2).value", 0.0), 0.0); assert!(f.err("key(3).time").contains("out of range")); } #[test] fn loop_out_cycle() { let f = fx(); assert_close!(f.num("loopOut('cycle')", 1.25), 22.5); assert_close!(f.num("loopOut()", 2.5), 45.0); assert_close!(f.num("loopOut('cycle')", 0.5), 45.0); } #[test] fn loop_out_pingpong() { let f = fx(); assert_close!(f.num("loopOut('pingpong')", 1.25), 67.5); assert_close!(f.num("loopOut('pingpong')", 2.25), 22.5); } #[test] fn loop_out_offset() { let f = fx(); assert_close!(f.num("loopOut('offset')", 1.5), 135.0); assert_close!(f.num("loopOut('offset')", 3.5), 315.0); } #[test] fn loop_out_continue() { let f = fx(); assert_close!(f.num("loopOut('continue')", 2.0), 180.0, 1e-3); } #[test] fn loop_in_cycle_and_offset() { let mut f = fx(); f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(secs(1.0), Value::Scalar(0.0)), Keyframe::new(secs(2.0), Value::Scalar(100.0))]; assert_close!(f.num("loopIn('cycle')", 0.25), 25.0); assert_close!(f.num("loopIn('offset')", 0.25), -75.0); assert_close!(f.num("loopIn('pingpong')", 0.75), 25.0); assert_close!(f.num("loopIn('cycle')", 1.5), 50.0); } #[test] fn loop_out_num_keyframes_and_duration() { let mut f = fx(); let keys = vec![Keyframe::new(secs(0.0), Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(100.0)), Keyframe::new(secs(2.0), Value::Scalar(200.0))]; f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = keys; // Only the last segment (1 s → 2 s) loops. assert_close!(f.num("loopOut('cycle', 1)", 2.5), 150.0); assert_close!(f.num("loopOut('cycle', 0)", 2.5), 50.0); assert_close!(f.num("loopOutDuration('cycle', 0.5)", 2.25), 175.0); } #[test] fn loop_on_two_dimensional_position() { let mut f = fx(); f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys = vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))]; assert_eq!(f.pos("loopOut('offset')", 1.5), [150.0, 75.0, 0.0]); } #[test] fn smooth_on_linear_ramp_is_identity() { let f = fx(); assert_close!(f.num("smooth(0.2, 5)", 0.5), 45.0, 1e-6); } #[test] fn time_conversions() { let f = fx(); assert_close!(f.num("timeToFrames()", 2.0), 60.0); assert_close!(f.num("timeToFrames(1, 24)", 0.0), 24.0); assert_close!(f.num("framesToTime(45)", 0.0), 1.5); assert_eq!(f.text("timeToTimecode()", 61.5), "0:01:01:15"); assert_eq!(f.text("timeToCurrentFormat(2.5)", 0.0), "0:00:02:15"); } #[test] fn posterize_time() { let f = fx(); assert_close!(f.num("posterizeTime(2); time", 0.7), 0.5); assert_close!(f.num("posterizeTime(2); value", 0.7), 45.0); } // ---- wiggle / random / noise ------------------------------------------------------------------ #[test] fn wiggle_is_deterministic() { let f = fx(); let a = f.pos("wiggle(3, 25)", 1.234); let b = f.pos("wiggle(3, 25)", 1.234); assert_eq!(a, b); let g = fx(); assert_eq!(g.pos("wiggle(3, 25)", 1.234), a); let other_thread = std::thread::spawn(move || g.pos("wiggle(3, 25)", 1.234)).join().unwrap(); assert_eq!(other_thread, a); } #[test] fn wiggle_amplitude_bounds_and_motion() { let f = fx(); let mut seen = Vec::new(); for i in 0..120 { let p = f.pos("wiggle(5, 30)", i as f64 / 30.0); assert!((p[0] - 50.0).abs() <= 30.0 + 1e-9 && (p[1] - 40.0).abs() <= 30.0 + 1e-9, "{p:?}"); assert_eq!(p[2], 0.0, "2D layers keep z"); seen.push(p[0]); } let spread = seen.iter().cloned().fold(f64::MIN, f64::max) - seen.iter().cloned().fold(f64::MAX, f64::min); assert!(spread > 10.0, "wiggle should move: spread {spread}"); } #[test] fn wiggle_octaves_bound() { let f = fx(); for i in 0..60 { let r = f.num("wiggle(2, 10, 3, 0.5) - value", i as f64 / 10.0); assert!(r.abs() <= 10.0 * 1.75 + 1e-9, "{r}"); } } #[test] fn wiggle_differs_per_layer_and_dimension() { let f = fx(); let a = f.ev(f.a, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64(); let b = f.ev(f.b, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64(); assert_ne!(a, b); let p = f.pos("wiggle(2, 20) - value", 0.7); assert_ne!(p[0], p[1]); } #[test] fn wiggle_on_other_layer_property() { let f = fx(); let v = f.num("thisComp.layer('B').transform.rotation.wiggle(1, 5)", 0.3); assert!(v.abs() <= 5.0 && v != 0.0, "{v}"); } #[test] fn temporal_wiggle_stays_on_the_curve() { let f = fx(); for i in 0..10 { let v = f.num("temporalWiggle(2, 0.1)", 0.2 + i as f64 * 0.05); assert!((0.0..=90.0).contains(&v), "{v}"); } } #[test] fn random_is_seeded_and_in_range() { let f = fx(); let a = f.num("random()", 0.5); assert!((0.0..1.0).contains(&a)); assert_eq!(a, f.num("random()", 0.5)); assert_ne!(a, f.num("random()", 0.6)); let r = f.num("random(10, 20)", 0.0); assert!((10.0..20.0).contains(&r)); let p = f.pos("random([100, 50])", 0.0); assert!((0.0..100.0).contains(&p[0]) && (0.0..50.0).contains(&p[1])); assert_ne!(f.num("random() - random()", 0.0), 0.0, "successive calls differ"); } #[test] fn seed_random_timeless() { let f = fx(); let a = f.num("seedRandom(5, true); random(100)", 0.1); let b = f.num("seedRandom(5, true); random(100)", 3.7); assert_eq!(a, b); assert_ne!(a, f.num("seedRandom(6, true); random(100)", 0.1)); } #[test] fn gauss_random_distribution() { let f = fx(); let e = "var s = 0, n = 2000, inside = 0;\nfor (var i = 0; i < n; i++) { var g = gaussRandom(); s += g; if (g >= 0 && g <= 1) inside++; }\n[s / n, inside / n]"; let r = f.pos(e, 0.0); assert!((r[0] - 0.5).abs() < 0.05, "mean {}", r[0]); assert!((0.85..0.95).contains(&r[1]), "inside {}", r[1]); } #[test] fn noise_is_smooth_and_bounded() { let f = fx(); let a = f.num("noise(time)", 1.0); let b = f.num("noise(time)", 1.001); assert!(a.abs() <= 1.0 && (a - b).abs() < 0.01); assert!(f.num("noise([1.5, 2.5, 3.5])", 0.0).abs() <= 1.0); } // ---- text ------------------------------------------------------------------------------------- #[test] fn source_text_from_expression_keeps_style() { let f = fx(); let v = f.ev(f.title, "text/sourceText", "'Frame ' + timeToFrames()", 1.0).unwrap(); let Value::Text(doc) = v else { panic!() }; assert_eq!(doc.text, "Frame 30"); assert_eq!(doc.size, 40.0, "style from the keyframed document"); } #[test] fn source_text_reading_and_string_methods() { let f = fx(); assert_eq!(f.text("value + '!'", 0.0), "Hello!"); assert_eq!(f.text("thisComp.layer('Title').text.sourceText + ' world'", 0.0), "Hello world"); assert_eq!(f.text("text.sourceText.toUpperCase()", 0.0), "HELLO"); assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0); } #[test] fn source_text_style_api() { let mut f = fx(); let doc = |f: &Fx, e: &str| -> TextDoc { match f.ev(f.title, "text/sourceText", e, 0.0) { Ok(Value::Text(d)) => *d, r => panic!("{e}: {r:?}"), } }; // Getters read the first character's style. assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fontSize", 0.0), 40.0); assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.isAllCaps ? 1 : 0", 0.0), 0.0); assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.autoLeading ? 1 : 0", 0.0), 1.0); assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.leading", 0.0), 48.0); assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fillColor[1]", 0.0), 1.0); assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setFontSize(12).fontSize", 0.0), 12.0); // Setters chain and return a styled document. let d = doc(&f, "text.sourceText.style.setFontSize(80).setFillColor([1, 0, 0]).setAllCaps(true)"); assert_eq!(d.text, "Hello"); assert!(d.is_uniform()); assert_eq!((d.size, d.fill, d.all_caps), (80.0, [1.0, 0.0, 0.0, 1.0], true)); // Character ranges create runs; setText replaces the text. let d = doc(&f, "text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)"); assert_eq!(d.text, "Hi there"); let sizes: Vec<(usize, f64, bool)> = d.runs().iter().map(|r| (r.len, r.style.size, r.style.faux_bold)).collect(); assert_eq!(sizes, vec![(2, 40.0, true), (1, 40.0, false), (5, 20.0, false)]); // getStyleAt reads a styled document per character (and at a time). { let l = f.layer_mut(f.title); let p = l.props.prop_mut("text/sourceText").unwrap(); let Value::Text(d) = &mut p.value else { panic!() }; d.apply_style(1..3, |s| s.size = 99.0); } assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(2, 0).fontSize", 0.0), 99.0); assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(4).fontSize", 0.0), 40.0); // Returning the own style keeps its runs; plain strings keep the first character's style. let d = doc(&f, "text.sourceText.style"); assert_eq!(d.runs().len(), 3); let d = doc(&f, "'Bye'"); assert_eq!((d.text.as_str(), d.size), ("Bye", 40.0)); // Paragraph setters. let d = doc(&f, "text.sourceText.style.setJustification('CENTER_JUSTIFY').setStartIndent(12).setSpaceBefore(4).setEveryLineComposer(false)"); assert_eq!(d.justify, effectcraft_keyframe::Justify::Center); assert_eq!((d.indent_left, d.space_before), (12.0, 4.0)); assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setStartIndent(7).startIndent", 0.0), 7.0); // createStyle sets only what's given and keeps the existing runs. let d = doc(&f, "thisComp.layer('Title').text.sourceText.createStyle().setTsume(50).setBaselineOption('superscript')"); assert_eq!(d.text, "Hello"); assert!(d.runs().iter().all(|r| r.style.tsume == 50.0 && r.style.baseline == effectcraft_keyframe::BaselineOption::Superscript)); assert_eq!(d.runs().len(), 3); // Non-text properties have no style. assert!(f.ev(f.a, ROT, "transform.rotation.style.fontSize", 0.0).is_err()); } #[test] fn numbers_to_source_text() { let f = fx(); assert_eq!(f.text("Math.round(time * 10) / 10", 1.25), "1.3"); assert_eq!(f.text("(time * 2).toFixed(2)", 1.0), "2.00"); assert_eq!(f.text("12", 0.0), "12"); } // ---- space conversions ------------------------------------------------------------------------ #[test] fn to_comp_and_from_comp() { let f = fx(); // A: 20×20 solid, anchor (10, 10), position (50, 40). assert_eq!(f.pos("toComp([0, 0])", 0.0), [40.0, 30.0, 0.0]); assert_eq!(f.pos("fromComp([50, 40])", 0.0), [10.0, 10.0, 0.0]); // Rotation keyed to 90° at 1 s. let p = f.pos("toComp([20, 10])", 1.0); assert!(close(p[0], 50.0) && close(p[1], 50.0), "{p:?}"); let q = f.pos("toWorld([10, 10, 0])", 0.0); assert!(close(q[0], 50.0) && close(q[1], 40.0), "{q:?}"); let v = f.pos("toCompVec([1, 0])", 1.0); assert!(close(v[0], 0.0) && close(v[1], 1.0), "{v:?}"); } #[test] fn source_rect_at_time() { let f = fx(); assert_eq!(f.pos("var r = sourceRectAtTime(); [r.width, r.height]", 0.0), [20.0, 20.0, 0.0]); let t = f.ev(f.title, POS, "var r = sourceRectAtTime(); [r.width, r.height]", 0.0).unwrap().as_vec3(); assert!(t[0] > 40.0 && t[1] > 15.0, "{t:?}"); let s = f.ev(f.shape, POS, "var r = sourceRectAtTime(); [r.left, r.top, r.width]", 0.0).unwrap().as_vec3(); assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}"); } // ---- colour ----------------------------------------------------------------------------------- #[test] fn colour_conversions() { let f = fx(); let hsl = f.pos("rgbToHsl([1, 0, 0, 1])", 0.0); assert_eq!(hsl, [0.0, 1.0, 0.5]); let back = f.pos("hslToRgb([2 / 3, 1, 0.5, 1])", 0.0); assert!(close(back[0], 0.0) && close(back[1], 0.0) && close(back[2], 1.0), "{back:?}"); let hex = f.pos("hexToRgb('#FF8000')", 0.0); assert!(close(hex[0], 1.0) && close(hex[1], 128.0 / 255.0) && close(hex[2], 0.0), "{hex:?}"); } #[test] fn colour_result_keeps_alpha() { let f = fx(); let path = "contents/#1/contents/fill/color"; let v = f.ev(f.shape, path, "hslToRgb([0, 0, 0.5])", 0.0).unwrap(); assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); } // ---- result conversion and errors ------------------------------------------------------------- #[test] fn checkbox_and_dropdown_results() { let mut f = fx(); let spec = effectcraft_effects::find("ec.control.checkbox").unwrap(); let cb = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Checkbox Control", [20.0, 20.0])); let spec = effectcraft_effects::find("ec.control.dropdown").unwrap(); let dd = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Dropdown Menu Control", [20.0, 20.0])); let fxg = f.layer_mut(f.b).props.sub_mut("effects").unwrap(); fxg.children.push(cb.into()); fxg.children.push(dd.into()); assert_eq!(f.ev(f.b, "effects/#1/checkbox", "time > 1", 2.0).unwrap(), Value::Bool(true)); assert_eq!(f.ev(f.b, "effects/#1/checkbox", "0", 2.0).unwrap(), Value::Bool(false)); assert_eq!(f.ev(f.b, "effects/#2/menu", "3", 0.0).unwrap(), Value::Enum(2)); assert_close!(f.ev(f.b, ROT, "effect('Dropdown Menu Control')('Menu') * 10", 0.0).unwrap().as_f64(), 10.0); } #[test] fn syntax_error_reports_line() { let f = fx(); let e = f.err("var a = 1;\nvar b = ;\na"); assert!(e.contains("line 2"), "{e}"); assert!(check_syntax("var a = (1;").is_err()); assert!(check_syntax("value + [1, 2]").is_ok()); } #[test] fn runtime_error_reports_line() { let f = fx(); let e = f.err("var a = 1;\nfoo.bar + a"); assert!(e.contains("foo"), "{e}"); assert!(e.contains("line 2"), "{e}"); } #[test] fn thrown_errors_and_missing_layers() { let f = fx(); assert!(f.err("throw new Error('custom failure')").contains("custom failure")); assert!(f.err("thisComp.layer('Ghost').position").contains("Ghost")); assert!(f.err("thisComp.layer(99).name").contains("99")); } #[test] fn result_kind_errors() { let f = fx(); assert!(f.ev(f.a, POS, "5", 0.0).unwrap_err().contains("dimension")); assert!(f.err("'abc'").contains("number")); assert!(f.err("var x = 1").contains("undefined")); assert!(f.err("0 / 0").contains("finite")); } #[test] fn shadowing_layer_attribute_names_is_local() { let f = fx(); assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0); assert_close!(f.num("width", 0.0), 20.0, 1e-9); } #[test] fn try_catch_does_not_swallow_host_reads() { let f = fx(); assert_close!(f.num("try { thisComp.layer('B').transform.position[0] } catch (e) { -1 }", 0.0), 100.0); assert_close!(f.num("try { thisComp.layer('Ghost').name; 1 } catch (e) { -1 }", 0.0), -1.0); } #[test] fn loops_over_layers() { let f = fx(); let e = "var s = 0;\nfor (var i = 1; i <= thisComp.numLayers; i++) s += thisComp.layer(i).transform.position[0];\ns"; assert_close!(f.num(e, 0.0), 50.0 + 100.0 + 20.0 + 100.0); } #[test] fn infinite_loops_are_stopped() { let f = fx(); let e = f.err("while (true) {}"); assert!(!e.is_empty()); } #[test] fn renderer_uses_expressions_through_eval_ctx() { let mut f = fx(); f.set_expr(f.b, ROT, "time * 360"); let host = Expressions; let comp = f.p.comp(f.cid).unwrap(); let b = comp.layer(f.b).unwrap(); let prop = b.props.prop(ROT).unwrap(); let mut ctx = EvalCtx::new(&f.p, f.cid, comp, secs(0.25)); assert_eq!(ctx.value(b, prop), Value::Scalar(0.0), "no host: keyframed value"); ctx.expr = Some(&host); assert_eq!(ctx.value(b, prop), Value::Scalar(90.0)); } #[test] fn disabled_expression_is_ignored() { let mut f = fx(); f.set_expr(f.b, ROT, "time * 360"); f.layer_mut(f.b).props.prop_mut(ROT).unwrap().expr.as_mut().unwrap().enabled = false; assert_eq!(eval_property(&f.p, f.cid, f.b, ROT, 0.25).unwrap(), Value::Scalar(0.0)); } // ---- render & performance --------------------------------------------------------------------- #[test] fn expression_drives_rotation_in_a_rendered_frame() { let mut p = Project::default(); let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); // A 80×10 horizontal bar at the centre; rotating 90° makes it vertical. let mut bar = solid(&mut p, &comp, "Bar", [1.0, 1.0, 1.0], 80, 10); bar.props.prop_mut(ROT).unwrap().expr = Some(Expression { text: "time * 360".into(), enabled: true }); p.comp_mut(cid).unwrap().layers.push(bar); let host = Expressions; let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() }); let still = r.comp_frame(cid, Tick::ZERO); assert!(still.get(85, 50)[3] > 0.99 && still.get(50, 85)[3] < 0.01, "horizontal at t=0"); let no_expr = r.comp_frame(cid, secs(0.25)); assert!(no_expr.get(85, 50)[3] > 0.99, "without a host the bar stays put"); r.expr = Some(&host); let img = r.comp_frame(cid, secs(0.25)); assert!(img.get(50, 85)[3] > 0.99, "vertical at t=0.25 (90°)"); assert!(img.get(85, 50)[3] < 0.01); } #[test] fn hundred_wiggles_are_fast() { let mut p = Project::default(); let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(10.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); for i in 0..100 { let mut l = solid(&mut p, &comp, &format!("L{i}"), [1.0, 1.0, 1.0], 10, 10); l.props.prop_mut(POS).unwrap().expr = Some(Expression { text: "wiggle(2, 50)".into(), enabled: true }); p.comp_mut(cid).unwrap().layers.push(l); } let host = Expressions; let c = p.comp(cid).unwrap(); let frame = |t: f64| { let mut ctx = EvalCtx::new(&p, cid, c, secs(t)); ctx.expr = Some(&host); c.layers.iter().map(|l| ctx.value(l, l.props.prop(POS).unwrap()).as_vec3()[0]).sum::() }; frame(0.0); // warm up: runtime + compile let n = 20; let start = Instant::now(); let mut acc = 0.0; for i in 0..n { acc += frame(i as f64 / 30.0); } let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64; println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})"); // Generous bound so unoptimised CI builds pass; release is far below. assert!(per_frame < 100.0, "{per_frame} ms/frame"); } #[test] fn error_messages_point_at_the_users_line() { let f = fx(); assert_eq!(f.err("var a = 1;\n\nthisComp.layer('Ghost').position"), "Error at line 3: Layer named \"Ghost\" does not exist"); assert_eq!(f.err("var a = 1;\nfoo.bar + a"), "Error at line 2: ReferenceError: foo is not defined"); // An unfinished last line is reported on that line, not on the hidden wrapper. assert!(f.err("5 +").starts_with("Error at line 1: SyntaxError")); } /// Text Expression Selector: Amount evaluated per unit with textIndex / textTotal / /// selectorValue. #[test] fn text_expression_selector_values() { let mut p = Project::default(); let comp = Comp::new(400, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); let mut l = build::layer(&mut p, &comp, "T", LayerSource::Text, (400, 200), None); l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "ABCD".into(), size: 40.0, ..Default::default() })); let mut next = p.next_id; let mut ids = Ids(&mut next); let props = build::text_anim_props(&mut ids, "opacity", false); let mut a = build::text_animator(&mut ids, "Animator 1", props); let sel = build::expression_selector(&mut ids, "Expression Selector 1"); a.sub_mut("selectors").unwrap().children.push(sel.into()); p.next_id = next; l.props.group_mut("text/animators").unwrap().children.push(a.into()); let lid = l.id; p.comp_mut(cid).unwrap().layers.push(l); let sel_of = |p: &Project| { let c = p.comp(cid).unwrap(); let l = c.layer(lid).unwrap(); let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); let anim = l.props.group("text/animators/#1").unwrap(); effectcraft_render::text::animator_selection(&ctx, l, anim, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect::>() }; // Default: selectorValue * textIndex / textTotal (selectorValue = the full range selector). assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.75, 1.0]); let set_expr = |p: &mut Project, text: &str| { let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap(); l.props.prop_mut("text/animators/#1/selectors/#2/amount").unwrap().expr = Some(Expression { text: text.into(), enabled: true }); }; // Range selector covering the first half → selectorValue 0 for the second half. p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(50.0); assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.0, 0.0]); // Plain numbers, conditionals on textIndex (1-based) and arrays all work. p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(100.0); set_expr(&mut p, "textIndex % 2 == 0 ? 100 : 0"); assert_eq!(sel_of(&p), vec![0.0, 1.0, 0.0, 1.0]); set_expr(&mut p, "[50, 0, 0]"); assert_eq!(sel_of(&p), vec![0.5; 4]); // The result replaces the selection so far (which it reads as selectorValue). set_expr(&mut p, "selectorValue - textIndex / textTotal * 100"); assert_eq!(sel_of(&p), vec![0.75, 0.5, 0.25, 0.0]); // Errors fall back to the static amount (100%). set_expr(&mut p, "nope("); assert_eq!(sel_of(&p), vec![1.0; 4]); // Outside a selector the expression still evaluates (textIndex = textTotal = 1). set_expr(&mut p, build::EXPRESSION_SELECTOR_DEFAULT); let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap(); assert_eq!(v, Value::Vec3([100.0; 3])); } /// `sampleImage` reads the layer's rendered pixels (layer space, straight colour) after masks /// and effects, or the bare source with `postEffect = false`; the radius averages a box. #[test] fn sample_image_reads_rendered_pixels() { let f = fx(); let code = |c: &str| format!("var c = {c}; c[0] * 1000 + c[1] * 100 + c[2] * 10 + c[3]"); // B: 40×40 blue solid. assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([20, 20])"), 0.0), 11.0); // Half the box is outside the layer: alpha 0.5, colour still pure blue (straight). assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([0, 20], [1, 1])"), 0.0), 10.5); // A: red with a mask covering 5..15: outside the mask it's transparent after masks, red before. assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2])"), 0.0), 0.0); assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2], [0.5, 0.5], false)"), 0.0), 1001.0); assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([10, 10])"), 0.0), 1001.0); // The result has four values; outside the layer it's transparent. assert_close!(f.num("thisComp.layer(\"B\").sampleImage([20, 20]).length", 0.0), 4.0); assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0); } fn data_item(p: &mut Project, name: &str, text: &str) { let f = effectcraft_project::Footage { path: format!("/data/{name}"), kind: effectcraft_project::FootageKind::Data, data: Some(text.into()), ..Default::default() }; p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f)); } /// Data-driven animation: `footage(name)` with `sourceData` / `sourceText` / `dataValue` for /// JSON, CSV and TSV items. #[test] fn data_footage_json_csv_tsv() { let mut f = fx(); data_item(&mut f.p, "stats.json", r#"{"title": "Q3", "items": [{"v": 3}, {"v": 7.5}]}"#); data_item(&mut f.p, "scores.csv", "name,score\n\"Smith, J\",12\nLee,30.5\n"); data_item(&mut f.p, "table.tsv", "a\tb\n1\t2\n3\t4\n"); assert_close!(f.num("footage(\"stats.json\").sourceData.items[1].v", 0.0), 7.5); assert_eq!(f.text("footage(\"stats.json\").sourceData.title", 0.0), "Q3"); assert_close!(f.num("footage(\"stats.json\").dataValue([\"items\", 0, \"v\"])", 0.0), 3.0); assert_close!(f.num("footage(\"scores.csv\").sourceData[1].score", 0.0), 30.5); assert_eq!(f.text("footage(\"scores.csv\").sourceData[0].name", 0.0), "Smith, J"); assert_close!(f.num("footage(\"scores.csv\").dataValue([1, 0])", 0.0), 12.0); assert_close!(f.num("footage(\"scores.csv\").dataKeyCount", 0.0), 2.0); assert_eq!(f.text("footage(\"scores.csv\").dataKeyNames.join('|')", 0.0), "name|score"); assert_close!(f.num("footage(\"table.tsv\").dataValue([1, 1])", 0.0), 4.0); assert_close!(f.num("footage(\"table.tsv\").sourceText.length", 0.0), 12.0); assert!(f.err("footage(\"missing.json\").sourceData").contains("does not exist")); // Animated from data: the value follows the CSV row picked by time. assert_close!(f.num("footage(\"scores.csv\").dataValue([1, Math.floor(time)])", 1.2), 30.5); } #[test] fn project_and_time_globals() { let mut f = fx(); assert_close!(f.num("thisProject.bitsPerChannel", 0.0), 8.0); assert_close!(f.num("colorDepth", 0.0), 8.0); assert_close!(f.num("thisProject.linearBlending ? 1 : 0", 0.0), 0.0); assert_eq!(f.text("timeToFeetAndFrames(2)", 0.0), "3+12"); assert_close!(f.num("posterizeTime(4); time", 0.6), 0.5); // Path API. assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0); assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0); assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0); assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[1]", 0.0), 5.0); assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[0]", 0.0), 10.0); assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).tangentOnPath(0.25)[0]", 0.0), 1.0); assert_close!(f.num("mask(\"Mask 1\").maskPath.pointOnPath(0)[0] - mask(\"Mask 1\").maskPath.points()[0][0]", 0.0), 0.0); assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0); // Marker keys carry protected regions and cue points. { let a = f.a; let l = f.layer_mut(a); l.markers[0].protected = true; l.markers[0].cue_point = Some(effectcraft_project::CuePoint { name: "go".into(), navigation: false, params: vec![("k".into(), "v".into())] }); } assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0); assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov"); } #[test] fn thousands_of_host_reads_on_one_thread_keep_working() { // Every pending host request unwinds through call frames, which leaks boa stack slots; the // runtime replaces a context that fills up instead of failing every later expression. let f = fx(); for i in 0..2000 { let t = (i % 20) as f64 * 0.05; assert_eq!(f.num("effect(\"Slider Control\")(\"Slider\")", t), 42.0, "evaluation {i}"); assert!(close(f.num("thisComp.layer(\"A\").transform.rotation.valueAtTime(1)", t), 90.0), "evaluation {i}"); assert_eq!(f.num("function a() { return effect(1)(1); } function b() { return a(); } b() + 1", t), 43.0, "evaluation {i}"); } }