use std::time::Instant; mod deep_nesting; mod fuzz; 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].into(); 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 an_expression_cannot_rebind_a_prelude_name_for_the_next_one() { let f = fx(); // #2133: one runtime serves every expression on a thread and user code runs at global scope, so // a write to a prelude name used to outlive the expression that made it and poison every later // one — in this session's other projects too. assert_close!(f.num("linear = function () { return 777 };\n50", 0.0), 50.0); assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0); assert_close!(f.num("var clamp = 5;\n10", 0.0), 10.0); assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); assert_close!(f.num("Math.sin = function () { return 9 };\n1", 0.0), 1.0); assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); assert_close!(f.num("Object.prototype.leak = 9;\n2", 0.0), 2.0); assert_close!(f.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0); // The runtime is one thread-local singleton, so a second project on this thread meets the same // objects: the isolation has to hold between projects, not only between the evaluations of one. let g = fx(); assert_close!(g.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0); assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0); assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); assert_close!(g.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0); } /// The four ways a script can reach a global binding, against a prelude *function* rather than one /// of the unqualified accessors: assignment, `var`, `delete` and `defineProperty`. The lock is /// `writable: false, configurable: false`, and it is the second half of that pair that answers the /// last two — a `delete` of a non-configurable property is false, and a `defineProperty` over one /// is a `TypeError`. Asked for by a reviewer on #2176. #[test] fn a_prelude_function_survives_every_way_a_script_can_reach_its_binding() { let f = fx(); // Assignment and `var`: silent, and the next expression still has the original. assert_close!(f.num("clamp = function () { return 777 };\n1", 0.0), 1.0); assert_close!(f.num("var clamp = 5;\n2", 0.0), 2.0); assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); // `delete` of a non-configurable binding answers false in sloppy mode rather than throwing. assert_close!(f.num("delete clamp ? 1 : 0", 0.0), 0.0); assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); assert_close!(f.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0); // `defineProperty` over one is a `TypeError`, so this is the one route the user does see. The // expression fails; what it was reaching for is unchanged for the next one. assert!(f.err("Object.defineProperty(globalThis, 'clamp', { value: 5 })").contains("TypeError")); assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); // Reconfiguring it to writable is refused the same way, so the lock cannot be reopened. assert!(f.err("Object.defineProperty(globalThis, 'clamp', { writable: true })").contains("TypeError")); assert_close!(f.num("clamp = 7;\n3", 0.0), 3.0); assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); // A second project on the same thread-local runtime meets the same bindings. let g = fx(); assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0); assert_close!(g.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0); } /// Which mode a write is silent in decides what the user sees, and the answer is that an expression /// cannot reach strict mode at all: `Runtime::script` wraps the text in `try{…}catch(__e){throw __e}`, /// so a `"use strict"` the user writes is an expression statement inside a block rather than a /// directive prologue, and has no effect. A failed write is therefore always the silent kind. /// Asked for by a reviewer on #2176. #[test] fn a_use_strict_in_an_expression_is_inert_so_a_refused_write_stays_silent() { let f = fx(); assert_close!(f.num("\"use strict\";\nlinear = function () { return 777 };\n50", 0.0), 50.0); assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0); // The directive is inert rather than honoured: in strict mode an assignment to an undeclared // name is a ReferenceError, and here it quietly creates a global instead. assert_close!(f.num("\"use strict\";\nundeclared_global = 5;\nundeclared_global", 0.0), 5.0); // Which is also why the refused write above cannot be made to report itself by the user: the // only mode an expression runs in is the one where it is dropped. assert_close!(f.num("\"use strict\";\ntypeof linear === 'function' ? 1 : 0", 0.0), 1.0); } /// The same leak, reached through the objects instead of the names: a prelude constructor keeps its /// methods on its `prototype`, the prelude itself calls `Array.isArray` and `Object.setPrototypeOf` /// while it answers an expression, and `thisProject` is reachable surface that holds no function at /// all (#2133). #[test] fn an_expression_cannot_tamper_with_a_prelude_object_for_the_next_one() { let f = fx(); // An unqualified name is an accessor, so it takes a `defineProperty` or a `delete` rather than // an assignment; the setter an assignment goes through writes per-evaluation state. assert_close!(f.num("try { Object.defineProperty(globalThis, 'width', { value: 777 }) } catch (e) {}\n1", 0.0), 1.0); assert_close!(f.num("delete width;\n2", 0.0), 2.0); assert_close!(f.num("width", 0.0), 20.0); assert_close!(f.num("Comp.prototype = {};\n3", 0.0), 3.0); assert_close!(f.num("Comp.prototype.leak = 9;\n4", 0.0), 4.0); assert_close!(f.num("thisComp.width", 0.0), 200.0); // `add` reads its arguments through `Array.isArray`, and reaching an effect builds a group with // `Object.setPrototypeOf`, so a replacement for either would change the answers below. assert_close!(f.num("Array.isArray = function () { return false };\n5", 0.0), 5.0); assert_close!(f.num("Object.setPrototypeOf = function (o) { return o };\n6", 0.0), 6.0); assert_close!(f.num("add([1, 2], [3, 4])[0]", 0.0), 4.0); assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0); assert_close!(f.num("thisProject = 5;\nthisProject.leak = 9;\n7", 0.0), 7.0); assert_close!(f.num("typeof thisProject === 'object' && thisProject.leak === undefined ? 1 : 0", 0.0), 1.0); } /// `Math` and `JSON` are namespaces rather than prototypes: nothing inherits from them, so the /// prelude freezes them plainly instead of turning every method into a shadowable accessor, which /// would put a getter call on every `Math.sin` of a per-frame expression for a shadowing no object /// can do. They still cannot be rewritten, through a member or through the binding (#2176). #[test] fn math_and_json_are_frozen_and_cannot_be_replaced_for_the_next_expression() { let f = fx(); assert_close!(f.num("Object.isFrozen(Math) && Object.isFrozen(JSON) ? 1 : 0", 0.0), 1.0); // Plain data properties, not the accessor repair: the hot path reads the native method directly. assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(Math, 'sin') ? 1 : 0", 0.0), 1.0); assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(JSON, 'stringify') ? 1 : 0", 0.0), 1.0); // A member write is dropped in silence, and the next expression still has the original. assert_close!(f.num("Math.sin = function () { return 9 }; Math.max = function () { return -1 };\n1", 0.0), 1.0); assert_close!(f.num("Math.sin(Math.PI / 2) * 100 + Math.max(3, 7)", 0.0), 107.0); assert_close!(f.num("JSON.stringify = function () { return 'x' }; JSON.parse = null;\n2", 0.0), 2.0); assert_close!(f.num("JSON.stringify(JSON.parse('[1,2]')) === '[1,2]' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("Math.leak = 5; JSON.leak = 6;\n3", 0.0), 3.0); assert_close!(f.num("Math.leak === undefined && JSON.leak === undefined ? 1 : 0", 0.0), 1.0); assert_close!(f.num("delete Math.floor ? 1 : 0", 0.0), 0.0); assert!(f.err("Object.defineProperty(Math, 'floor', { value: 5 })").contains("TypeError")); // The bindings are locked too: assignment, `var` and `delete` leave both in place. assert_close!(f.num("Math = { sin: function () { return 9 } }; JSON = null;\n4", 0.0), 4.0); assert_close!(f.num("var Math = 5; var JSON = 6;\n5", 0.0), 5.0); assert_close!(f.num("delete Math || delete JSON ? 1 : 0", 0.0), 0.0); assert_close!(f.num("Math.floor(2.7) + JSON.parse('3')", 0.0), 5.0); // A second project on the same thread-local runtime meets the same objects. let g = fx(); assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); assert_close!(g.num("JSON.stringify([1]) === '[1]' ? 1 : 0", 0.0), 1.0); } /// Closing the prototypes must not cost an expression its own objects: once an inherited data /// property is non-writable, an assignment meaning to shadow it on an instance is dropped in sloppy /// mode rather than creating an own property, and using an object as a dictionary runs into exactly /// those names (#2133). #[test] fn an_expression_can_shadow_an_inherited_name_on_its_own_object() { let f = fx(); assert_close!(f.num("var o = {}; o.toString = function () { return 'x' }; o.toString() === 'x' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("var d = {}; d.constructor = 3; d.constructor", 0.0), 3.0); assert_close!(f.num("var c = {}; c['valueOf'] = 2; c['valueOf']", 0.0), 2.0); // The shared prototype itself still cannot be written through, which is the point of freezing. assert_close!(f.num("Object.prototype.toString = function () { return 'z' };\n1", 0.0), 1.0); assert_close!(f.num("({}).toString() === 'z' ? 0 : 1", 0.0), 1.0); } /// The names an expression shadows are not only `Object.prototype`'s: giving one array its own /// `map` or one function its own `call` is ordinary JavaScript, and a prototype frozen without the /// repair takes it away. The two halves fail differently, which is why both are checked here — /// `map`, `forEach` and `filter` throw, while `push` and `join` silently run the native method and /// return its answer, so an expression using them keeps working and quietly computes something /// else (#2133). #[test] fn an_expression_can_shadow_an_inherited_method_on_its_own_array_or_function() { let f = fx(); assert_close!(f.num("var a = []; a.map = function () { return 42 }; a.map()", 0.0), 42.0); assert_close!(f.num("var a = [1, 2]; a.forEach = function () { return 5 }; a.forEach()", 0.0), 5.0); assert_close!(f.num("var a = [1, 2]; a.filter = function () { return 6 }; a.filter()", 0.0), 6.0); // `push` returned the native length and `join` the native string before the repair. assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2)", 0.0), 99.0); assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2); a.length", 0.0), 1.0); assert_close!(f.num("var a = [1]; a.join = function () { return 'J' }; a.join() === 'J' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("var fn = function () {}; fn.call = function () { return 7 }; fn.call()", 0.0), 7.0); assert_close!(f.num("var fn = function () {}; fn.apply = function () { return 8 }; fn.apply()", 0.0), 8.0); // A symbol key on a prototype the loop reaches goes the same way. assert_close!(f.num("var a = [1]; a[Symbol.toPrimitive] = function () { return 11 }; +a", 0.0), 11.0); // Reading an unshadowed method still answers with the native one, through the repair. assert_close!(f.num("[1, 2, 3].map(function (x) { return x * 2 })[2]", 0.0), 6.0); assert_close!(f.num("[1, 2, 3].filter(function (x) { return x > 1 }).length", 0.0), 2.0); assert_close!(f.num("[3, 1, 2].sort()[0]", 0.0), 1.0); assert_close!(f.num("[1, 2].concat([3]).length", 0.0), 3.0); // The shared prototype itself still cannot be written through, which is the point of freezing. assert_close!(f.num("Array.prototype.map = function () { return 0 };\n1", 0.0), 1.0); assert_close!(f.num("[1].map(function (x) { return x + 1 })[0]", 0.0), 2.0); assert_close!(f.num("Function.prototype.call = function () { return 0 };\n1", 0.0), 1.0); assert_close!(f.num("(function () { return 4 }).call(null)", 0.0), 4.0); } /// `name` and `message` on an error's prototype are the one exception to the repair, because the /// host reads both off a thrown object to render it and has no context to call a getter with, nor /// anywhere safe to call one: formatting happens after the evaluation's time budget stops being /// checked. What matters is that the message still names the error and the line, and that the /// assignment the exception costs is dropped in silence rather than failing the expression (#2133). #[test] fn an_error_still_renders_with_its_name_and_the_users_line() { let f = fx(); // The write the exception costs: a no-op, not a throw, and the next statement still runs. assert_close!(f.num("var e = new Error('x'); e.name = 'MyError'; 1", 0.0), 1.0); assert_close!(f.num("var e = new Error('x'); e.name === 'Error' ? 1 : 0", 0.0), 1.0); // An own `message` is the instance's, so it is untouched by any of this. assert_close!(f.num("new Error('x').message === 'x' ? 1 : 0", 0.0), 1.0); // The prototype itself still cannot be rebound for the next expression, which is the point. assert_close!(f.num("Error.prototype.name = 'Hacked';\n1", 0.0), 1.0); assert_close!(f.num("new Error('x').name === 'Error' ? 1 : 0", 0.0), 1.0); // A getter the expression installs on its own error is reached through its own property, so it // never goes near the host's read of the prototype. assert_close!(f.num("var e = new Error('x'); Object.defineProperty(e, 'name', { get: function () { return 'Own' } }); e.name === 'Own' ? 1 : 0", 0.0), 1.0); } /// The repair must not turn a dropped assignment into a failed expression: in sloppy mode a write /// to a frozen target is ignored, and the setter runs for every receiver that inherits the name, /// including the other frozen prototypes and an object the expression froze itself. #[test] fn assigning_a_repaired_name_on_a_frozen_object_is_dropped_not_an_error() { let f = fx(); // `hasOwnProperty` is `Object.prototype`'s, so this runs the repaired setter with a frozen // `Array.prototype` as the receiver. assert_close!(f.num("Array.prototype.hasOwnProperty = function () { return true };\n1", 0.0), 1.0); assert_close!(f.num("[].hasOwnProperty('length') ? 1 : 0", 0.0), 1.0); // The prelude freezes these, and each inherits the repaired names. assert_close!(f.num("Math.toString = function () { return 'm' };\n2", 0.0), 2.0); assert_close!(f.num("JSON.valueOf = 3;\n3", 0.0), 3.0); assert_close!(f.num("thisProject.toLocaleString = 4;\n4", 0.0), 4.0); // An object the expression froze itself behaves as it does outside the prelude. assert_close!(f.num("var o = {}; Object.freeze(o); o.toString = function () { return 'x' };\n5", 0.0), 5.0); assert_close!(f.num("var s = {}; Object.seal(s); s.constructor = 6;\n6", 0.0), 6.0); // Still a no-op where it has to be, and still working where it has to work. assert_close!(f.num("({}).toString() === '[object Object]' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("var k = {}; k.toString = function () { return 'y' }; k.toString() === 'y' ? 1 : 0", 0.0), 1.0); // A primitive target boxes into a throwaway wrapper, and `null` is not extensible, which is why // the receiver check alone is enough. assert_close!(f.num("'abc'.toString = function () { return 'q' };\n7", 0.0), 7.0); assert_close!(f.num("(5).constructor = 8;\n8", 0.0), 8.0); assert_close!(f.num("Object.isExtensible(null) === false && Object.isExtensible(undefined) === false ? 1 : 0", 0.0), 1.0); } /// The repaired name is an accessor standing in for a data property, and an accessor is handed the /// receiver while a data property has the receiver's own property consulted first, so the setter /// has to do that itself or the substitution shows through. An ordinary assignment finds the own /// property and never reaches the setter, so these call it with the receiver directly, which is /// what `Reflect.set(Object.prototype, name, v, o)` would do; `Reflect` is not available to /// expressions. Every expected value is what that `Reflect.set` answers in node, where the name /// is still the data property this one stands in for. #[test] fn the_repair_answers_a_write_as_the_data_property_it_replaced_would() { let f = fx(); let set = "var put = Object.getOwnPropertyDescriptor(Object.prototype, 'toString').set;"; // An own writable property is updated in place, even when the receiver takes no new ones. assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; Object.preventExtensions(o); put.call(o, 2); o.toString"), 0.0), 2.0); assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; put.call(o, 2); o.toString"), 0.0), 2.0); // ... and keeps the attributes it had, rather than being redefined as a fresh enumerable one. assert_close!( f.num( &format!( "{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: true, enumerable: false, configurable: true }}); put.call(o, 2); Object.getOwnPropertyDescriptor(o, 'toString').enumerable ? 1 : 0" ), 0.0 ), 0.0 ); // An own non-writable property drops the write instead of being overwritten. assert_close!( f.num( &format!( "{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: false, enumerable: false, configurable: true }}); put.call(o, 2); o.toString" ), 0.0 ), 1.0 ); // Writing through the prototype itself changes nothing for anyone. assert_close!(f.num(&format!("{set} put.call(Object.prototype, 2); ({{}}).toString === undefined ? 0 : 1"), 0.0), 1.0); // An ordinary assignment finds the own property first and never reaches the setter at all. assert_close!(f.num("var o = { toString: 1 }; o.toString = 2; o.toString", 0.0), 2.0); } /// `for…of`, destructuring and spread all go through the iterator intrinsics, and those are the /// `prototype` of no global function, so freezing what the globals reach never touched them (#2133). #[test] fn an_expression_cannot_poison_iteration_for_the_next_one() { let f = fx(); assert_close!(f.num("Object.getPrototypeOf([][Symbol.iterator]()).next = function () { return { done: true } };\n1", 0.0), 1.0); assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0); assert_close!(f.num("var [a, b] = [4, 5]; a + b", 0.0), 9.0); assert_close!(f.num("Math.max(...[1, 7, 3])", 0.0), 7.0); } /// Shadowing an inherited name on an instance has to keep working for symbol keys too: making an /// object iterable by assigning `Symbol.iterator` is ordinary JavaScript, and a frozen /// `Array.prototype` drops that write in silence, so `for…of` walks the array's own elements and /// quietly returns a different number rather than failing (#2133). #[test] fn an_expression_can_give_its_own_array_an_iterator() { let f = fx(); assert_close!( f.num( "var a = [1, 2]; a[Symbol.iterator] = function () { var d = false; return { next: function () { if (d) return { done: true }; d = true; return { value: 9, done: false } } } }; var s = 0; for (var x of a) s += x; s", 0.0 ), 9.0 ); assert_close!(f.num("var a = [1, 2]; a[Symbol.iterator] = 1; a[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0); // `Object.prototype` carries no symbol keys, so a plain object was never affected. assert_close!(f.num("var o = {}; o[Symbol.iterator] = 1; o[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0); // An inherited name that is already non-writable is left alone, as it is outside the prelude: // `Date.prototype[Symbol.toPrimitive]` drops the write on `main` and in node as well. assert_close!(f.num("var d = new Date(0); d[Symbol.toPrimitive] = 1; d[Symbol.toPrimitive] === 1 ? 1 : 0", 0.0), 0.0); // Repairing the key must not reopen it: the shared prototype still cannot be written through. assert_close!( f.num( "Array.prototype[Symbol.iterator] = function () { return { next: function () { return { done: true } } } }; 1", 0.0 ), 1.0 ); assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.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:?}"); // After Effects 2026 read-backs of lookAt(position, …) on a layer at [960, 540, 0]. for (at, want) in [([1500.0, 200.0, 300.0], [48.5763, 49.9802, 319.0442]), ([500.0, 900.0, -400.0], [221.9872, 319.4766, 210.3184])] { let e = format!("lookAt([960, 540, 0], [{}, {}, {}])", at[0], at[1], at[2]); let o = f.ev(f.a, "transform/orientation", &e, 0.0).unwrap().as_vec3(); assert!((0..3).all(|i| (o[i] - want[i]).abs() < 1e-3), "{e}: {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]); } #[test] fn cubic_bezier_curves() { let f = fx(); // ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³. assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0)); assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0); // Symmetric ease-in-out: slower at both ends, faster in the middle. let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}"); // Control points on the diagonal are linear. assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0); // t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers. assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0); assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); } #[test] fn cubic_bezier_short_form_arrays_and_fallbacks() { let f = fx(); // Short form: no tMin / tMax. assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5); assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]); // y outside 0..1 overshoots, as CSS allows. let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0); assert!(over > 100.0, "overshoot: {over}"); // A curve that is not four finite numbers falls back to linear instead of throwing. assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); // A missing or unusable curve must not consume the last value: read the curve by position. assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0); let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap(); assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); // x control points outside 0..1 are clamped, as CSS requires. let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); assert!(v.is_finite(), "clamped curve: {v}"); } #[test] fn cubic_bezier_is_accurate_near_the_endpoints() { /// The value the curve reaches at `x`, by bisection on its monotone x(u). fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u; let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u; let (mut lo, mut hi) = (0.0f64, 1.0f64); for _ in 0..100 { let mid = 0.5 * (lo + hi); if bx(mid) < x { lo = mid; } else { hi = mid; } } by(0.5 * (lo + hi)) } // Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the // thousands), so the solver stops on the estimated y error instead. let f = fx(); for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] { let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0); let want = reference(c[0], c[1], c[2], c[3], x); assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}"); } } // ---- 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); // Only a hang guard (the budgets stop it far sooner); generous for a loaded machine. assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed()); // The cycle is caught when A needs itself again, and falls back to keyframed values. assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}"); } #[test] fn an_expression_needing_its_own_value_fails_fast() { // Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform, // which evaluates rotation's expression again, twice per level (layer-to-comp and world // matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s. let mut f = fx(); f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]"); let start = Instant::now(); let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5); assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed()); // The inner read falls back to the keyframed rotation, as in a standalone evaluation. assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5)); } #[test] fn fanning_out_reads_stop_at_a_budget() { // Two layers reading each other twice at other times: no cycle (the times differ), but // 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs. let mut f = fx(); let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)"); f.set_expr(f.a, ROT, &text("B")); f.set_expr(f.b, ROT, &text("A")); // Take the clock out of the race: on a loaded machine the time budget could otherwise stop // the evaluation before the run count does, with a different error. crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600))); let start = Instant::now(); let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5); crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME)); assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed()); assert!(r.unwrap_err().contains("too many")); // The budget is per outermost evaluation: the next one starts afresh. assert_close!(f.num("time * 2", 1.0), 2.0); assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0); } #[test] fn loops_stop_at_a_million_iterations() { // Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations // per run (seconds), once more for every property the script reads after it. let f = fx(); assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0); assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration")); assert!(f.err("for (;;) {}").contains("iteration")); } #[test] fn a_long_running_script_stops_at_the_time_budget() { // Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in // one run (tens of seconds), and the loop limit counts per function call, so loops calling // functions that loop run for hours. The run is now abandoned after `MAX_TIME`. let f = fx(); let start = Instant::now(); let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()"); assert!(e.contains("too long"), "{e}"); assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed()); // The thread's runtime starts afresh. assert_close!(f.num("time * 2", 1.0), 2.0); } #[test] fn a_path_with_non_numeric_points_is_an_error() { // Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the // rasterizer's clipping assertion when the mask was drawn. let mut f = fx(); let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)"; assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite")); assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err()); f.set_expr(f.a, "masks/#1/path", text); let host = Expressions; let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() }); r.expr = Some(&host); r.comp_frame(f.cid, Tick::ZERO); assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok()); } #[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]))]; let p = f.pos("loopOut('offset')", 1.5); assert!(p.iter().zip([150.0, 75.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{p:?}"); } #[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:?}"); } #[test] fn source_dimensions_follow_media_replacement() { let mut p = Project::default(); let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); let footage = |p: &mut Project, name: &str, width, height| { p.add_item( name, Label::SeaFoam, None, ItemKind::Footage(effectcraft_project::Footage { path: name.into(), kind: effectcraft_project::FootageKind::Still, width, height, has_video: true, ..Default::default() }), ) }; let original = footage(&mut p, "original.png", 100, 80); let replacement = footage(&mut p, "replacement.png", 30, 40); let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None); l.props.prop_mut(ROT).unwrap().expr = Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true }); let lid = l.id; p.comp_mut(cid).unwrap().layers.push(l); let c = p.comp(cid).unwrap(); let l = c.layer(lid).unwrap(); let media = effectcraft_render::MediaOverride::new(lid, replacement); let ctx = EvalCtx { project: &p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None, property_overrides: &[], media_overrides: &[media], reads: None, }; assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); } // ---- 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); assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.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 < 1000.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, property_overrides: &[], media_overrides: &[], reads: 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); } /// The footage `sampleImage` renders is a file the evaluating render read (#3184): it is noted in /// the render's log, also when the sampled buffer comes from the per-thread cache. #[test] fn sample_image_notes_the_files_it_reads() { use effectcraft_render::reads::{FileStamp, ReadFile, ReadLog}; /// A grey picture of any footage, noting `/clip.png` for every read. struct Clip; impl effectcraft_render::FootageSource for Clip { fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { let mut img = effectcraft_render::Image::new(f.width, f.height); img.data.fill([0.5, 0.5, 0.5, 1.0]); Some(std::sync::Arc::new(img)) } fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) { f(); reads.note(ReadFile::new("/clip.png", FileStamp::Native { len: 3, modified: Some(1) })); } } let mut p = Project::default(); let comp = Comp::new(20, 20, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let footage = effectcraft_project::Footage { path: "/clip.png".into(), width: 8, height: 8, has_video: true, frame_rate: FrameRate::FPS_30, ..Default::default() }; let item = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage)); let layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item }, (8, 8), None); let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); let c = p.comp(cid).unwrap(); for pass in ["rendered", "cached"] { let log = ReadLog::new(); let ctx = EvalCtx { footage: Some(&Clip), reads: Some(&log), ..EvalCtx::new(&p, cid, c, Tick::ZERO) }; let px = crate::sample::sample_image(ctx, cid, &layer, [4.0, 4.0], [0.5, 0.5], false, 0.0).unwrap(); assert!((px[0] - 0.5).abs() < 1e-3 && px[3] > 0.99, "{pass}: {px:?}"); let files = log.into_files(); assert_eq!(files.files().iter().map(|f| &*f.path).collect::>(), ["/clip.png"], "{pass}"); assert!(files.is_complete(), "{pass}"); } } /// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds. #[test] fn sample_image_huge_point_is_transparent() { let f = fx(); assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 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}"); } } #[test] fn pending_text_style_baseline_setters_are_visible_to_expression_getters() { let x = fx(); assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).isSuperscript ? 1 : 0", 0.0), 1.0); assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0); assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSuperscript ? 1 : 0", 0.0), 0.0); assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0); assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).setSubscript(false).isSubscript ? 1 : 0", 0.0), 0.0); assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setBaselineOption('superscript').isSuperscript ? 1 : 0", 0.0), 1.0); assert_eq!(x.text("var s = text.sourceText.style.setSuperscript(true); s.setText(s.isSuperscript ? 'super' : 'normal')", 0.0), "super"); } #[test] fn layer_source_is_the_layers_source_item() { let mut f = fx(); // A solid's source is an item of the solid's size and name (#809). assert_close!(f.num("thisComp.layer('A').source.width", 0.0), 20.0); assert_close!(f.num("thisComp.layer('A').source.height", 0.0), 20.0); assert_close!(f.num("thisComp.layer('A').source.name == 'A' ? 1 : 0", 0.0), 1.0); // Text and shape layers have no source item. assert_close!(f.num("thisComp.layer('Title').source == null ? 1 : 0", 0.0), 1.0); // A precomp layer's source is its composition. let main = f.p.comp(f.cid).unwrap().clone(); let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id; let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None); f.p.comp_mut(f.cid).unwrap().layers.push(pre); assert_close!(f.num("thisComp.layer('Pre').source.width", 0.0), 64.0); assert_close!(f.num("thisComp.layer('Pre').source.numLayers", 0.0), 1.0); } /// The fixture plus a 20×12 solid "Wide", a 48×36 still "Still" (item "still.png") and a precomp /// layer "Pre" of the 64×32 "Other" comp. fn implicit_source_fx() -> (Fx, LayerId, LayerId, LayerId) { let mut f = fx(); let main = f.p.comp(f.cid).unwrap().clone(); let wide = solid(&mut f.p, &main, "Wide", [1.0, 1.0, 0.0], 20, 12); let item = f.p.add_item( "still.png", Label::SeaFoam, None, ItemKind::Footage(effectcraft_project::Footage { path: "still.png".into(), kind: effectcraft_project::FootageKind::Still, width: 48, height: 36, has_video: true, ..Default::default() }), ); let still = build::layer(&mut f.p, &main, "Still", LayerSource::Footage { item }, (48, 36), None); let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id; let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None); let ids = (wide.id, still.id, pre.id); let c = f.p.comp_mut(f.cid).unwrap(); c.layers.push(wide); c.layers.push(still); c.layers.push(pre); (f, ids.0, ids.1, ids.2) } fn num_on(f: &Fx, layer: LayerId, text: &str) -> f64 { match f.ev(layer, ROT, text, 0.0) { Ok(v) => v.as_f64(), Err(e) => panic!("{text}: {e}"), } } #[test] fn implicit_source_matches_this_layer_source() { let (f, wide, still, pre) = implicit_source_fx(); let same = "['name', 'width', 'height', 'duration', 'frameDuration', 'pixelAspect']\ .every(function (k) { return source[k] === thisLayer.source[k]; }) ? 1 : 0"; for (layer, name, w, h) in [(wide, "Wide", 20.0, 12.0), (still, "still.png", 48.0, 36.0), (pre, "Other", 64.0, 32.0)] { assert_close!(num_on(&f, layer, "source.width"), w); assert_close!(num_on(&f, layer, "source.height"), h); assert_close!(num_on(&f, layer, &format!("source.name == '{name}' ? 1 : 0")), 1.0); assert_close!(num_on(&f, layer, same), 1.0); } assert_close!(num_on(&f, pre, "source.numLayers"), 1.0); } #[test] fn implicit_source_is_null_without_a_source_item() { let f = fx(); for layer in [f.title, f.shape] { assert_close!(num_on(&f, layer, "source === null && thisLayer.source === null ? 1 : 0"), 1.0); let bare = f.ev(layer, ROT, "source.width", 0.0).unwrap_err(); let qualified = f.ev(layer, ROT, "thisLayer.source.width", 0.0).unwrap_err(); assert!(qualified.contains("TypeError"), "{qualified}"); assert_eq!(bare, qualified); } } #[test] fn implicit_source_can_be_shadowed_locally() { let (f, wide, ..) = implicit_source_fx(); assert_close!(num_on(&f, wide, "var source = 7;\nsource * 2"), 14.0); assert_close!(num_on(&f, wide, "source.width"), 20.0); assert_close!(num_on(&f, wide, "var source = thisComp.layer('B').source;\nsource.width"), 40.0); assert_close!(num_on(&f, wide, "source.height"), 12.0); } #[test] fn implicit_source_follows_the_current_layer() { let (f, wide, still, pre) = implicit_source_fx(); let e = "source === null ? -1 : source.width * 1000 + source.height"; for _ in 0..2 { for (layer, want) in [(wide, 20012.0), (still, 48036.0), (pre, 64032.0), (f.title, -1.0), (f.b, 40040.0)] { assert_close!(num_on(&f, layer, e), want); } } } #[test] fn layer_control_property_stands_for_its_layer() { let mut f = fx(); // Layer A gets a Layer Control ("Map") that points at layer B (#809). let spec = effectcraft_effects::find("ec.control.layer").unwrap(); let mut map = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Map", [20.0, 20.0])); map.prop_mut("layer").unwrap().value = Value::Layer(Some(f.b.0)); f.p.comp_mut(f.cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(map.into()); // effect("Map")(1) is the layer itself: its name, index, transform and value. assert_close!(f.num("effect('Map')(1).name == 'B' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("effect('Map')(1).index", 0.0), 2.0); assert_close!(f.num("effect('Map')(1).transform.position[0]", 0.0), 100.0); assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0); assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0); } #[test] fn time_to_frames_rounds_durations_away_from_zero() { let f = fx(); // Adobe's public expression reference distinguishes durations from absolute times. // Expected integer counts are literal values, independent of the implementation. for (expression, expected) in [ ("timeToFrames(-0.01, 24, true)", -1.0), ("timeToFrames(-0.05, 24, true)", -2.0), ("timeToFrames(0, 24, true)", 0.0), ("timeToFrames(-0.125, 24, true)", -3.0), ("timeToFrames(0.125, 24, true)", 3.0), ("timeToFrames(0.01, 24, true)", 1.0), ("timeToFrames(0.05, 24, true)", 2.0), ] { assert_eq!(f.num(expression, 0.0), expected, "{expression}"); } } #[test] fn time_to_frames_rounds_absolute_times_toward_negative_infinity() { let f = fx(); for (expression, expected) in [ ("timeToFrames(-0.01, 24, false)", -1.0), ("timeToFrames(-0.05, 24, false)", -2.0), ("timeToFrames(0, 24, false)", 0.0), ("timeToFrames(-0.125, 24, false)", -3.0), ("timeToFrames(0.125, 24, false)", 3.0), ("timeToFrames(0.01, 24, false)", 0.0), ("timeToFrames(0.05, 24, false)", 1.0), ] { assert_eq!(f.num(expression, 0.0), expected, "{expression}"); } } #[test] fn time_to_frames_preserves_default_arguments() { let f = fx(); // The fixture's composition is 30 fps with displayStartTime = 0. for (expression, evaluation_time, expected) in [ ("timeToFrames(0.01, 24)", 0.0, 0.0), ("timeToFrames(-0.01, 24)", 0.0, -1.0), ("timeToFrames(0.05)", 0.0, 1.0), ("timeToFrames(-0.05)", 0.0, -2.0), ("timeToFrames()", 0.0, 0.0), ("timeToFrames()", 0.05, 1.0), ("timeToFrames()", -0.05, -2.0), ("timeToFrames(undefined, 24, true)", 0.05, 2.0), ("timeToFrames(0.05, undefined, true)", 0.0, 2.0), ] { assert_eq!(f.num(expression, evaluation_time), expected, "{expression} at {evaluation_time}s"); } } #[test] fn time_to_frames_preserves_coercion_and_small_durations() { let f = fx(); // Duration rounding must not erase tiny nonzero values; absolute times keep the existing tolerance. for (expression, expected) in [ ("timeToFrames('0.05', '24', false)", 1.0), ("timeToFrames('0.05', '24', true)", 2.0), ("timeToFrames('-0.05', '24', true)", -2.0), ("timeToFrames(null, 24, true)", 0.0), ("timeToFrames(0.05, null, true)", 0.0), ("timeToFrames(0.05, 0, true)", 0.0), ("timeToFrames(31 / 24 - 5 / 24, 24, true)", 26.0), ("timeToFrames(5 / 24 - 31 / 24, 24, true)", -26.0), ("timeToFrames(-1e-10, 24, false)", 0.0), ("1 / timeToFrames(-0, 24, true) === Infinity ? 1 : 0", 1.0), ("isNaN(timeToFrames(NaN, 24, true)) ? 1 : 0", 1.0), ("timeToFrames(Infinity, 24, true) === Infinity ? 1 : 0", 1.0), ("timeToFrames(-Infinity, 24, true) === -Infinity ? 1 : 0", 1.0), ("var calls = 0; var t = {valueOf: function () { calls++; return 0.05; }}; timeToFrames(t, 24, true) + 100 * calls", 102.0), ] { assert_eq!(f.num(expression, 0.0), expected, "{expression}"); } } /// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another /// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact /// English off (they failed: "not a callable function"). It is still a layer. #[test] fn layers_are_callable_property_groups() { let f = fx(); assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0); assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0); assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0); assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0); // Its own name, index and methods, and a layer as the result still reads as its index. assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0); assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0); assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0); assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")")); } /// A layer is kept from one evaluation to the next (it is dear to make) but reads the project /// afresh in each, and an expression's own fields on it don't reach the next one. #[test] fn a_kept_layer_reads_the_project_again() { let mut f = fx(); let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length"; assert_close!(f.num(first, 0.0), 21.0); f.layer_mut(f.b).name = "Bee".into(); f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0); assert_close!(f.num("thisLayer.index", 0.0), 2.0); let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0); assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0); assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0); assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0); f.layer_mut(f.a).name = "Ay".into(); f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0); } /// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more /// layers than are kept still sees one object for each layer it had kept, and the evaluations /// after it, which start over, read the right layers. #[test] fn the_kept_layers_are_bounded() { let f = fx(); let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)"; let many = format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)"); assert_close!(f.num(&many, 0.0), 11.0); assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); } /// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under /// the comp's id, before any of its layers is kept, doesn't stop them being kept. #[test] fn the_kept_layers_dont_go_through_object_prototype() { let f = fx(); let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0); assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0); } /// The prelude is kept from one evaluation to the next, so an expression can't change its object /// model or internals for the ones after it. #[test] fn an_expression_cant_change_the_prelude_for_the_next() { let f = fx(); let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]"; let before = f.ev(f.a, ROT, read, 0.5).unwrap(); let attacks = [ "Comp.prototype.layer = function () { return 0; }", "Object.getPrototypeOf(thisLayer).index = 0", "Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }", "Object.getPrototypeOf(thisLayer.transform.opacity).value = 0", "Comp = function () {}", "Prop = function () {}", "thisProject = null", "__h = function () { return null; }", "__layer = function () { return null; }", "__child = function () {}", "delete __mkProp", "delete thisLayer", "delete thisComp", "delete width", "Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })", ]; for a in attacks { let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0); assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`"); } } mod property_group; /// Timing harness for the prelude's cost on the evaluation path. Ignored: it is a measurement, not /// an assertion. `cargo test -p effectcraft-expr --release --lib bench_evaluation -- --ignored /// --nocapture`. #[test] #[ignore = "timing measurement, not a test"] fn bench_evaluation() { let f = fx(); // Each case runs on a property whose dimension its answer fits: a scalar handed to `position` // is rejected after the script has already run, so the case would be timing an error path. // Ordinary expressions first, then three that read an inherited name in a loop: the repair // turns each of those reads into an accessor call, and `Array.prototype.map` is the one an // expression is most likely to reach for in quantity. let cases: [(&str, &str, &str, u32); 10] = [ ("array maths", POS, "value + [10, 0]", 2500), ("scalar maths", ROT, "time * 100 + 5", 3000), ("prelude call", ROT, "linear(time, 0, 1, 0, 100)", 2500), ("cross-layer read", ROT, "thisComp.layer(\"B\").transform.position[0]", 200), ("effect read", ROT, "effect(\"Slider Control\")(\"Slider\")", 150), ("wiggle", POS, "wiggle(2, 10)", 800), ("dictionary", ROT, "var c = {}; var k = 'constructor'; c[k] = 1; c[k] + value", 2500), ("toString x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.toString) n++; } n", 250), ("hasOwnProperty x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.hasOwnProperty) n++; } n", 250), ("array method x100", ROT, "var a = [1]; var n = 0; for (var i = 0; i < 100; i++) { if (a.map) n++; } n", 250), ]; for (label, path, text, iters) in cases { let mut best = u128::MAX; // Three passes, keeping the fastest: a slower pass is scheduling noise, not the prelude. for _ in 0..3 { // The warm-up checks as it goes, at every time the measured loop uses: that loop drops // its answer by design, so a case that had started failing would be timed on its error // path and still print a number. for i in 0..50 { assert!(f.ev(f.a, path, text, f64::from(i % 10) * 0.1).is_ok(), "{label}"); } let start = std::time::Instant::now(); for i in 0..iters { let _ = f.ev(f.a, path, text, f64::from(i % 10) * 0.1); } best = best.min(start.elapsed().as_nanos() / u128::from(iters)); } println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})"); } } /// Viewport clipping must never affect layer-space expression reads (#1388). #[test] fn sample_image_reads_3d_shape_behind_camera_before_and_after_effects() { let mut p = Project::default(); let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (100, 100), None); let mut next = p.next_id; let mut ids = build::Ids(&mut next); let rect = build::shape_rect(&mut ids, [40.0, 40.0], [0.0, 0.0], 0.0); let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]); let group = build::shape_group(&mut ids, "Box", vec![rect, fill]); layer.props.sub_mut("contents").unwrap().children.push(group.into()); p.next_id = next; layer.switches.three_d = true; layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, -300.0]); let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); p.comp_mut(cid).unwrap().layers.push(layer); let comp = p.comp(cid).unwrap(); let layer = &comp.layers[0]; let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO); for post in [false, true] { let pixel = crate::sample::sample_image(ctx, cid, layer, [0.0, 0.0], [0.5, 0.5], post, 0.0).unwrap(); assert!(pixel[0] > 0.99 && pixel[3] > 0.99, "postEffect={post}: {pixel:?}"); } }