Merge expressions engine (boa)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 14:40:22 -04:00
commit e2ba01a11f
10 changed files with 4115 additions and 10 deletions

769
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@ effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-render = { path = "crates/render" }
effectcraft-media = { path = "crates/media" }
effectcraft-expr = { path = "crates/expr" }
effectcraft-engine = { path = "crates/engine" }
effectcraft-ui-egui = { path = "crates/ui-egui" }
effectcraft-automation = { path = "crates/automation" }
@ -35,6 +36,9 @@ log = "0.4"
rayon = "1"
proptest = "1"
# scripting (expressions)
boa_engine = { version = "0.22", default-features = false }
# geometry / fonts
kurbo = { version = "0.13", features = ["serde"] }
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] }

27
crates/expr/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "effectcraft-expr"
description = "After Effects-compatible expressions: a boa JavaScript runtime with the AE object model (thisComp, thisLayer, wiggle, loopOut, valueAtTime…) and array maths"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-render = { workspace = true }
boa_engine = { workspace = true }
# On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time).
[target.'cfg(target_arch = "wasm32")'.dependencies]
boa_engine = { workspace = true, features = ["js"] }
[dev-dependencies]
effectcraft-color = { workspace = true }
effectcraft-effects = { workspace = true }

352
crates/expr/src/host.rs Normal file
View File

@ -0,0 +1,352 @@
//! Host requests: everything an expression reads from the project.
//!
//! Scripts never hold references into the project. Instead they ask for plain data
//! ([`Req`] → [`Resp`]) through one native function. A request that isn't answered yet is
//! recorded as a *miss* and the script is aborted; the evaluator then resolves the misses here
//! (with full access to the project, which may recursively evaluate other expressions), memoizes
//! the answers and runs the script again. Expressions are pure functions of these answers, so
//! re-running is exact, and the runtime stays free of borrowed state.
use effectcraft_geom::{Mat3, Mat4};
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property};
use effectcraft_render::{EvalCtx, source_size};
use effectcraft_time::Tick;
/// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Key {
Index(i64),
Name(String),
}
/// Seconds as hashable bits.
pub type Secs = u64;
pub fn secs(t: f64) -> Secs {
t.to_bits()
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Req {
/// `[name, width, height, duration, frameDuration, numLayers, pixelAspect, displayStart, bgColor]`.
CompInfo { comp: u64 },
/// Comp item id or null.
CompByName { name: String },
/// Layer id or null.
LayerLookup { comp: u64, key: Key },
/// `[name, index, inPoint, outPoint, startTime, width, height, parentId|null, hasVideo, is3D, source, enabled]`.
LayerInfo { comp: u64, layer: u64 },
/// `[path, isGroup, name, matchName, numChildren]` or null.
Child { comp: u64, layer: u64, group: String, key: Key },
/// `[name, uid, kind, dims, keyTimes, keyValues, hasExpression, propertyIndex, matchName]`.
PropInfo { comp: u64, layer: u64, path: String },
/// Value at comp time `t`; `pre` = keyframes only (no expression).
Value { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
/// `[layerToComp (3×3), compToLayer, layerToWorld (4×4), worldToLayer]`, row-major.
Xform { comp: u64, layer: u64, t: Secs },
/// `[top, left, width, height]` in layer space.
SourceRect { comp: u64, layer: u64, t: Secs, extents: bool },
/// `[[time, duration, comment, chapter, url], …]` for a layer, or the comp when `layer` is None.
Markers { comp: u64, layer: Option<u64> },
}
/// Plain data answer, converted to a JS value by the runtime.
#[derive(Clone, Debug, PartialEq)]
pub enum Resp {
Null,
Bool(bool),
Num(f64),
Str(String),
List(Vec<Resp>),
}
impl Resp {
fn nums(v: impl IntoIterator<Item = f64>) -> Resp {
Resp::List(v.into_iter().map(Resp::Num).collect())
}
}
/// Number of components an expression sees: 2D layers see two-dimensional Position, Scale and
/// Anchor Point even though they're stored in 3D.
pub fn prop_dims(layer: &Layer, prop: &Property) -> usize {
match &prop.value {
Value::Vec3(_) if prop.shown_dims == 2 && !layer.is_3d() => 2,
v => v.dims(),
}
}
/// JS-facing kind of a property's value.
pub fn kind_name(v: &Value) -> &'static str {
match v {
Value::Scalar(_) => "number",
Value::Vec2(_) | Value::Vec3(_) => "array",
Value::Color(_) => "color",
Value::Bool(_) => "bool",
Value::Enum(_) => "enum",
Value::Path(_) => "path",
Value::Text(_) | Value::Str(_) => "text",
Value::Layer(_) => "layer",
Value::Gradient(_) => "other",
}
}
/// A property value as expression data.
pub fn value_resp(v: &Value, dims: usize) -> Resp {
match v {
Value::Scalar(x) => Resp::Num(*x),
Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) => Resp::nums(v.components().into_iter().take(dims.max(1))),
Value::Bool(b) => Resp::Num(if *b { 1.0 } else { 0.0 }),
// Popups are 1-based in expressions.
Value::Enum(i) => Resp::Num(*i as f64 + 1.0),
Value::Text(t) => Resp::Str(t.text.clone()),
Value::Str(s) => Resp::Str(s.clone()),
Value::Layer(l) => l.map_or(Resp::Null, |l| Resp::Num(l as f64)),
Value::Path(p) => {
let pts = |v: &[[f64; 2]]| Resp::List(v.iter().map(|q| Resp::nums(q.iter().copied())).collect());
Resp::List(vec![pts(&p.vertices), pts(&p.in_tangents), pts(&p.out_tangents), Resp::Bool(p.closed)])
}
Value::Gradient(_) => Resp::Null,
}
}
/// The property being evaluated (it may not be reachable by path, e.g. in standalone use).
pub struct Own<'a> {
pub comp: u64,
pub layer: &'a Layer,
pub prop: &'a Property,
pub path: String,
}
pub struct Resolver<'a> {
pub ctx: EvalCtx<'a>,
pub own: Own<'a>,
}
fn mat3(m: &Mat3) -> Resp {
Resp::nums(m.0.iter().flatten().copied())
}
fn mat4(m: &Mat4) -> Resp {
Resp::nums(m.0.iter().flatten().copied())
}
fn compact(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect()
}
/// AE attribute names that differ from our match ids.
fn alias(key: &str) -> &str {
match key {
"anchorPoint" => "anchor",
"xRotation" => "rotationX",
"yRotation" => "rotationY",
"zRotation" => "rotation",
"pointOfInterest" => "poi",
"maskPath" => "path",
"maskOpacity" => "opacity",
"maskFeather" => "feather",
"maskExpansion" => "expansion",
"effect" => "effects",
"mask" => "masks",
"content" => "contents",
"strokeWidth" => "width",
k => k,
}
}
fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> {
match key {
Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| g.children.get(i - 1)),
Key::Name(name) => {
let m = alias(name);
let c = compact(name);
g.children
.iter()
.find(|n| n.match_id() == m)
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(name)))
.or_else(|| g.children.iter().find(|n| compact(n.name()) == c || compact(n.match_id()) == c))
}
}
}
impl<'a> Resolver<'a> {
fn comp(&self, c: u64) -> Option<&'a Comp> {
if c == self.ctx.comp_id.0 { Some(self.ctx.comp) } else { self.ctx.project.comp(ItemId(c)) }
}
fn layer(&self, c: u64, l: u64) -> Option<&'a Layer> {
if c == self.own.comp && l == self.own.layer.id.0 {
return Some(self.own.layer);
}
self.comp(c)?.layer(LayerId(l))
}
fn ctx_at(&self, c: u64, t: f64) -> Option<EvalCtx<'a>> {
let comp = self.comp(c)?;
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr })
}
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
let layer = self.layer(c, l)?;
if c == self.own.comp && l == self.own.layer.id.0 && path == self.own.path {
return Some((layer, self.own.prop));
}
Some((layer, layer.props.prop(path)?))
}
pub fn resolve(&self, req: &Req) -> Resp {
self.try_resolve(req).unwrap_or(Resp::Null)
}
fn try_resolve(&self, req: &Req) -> Option<Resp> {
let project = self.ctx.project;
Some(match req {
Req::CompInfo { comp } => {
let c = self.comp(*comp)?;
let name = project.item(ItemId(*comp)).map(|i| i.name.clone()).unwrap_or_default();
Resp::List(vec![
Resp::Str(name),
Resp::Num(c.width as f64),
Resp::Num(c.height as f64),
Resp::Num(c.duration.seconds()),
Resp::Num(c.frame_duration().seconds()),
Resp::Num(c.layers.len() as f64),
Resp::Num(c.pixel_aspect),
Resp::Num(c.display_start.seconds()),
Resp::nums([c.background[0] as f64, c.background[1] as f64, c.background[2] as f64, 1.0]),
])
}
Req::CompByName { name } => {
let item = project.items.values().find(|i| matches!(i.kind, ItemKind::Comp(_)) && &i.name == name)?;
Resp::Num(item.id.0 as f64)
}
Req::LayerLookup { comp, key } => {
let c = self.comp(*comp)?;
let l = match key {
Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| c.layers.get(i - 1))?,
Key::Name(n) => c.layer_by_name(n)?,
};
Resp::Num(l.id.0 as f64)
}
Req::LayerInfo { comp, layer } => {
let c = self.comp(*comp)?;
let l = self.layer(*comp, *layer)?;
let (mut w, mut h) = source_size(project, l);
if w == 0 || h == 0 {
(w, h) = (c.width, c.height);
}
Resp::List(vec![
Resp::Str(l.name.clone()),
Resp::Num(c.index_of(l.id).unwrap_or(0) as f64),
Resp::Num(l.in_point.seconds()),
Resp::Num(l.out_point.seconds()),
Resp::Num(l.start_time.seconds()),
Resp::Num(w as f64),
Resp::Num(h as f64),
l.parent.map_or(Resp::Null, |p| Resp::Num(p.0 as f64)),
Resp::Bool(l.has_video()),
Resp::Bool(l.is_3d()),
Resp::Str(l.source.type_name().to_string()),
Resp::Bool(l.switches.video),
])
}
Req::Child { comp, layer, group, key } => {
let l = self.layer(*comp, *layer)?;
let g = if group.is_empty() { &l.props } else { l.props.group(group)? };
let n = find_child(g, key)?;
let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) };
let count = n.as_group().map_or(0, |g| g.children.len());
Resp::List(vec![
Resp::Str(path),
Resp::Bool(n.as_group().is_some()),
Resp::Str(n.name().into()),
Resp::Str(n.match_id().into()),
Resp::Num(count as f64),
])
}
Req::PropInfo { comp, layer, path } => {
let (l, p) = self.prop(*comp, *layer, path)?;
let dims = prop_dims(l, p);
let times = p.keys.iter().map(|k| Resp::Num(l.comp_time(k.time).seconds())).collect();
let values = p.keys.iter().map(|k| value_resp(&k.value, dims)).collect();
let index = path.rsplit('/').next().and_then(|last| {
let parent = path.rsplit_once('/').map(|(g, _)| g);
let g = match parent {
Some(g) => l.props.group(g)?,
None => &l.props,
};
g.children.iter().position(|n| format!("@{}", n.uid()) == last).map(|i| i + 1)
});
Resp::List(vec![
Resp::Str(p.name.clone()),
Resp::Num(p.uid as f64),
Resp::Str(kind_name(&p.value).into()),
Resp::Num(dims as f64),
Resp::List(times),
Resp::List(values),
Resp::Bool(p.has_expression()),
Resp::Num(index.unwrap_or(1) as f64),
Resp::Str(p.match_id.clone()),
])
}
Req::Value { comp, layer, path, t, pre } => {
let (l, p) = self.prop(*comp, *layer, path)?;
let t = f64::from_bits(*t);
let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) };
value_resp(&v, prop_dims(l, p))
}
Req::Xform { comp, layer, t } => {
let l = self.layer(*comp, *layer)?;
let ctx = self.ctx_at(*comp, f64::from_bits(*t))?;
let (m3, _) = ctx.layer_to_comp(l);
let m4 = ctx.world_matrix(l);
Resp::List(vec![mat3(&m3), mat3(&m3.inverse().unwrap_or_default()), mat4(&m4), mat4(&m4.inverse().unwrap_or_default())])
}
Req::SourceRect { comp, layer, t, extents: _ } => {
let l = self.layer(*comp, *layer)?;
let ctx = self.ctx_at(*comp, f64::from_bits(*t))?;
let r = match &l.source {
LayerSource::Text => {
let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect();
effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4])
}
LayerSource::Shape => {
let contents = l.props.sub("contents")?;
let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0);
if buf.img.width <= 4 {
[0.0; 4]
} else {
// `render` pads its buffer by 2 px on each side.
[2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0]
}
}
_ => {
let (w, h) = source_size(project, l);
[0.0, 0.0, w as f64, h as f64]
}
};
Resp::nums(r)
}
Req::Markers { comp, layer } => {
let marks: Vec<(f64, &effectcraft_project::Marker)> = match layer {
Some(l) => {
let l = self.layer(*comp, *l)?;
l.markers.iter().map(|m| (l.comp_time(m.time).seconds(), m)).collect()
}
None => self.comp(*comp)?.markers.iter().map(|m| (m.time.seconds(), m)).collect(),
};
Resp::List(
marks
.into_iter()
.map(|(t, m)| {
Resp::List(vec![
Resp::Num(t),
Resp::Num(m.duration.seconds()),
Resp::Str(m.comment.clone()),
Resp::Str(m.chapter.clone()),
Resp::Str(m.url.clone()),
])
})
.collect(),
)
}
})
}
}

144
crates/expr/src/lib.rs Normal file
View File

@ -0,0 +1,144 @@
//! After Effects-compatible expressions on the boa JavaScript engine.
//!
//! [`Expressions`] implements [`ExprHost`] so the renderer can evaluate property expressions:
//!
//! ```text
//! let host = effectcraft_expr::Expressions;
//! let mut r = Renderer::new(&project, &NoFootage, RenderOpts::default());
//! r.expr = Some(&host);
//! ```
//!
//! * **Language**: JavaScript plus AE's legacy array maths (`value + [10, 0]`, `[1, 2] * 2`),
//! implemented by rewriting `+ - * /` into helper calls ([`rewrite`]). The last evaluated
//! statement is the result (`var x = …; x`, `if/else` blocks).
//! * **Object model** (`prelude.js`): `time`, `value`, `thisComp`, `thisLayer`, `thisProperty`,
//! `comp(name)`, layer attributes (`index`, `inPoint`, `width`, `transform`, `effect(…)(…)`,
//! `mask`, `content`, `text.sourceText`, `marker`, `toComp`/`fromComp`/`toWorld`/`fromWorld`,
//! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`,
//! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the
//! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/
//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`).
//! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time /
//! `seedRandom`), so frames render identically on every thread and platform.
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
pub mod host;
mod noise;
pub mod rewrite;
mod runtime;
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemId, Layer, LayerId, Project, Property};
use effectcraft_render::{EvalCtx, ExprHost};
use effectcraft_time::Tick;
use host::{Own, Req, Resolver, prop_dims, value_resp};
use runtime::{Begin, FRAMES, Frame, Run};
/// Maximum nesting of expressions that read other expressions (cycles stop here).
pub const MAX_DEPTH: usize = 16;
/// Maximum script re-runs while answering host requests.
const MAX_PASSES: usize = 2000;
/// The expression engine (stateless; runtimes are per thread).
#[derive(Clone, Copy, Debug, Default)]
pub struct Expressions;
impl ExprHost for Expressions {
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String> {
match &prop.expr {
Some(e) => evaluate(self, ctx, layer, prop, value, &e.text),
None => Ok(value.clone()),
}
}
}
/// Pops the evaluation frame on every exit path.
struct FrameGuard;
impl Drop for FrameGuard {
fn drop(&mut self) {
FRAMES.with(|f| f.borrow_mut().pop());
}
}
/// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed
/// `value`. Other properties' expressions are evaluated through `host`.
pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result<Value, String> {
let depth = FRAMES.with(|f| f.borrow().len());
if depth >= MAX_DEPTH {
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host) };
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } };
let current = value_resp(value, prop_dims(layer, prop));
let begin = Begin {
time: ctx.time.seconds(),
value: &current,
comp,
layer: layer.id.0,
path: &path,
uid: prop.uid,
index: ctx.comp.index_of(layer.id).unwrap_or(0) as f64,
frame_duration: ctx.comp.frame_duration().seconds(),
};
// Answer the requests nearly every expression makes up front.
let mut frame = Frame::default();
for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] {
let r = resolver.resolve(&req);
frame.memo.insert(req, r);
}
FRAMES.with(|f| f.borrow_mut().push(frame));
let _guard = FrameGuard;
for _ in 0..MAX_PASSES {
match runtime::run(text, &begin) {
Run::Done(r) => return r.and_then(|o| runtime::to_value(&o, value)),
Run::Pending => {
let misses = FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| std::mem::take(&mut fr.misses)).unwrap_or_default());
for m in misses {
// May evaluate other expressions (nested frames above ours).
let r = resolver.resolve(&m);
FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| fr.memo.insert(m, r)));
}
}
}
}
Err("Error: expression needs too many property reads".into())
}
/// Evaluate `text` as if it were the expression on the property at `path` (e.g.
/// `transform/rotation`) of `layer` in `comp`, at comp time `t` seconds. Other properties'
/// expressions are evaluated too.
pub fn eval_standalone(project: &Project, comp: ItemId, layer: LayerId, path: &str, text: &str, t: f64) -> Result<Value, String> {
let c = project.comp(comp).ok_or("no such comp")?;
let l = c.layer(layer).ok_or("no such layer")?;
let prop = l.props.prop(path).ok_or_else(|| format!("no property at {path}"))?;
let ctx = EvalCtx::new(project, comp, c, Tick::from_seconds_f64(t));
let value = prop.value_at(l.layer_time(ctx.time));
evaluate(&Expressions, &ctx, l, prop, &value, text)
}
/// Evaluate the expression stored on the property at `path`, reporting errors (the renderer
/// silently falls back to the keyframed value instead).
pub fn eval_property(project: &Project, comp: ItemId, layer: LayerId, path: &str, t: f64) -> Result<Value, String> {
let c = project.comp(comp).ok_or("no such comp")?;
let l = c.layer(layer).ok_or("no such layer")?;
let prop = l.props.prop(path).ok_or_else(|| format!("no property at {path}"))?;
let ctx = EvalCtx::new(project, comp, c, Tick::from_seconds_f64(t));
let value = prop.value_at(l.layer_time(ctx.time));
match &prop.expr {
Some(e) if prop.has_expression() => evaluate(&Expressions, &ctx, l, prop, &value, &e.text),
_ => Ok(value),
}
}
/// Syntax-check an expression (after the array-maths rewrite) without evaluating it.
pub fn check_syntax(text: &str) -> Result<(), String> {
runtime::check(text)
}
#[cfg(test)]
mod tests;

109
crates/expr/src/noise.rs Normal file
View File

@ -0,0 +1,109 @@
//! Deterministic hashing, random numbers and smooth noise for `wiggle`, `random` and `noise`.
//!
//! Everything is a pure function of its seeds, so a frame renders identically on any thread,
//! in any order, on native and on the web.
/// SplitMix64 finaliser: a good 64-bit mixing function.
pub fn hash64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
x ^ (x >> 31)
}
/// Combine several seeds into one.
pub fn mix(parts: &[u64]) -> u64 {
parts.iter().fold(0x2545_f491_4f6c_dd1d, |h, p| hash64(h ^ hash64(*p)))
}
/// Uniform in `[0, 1)`.
pub fn unit(seed: u64) -> f64 {
(hash64(seed) >> 11) as f64 / (1u64 << 53) as f64
}
/// Lattice value in `[-1, 1]`.
fn lattice(seed: u64, i: i64) -> f64 {
unit(seed ^ hash64(i as u64)) * 2.0 - 1.0
}
fn fade(t: f64) -> f64 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
/// Smooth 1D value noise in `[-1, 1]` (C2-continuous, bounded by the lattice values).
pub fn noise1(seed: u64, x: f64) -> f64 {
if !x.is_finite() {
return 0.0;
}
let i = x.floor();
let u = fade(x - i);
let i = i as i64;
let (a, b) = (lattice(seed, i), lattice(seed, i + 1));
a + (b - a) * u
}
/// Smooth 3D value noise in `[-1, 1]` (AE's `noise()`: nearby inputs give nearby outputs).
pub fn noise3(x: f64, y: f64, z: f64) -> f64 {
if !(x.is_finite() && y.is_finite() && z.is_finite()) {
return 0.0;
}
let (fx, fy, fz) = (x.floor(), y.floor(), z.floor());
let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz));
let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64);
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0;
let l = |a: f64, b: f64, t: f64| a + (b - a) * t;
let x00 = l(g(0, 0, 0), g(1, 0, 0), u);
let x10 = l(g(0, 1, 0), g(1, 1, 0), u);
let x01 = l(g(0, 0, 1), g(1, 0, 1), u);
let x11 = l(g(0, 1, 1), g(1, 1, 1), u);
l(l(x00, x10, v), l(x01, x11, v), w)
}
/// Per-dimension wiggle offsets: `Σ amp·mult^k · noise(freq·2^k·t)` over `octaves`.
/// With one octave every offset lies in `[-amp, amp]`.
pub fn wiggle(seed: u64, dims: usize, freq: f64, amp: f64, octaves: f64, amp_mult: f64, t: f64) -> Vec<f64> {
let octaves = if octaves.is_finite() { octaves.clamp(1.0, 16.0) as usize } else { 1 };
(0..dims)
.map(|d| {
let mut a = amp;
let mut f = freq;
let mut sum = 0.0;
for k in 0..octaves {
let s = mix(&[seed, d as u64, k as u64]);
// Offset each octave's phase so octaves don't share lattice points.
sum += a * noise1(s, f * t + unit(s) * 1000.0);
a *= amp_mult;
f *= 2.0;
}
sum
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noise_is_bounded_smooth_and_deterministic() {
let mut prev = noise1(7, 0.0);
for i in 1..10_000 {
let x = i as f64 * 0.001;
let v = noise1(7, x);
assert!((-1.0..=1.0).contains(&v));
assert!((v - prev).abs() < 0.01, "jump at {x}");
prev = v;
}
assert_eq!(noise1(3, 1.25), noise1(3, 1.25));
assert_ne!(noise1(3, 1.25), noise1(4, 1.25));
assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5)));
}
#[test]
fn wiggle_bounds() {
for i in 0..2000 {
let w = wiggle(99, 2, 3.0, 50.0, 1.0, 0.5, i as f64 / 30.0);
assert!(w.iter().all(|v| v.abs() <= 50.0));
}
}
}

724
crates/expr/src/prelude.js Normal file
View File

@ -0,0 +1,724 @@
// EffectCraft expression runtime: the After Effects expression object model and global
// functions, written from the public Expression Language Reference (behaviour only).
//
// Natives (Rust): __h(op, ...) host data request; __wig(...) wiggle offsets; __noise(x, y, z);
// __rnd(...) seeded random. This file is evaluated once per runtime and is NOT passed through
// the array-maths rewriter, so it uses plain JS operators.
var time = 0;
var value = 0;
var __S = null;
var __rc = 0;
var __seed = 0;
var __timeless = false;
// Request ops (must match runtime.rs).
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9;
function __begin(t, v, c, l, path, uid, idx, fd) {
time = t;
value = v;
__S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {} };
__rc = 0;
__seed = 0;
__timeless = false;
}
function __finish(r) {
r = __v(r);
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
return r;
}
function __err(msg) {
throw new Error(msg);
}
// ---------------------------------------------------------------------------------------------
// Array maths (targets of the operator rewrite).
function __v(x) {
if (x !== null && typeof x === 'object' && x.__isProp === true) return x.value;
return x;
}
function __bin(a, b, f) {
a = __v(a);
b = __v(b);
var aa = Array.isArray(a), ba = Array.isArray(b), i, r;
if (aa && ba) {
var n = Math.max(a.length, b.length);
r = new Array(n);
for (i = 0; i < n; i++) r[i] = f(i < a.length ? a[i] : 0, i < b.length ? b[i] : 0);
return r;
}
if (aa && (typeof b === 'number' || typeof b === 'boolean')) {
r = new Array(a.length);
for (i = 0; i < a.length; i++) r[i] = f(a[i], b);
return r;
}
if (ba && (typeof a === 'number' || typeof a === 'boolean')) {
r = new Array(b.length);
for (i = 0; i < b.length; i++) r[i] = f(a, b[i]);
return r;
}
return f(a, b);
}
function __fadd(x, y) { return x + y; }
function __fsub(x, y) { return x - y; }
function __fmul(x, y) { return x * y; }
function __fdiv(x, y) { return x / y; }
function __add(a, b) {
if (typeof a === 'number' && typeof b === 'number') return a + b;
if (typeof a === 'string' || typeof b === 'string') return __v(a) + __v(b);
var x = __v(a), y = __v(b);
if (typeof x === 'string' || typeof y === 'string') return x + y;
if ((Array.isArray(x) && typeof y === 'string') || (Array.isArray(y) && typeof x === 'string')) return x + y;
return __bin(x, y, __fadd);
}
function __sub(a, b) {
if (typeof a === 'number' && typeof b === 'number') return a - b;
return __bin(a, b, __fsub);
}
function __mul(a, b) {
if (typeof a === 'number' && typeof b === 'number') return a * b;
return __bin(a, b, __fmul);
}
function __div(a, b) {
if (typeof a === 'number' && typeof b === 'number') return a / b;
return __bin(a, b, __fdiv);
}
function __neg(a) {
if (typeof a === 'number') return -a;
a = __v(a);
if (Array.isArray(a)) return a.map(function (x) { return -x; });
return -a;
}
// ---------------------------------------------------------------------------------------------
// Vector maths.
function add(a, b) { return __bin(a, b, __fadd); }
function sub(a, b) { return __bin(a, b, __fsub); }
function mul(a, b) { return __bin(a, b, __fmul); }
function div(a, b) { return __bin(a, b, __fdiv); }
function clamp(v, a, b) {
v = __v(v); a = __v(a); b = __v(b);
var lo = function (x, y) { return Math.min(Math.max(x, Math.min(y[0], y[1])), Math.max(y[0], y[1])); };
if (Array.isArray(v)) {
return v.map(function (x, i) {
return lo(x, [Array.isArray(a) ? a[i] : a, Array.isArray(b) ? b[i] : b]);
});
}
return lo(v, [a, b]);
}
function dot(a, b) {
a = __v(a); b = __v(b);
var s = 0;
for (var i = 0; i < Math.min(a.length, b.length); i++) s += a[i] * b[i];
return s;
}
function cross(a, b) {
a = __v(a); b = __v(b);
var a2 = a[2] || 0, b2 = b[2] || 0;
return [a[1] * b2 - a2 * b[1], a2 * b[0] - a[0] * b2, a[0] * b[1] - a[1] * b[0]];
}
function length(a, b) {
a = __v(a);
if (b !== undefined) a = sub(a, __v(b));
if (!Array.isArray(a)) return Math.abs(a);
var s = 0;
for (var i = 0; i < a.length; i++) s += a[i] * a[i];
return Math.sqrt(s);
}
function normalize(a) {
a = __v(a);
var l = length(a);
return l === 0 ? a.map(function () { return 0; }) : div(a, l);
}
function lookAt(from, at) {
var d = normalize(sub(__v(at), __v(from)));
var x = d[0], y = d[1], z = d[2] || 0;
var rx = -Math.asin(Math.max(-1, Math.min(1, y))) * 180 / Math.PI;
var ry = Math.atan2(x, z) * 180 / Math.PI;
return [rx, ry, 0];
}
function degreesToRadians(d) { return d * Math.PI / 180; }
function radiansToDegrees(r) { return r * 180 / Math.PI; }
// ---------------------------------------------------------------------------------------------
// Interpolation.
function __interp(shape, args) {
var t, tMin, tMax, v1, v2;
if (args.length <= 3) {
t = args[0]; tMin = 0; tMax = 1; v1 = args[1]; v2 = args[2];
} else {
t = args[0]; tMin = args[1]; tMax = args[2]; v1 = args[3]; v2 = args[4];
}
t = __v(t); v1 = __v(v1); v2 = __v(v2);
var u;
if (tMax === tMin) u = t >= tMax ? 1 : 0;
else u = (t - tMin) / (tMax - tMin);
u = Math.max(0, Math.min(1, u));
u = shape(u);
if (Array.isArray(v1) || Array.isArray(v2)) return add(v1, mul(sub(v2, v1), u));
return v1 + (v2 - v1) * u;
}
function __lin(u) { return u; }
function __ease(u) { return u * u * (3 - 2 * u); }
function __easeIn(u) { return u * u * (2 - u); }
function __easeOut(u) { var w = 1 - u; return 1 - w * w * (2 - w); }
function linear() { return __interp(__lin, arguments); }
function ease() { return __interp(__ease, arguments); }
function easeIn() { return __interp(__easeIn, arguments); }
function easeOut() { return __interp(__easeOut, arguments); }
// ---------------------------------------------------------------------------------------------
// Random numbers and noise.
function seedRandom(offset, timeless) {
__seed = offset;
__timeless = !!timeless;
}
function __r01() {
return __rnd(__S.idx, __S.uid, __seed, __timeless, time, __rc++);
}
function __scaleRand(r, args) {
if (args.length === 0) return r();
var a = __v(args[0]);
if (args.length === 1) {
if (Array.isArray(a)) return a.map(function (m) { return r() * m; });
return r() * a;
}
var b = __v(args[1]);
if (Array.isArray(a) || Array.isArray(b)) {
var n = Math.max(Array.isArray(a) ? a.length : 1, Array.isArray(b) ? b.length : 1), out = [];
for (var i = 0; i < n; i++) {
var lo = Array.isArray(a) ? (a[i] || 0) : a, hi = Array.isArray(b) ? (b[i] || 0) : b;
out.push(lo + r() * (hi - lo));
}
return out;
}
return a + r() * (b - a);
}
function random() { return __scaleRand(__r01, arguments); }
function __gauss01() {
var u1 = Math.max(__r01(), 1e-12), u2 = __r01();
var z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
// ~90% of results fall in [0, 1].
return 0.5 + z * 0.304;
}
function gaussRandom() { return __scaleRand(__gauss01, arguments); }
function noise(x) {
x = __v(x);
if (Array.isArray(x)) return __noise(x[0] || 0, x[1] || 0, x[2] || 0);
return __noise(x, 0, 0);
}
// ---------------------------------------------------------------------------------------------
// Time conversion.
function timeToFrames(t, fps, isDuration) {
if (t === undefined) t = time + thisComp.displayStartTime;
if (fps === undefined) fps = 1 / __S.fd;
return Math.floor(t * fps + 1e-6);
}
function framesToTime(frames, fps) {
if (fps === undefined) fps = 1 / __S.fd;
return frames / fps;
}
function __pad(n) { return n < 10 ? '0' + n : '' + n; }
function timeToTimecode(t, base, isDuration) {
if (t === undefined) t = time + thisComp.displayStartTime;
if (base === undefined) base = 30;
var neg = t < 0;
var f = Math.floor(Math.abs(t) * base + 1e-6);
var ff = f % base, s = Math.floor(f / base), ss = s % 60, m = Math.floor(s / 60), mm = m % 60, h = Math.floor(m / 60);
return (neg ? '-' : '') + h + ':' + __pad(mm) + ':' + __pad(ss) + ':' + __pad(ff);
}
function timeToCurrentFormat(t, fps) {
if (fps === undefined) fps = Math.round(1 / __S.fd);
return timeToTimecode(t, fps);
}
function posterizeTime(fps) {
if (!(fps > 0)) return;
var t = Math.floor(time * fps + 1e-6) / fps;
if (t !== time) {
var v = thisProperty.valueAtTime(t);
time = t;
value = v;
}
}
// ---------------------------------------------------------------------------------------------
// Colour.
function rgbToHsl(c) {
c = __v(c);
var r = c[0], g = c[1], b = c[2], a = c.length > 3 ? c[3] : 1;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2, h = 0, s = 0;
if (mx !== mn) {
var d = mx - mn;
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
if (mx === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (mx === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h /= 6;
}
return [h, s, l, a];
}
function hslToRgb(c) {
c = __v(c);
var h = c[0], s = c[1], l = c[2], a = c.length > 3 ? c[3] : 1;
if (s === 0) return [l, l, l, a];
var hue = function (p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
var q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
return [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3), a];
}
function hexToRgb(hex) {
var s = String(hex).replace(/^#|^0x/i, '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s.slice(0, 6), 16);
var a = s.length >= 8 ? parseInt(s.slice(6, 8), 16) / 255 : 1;
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, a];
}
// ---------------------------------------------------------------------------------------------
// Paths.
function __Path(pts, ins, outs, closed) {
this.__path = true;
this.__pts = pts; this.__in = ins; this.__out = outs; this.__closed = !!closed;
}
__Path.prototype.points = function () { return this.__pts; };
__Path.prototype.inTangents = function () { return this.__in; };
__Path.prototype.outTangents = function () { return this.__out; };
__Path.prototype.isClosed = function () { return this.__closed; };
function createPath(points, inTangents, outTangents, isClosed) {
points = points || [];
var zero = points.map(function () { return [0, 0]; });
return new __Path(points, inTangents || zero, outTangents || zero, isClosed === undefined ? true : isClosed);
}
// ---------------------------------------------------------------------------------------------
// Object model.
function __mod(a, d) { return ((a % d) + d) % d; }
function __child(c, l, group, key) {
var info = __h(__CHILD, c, l, group, key);
if (info === null) return undefined;
return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]);
}
// Comp ------------------------------------------------------------------------------------------
function Comp(c) { this.__c = c; this.__i = null; }
Comp.prototype = {
__isComp: true,
get __info() { return this.__i || (this.__i = __h(__COMP, this.__c)); },
get name() { return this.__info[0]; },
get width() { return this.__info[1]; },
get height() { return this.__info[2]; },
get duration() { return this.__info[3]; },
get frameDuration() { return this.__info[4]; },
get numLayers() { return this.__info[5]; },
get pixelAspect() { return this.__info[6]; },
get displayStartTime() { return this.__info[7]; },
get bgColor() { return this.__info[8]; },
get marker() { return new Markers(this.__c, -1); },
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
var id = __h(__LAYER, this.__c, a);
if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range');
return new Layer(this.__c, id);
},
toString: function () { return '[object Comp]'; },
};
function comp(name) {
var id = __h(__COMPNAME, String(name));
if (id === null) __err('Comp named "' + name + '" does not exist');
return new Comp(id);
}
// Layer -----------------------------------------------------------------------------------------
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
function __m3(m, p) {
var x = p[0] || 0, y = p[1] || 0;
var X = m[0] * x + m[1] * y + m[2], Y = m[3] * x + m[4] * y + m[5], W = m[6] * x + m[7] * y + m[8];
if (W !== 0 && W !== 1) { X /= W; Y /= W; }
return p.length > 2 ? [X, Y, 0] : [X, Y];
}
function __m3v(m, p) {
var x = p[0] || 0, y = p[1] || 0;
return p.length > 2 ? [m[0] * x + m[1] * y, m[3] * x + m[4] * y, 0] : [m[0] * x + m[1] * y, m[3] * x + m[4] * y];
}
function __m4(m, p, vec) {
var x = p[0] || 0, y = p[1] || 0, z = p[2] || 0, k = vec ? 0 : 1;
var X = m[0] * x + m[1] * y + m[2] * z + m[3] * k;
var Y = m[4] * x + m[5] * y + m[6] * z + m[7] * k;
var Z = m[8] * x + m[9] * y + m[10] * z + m[11] * k;
var W = vec ? 1 : m[12] * x + m[13] * y + m[14] * z + m[15];
if (W !== 0 && W !== 1) { X /= W; Y /= W; Z /= W; }
return [X, Y, Z];
}
Layer.prototype = {
__isLayer: true,
get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); },
get name() { return this.__info[0]; },
get index() { return this.__info[1]; },
get inPoint() { return this.__info[2]; },
get outPoint() { return this.__info[3]; },
get startTime() { return this.__info[4]; },
get width() { return this.__info[5]; },
get height() { return this.__info[6]; },
get hasParent() { return this.__info[7] !== null; },
get parent() {
var p = this.__info[7];
if (p === null) __err('Layer "' + this.name + '" has no parent');
return new Layer(this.__c, p);
},
get hasVideo() { return this.__info[8]; },
get enabled() { return this.__info[11]; },
get active() { return this.__info[11] && time >= this.inPoint && time < this.outPoint; },
get containingComp() { return new Comp(this.__c); },
__group: function (k) {
var g = __child(this.__c, this.__l, '', k);
if (g === undefined) __err('Layer "' + this.name + '" has no property "' + k + '"');
return g;
},
get transform() { return this.__group('transform'); },
get position() { return this.transform.position; },
get scale() { return this.transform.scale; },
get rotation() { return this.transform.rotation; },
get opacity() { return this.transform.opacity; },
get anchorPoint() { return this.transform.anchorPoint; },
get text() { return this.__group('text'); },
get marker() { return new Markers(this.__c, this.__l); },
effect: function (k) {
var g = __child(this.__c, this.__l, '', 'effects');
var e = g === undefined ? undefined : __child(g.__c, g.__l, g.__p, k);
if (e === undefined) __err('Effect ' + (typeof k === 'string' ? '"' + k + '"' : k) + ' not found on layer "' + this.name + '"');
return e;
},
mask: function (k) { return this.__group('masks').__get(k); },
content: function (k) { return this.__group('contents').__get(k); },
__x: function (t) { return __h(__XFORM, this.__c, this.__l, t === undefined ? time : t); },
toComp: function (p, t) { return __m3(this.__x(t)[0], __v(p)); },
fromComp: function (p, t) { return __m3(this.__x(t)[1], __v(p)); },
toWorld: function (p, t) { return __m4(this.__x(t)[2], __v(p), false); },
fromWorld: function (p, t) { return __m4(this.__x(t)[3], __v(p), false); },
toCompVec: function (p, t) { return __m3v(this.__x(t)[0], __v(p)); },
fromCompVec: function (p, t) { return __m3v(this.__x(t)[1], __v(p)); },
toWorldVec: function (p, t) { return __m4(this.__x(t)[2], __v(p), true); },
fromWorldVec: function (p, t) { return __m4(this.__x(t)[3], __v(p), true); },
sourceRectAtTime: function (t, includeExtents) {
var r = __h(__RECT, this.__c, this.__l, t === undefined ? time : t, !!includeExtents);
return { top: r[0], left: r[1], width: r[2], height: r[3] };
},
sampleImage: function () { return [0, 0, 0, 0]; },
toString: function () { return '[object Layer]'; },
};
// Property groups (callable: `effect("Slider Control")("Slider")`) ---------------------------------
var __GroupProto = {
__isGroup: true,
__get: function (k) {
var r = __child(this.__c, this.__l, this.__p, k);
if (r === undefined) __err('Property ' + (typeof k === 'string' ? '"' + k + '"' : k) + ' not found in "' + this.__info[2] + '"');
return r;
},
param: function (k) { return this.__get(k); },
property: function (k) { return this.__get(k); },
content: function (k) {
var inner = __child(this.__c, this.__l, this.__p, 'contents');
return (inner !== undefined && inner.__isGroup ? inner : this).__get(k);
},
get name() { return this.__info[2]; },
get matchName() { return this.__info[3]; },
get numProperties() { return this.__info[4]; },
get active() { return true; },
toString: function () { return '[object PropertyGroup]'; },
};
var __groupHandler = {
get: function (t, k) {
if (typeof k !== 'string' || k in t) return t[k];
return __child(t.__c, t.__l, t.__p, k);
},
};
function __mkGroup(c, l, info) {
var f = function (k) { return f.__get(k); };
delete f.name;
delete f.length;
Object.setPrototypeOf(f, __GroupProto);
f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info;
return new Proxy(f, __groupHandler);
}
// Properties ------------------------------------------------------------------------------------
function Prop(c, l, path) {
this.__c = c; this.__l = l; this.__p = path; this.__i = null;
this.__self = __S !== null && c === __S.c && l === __S.l && path === __S.path;
}
function __avg(vals) {
var s = vals[0];
for (var i = 1; i < vals.length; i++) s = add(s, vals[i]);
return div(s, vals.length);
}
function __loop(p, out, type, n, dur) {
type = type === undefined ? 'cycle' : String(type).toLowerCase();
var times = p.__info[4], N = times.length, t = time;
if (N === 0) return p.value;
var tStart, tEnd, h = 1e-3, k, ph;
if (out) {
tEnd = times[N - 1];
if (t <= tEnd) return p.value;
if (dur !== undefined && dur > 0) tStart = tEnd - dur;
else if (n > 0 && N - 1 - n >= 0) tStart = times[N - 1 - n];
else tStart = times[0];
} else {
tStart = times[0];
if (t >= tStart) return p.value;
if (dur !== undefined && dur > 0) tEnd = tStart + dur;
else if (n > 0 && n < N) tEnd = times[n];
else tEnd = times[N - 1];
}
var d = tEnd - tStart;
if (type === 'continue') {
if (out) {
var ve = p.valueAtTime(tEnd);
return add(ve, mul(div(sub(ve, p.valueAtTime(tEnd - h)), h), t - tEnd));
}
var vs = p.valueAtTime(tStart);
return add(vs, mul(div(sub(p.valueAtTime(tStart + h), vs), h), t - tStart));
}
if (!(d > 0)) return p.value;
ph = __mod(t - tStart, d);
if (type === 'pingpong') {
if (out) {
k = Math.floor((t - tStart) / d);
return p.valueAtTime(k % 2 === 1 ? tEnd - ph : tStart + ph);
}
k = Math.floor((tEnd - t) / d);
return p.valueAtTime(k % 2 === 1 ? tStart + __mod(tStart - t, d) : tEnd - __mod(tStart - t, d));
}
if (type === 'offset') {
k = Math.floor((t - tStart) / d);
return add(p.valueAtTime(tStart + ph), mul(sub(p.valueAtTime(tEnd), p.valueAtTime(tStart)), k));
}
return p.valueAtTime(tStart + ph);
}
Prop.prototype = {
__isProp: true,
get __info() { return this.__i || (this.__i = __h(__PINFO, this.__c, this.__l, this.__p)); },
get name() { return this.__info[0]; },
get __uid() { return this.__info[1]; },
get numKeys() { return this.__info[4].length; },
get propertyIndex() { return this.__info[7]; },
get matchName() { return this.__info[8]; },
__wrap: function (v) {
var kind = this.__info[2];
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]);
return v;
},
get value() { return this.__self ? value : this.valueAtTime(time); },
valueAtTime: function (t) {
t = __v(t);
if (this.__self && t === time) return value;
return this.__wrap(__h(__VALUE, this.__c, this.__l, this.__p, t, this.__self));
},
velocityAtTime: function (t) {
var h = 1e-3;
return div(sub(this.valueAtTime(t + h), this.valueAtTime(t - h)), 2 * h);
},
get velocity() { return this.velocityAtTime(time); },
speedAtTime: function (t) { return length(this.velocityAtTime(t)); },
get speed() { return this.speedAtTime(time); },
key: function (n) {
var info = this.__info, N = info[4].length;
if (!(n >= 1 && n <= N)) __err('Key index ' + n + ' out of range (property has ' + N + ' keys)');
n = Math.floor(n);
return { time: info[4][n - 1], value: this.__wrap(info[5][n - 1]), index: n };
},
nearestKey: function (t) {
var times = this.__info[4];
if (times.length === 0) __err('Property has no keyframes');
var best = 0;
for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
return this.key(best + 1);
},
__seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; },
wiggle: function (freq, amp, octaves, ampMult, t) {
if (octaves === undefined) octaves = 1;
if (ampMult === undefined) ampMult = 0.5;
if (t === undefined) t = time;
var base = this.valueAtTime(t);
var dims = Array.isArray(base) ? base.length : 1;
var off = __wig(this.__seedIndex(), this.__uid, dims, __v(freq), __v(amp), octaves, ampMult, t);
if (!Array.isArray(base)) return base + off[0];
var r = base.slice();
for (var i = 0; i < r.length; i++) r[i] += off[i];
return r;
},
temporalWiggle: function (freq, amp, octaves, ampMult, t) {
if (octaves === undefined) octaves = 1;
if (ampMult === undefined) ampMult = 0.5;
if (t === undefined) t = time;
var off = __wig(this.__seedIndex(), this.__uid, 1, __v(freq), __v(amp), octaves, ampMult, t);
return this.valueAtTime(t + off[0]);
},
smooth: function (width, samples, t) {
if (width === undefined) width = 0.2;
if (samples === undefined) samples = 5;
if (t === undefined) t = time;
samples = Math.max(1, Math.round(samples));
if (samples === 1) return this.valueAtTime(t);
var vals = [];
for (var i = 0; i < samples; i++) vals.push(this.valueAtTime(t - width / 2 + width * i / (samples - 1)));
return __avg(vals);
},
loopOut: function (type, n) { return __loop(this, true, type, n || 0); },
loopIn: function (type, n) { return __loop(this, false, type, n || 0); },
loopOutDuration: function (type, d) { return __loop(this, true, type, 0, d === undefined ? 0 : d); },
loopInDuration: function (type, d) { return __loop(this, false, type, 0, d === undefined ? 0 : d); },
points: function (t) { return this.valueAtTime(t === undefined ? time : t).points(); },
inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); },
outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); },
isClosed: function () { return this.value.isClosed(); },
valueOf: function () { return this.value; },
toString: function () { return String(this.value); },
};
var __propHandler = {
get: function (t, k) {
if (typeof k !== 'string' || k in t) return t[k];
var v = t.value;
if (v === null || v === undefined) return undefined;
var x = v[k];
return typeof x === 'function' ? x.bind(v) : x;
},
};
function __mkProp(c, l, path) {
return new Proxy(new Prop(c, l, path), __propHandler);
}
// Markers ---------------------------------------------------------------------------------------
function Markers(c, l) { this.__c = c; this.__l = l; this.__m = null; }
Markers.prototype = {
get __list() { return this.__m || (this.__m = __h(__MARKERS, this.__c, this.__l)); },
get numKeys() { return this.__list.length; },
key: function (n) {
var list = this.__list, i;
if (typeof n === 'string') {
for (i = 0; i < list.length; i++) if (list[i][2] === n) return this.key(i + 1);
__err('Marker "' + n + '" not found');
}
if (!(n >= 1 && n <= list.length)) __err('Marker index ' + n + ' out of range');
var m = list[n - 1];
return { time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n };
},
nearestKey: function (t) {
var list = this.__list;
if (list.length === 0) __err('No markers');
var best = 0;
for (var i = 1; i < list.length; i++) if (Math.abs(list[i][0] - t) < Math.abs(list[best][0] - t)) best = i;
return this.key(best + 1);
},
};
// ---------------------------------------------------------------------------------------------
// Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're
// accessors on the global object; assigning one (`var width = 10`) shadows it for the current
// evaluation only.
function __global(name, get) {
Object.defineProperty(globalThis, name, {
configurable: true,
get: function () { return name in __S.vars ? __S.vars[name] : get(); },
set: function (v) { __S.vars[name] = v; },
});
}
__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); });
__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); });
__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); });
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active',
'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) {
__global(k, function () { return thisLayer[k]; });
});
['numKeys', 'velocity', 'speed'].forEach(function (k) {
__global(k, function () { return thisProperty[k]; });
});
function effect(k) { return thisLayer.effect(k); }
function mask(k) { return thisLayer.mask(k); }
function content(k) { return thisLayer.content(k); }
function toComp(p, t) { return thisLayer.toComp(p, t); }
function fromComp(p, t) { return thisLayer.fromComp(p, t); }
function toWorld(p, t) { return thisLayer.toWorld(p, t); }
function fromWorld(p, t) { return thisLayer.fromWorld(p, t); }
function toCompVec(p, t) { return thisLayer.toCompVec(p, t); }
function fromCompVec(p, t) { return thisLayer.fromCompVec(p, t); }
function toWorldVec(p, t) { return thisLayer.toWorldVec(p, t); }
function fromWorldVec(p, t) { return thisLayer.fromWorldVec(p, t); }
function sourceRectAtTime(t, e) { return thisLayer.sourceRectAtTime(t, e); }
function wiggle(f, a, o, m, t) { return thisProperty.wiggle(f, a, o, m, t); }
function temporalWiggle(f, a, o, m, t) { return thisProperty.temporalWiggle(f, a, o, m, t); }
function smooth(w, s, t) { return thisProperty.smooth(w, s, t); }
function loopOut(type, n) { return thisProperty.loopOut(type, n); }
function loopIn(type, n) { return thisProperty.loopIn(type, n); }
function loopOutDuration(type, d) { return thisProperty.loopOutDuration(type, d); }
function loopInDuration(type, d) { return thisProperty.loopInDuration(type, d); }
function valueAtTime(t) { return thisProperty.valueAtTime(t); }
function velocityAtTime(t) { return thisProperty.velocityAtTime(t); }
function speedAtTime(t) { return thisProperty.speedAtTime(t); }
function key(n) { return thisProperty.key(n); }
function nearestKey(t) { return thisProperty.nearestKey(t); }

602
crates/expr/src/rewrite.rs Normal file
View File

@ -0,0 +1,602 @@
//! Source-to-source rewrite that gives JavaScript After Effects' array maths.
//!
//! AE's legacy expression engine lets `+ - * /` operate on arrays (`value + [10, 0]`,
//! `[1, 2] * 2`). We get the same by rewriting every binary `+ - * /` into a helper call
//! (`__add(a, b)`, `__sub`, `__mul`, `__div`) and unary minus into `__neg(a)`; the helpers fall
//! back to the native operator for numbers and strings, so string concatenation is unchanged.
//! Compound assignments (`x += [1, 0]`) become `x = __add(x, (…))`.
//!
//! The rewriter is a small tokenizer plus a precedence-aware operand/chain parser over bracket
//! groups. It is deliberately lenient: anything it does not understand is copied verbatim, and
//! every newline is preserved so the engine's error line numbers match the user's text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Ident,
Num,
Str,
Template,
Regex,
Punct,
}
#[derive(Clone, Debug)]
struct Tok {
kind: Kind,
text: String,
/// Whitespace (comments become spaces/newlines) before the token.
ws: String,
/// A line break precedes the token.
nl: bool,
}
impl Tok {
fn is(&self, p: &str) -> bool {
self.kind == Kind::Punct && self.text == p
}
fn is_kw(&self, k: &[&str]) -> bool {
self.kind == Kind::Ident && k.contains(&self.text.as_str())
}
}
enum Item {
Tok(Tok),
Group { open: Tok, inner: Vec<Item>, close: Option<Tok> },
}
impl Item {
fn tok(&self) -> Option<&Tok> {
if let Item::Tok(t) = self { Some(t) } else { None }
}
fn is(&self, p: &str) -> bool {
self.tok().is_some_and(|t| t.is(p))
}
fn group(&self) -> Option<&str> {
if let Item::Group { open, .. } = self { Some(open.text.as_str()) } else { None }
}
fn ws(&self) -> &str {
match self {
Item::Tok(t) => &t.ws,
Item::Group { open, .. } => &open.ws,
}
}
fn nl(&self) -> bool {
match self {
Item::Tok(t) => t.nl,
Item::Group { open, .. } => open.nl,
}
}
}
/// Keywords that end an operand chain (statement or lower-precedence syntax).
const DELIM_KW: &[&str] = &[
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"do",
"else",
"export",
"extends",
"finally",
"for",
"if",
"import",
"in",
"instanceof",
"let",
"return",
"switch",
"throw",
"try",
"var",
"while",
"with",
"yield",
"of",
];
/// Prefix operators spelled as keywords.
const PREFIX_KW: &[&str] = &["typeof", "void", "delete", "await", "new"];
/// Keywords followed by a parenthesised header that is not an operand.
const HEADER_KW: &[&str] = &["if", "while", "for", "switch", "catch", "with"];
const PUNCT: &[&str] = &[
">>>=", "...", "===", "!==", "**=", "<<=", ">>=", ">>>", "&&=", "||=", "??=", "=>", "==", "!=", "<=", ">=", "&&", "||", "??", "?.", "++", "--", "+=", "-=",
"*=", "/=", "%=", "&=", "|=", "^=", "**", "<<", ">>",
];
fn is_ident_start(c: char) -> bool {
c.is_alphabetic() || c == '_' || c == '$' || (!c.is_ascii() && !c.is_whitespace())
}
fn is_ident_part(c: char) -> bool {
is_ident_start(c) || c.is_ascii_digit()
}
/// Can a `/` after `prev` start a regular expression literal?
fn regex_allowed(prev: Option<&Tok>) -> bool {
match prev {
None => true,
Some(t) => match t.kind {
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => false,
Kind::Ident => t.is_kw(DELIM_KW) || t.is_kw(PREFIX_KW),
Kind::Punct => !matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"),
},
}
}
fn skip_string(c: &[char], mut i: usize) -> usize {
let q = c[i];
i += 1;
while i < c.len() && c[i] != q && c[i] != '\n' {
if c[i] == '\\' {
i += 1;
}
i += 1;
}
(i + 1).min(c.len())
}
fn skip_template(c: &[char], mut i: usize) -> usize {
i += 1;
let mut depth = 0usize;
while i < c.len() {
match c[i] {
'\\' => i += 1,
'`' if depth == 0 => return i + 1,
'$' if c.get(i + 1) == Some(&'{') => {
depth += 1;
i += 1;
}
'{' if depth > 0 => depth += 1,
'}' if depth > 0 => depth -= 1,
'\'' | '"' if depth > 0 => {
i = skip_string(c, i);
continue;
}
_ => {}
}
i += 1;
}
c.len()
}
fn skip_number(c: &[char], mut i: usize) -> usize {
if c[i] == '0' && c.get(i + 1).is_some_and(|x| matches!(x, 'x' | 'X' | 'b' | 'B' | 'o' | 'O')) {
i += 2;
while i < c.len() && (c[i].is_ascii_alphanumeric() || c[i] == '_') {
i += 1;
}
return i;
}
let mut seen_dot = false;
while i < c.len() {
let x = c[i];
if x.is_ascii_digit() || x == '_' {
i += 1;
} else if x == '.' && !seen_dot {
seen_dot = true;
i += 1;
} else if (x == 'e' || x == 'E') && c.get(i + 1).is_some_and(|y| y.is_ascii_digit() || *y == '+' || *y == '-') {
i += 2;
while i < c.len() && c[i].is_ascii_digit() {
i += 1;
}
break;
} else {
break;
}
}
if c.get(i) == Some(&'n') {
i += 1;
}
i
}
fn skip_regex(c: &[char], mut i: usize) -> usize {
i += 1;
let mut class = false;
while i < c.len() && c[i] != '\n' {
match c[i] {
'\\' => i += 1,
'[' => class = true,
']' => class = false,
'/' if !class => break,
_ => {}
}
i += 1;
}
i += 1;
while i < c.len() && is_ident_part(c[i]) {
i += 1;
}
i.min(c.len())
}
fn punct_len(c: &[char], i: usize) -> usize {
for p in PUNCT {
let n = p.chars().count();
if i + n <= c.len() && c[i..i + n].iter().copied().eq(p.chars()) {
// `a?.5:b` is a conditional, not optional chaining.
if *p == "?." && c.get(i + 2).is_some_and(|d| d.is_ascii_digit()) {
return 1;
}
return n;
}
}
1
}
fn tokenize(src: &str) -> (Vec<Tok>, String) {
let c: Vec<char> = src.chars().collect();
let mut out: Vec<Tok> = Vec::new();
let mut ws = String::new();
let mut i = 0;
while i < c.len() {
let ch = c[i];
if ch.is_whitespace() {
ws.push(ch);
i += 1;
continue;
}
if ch == '/' && c.get(i + 1) == Some(&'/') {
while i < c.len() && c[i] != '\n' {
i += 1;
}
ws.push(' ');
continue;
}
if ch == '/' && c.get(i + 1) == Some(&'*') {
i += 2;
while i < c.len() && !(c[i] == '*' && c.get(i + 1) == Some(&'/')) {
if c[i] == '\n' {
ws.push('\n');
}
i += 1;
}
i = (i + 2).min(c.len());
ws.push(' ');
continue;
}
let start = i;
let kind = if ch == '"' || ch == '\'' {
i = skip_string(&c, i);
Kind::Str
} else if ch == '`' {
i = skip_template(&c, i);
Kind::Template
} else if ch.is_ascii_digit() || (ch == '.' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
i = skip_number(&c, i);
Kind::Num
} else if is_ident_start(ch) {
while i < c.len() && is_ident_part(c[i]) {
i += 1;
}
Kind::Ident
} else if ch == '/' && regex_allowed(out.last()) {
i = skip_regex(&c, i);
Kind::Regex
} else {
i += punct_len(&c, i);
Kind::Punct
};
let nl = ws.contains('\n');
out.push(Tok { kind, text: c[start..i].iter().collect(), ws: std::mem::take(&mut ws), nl });
}
(out, ws)
}
fn closer(open: &str) -> &'static str {
match open {
"(" => ")",
"[" => "]",
_ => "}",
}
}
/// Bracket tree. Stray closers stay as plain tokens (the engine reports the syntax error).
fn build(toks: Vec<Tok>) -> Vec<Item> {
let mut stack: Vec<(Tok, Vec<Item>)> = Vec::new();
let mut cur: Vec<Item> = Vec::new();
for t in toks {
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
stack.push((t, std::mem::take(&mut cur)));
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") && stack.last().is_some_and(|(o, _)| closer(&o.text) == t.text) {
let (open, parent) = stack.pop().unwrap_or_else(|| unreachable!());
let inner = std::mem::replace(&mut cur, parent);
cur.push(Item::Group { open, inner, close: Some(t) });
} else {
cur.push(Item::Tok(t));
}
}
while let Some((open, parent)) = stack.pop() {
let inner = std::mem::replace(&mut cur, parent);
cur.push(Item::Group { open, inner, close: None });
}
cur
}
/// A rewritten operand or expression: leading whitespace kept apart so helper calls start where
/// the expression did (keeps automatic semicolon insertion unchanged).
struct Piece {
ws: String,
body: String,
}
fn raw(item: &Item) -> Piece {
match item {
Item::Tok(t) => Piece { ws: t.ws.clone(), body: t.text.clone() },
Item::Group { open, inner, close } => {
let mut body = open.text.clone();
body.push_str(&rewrite_seq(inner));
if let Some(c) = close {
body.push_str(&c.ws);
body.push_str(&c.text);
}
Piece { ws: open.ws.clone(), body }
}
}
}
fn starts_operand(items: &[Item], i: usize) -> bool {
match &items[i] {
Item::Group { open, .. } => open.text != "{",
Item::Tok(t) => match t.kind {
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true,
Kind::Ident => !t.is_kw(DELIM_KW),
Kind::Punct => matches!(t.text.as_str(), "-" | "+" | "!" | "~" | "++" | "--"),
},
}
}
/// Parse one unary/postfix operand starting at `i`.
fn operand(items: &[Item], i: usize) -> Option<(Piece, usize)> {
let first = items.get(i)?;
if !starts_operand(items, i) {
return None;
}
// Prefix operators.
if let Some(t) = first.tok()
&& (t.kind == Kind::Punct || t.is_kw(PREFIX_KW))
{
let (inner, j) = operand(items, i + 1)?;
let body = if t.is("-") {
if items[i + 1].tok().is_some_and(|n| n.kind == Kind::Num) {
format!("-{}{}", inner.ws, inner.body)
} else {
format!("__neg({}{})", inner.ws, inner.body)
}
} else {
let sep = if t.kind == Kind::Ident && inner.ws.is_empty() { " " } else { "" };
format!("{}{sep}{}{}", t.text, inner.ws, inner.body)
};
return Some((Piece { ws: t.ws.clone(), body }, j));
}
let mut p = raw(first);
let mut j = i + 1;
// `function name(args) { body }` is one atom.
if first.tok().is_some_and(|t| t.is_kw(&["function"])) {
if items.get(j).is_some_and(|x| x.is("*")) {
push(&mut p, &items[j]);
j += 1;
}
if items.get(j).and_then(Item::tok).is_some_and(|t| t.kind == Kind::Ident) {
push(&mut p, &items[j]);
j += 1;
}
for g in ["(", "{"] {
if items.get(j).and_then(Item::group) == Some(g) {
push(&mut p, &items[j]);
j += 1;
}
}
}
// Postfix: member access, calls, indexing, tagged templates, `x++`.
while let Some(it) = items.get(j) {
let member = it.is(".") || it.is("?.");
let call = matches!(it.group(), Some("(" | "[")) || it.tok().is_some_and(|t| t.kind == Kind::Template);
let incr = (it.is("++") || it.is("--")) && !it.nl();
if !(member || call || incr) {
break;
}
push(&mut p, it);
j += 1;
if member
&& let Some(n) = items.get(j)
&& (n.tok().is_some_and(|t| t.kind == Kind::Ident) || matches!(n.group(), Some("(" | "[")))
{
push(&mut p, n);
j += 1;
}
}
// Exponentiation binds tighter than `*` and is right-associative: keep it native.
if items.get(j).is_some_and(|x| x.is("**"))
&& let Some((rhs, k)) = operand(items, j + 1)
{
p.body.push_str(items[j].ws());
p.body.push_str("**");
p.body.push_str(&rhs.ws);
p.body.push_str(&rhs.body);
j = k;
}
Some((p, j))
}
fn push(p: &mut Piece, item: &Item) {
let r = raw(item);
p.body.push_str(&r.ws);
p.body.push_str(&r.body);
}
fn helper(op: &str) -> Option<&'static str> {
Some(match op {
"+" => "__add",
"-" => "__sub",
"*" => "__mul",
"/" => "__div",
_ => return None,
})
}
/// Fold `a op b op c…` with `* / %` binding tighter than `+ -`.
fn fold(operands: Vec<Piece>, ops: Vec<(String, String)>) -> Piece {
let mut terms: Vec<Piece> = Vec::new();
let mut add_ops: Vec<(String, String)> = Vec::new();
let mut it = operands.into_iter();
let mut acc = it.next().unwrap_or(Piece { ws: String::new(), body: String::new() });
for ((op, ws), rhs) in ops.into_iter().zip(it) {
if op == "+" || op == "-" {
terms.push(acc);
add_ops.push((op, ws));
acc = rhs;
} else {
acc = bin(acc, &op, &ws, rhs);
}
}
terms.push(acc);
let mut it = terms.into_iter();
let mut acc = it.next().unwrap_or(Piece { ws: String::new(), body: String::new() });
for ((op, ws), rhs) in add_ops.into_iter().zip(it) {
acc = bin(acc, &op, &ws, rhs);
}
acc
}
fn bin(a: Piece, op: &str, op_ws: &str, b: Piece) -> Piece {
let body = match helper(op) {
Some(h) => format!("{h}({}, {op_ws}{}{})", a.body, b.ws, b.body),
None => format!("({}{op_ws}{op}{}{})", a.body, b.ws, b.body),
};
Piece { ws: a.ws, body }
}
fn is_arith(item: &Item) -> bool {
item.tok().is_some_and(|t| t.kind == Kind::Punct && matches!(t.text.as_str(), "+" | "-" | "*" | "/" | "%"))
}
/// Parse `operand (op operand)*` from `i`.
fn chain(items: &[Item], i: usize) -> Option<(Piece, usize)> {
let (first, mut j) = operand(items, i)?;
let mut operands = vec![first];
let mut ops = Vec::new();
while let Some(op) = items.get(j).filter(|x| is_arith(x)).and_then(Item::tok) {
let Some((rhs, k)) = operand(items, j + 1) else { break };
ops.push((op.text.clone(), op.ws.clone()));
operands.push(rhs);
j = k;
}
Some((fold(operands, ops), j))
}
/// End of the right-hand side of an assignment starting at `i`.
fn assignment_end(items: &[Item], i: usize) -> usize {
let mut j = i;
while j < items.len() {
let it = &items[j];
if it.is(";") || it.is(",") {
break;
}
if j > i && it.nl() && !items.get(j - 1).is_some_and(|p| p.tok().is_some_and(|t| t.kind == Kind::Punct && !t.is(")") && !t.is("]"))) {
break;
}
j += 1;
}
j
}
fn rewrite_seq(items: &[Item]) -> String {
let mut out = String::new();
let mut i = 0;
while i < items.len() {
let it = &items[i];
if it.tok().is_some_and(|t| t.is_kw(HEADER_KW)) && items.get(i + 1).and_then(Item::group) == Some("(") {
push_piece(&mut out, raw(it));
push_piece(&mut out, raw(&items[i + 1]));
i += 2;
continue;
}
if let Some((p, j)) = chain(items, i) {
// Compound assignment: `x += v` → `x = __add(x, (v))` (the target is evaluated twice,
// which only matters for targets with side effects).
if let Some(op) = items.get(j).and_then(Item::tok).filter(|t| t.kind == Kind::Punct && matches!(t.text.as_str(), "+=" | "-=" | "*=" | "/=")) {
let end = assignment_end(items, j + 1);
let rhs = rewrite_seq(&items[j + 1..end]);
let h = helper(&op.text[..1]).unwrap_or("__add");
out.push_str(&p.ws);
out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body));
i = end;
continue;
}
push_piece(&mut out, p);
i = j;
} else {
push_piece(&mut out, raw(it));
i += 1;
}
}
out
}
fn push_piece(out: &mut String, p: Piece) {
out.push_str(&p.ws);
out.push_str(&p.body);
}
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
pub fn rewrite(src: &str) -> String {
let (toks, tail) = tokenize(src);
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
out
}
#[cfg(test)]
mod tests {
use super::rewrite;
/// Compare ignoring spaces (newlines are significant).
fn n(s: &str) -> String {
s.replace(' ', "")
}
#[test]
fn precedence_and_unary() {
assert_eq!(n(&rewrite("a + b * c")), n("__add(a, __mul(b, c))"));
assert_eq!(n(&rewrite("-x * 2")), n("__mul(__neg(x), 2)"));
assert_eq!(n(&rewrite("-2*x")), n("__mul(-2, x)"));
assert_eq!(n(&rewrite("a % b + c")), n("__add((a % b), c)"));
assert_eq!(n(&rewrite("a - b - c")), n("__sub(__sub(a, b), c)"));
}
#[test]
fn calls_members_and_groups() {
assert_eq!(n(&rewrite("f(a+b)[0] / 2")), n("__div(f(__add(a, b))[0], 2)"));
assert_eq!(n(&rewrite("thisComp.layer(\"A\").transform.position[0] + 1")), n("__add(thisComp.layer(\"A\").transform.position[0], 1)"));
assert_eq!(n(&rewrite("x = a ? b + 1 : c")), n("x = a ? __add(b, 1) : c"));
assert_eq!(n(&rewrite("if (a) -b; else c")), n("if (a) __neg(b); else c"));
assert_eq!(n(&rewrite("return -x")), n("return __neg(x)"));
}
#[test]
fn strings_regex_comments_and_lines() {
assert_eq!(n(&rewrite("'a+b' + \"c\"")), n("__add('a+b', \"c\")"));
assert_eq!(n(&rewrite("s.replace(/a+b/g, '')")), n("s.replace(/a+b/g, '')"));
assert_eq!(n(&rewrite("a // x + y\n+ b")), n("__add(a, \n b)"));
assert_eq!(rewrite("a /* + */ * b").matches("__mul").count(), 1);
assert_eq!(rewrite("x\n\n+ 1").lines().count(), 3);
assert_eq!(n(&rewrite("`${a+b}` + 1")), n("__add(`${a+b}`, 1)"));
}
#[test]
fn compound_assignment_and_functions() {
assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));"));
assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }"));
assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)"));
assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')"));
assert_eq!(n(&rewrite("i++ + 1")), n("__add(i++, 1)"));
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
}
}

450
crates/expr/src/runtime.rs Normal file
View File

@ -0,0 +1,450 @@
//! The per-thread boa runtime: one `Context` with the prelude loaded, a compiled-script cache,
//! the native functions, and the request/miss bookkeeping (see [`crate::host`]).
use std::cell::RefCell;
use std::collections::HashMap;
use std::mem::ManuallyDrop;
use boa_engine::object::builtins::JsArray;
use boa_engine::{Context, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Script, Source, js_string};
use effectcraft_keyframe::Value;
use crate::host::{Key, Req, Resp, secs};
use crate::noise;
const PRELUDE: &str = include_str!("prelude.js");
/// Message of the error thrown when a request isn't answered yet (never shown to users).
const PENDING: &str = "\u{1}effectcraft: pending host request";
const MAX_SCRIPTS: usize = 4096;
/// Memoized host answers and pending misses of one expression evaluation.
#[derive(Default)]
pub struct Frame {
pub memo: HashMap<Req, Resp>,
pub misses: Vec<Req>,
}
thread_local! {
/// Never dropped: boa's own thread-local heap may be torn down first at thread exit, and
/// dropping GC handles after that aborts the process. The OS reclaims it with the thread.
static RUNTIME: RefCell<Option<ManuallyDrop<Result<Runtime, String>>>> = const { RefCell::new(None) };
/// Evaluation stack (nested when one expression reads another's value).
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
}
struct Runtime {
ctx: Context,
scripts: HashMap<String, Result<Script, String>>,
begin: JsObject,
finish: JsObject,
}
/// Per-evaluation inputs for `__begin`.
pub struct Begin<'a> {
pub time: f64,
pub value: &'a Resp,
pub comp: u64,
pub layer: u64,
pub path: &'a str,
pub uid: u64,
pub index: f64,
pub frame_duration: f64,
}
/// Outcome of one script run.
pub enum Run {
Done(Result<Out, String>),
/// The script needs host answers first.
Pending,
}
/// A script result, detached from the JS heap.
#[derive(Clone, Debug, PartialEq)]
pub enum Out {
Undefined,
Null,
Bool(bool),
Num(f64),
Str(String),
Arr(Vec<Out>),
Path { points: Vec<[f64; 2]>, ins: Vec<[f64; 2]>, outs: Vec<[f64; 2]>, closed: bool },
Object(String),
}
fn arg(args: &[JsValue], i: usize) -> JsValue {
args.get(i).cloned().unwrap_or_default()
}
fn num(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<f64> {
arg(args, i).to_number(ctx)
}
fn id(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<u64> {
let v = num(args, i, ctx)?;
Ok(if v.is_finite() && v >= 0.0 { v as u64 } else { u64::MAX })
}
fn string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
Ok(arg(args, i).to_string(ctx)?.to_std_string_escaped())
}
fn key(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<Key> {
let v = arg(args, i);
Ok(match v.as_number() {
Some(n) => Key::Index(n as i64),
None => Key::Name(v.to_string(ctx)?.to_std_string_escaped()),
})
}
fn to_js(r: &Resp, ctx: &mut Context) -> JsValue {
match r {
Resp::Null => JsValue::null(),
Resp::Bool(b) => JsValue::from(*b),
Resp::Num(n) => JsValue::from(*n),
Resp::Str(s) => JsValue::from(JsString::from(s.as_str())),
Resp::List(items) => {
let vals: Vec<JsValue> = items.iter().map(|i| to_js(i, ctx)).collect();
JsArray::from_iter(vals, ctx).into()
}
}
}
/// `__h(op, …)`: answer a host request from the memo, or record a miss and abort.
fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = num(args, 0, ctx)? as i64;
let req = match op {
0 => Req::CompInfo { comp: id(args, 1, ctx)? },
1 => Req::CompByName { name: string(args, 1, ctx)? },
2 => Req::LayerLookup { comp: id(args, 1, ctx)?, key: key(args, 2, ctx)? },
3 => Req::LayerInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? },
4 => Req::Child { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, group: string(args, 3, ctx)?, key: key(args, 4, ctx)? },
5 => Req::PropInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, path: string(args, 3, ctx)? },
6 => Req::Value {
comp: id(args, 1, ctx)?,
layer: id(args, 2, ctx)?,
path: string(args, 3, ctx)?,
t: secs(num(args, 4, ctx)?),
pre: arg(args, 5).to_boolean(),
},
7 => Req::Xform { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, t: secs(num(args, 3, ctx)?) },
8 => Req::SourceRect { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, t: secs(num(args, 3, ctx)?), extents: arg(args, 4).to_boolean() },
9 => {
let l = num(args, 2, ctx)?;
Req::Markers { comp: id(args, 1, ctx)?, layer: (l >= 0.0).then_some(l as u64) }
}
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
};
let found = FRAMES.with(|f| {
let mut f = f.borrow_mut();
let frame = f.last_mut()?;
match frame.memo.get(&req) {
Some(r) => Some(r.clone()),
None => {
frame.misses.push(req);
None
}
}
});
match found {
Some(r) => Ok(to_js(&r, ctx)),
None => Err(JsNativeError::error().with_message(PENDING).into()),
}
}
/// `__wig(layerIndex, uid, dims, freq, amp, octaves, ampMult, t)`.
fn native_wiggle(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let mut a = [0.0; 8];
for (i, x) in a.iter_mut().enumerate() {
*x = num(args, i, ctx)?;
}
let seed = noise::mix(&[a[0].to_bits(), a[1].to_bits()]);
let dims = (a[2].max(1.0) as usize).min(16);
let off = noise::wiggle(seed, dims, a[3], a[4], a[5], a[6], a[7]);
let vals: Vec<JsValue> = off.into_iter().map(JsValue::from).collect();
Ok(JsArray::from_iter(vals, ctx).into())
}
/// `__noise(x, y, z)`.
fn native_noise(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
Ok(JsValue::from(noise::noise3(num(args, 0, ctx)?, num(args, 1, ctx)?, num(args, 2, ctx)?)))
}
/// `__rnd(layerIndex, uid, userSeed, timeless, time, counter)`.
fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let timeless = arg(args, 3).to_boolean();
let t = if timeless { 0.0 } else { num(args, 4, ctx)? };
let parts = [num(args, 0, ctx)?.to_bits(), num(args, 1, ctx)?.to_bits(), num(args, 2, ctx)?.to_bits(), t.to_bits(), num(args, 5, ctx)?.to_bits()];
Ok(JsValue::from(noise::unit(noise::mix(&parts))))
}
impl Runtime {
fn new() -> Result<Runtime, String> {
let mut ctx = Context::default();
let limits = ctx.runtime_limits_mut();
limits.set_loop_iteration_limit(10_000_000);
limits.set_recursion_limit(400);
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
(js_string!("__h"), 6, native_host),
(js_string!("__wig"), 8, native_wiggle),
(js_string!("__noise"), 3, native_noise),
(js_string!("__rnd"), 6, native_random),
];
for (name, len, f) in natives {
ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?;
}
ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("expression prelude: {e}"))?;
let global = ctx.global_object();
let func = |name: JsString, ctx: &mut Context| -> Result<JsObject, String> {
global.get(name, ctx).ok().and_then(|v| v.as_object()).ok_or_else(|| "expression prelude: missing function".to_string())
};
let begin = func(js_string!("__begin"), &mut ctx)?;
let finish = func(js_string!("__finish"), &mut ctx)?;
Ok(Runtime { ctx, scripts: HashMap::new(), begin, finish })
}
fn script(&mut self, text: &str) -> Result<Script, String> {
if let Some(s) = self.scripts.get(text) {
return s.clone();
}
if self.scripts.len() >= MAX_SCRIPTS {
self.scripts.clear();
}
// A block keeps `let`/`const` local to one evaluation; `{` shares line 1 with the code so
// error line numbers match the user's text.
let src = format!("{{{}\n}}", crate::rewrite::rewrite(text));
let s = Script::parse(Source::from_bytes(src.as_bytes()), None, &mut self.ctx).map_err(|e| format_error(&e.to_string(), line_count(text)));
self.scripts.insert(text.to_string(), s.clone());
s
}
fn run(&mut self, text: &str, b: &Begin) -> Run {
let script = match self.script(text) {
Ok(s) => s,
Err(e) => return Run::Done(Err(e)),
};
let ctx = &mut self.ctx;
let args = [
JsValue::from(b.time),
to_js(b.value, ctx),
JsValue::from(b.comp as f64),
JsValue::from(b.layer as f64),
JsValue::from(JsString::from(b.path)),
JsValue::from(b.uid as f64),
JsValue::from(b.index),
JsValue::from(b.frame_duration),
];
if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) {
return Run::Done(Err(format_error(&e.to_string(), line_count(text))));
}
let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx));
let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty()));
if pending {
return Run::Pending;
}
Run::Done(match result {
Ok(v) => detach(&v, ctx, 0),
Err(e) => Err(format_error(&e.to_string(), line_count(text))),
})
}
}
/// Copy a JS value out of the heap.
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
if v.is_undefined() {
return Ok(Out::Undefined);
}
if v.is_null() {
return Ok(Out::Null);
}
if let Some(b) = v.as_boolean() {
return Ok(Out::Bool(b));
}
if let Some(n) = v.as_number() {
return Ok(Out::Num(n));
}
if let Some(s) = v.as_string() {
return Ok(Out::Str(s.to_std_string_escaped()));
}
let err = |e: boa_engine::JsError| format_error(&e.to_string(), usize::MAX);
if let Some(o) = v.as_object() {
if o.is_array() && depth < 4 {
let arr = JsArray::from_object(o).map_err(err)?;
let n = arr.length(ctx).map_err(err)?.min(1 << 16);
let mut out = Vec::with_capacity(n as usize);
for i in 0..n {
out.push(detach(&arr.get(i, ctx).map_err(err)?, ctx, depth + 1)?);
}
return Ok(Out::Arr(out));
}
if o.get(js_string!("__path"), ctx).map_err(err)?.to_boolean() {
let pts = |name: JsString, ctx: &mut Context| -> Result<Vec<[f64; 2]>, String> {
let v = o.get(name, ctx).map_err(err)?;
Ok(match detach(&v, ctx, depth + 1)? {
Out::Arr(items) => items.iter().map(|p| out_vec(p, 2)).map(|c| [c[0], c[1]]).collect(),
_ => vec![],
})
};
let points = pts(js_string!("__pts"), ctx)?;
let ins = pts(js_string!("__in"), ctx)?;
let outs = pts(js_string!("__out"), ctx)?;
let closed = o.get(js_string!("__closed"), ctx).map_err(err)?.to_boolean();
return Ok(Out::Path { points, ins, outs, closed });
}
}
Ok(Out::Object(v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default()))
}
fn out_vec(o: &Out, n: usize) -> Vec<f64> {
let mut v = match o {
Out::Arr(items) => items.iter().map(|i| if let Out::Num(x) = i { *x } else { f64::NAN }).collect(),
Out::Num(x) => vec![*x],
_ => vec![],
};
v.resize(n.max(v.len()), 0.0);
v
}
/// Make boa's messages read like AE's: `Error at line N: message`.
fn format_error(msg: &str, max_line: usize) -> String {
let msg = msg.trim();
let first = msg.lines().next().unwrap_or("").trim();
// The wrapper's closing brace sits one line below the user's text.
let line = find_line(msg).map(|l| l.min(max_line.max(1)));
// Drop the position boa appends to the first line (` at line 2, col 5`, ` (unknown at :2:11)`).
let mut text = first.split(" at line ").next().unwrap_or(first).trim();
if let Some(i) = text.rfind(" (")
&& text.ends_with(')')
&& text[i..].contains(" at ")
{
text = text[..i].trim();
}
// `throw new Error("x")` reads better as just the message.
let text = text.strip_prefix("Error: ").unwrap_or(text);
match line {
Some(l) => format!("Error at line {l}: {text}"),
None => format!("Error: {text}"),
}
}
fn line_count(text: &str) -> usize {
text.lines().count()
}
/// Line number from `at line N` (parser errors), else the user script's (`<main>`) backtrace
/// entry, else the first `:line:col` position.
fn find_line(msg: &str) -> Option<usize> {
if let Some(i) = msg.find("at line ") {
return msg[i + 8..].split(|c: char| !c.is_ascii_digit()).next()?.parse().ok();
}
if let Some(l) = msg.lines().find(|l| l.trim_start().starts_with("at <main>")).and_then(position_line) {
return Some(l);
}
position_line(msg)
}
/// First `:line:col` in `msg`.
fn position_line(msg: &str) -> Option<usize> {
let b = msg.as_bytes();
for i in 0..b.len() {
if b[i] != b':' {
continue;
}
let digits = |from: usize| b[from..].iter().take_while(|c| c.is_ascii_digit()).count();
let n = digits(i + 1);
if n > 0 && b.get(i + 1 + n) == Some(&b':') && digits(i + 2 + n) > 0 {
return msg[i + 1..i + 1 + n].parse().ok();
}
}
None
}
/// Run `text` once on this thread's runtime.
pub fn run(text: &str, b: &Begin) -> Run {
RUNTIME.with(|r| {
let mut r = r.borrow_mut();
match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) {
Ok(rt) => rt.run(text, b),
Err(e) => Run::Done(Err(e.clone())),
}
})
}
/// Compile `text` (cached) without running it.
pub fn check(text: &str) -> Result<(), String> {
RUNTIME.with(|r| {
let mut r = r.borrow_mut();
match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) {
Ok(rt) => rt.script(text).map(|_| ()),
Err(e) => Err(e.clone()),
}
})
}
/// Convert a script result to the property's value kind.
pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
let finite = |x: f64| if x.is_finite() { Ok(x) } else { Err("Error: expression result is not a finite number".to_string()) };
let numeric = |o: &Out| -> Option<f64> {
match o {
Out::Num(x) => Some(*x),
Out::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
Out::Arr(a) => a.first().and_then(|x| if let Out::Num(x) = x { Some(*x) } else { None }),
_ => None,
}
};
let truthy = |o: &Out| match o {
Out::Undefined | Out::Null => false,
Out::Bool(b) => *b,
Out::Num(x) => *x != 0.0 && !x.is_nan(),
Out::Str(s) => !s.is_empty(),
_ => true,
};
let text = |o: &Out| -> String {
match o {
Out::Str(s) => s.clone(),
Out::Num(x) => js_number(*x),
Out::Bool(b) => b.to_string(),
Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::<Vec<_>>().join(","),
Out::Undefined | Out::Null => String::new(),
Out::Path { .. } => "[object Path]".into(),
Out::Object(s) => s.clone(),
}
};
if matches!(out, Out::Undefined) && !matches!(current, Value::Text(_) | Value::Str(_)) {
return Err("Error: expression result is undefined".into());
}
Ok(match current {
Value::Scalar(_) => Value::Scalar(finite(numeric(out).ok_or_else(|| "Error: expression result must be a number".to_string())?)?),
Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) => {
let Out::Arr(items) = out else {
return Err(format!("Error: expression result must be of dimension {}, not 1", current.dims()));
};
let mut c = current.components();
for (i, x) in items.iter().enumerate().take(c.len()) {
c[i] = finite(numeric(x).ok_or_else(|| "Error: expression result array must contain numbers".to_string())?)?;
}
current.with_components(&c)
}
Value::Bool(_) => Value::Bool(truthy(out)),
Value::Enum(_) => Value::Enum((finite(numeric(out).unwrap_or(1.0))? - 1.0).round().max(0.0) as u32),
Value::Text(doc) => {
let mut d = doc.clone();
d.text = text(out);
Value::Text(d)
}
Value::Str(_) => Value::Str(text(out)),
Value::Path(_) => match out {
Out::Path { points, ins, outs, closed } => {
let n = points.len();
let fit = |v: &[[f64; 2]]| {
let mut v = v.to_vec();
v.resize(n, [0.0; 2]);
v
};
Value::Path(effectcraft_keyframe::ShapePath { vertices: points.clone(), in_tangents: fit(ins), out_tangents: fit(outs), closed: *closed })
}
_ => return Err("Error: expression result must be a path".into()),
},
Value::Layer(_) | Value::Gradient(_) => current.clone(),
})
}
/// Number → string like JS (`3`, not `3.0`).
fn js_number(x: f64) -> String {
if x.is_finite() && x == x.trunc() && x.abs() < 1e21 { format!("{}", x as i64) } else { format!("{x}") }
}

944
crates/expr/src/tests.rs Normal file
View File

@ -0,0 +1,944 @@
use std::time::Instant;
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MaskMode, Project, PropGroup, Solid};
use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::{Expressions, check_syntax, eval_property, eval_standalone};
const ROT: &str = "transform/rotation";
const POS: &str = "transform/position";
const OPA: &str = "transform/opacity";
/// A 200×100, 30 fps, 10 s comp named "Main" with:
/// 1. "A": 20×20 red solid at (50, 40), slider effect = 42, rotation keyed 0→90 over 0–1 s
/// 2. "B": 40×40 blue solid at (100, 50)
/// 3. "Title": text layer "Hello"
/// 4. "Shape": shape layer with "Rectangle 1" (60×30 rect path, red fill)
///
/// plus an "Other" comp with one layer "Inner".
struct Fx {
p: Project,
cid: ItemId,
a: LayerId,
b: LayerId,
title: LayerId,
shape: LayerId,
}
fn solid(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], w: u32, h: u32) -> Layer {
let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None)
}
fn group_push(p: &mut Project, f: impl FnOnce(&mut Ids) -> PropGroup) -> PropGroup {
let mut next = p.next_id;
let g = f(&mut Ids(&mut next));
p.next_id = next;
g
}
fn fx() -> Fx {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(10.0));
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut a = solid(&mut p, &comp, "A", [1.0, 0.0, 0.0], 20, 20);
a.props.prop_mut(POS).unwrap().value = Value::Vec3([50.0, 40.0, 0.0]);
a.props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(90.0))];
let spec = effectcraft_effects::find("ec.control.slider").unwrap();
let mut slider = group_push(&mut p, |ids| effectcraft_effects::instantiate(spec, ids, "Slider Control", [20.0, 20.0]));
slider.prop_mut("slider").unwrap().value = Value::Scalar(42.0);
a.props.sub_mut("effects").unwrap().children.push(slider.into());
let mask = group_push(&mut p, |ids| build::mask(ids, "Mask 1", ShapePath::rect([10.0, 10.0], 10.0, 10.0), MaskMode::Add, [255, 0, 0]));
a.props.sub_mut("masks").unwrap().children.push(mask.into());
a.markers.push(Marker { time: secs(2.0), comment: "beat".into(), ..Default::default() });
a.markers.push(Marker { time: secs(4.0), comment: "drop".into(), ..Default::default() });
let b = solid(&mut p, &comp, "B", [0.0, 0.0, 1.0], 40, 40);
let mut title = build::layer(&mut p, &comp, "Title", LayerSource::Text, (200, 100), None);
title.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "Hello".into(), size: 40.0, ..Default::default() }));
title.props.prop_mut(POS).unwrap().value = Value::Vec3([20.0, 60.0, 0.0]);
let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None);
let g = group_push(&mut p, |ids| {
let rect = build::shape_rect(ids, [60.0, 30.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
build::shape_group(ids, "Rectangle 1", vec![rect, fill])
});
shape.props.sub_mut("contents").unwrap().children.push(g.into());
let ids = (a.id, b.id, title.id, shape.id);
let c = p.comp_mut(cid).unwrap();
c.layers = vec![a, b, title, shape];
c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() });
let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0));
let oid = p.add_item("Other", Label::Sandstone, None, ItemKind::Comp(other.clone().into()));
let inner = solid(&mut p, &other, "Inner", [0.0, 1.0, 0.0], 64, 32);
p.comp_mut(oid).unwrap().layers.push(inner);
Fx { p, cid, a: ids.0, b: ids.1, title: ids.2, shape: ids.3 }
}
fn secs(s: f64) -> Tick {
Tick::from_seconds_f64(s)
}
impl Fx {
fn ev(&self, layer: LayerId, path: &str, text: &str, t: f64) -> Result<Value, String> {
eval_standalone(&self.p, self.cid, layer, path, text, t)
}
/// Scalar result on layer A's rotation.
fn num(&self, text: &str, t: f64) -> f64 {
match self.ev(self.a, ROT, text, t) {
Ok(v) => v.as_f64(),
Err(e) => panic!("{text}: {e}"),
}
}
/// Position result on layer A (3 components).
fn pos(&self, text: &str, t: f64) -> [f64; 3] {
match self.ev(self.a, POS, text, t) {
Ok(v) => v.as_vec3(),
Err(e) => panic!("{text}: {e}"),
}
}
fn text(&self, text: &str, t: f64) -> String {
match self.ev(self.title, "text/sourceText", text, t) {
Ok(Value::Text(d)) => d.text,
Ok(v) => panic!("{text}: not text: {v:?}"),
Err(e) => panic!("{text}: {e}"),
}
}
fn err(&self, text: &str) -> String {
match self.ev(self.a, ROT, text, 0.0) {
Ok(v) => panic!("{text}: expected an error, got {v:?}"),
Err(e) => e,
}
}
fn set_expr(&mut self, layer: LayerId, path: &str, text: &str) {
let l = self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap();
l.props.prop_mut(path).unwrap().expr = Some(Expression { text: text.into(), enabled: true });
}
fn layer_mut(&mut self, layer: LayerId) -> &mut Layer {
self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap()
}
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
macro_rules! assert_close {
($a:expr, $b:expr) => {{
let (a, b): (f64, f64) = ($a, $b);
assert!((a - b).abs() < 1e-6, "{} = {a}, expected {b}", stringify!($a));
}};
($a:expr, $b:expr, $eps:expr) => {{
let (a, b): (f64, f64) = ($a, $b);
assert!((a - b).abs() < $eps, "{} = {a}, expected {b} ± {}", stringify!($a), $eps);
}};
}
// ---- basics -----------------------------------------------------------------------------------
#[test]
fn time_times_360() {
let f = fx();
assert_close!(f.num("time * 360", 0.5), 180.0);
assert_close!(f.num("time*360", 2.0), 720.0);
}
#[test]
fn value_is_the_keyframed_value() {
let f = fx();
assert_close!(f.num("value", 0.5), 45.0);
assert_close!(f.num("value + 10", 1.0), 100.0);
}
#[test]
fn math_object() {
let f = fx();
assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
assert_close!(f.num("Math.max(3, 7) - Math.floor(2.7)", 0.0), 5.0);
}
#[test]
fn last_statement_is_the_result() {
let f = fx();
assert_close!(f.num("var x = 5;\nvar y = x * 2;\ny", 0.0), 10.0);
assert_close!(f.num("x = 3; x + 1", 0.0), 4.0);
}
#[test]
fn if_else_blocks_end_in_a_value() {
let f = fx();
assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 2.0), 10.0);
assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 0.0), 20.0);
assert_close!(f.num("if (time > 1) 1; else if (time > 0.5) 2; else 3", 0.75), 2.0);
}
#[test]
fn functions_and_let_are_reusable_across_evaluations() {
let f = fx();
let e = "function sq(x) { return x * x }\nlet k = 2;\nconst j = 1;\nsq(time) * k + j";
assert_close!(f.num(e, 3.0), 19.0);
assert_close!(f.num(e, 2.0), 9.0);
}
#[test]
fn comments_are_ignored() {
let f = fx();
assert_close!(f.num("// spin\n/* fast */ time * 100 // per second", 1.0), 100.0);
}
#[test]
fn ternary_and_comparison() {
let f = fx();
assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 2.0), 2.0);
assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 0.0), 9.0);
}
// ---- array maths ------------------------------------------------------------------------------
#[test]
fn array_plus_array() {
let f = fx();
assert_eq!(f.pos("value + [10, 0]", 0.0), [60.0, 40.0, 0.0]);
assert_eq!(f.pos("[10, 5] + value", 0.0), [60.0, 45.0, 0.0]);
}
#[test]
fn array_times_and_divide_scalar() {
let f = fx();
assert_eq!(f.pos("[1, 2] * 2", 0.0), [2.0, 4.0, 0.0]);
assert_eq!(f.pos("2 * [1, 2]", 0.0), [2.0, 4.0, 0.0]);
assert_eq!(f.pos("[10, 20] / 2", 0.0), [5.0, 10.0, 0.0]);
assert_eq!(f.pos("value - [50, 40]", 0.0), [0.0, 0.0, 0.0]);
}
#[test]
fn array_precedence_and_unary_minus() {
let f = fx();
assert_eq!(f.pos("[1, 1] + [2, 3] * 2", 0.0), [5.0, 7.0, 0.0]);
assert_eq!(f.pos("([1, 1] + [2, 3]) * 2", 0.0), [6.0, 8.0, 0.0]);
assert_eq!(f.pos("-value + [100, 100]", 0.0), [50.0, 60.0, 0.0]);
}
#[test]
fn compound_assignment_on_arrays() {
let f = fx();
assert_eq!(f.pos("var p = value;\np += [5, 5];\np", 0.0), [55.0, 45.0, 0.0]);
assert_eq!(f.pos("var p = [1, 2];\np *= 3;\np", 0.0), [3.0, 6.0, 0.0]);
}
#[test]
fn string_concatenation_still_works() {
let f = fx();
assert_eq!(f.text("'Count: ' + 5", 0.0), "Count: 5");
assert_eq!(f.text("'a' + 1 + 2", 0.0), "a12");
assert_eq!(f.text("1 + 2 + 'a'", 0.0), "3a");
assert_eq!(f.text("'pos ' + [1, 2]", 0.0), "pos 1,2");
}
#[test]
fn vector_helpers() {
let f = fx();
assert_eq!(f.pos("add([1, 2], [3, 4])", 0.0), [4.0, 6.0, 0.0]);
assert_eq!(f.pos("sub([5, 5], [1, 2])", 0.0), [4.0, 3.0, 0.0]);
assert_eq!(f.pos("mul([1, 2], 3)", 0.0), [3.0, 6.0, 0.0]);
assert_eq!(f.pos("div([3, 6], 3)", 0.0), [1.0, 2.0, 0.0]);
assert_close!(f.num("dot([1, 2, 3], [4, 5, 6])", 0.0), 32.0);
assert_eq!(f.pos("cross([1, 0, 0], [0, 1, 0])", 0.0), [0.0, 0.0, 1.0]);
assert_close!(f.num("length([3, 4])", 0.0), 5.0);
assert_close!(f.num("length([1, 1], [4, 5])", 0.0), 5.0);
assert_close!(f.num("normalize([0, 10])[1]", 0.0), 1.0);
}
#[test]
fn clamp_scalar_and_array() {
let f = fx();
assert_close!(f.num("clamp(150, 0, 100)", 0.0), 100.0);
assert_close!(f.num("clamp(-5, 0, 100)", 0.0), 0.0);
assert_eq!(f.pos("clamp([-10, 500], 0, 100)", 0.0), [0.0, 100.0, 0.0]);
}
#[test]
fn angles_and_look_at() {
let f = fx();
assert_close!(f.num("radiansToDegrees(Math.PI)", 0.0), 180.0);
assert_close!(f.num("degreesToRadians(90)", 0.0), std::f64::consts::FRAC_PI_2);
let o = f.ev(f.a, "transform/orientation", "lookAt([0, 0, 0], [100, 0, 100])", 0.0).unwrap().as_vec3();
assert!(close(o[1], 45.0) && close(o[0], 0.0), "{o:?}");
}
// ---- interpolation ----------------------------------------------------------------------------
#[test]
fn linear_five_args_maps_and_clamps() {
let f = fx();
assert_close!(f.num("linear(time, 0, 2, 0, 100)", 1.0), 50.0);
assert_close!(f.num("linear(time, 0, 2, 0, 100)", 5.0), 100.0);
assert_close!(f.num("linear(time, 1, 2, 0, 100)", 0.0), 0.0);
assert_close!(f.num("linear(time, 2, 0, 0, 100)", 0.5), 75.0);
}
#[test]
fn linear_three_args() {
let f = fx();
assert_close!(f.num("linear(0.25, 10, 20)", 0.0), 12.5);
}
#[test]
fn ease_curves() {
let f = fx();
assert_close!(f.num("ease(time, 0, 1, 0, 100)", 0.5), 50.0);
assert!(f.num("ease(time, 0, 1, 0, 100)", 0.25) < 25.0);
assert!(f.num("easeIn(time, 0, 1, 0, 100)", 0.25) < 25.0);
assert!(f.num("easeOut(time, 0, 1, 0, 100)", 0.25) > 25.0);
assert_close!(f.num("easeIn(time, 0, 1, 0, 100)", 1.0), 100.0);
assert_close!(f.num("easeOut(0.5, 0, 10)", 0.0) + f.num("easeIn(0.5, 0, 10)", 0.0), 10.0);
}
#[test]
fn interpolation_with_arrays() {
let f = fx();
assert_eq!(f.pos("linear(time, 0, 1, [0, 0], [100, 50])", 0.5), [50.0, 25.0, 0.0]);
assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]);
}
// ---- object model -----------------------------------------------------------------------------
#[test]
fn comp_attributes() {
let f = fx();
assert_close!(f.num("thisComp.width + thisComp.height", 0.0), 300.0);
assert_close!(f.num("thisComp.duration", 0.0), 10.0);
assert_close!(f.num("thisComp.frameDuration * 30", 0.0), 1.0);
assert_close!(f.num("thisComp.numLayers", 0.0), 4.0);
assert_eq!(f.text("thisComp.name", 0.0), "Main");
}
#[test]
fn layer_attributes() {
let f = fx();
assert_eq!(f.text("thisLayer.name + ' ' + index", 0.0), "Title 3");
assert_close!(f.num("index", 0.0), 1.0);
assert_close!(f.num("inPoint + outPoint + startTime", 0.0), 10.0);
assert_close!(f.num("width * 1000 + height", 0.0), 20_020.0);
assert_close!(f.num("thisLayer.hasParent ? 1 : 0", 0.0), 0.0);
}
#[test]
fn layer_by_name_and_index() {
let f = fx();
assert_close!(f.num("thisComp.layer(\"B\").transform.position[0]", 0.0), 100.0);
assert_eq!(f.text("thisComp.layer(2).name", 0.0), "B");
assert_eq!(f.text("thisComp.layer(thisLayer, 1).name", 0.0), "Shape");
assert_eq!(f.text("thisComp.layer(thisLayer, -2).name", 0.0), "A");
}
#[test]
fn cross_layer_position_reference() {
let f = fx();
assert_eq!(f.pos("thisComp.layer(\"B\").transform.position", 0.0), [100.0, 50.0, 0.0]);
assert_eq!(f.pos("thisComp.layer(\"B\").position + [0, 10]", 0.0), [100.0, 60.0, 0.0]);
assert_close!(f.num("thisComp.layer('B').transform.scale[1]", 0.0), 100.0);
assert_close!(f.num("thisComp.layer('B').transform.opacity", 0.0), 100.0);
}
#[test]
fn layer_shorthand_attributes() {
let f = fx();
assert_close!(f.num("position[0] + anchorPoint[0]", 0.0), 60.0);
assert_close!(f.num("scale[0] + opacity", 0.0), 200.0);
assert_close!(f.num("transform.anchorPoint[1]", 0.0), 10.0);
}
#[test]
fn reading_a_property_that_has_an_expression() {
let mut f = fx();
f.set_expr(f.b, ROT, "time * 10");
assert_close!(f.num("thisComp.layer('B').transform.rotation + 1", 2.0), 21.0);
// Chains: B.opacity reads B.rotation (expression), A reads B.opacity.
f.set_expr(f.b, OPA, "transform.rotation * 2");
assert_close!(f.num("thisComp.layer('B').transform.opacity", 1.0), 20.0);
}
#[test]
fn own_property_reads_pre_expression_value() {
let f = fx();
assert_close!(f.num("transform.rotation + 1", 0.5), 46.0);
assert_close!(f.num("thisProperty.value * 2", 1.0), 180.0);
}
#[test]
fn circular_references_terminate() {
let mut f = fx();
f.set_expr(f.a, OPA, "thisComp.layer('B').transform.opacity");
f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity");
let start = Instant::now();
let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0);
assert!(start.elapsed().as_secs_f64() < 10.0);
// The cycle bottoms out at the depth limit and falls back to keyframed values.
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
}
#[test]
fn effect_slider_reference() {
let f = fx();
assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0);
assert_close!(f.num("effect('Slider Control')('Slider') * 2", 0.0), 84.0);
assert_close!(f.num("thisLayer.effect('Slider Control').param('Slider')", 0.0), 42.0);
assert_close!(f.num("effect(1).param(1) + effect(1)(1)", 0.0), 84.0);
assert_close!(f.num("thisComp.layer('A').effect('Slider Control')('Slider').value", 0.0), 42.0);
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 0.0).unwrap().as_f64(), 42.0);
}
#[test]
fn effect_slider_with_expression_drives_another_layer() {
let mut f = fx();
f.set_expr(f.a, "effects/#1/slider", "time * 5");
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 2.0).unwrap().as_f64(), 10.0);
}
#[test]
fn missing_effect_is_an_error() {
let f = fx();
let e = f.err("effect('Nope')('Slider')");
assert!(e.contains("Nope"), "{e}");
}
#[test]
fn group_names_and_counts() {
let f = fx();
assert_eq!(f.text("thisComp.layer('A').effect(1).name", 0.0), "Slider Control");
assert_close!(f.ev(f.title, ROT, "thisComp.layer('A').transform.numProperties", 0.0).unwrap().as_f64(), 8.0);
}
#[test]
fn mask_properties() {
let f = fx();
assert_close!(f.num("mask('Mask 1').maskOpacity", 0.0), 100.0);
assert_close!(f.num("mask('Mask 1').maskExpansion + 1", 0.0), 1.0);
}
#[test]
fn shape_content_paths() {
let f = fx();
let v = f.ev(f.shape, ROT, "content('Rectangle 1').content('Rectangle Path 1').size[0]", 0.0).unwrap();
assert_close!(v.as_f64(), 60.0);
let c = f.ev(f.shape, ROT, "content('Rectangle 1').content('Fill 1').color[0] * 10", 0.0).unwrap();
assert_close!(c.as_f64(), 10.0);
}
#[test]
fn shape_fill_color_expression() {
let f = fx();
let path = "contents/#1/contents/fill/color";
let v = f.ev(f.shape, path, "[0, 1, 0]", 0.0).unwrap();
assert_eq!(v, Value::Color([0.0, 1.0, 0.0, 1.0]));
}
#[test]
fn comp_by_name() {
let f = fx();
assert_eq!(f.text("comp('Other').layer(1).name", 0.0), "Inner");
assert_close!(f.num("comp('Other').width", 0.0), 64.0);
assert!(f.err("comp('Missing').width").contains("Missing"));
}
#[test]
fn parent_attributes() {
let mut f = fx();
let b = f.b;
f.layer_mut(f.a).parent = Some(b);
assert_eq!(f.text("thisComp.layer('A').parent.name", 0.0), "B");
assert_close!(f.num("hasParent ? parent.transform.position[0] : -1", 0.0), 100.0);
}
#[test]
fn markers() {
let f = fx();
assert_close!(f.num("marker.numKeys", 0.0), 2.0);
assert_close!(f.num("marker.key(2).time", 0.0), 4.0);
assert_eq!(f.text("thisComp.layer('A').marker.key(1).comment", 0.0), "beat");
assert_close!(f.num("marker.key('drop').time", 0.0), 4.0);
assert_close!(f.num("marker.nearestKey(time).index", 3.5), 2.0);
assert_close!(f.num("thisComp.marker.key(1).time", 0.0), 1.5);
}
#[test]
fn layer_markers_follow_start_time() {
let mut f = fx();
f.layer_mut(f.a).start_time = secs(1.0);
assert_close!(f.num("marker.key(1).time", 0.0), 3.0);
}
// ---- keyframes, time ---------------------------------------------------------------------------
#[test]
fn value_at_time() {
let f = fx();
assert_close!(f.num("valueAtTime(0.5)", 0.0), 45.0);
assert_close!(f.num("thisProperty.valueAtTime(time - 0.5)", 1.0), 45.0);
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation.valueAtTime(0.25)", 0.0).unwrap().as_f64(), 22.5);
}
#[test]
fn value_at_time_respects_layer_start_time() {
let mut f = fx();
f.layer_mut(f.a).start_time = secs(1.0);
// Keys at layer time 0 and 1 → comp time 1 and 2.
assert_close!(f.num("valueAtTime(1.5)", 0.0), 45.0);
assert_close!(f.num("key(1).time", 0.0), 1.0);
}
#[test]
fn velocity_and_speed() {
let f = fx();
assert_close!(f.num("velocityAtTime(0.5)", 0.0), 90.0, 1e-3);
assert_close!(f.num("velocity", 0.5), 90.0, 1e-3);
assert_close!(f.num("speedAtTime(0.5)", 0.0), 90.0, 1e-3);
assert_close!(f.num("thisComp.layer('B').transform.position.speed", 0.5), 0.0, 1e-6);
}
#[test]
fn keys_and_nearest_key() {
let f = fx();
assert_close!(f.num("numKeys", 0.0), 2.0);
assert_close!(f.num("key(2).time * 1000 + key(2).value", 0.0), 1090.0);
assert_close!(f.num("nearestKey(0.8).index", 0.0), 2.0);
assert_close!(f.num("nearestKey(0.2).value", 0.0), 0.0);
assert!(f.err("key(3).time").contains("out of range"));
}
#[test]
fn loop_out_cycle() {
let f = fx();
assert_close!(f.num("loopOut('cycle')", 1.25), 22.5);
assert_close!(f.num("loopOut()", 2.5), 45.0);
assert_close!(f.num("loopOut('cycle')", 0.5), 45.0);
}
#[test]
fn loop_out_pingpong() {
let f = fx();
assert_close!(f.num("loopOut('pingpong')", 1.25), 67.5);
assert_close!(f.num("loopOut('pingpong')", 2.25), 22.5);
}
#[test]
fn loop_out_offset() {
let f = fx();
assert_close!(f.num("loopOut('offset')", 1.5), 135.0);
assert_close!(f.num("loopOut('offset')", 3.5), 315.0);
}
#[test]
fn loop_out_continue() {
let f = fx();
assert_close!(f.num("loopOut('continue')", 2.0), 180.0, 1e-3);
}
#[test]
fn loop_in_cycle_and_offset() {
let mut f = fx();
f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(secs(1.0), Value::Scalar(0.0)), Keyframe::new(secs(2.0), Value::Scalar(100.0))];
assert_close!(f.num("loopIn('cycle')", 0.25), 25.0);
assert_close!(f.num("loopIn('offset')", 0.25), -75.0);
assert_close!(f.num("loopIn('pingpong')", 0.75), 25.0);
assert_close!(f.num("loopIn('cycle')", 1.5), 50.0);
}
#[test]
fn loop_out_num_keyframes_and_duration() {
let mut f = fx();
let keys =
vec![Keyframe::new(secs(0.0), Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(100.0)), Keyframe::new(secs(2.0), Value::Scalar(200.0))];
f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = keys;
// Only the last segment (1 s → 2 s) loops.
assert_close!(f.num("loopOut('cycle', 1)", 2.5), 150.0);
assert_close!(f.num("loopOut('cycle', 0)", 2.5), 50.0);
assert_close!(f.num("loopOutDuration('cycle', 0.5)", 2.25), 175.0);
}
#[test]
fn loop_on_two_dimensional_position() {
let mut f = fx();
f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys =
vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))];
assert_eq!(f.pos("loopOut('offset')", 1.5), [150.0, 75.0, 0.0]);
}
#[test]
fn smooth_on_linear_ramp_is_identity() {
let f = fx();
assert_close!(f.num("smooth(0.2, 5)", 0.5), 45.0, 1e-6);
}
#[test]
fn time_conversions() {
let f = fx();
assert_close!(f.num("timeToFrames()", 2.0), 60.0);
assert_close!(f.num("timeToFrames(1, 24)", 0.0), 24.0);
assert_close!(f.num("framesToTime(45)", 0.0), 1.5);
assert_eq!(f.text("timeToTimecode()", 61.5), "0:01:01:15");
assert_eq!(f.text("timeToCurrentFormat(2.5)", 0.0), "0:00:02:15");
}
#[test]
fn posterize_time() {
let f = fx();
assert_close!(f.num("posterizeTime(2); time", 0.7), 0.5);
assert_close!(f.num("posterizeTime(2); value", 0.7), 45.0);
}
// ---- wiggle / random / noise ------------------------------------------------------------------
#[test]
fn wiggle_is_deterministic() {
let f = fx();
let a = f.pos("wiggle(3, 25)", 1.234);
let b = f.pos("wiggle(3, 25)", 1.234);
assert_eq!(a, b);
let g = fx();
assert_eq!(g.pos("wiggle(3, 25)", 1.234), a);
let other_thread = std::thread::spawn(move || g.pos("wiggle(3, 25)", 1.234)).join().unwrap();
assert_eq!(other_thread, a);
}
#[test]
fn wiggle_amplitude_bounds_and_motion() {
let f = fx();
let mut seen = Vec::new();
for i in 0..120 {
let p = f.pos("wiggle(5, 30)", i as f64 / 30.0);
assert!((p[0] - 50.0).abs() <= 30.0 + 1e-9 && (p[1] - 40.0).abs() <= 30.0 + 1e-9, "{p:?}");
assert_eq!(p[2], 0.0, "2D layers keep z");
seen.push(p[0]);
}
let spread = seen.iter().cloned().fold(f64::MIN, f64::max) - seen.iter().cloned().fold(f64::MAX, f64::min);
assert!(spread > 10.0, "wiggle should move: spread {spread}");
}
#[test]
fn wiggle_octaves_bound() {
let f = fx();
for i in 0..60 {
let r = f.num("wiggle(2, 10, 3, 0.5) - value", i as f64 / 10.0);
assert!(r.abs() <= 10.0 * 1.75 + 1e-9, "{r}");
}
}
#[test]
fn wiggle_differs_per_layer_and_dimension() {
let f = fx();
let a = f.ev(f.a, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64();
let b = f.ev(f.b, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64();
assert_ne!(a, b);
let p = f.pos("wiggle(2, 20) - value", 0.7);
assert_ne!(p[0], p[1]);
}
#[test]
fn wiggle_on_other_layer_property() {
let f = fx();
let v = f.num("thisComp.layer('B').transform.rotation.wiggle(1, 5)", 0.3);
assert!(v.abs() <= 5.0 && v != 0.0, "{v}");
}
#[test]
fn temporal_wiggle_stays_on_the_curve() {
let f = fx();
for i in 0..10 {
let v = f.num("temporalWiggle(2, 0.1)", 0.2 + i as f64 * 0.05);
assert!((0.0..=90.0).contains(&v), "{v}");
}
}
#[test]
fn random_is_seeded_and_in_range() {
let f = fx();
let a = f.num("random()", 0.5);
assert!((0.0..1.0).contains(&a));
assert_eq!(a, f.num("random()", 0.5));
assert_ne!(a, f.num("random()", 0.6));
let r = f.num("random(10, 20)", 0.0);
assert!((10.0..20.0).contains(&r));
let p = f.pos("random([100, 50])", 0.0);
assert!((0.0..100.0).contains(&p[0]) && (0.0..50.0).contains(&p[1]));
assert_ne!(f.num("random() - random()", 0.0), 0.0, "successive calls differ");
}
#[test]
fn seed_random_timeless() {
let f = fx();
let a = f.num("seedRandom(5, true); random(100)", 0.1);
let b = f.num("seedRandom(5, true); random(100)", 3.7);
assert_eq!(a, b);
assert_ne!(a, f.num("seedRandom(6, true); random(100)", 0.1));
}
#[test]
fn gauss_random_distribution() {
let f = fx();
let e =
"var s = 0, n = 2000, inside = 0;\nfor (var i = 0; i < n; i++) { var g = gaussRandom(); s += g; if (g >= 0 && g <= 1) inside++; }\n[s / n, inside / n]";
let r = f.pos(e, 0.0);
assert!((r[0] - 0.5).abs() < 0.05, "mean {}", r[0]);
assert!((0.85..0.95).contains(&r[1]), "inside {}", r[1]);
}
#[test]
fn noise_is_smooth_and_bounded() {
let f = fx();
let a = f.num("noise(time)", 1.0);
let b = f.num("noise(time)", 1.001);
assert!(a.abs() <= 1.0 && (a - b).abs() < 0.01);
assert!(f.num("noise([1.5, 2.5, 3.5])", 0.0).abs() <= 1.0);
}
// ---- text -------------------------------------------------------------------------------------
#[test]
fn source_text_from_expression_keeps_style() {
let f = fx();
let v = f.ev(f.title, "text/sourceText", "'Frame ' + timeToFrames()", 1.0).unwrap();
let Value::Text(doc) = v else { panic!() };
assert_eq!(doc.text, "Frame 30");
assert_eq!(doc.size, 40.0, "style from the keyframed document");
}
#[test]
fn source_text_reading_and_string_methods() {
let f = fx();
assert_eq!(f.text("value + '!'", 0.0), "Hello!");
assert_eq!(f.text("thisComp.layer('Title').text.sourceText + ' world'", 0.0), "Hello world");
assert_eq!(f.text("text.sourceText.toUpperCase()", 0.0), "HELLO");
assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0);
}
#[test]
fn numbers_to_source_text() {
let f = fx();
assert_eq!(f.text("Math.round(time * 10) / 10", 1.25), "1.3");
assert_eq!(f.text("(time * 2).toFixed(2)", 1.0), "2.00");
assert_eq!(f.text("12", 0.0), "12");
}
// ---- space conversions ------------------------------------------------------------------------
#[test]
fn to_comp_and_from_comp() {
let f = fx();
// A: 20×20 solid, anchor (10, 10), position (50, 40).
assert_eq!(f.pos("toComp([0, 0])", 0.0), [40.0, 30.0, 0.0]);
assert_eq!(f.pos("fromComp([50, 40])", 0.0), [10.0, 10.0, 0.0]);
// Rotation keyed to 90° at 1 s.
let p = f.pos("toComp([20, 10])", 1.0);
assert!(close(p[0], 50.0) && close(p[1], 50.0), "{p:?}");
let q = f.pos("toWorld([10, 10, 0])", 0.0);
assert!(close(q[0], 50.0) && close(q[1], 40.0), "{q:?}");
let v = f.pos("toCompVec([1, 0])", 1.0);
assert!(close(v[0], 0.0) && close(v[1], 1.0), "{v:?}");
}
#[test]
fn source_rect_at_time() {
let f = fx();
assert_eq!(f.pos("var r = sourceRectAtTime(); [r.width, r.height]", 0.0), [20.0, 20.0, 0.0]);
let t = f.ev(f.title, POS, "var r = sourceRectAtTime(); [r.width, r.height]", 0.0).unwrap().as_vec3();
assert!(t[0] > 40.0 && t[1] > 15.0, "{t:?}");
let s = f.ev(f.shape, POS, "var r = sourceRectAtTime(); [r.left, r.top, r.width]", 0.0).unwrap().as_vec3();
assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}");
}
// ---- colour -----------------------------------------------------------------------------------
#[test]
fn colour_conversions() {
let f = fx();
let hsl = f.pos("rgbToHsl([1, 0, 0, 1])", 0.0);
assert_eq!(hsl, [0.0, 1.0, 0.5]);
let back = f.pos("hslToRgb([2 / 3, 1, 0.5, 1])", 0.0);
assert!(close(back[0], 0.0) && close(back[1], 0.0) && close(back[2], 1.0), "{back:?}");
let hex = f.pos("hexToRgb('#FF8000')", 0.0);
assert!(close(hex[0], 1.0) && close(hex[1], 128.0 / 255.0) && close(hex[2], 0.0), "{hex:?}");
}
#[test]
fn colour_result_keeps_alpha() {
let f = fx();
let path = "contents/#1/contents/fill/color";
let v = f.ev(f.shape, path, "hslToRgb([0, 0, 0.5])", 0.0).unwrap();
assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0]));
}
// ---- result conversion and errors -------------------------------------------------------------
#[test]
fn checkbox_and_dropdown_results() {
let mut f = fx();
let spec = effectcraft_effects::find("ec.control.checkbox").unwrap();
let cb = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Checkbox Control", [20.0, 20.0]));
let spec = effectcraft_effects::find("ec.control.dropdown").unwrap();
let dd = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Dropdown Menu Control", [20.0, 20.0]));
let fxg = f.layer_mut(f.b).props.sub_mut("effects").unwrap();
fxg.children.push(cb.into());
fxg.children.push(dd.into());
assert_eq!(f.ev(f.b, "effects/#1/checkbox", "time > 1", 2.0).unwrap(), Value::Bool(true));
assert_eq!(f.ev(f.b, "effects/#1/checkbox", "0", 2.0).unwrap(), Value::Bool(false));
assert_eq!(f.ev(f.b, "effects/#2/menu", "3", 0.0).unwrap(), Value::Enum(2));
assert_close!(f.ev(f.b, ROT, "effect('Dropdown Menu Control')('Menu') * 10", 0.0).unwrap().as_f64(), 10.0);
}
#[test]
fn syntax_error_reports_line() {
let f = fx();
let e = f.err("var a = 1;\nvar b = ;\na");
assert!(e.contains("line 2"), "{e}");
assert!(check_syntax("var a = (1;").is_err());
assert!(check_syntax("value + [1, 2]").is_ok());
}
#[test]
fn runtime_error_reports_line() {
let f = fx();
let e = f.err("var a = 1;\nfoo.bar + a");
assert!(e.contains("foo"), "{e}");
assert!(e.contains("line 2"), "{e}");
}
#[test]
fn thrown_errors_and_missing_layers() {
let f = fx();
assert!(f.err("throw new Error('custom failure')").contains("custom failure"));
assert!(f.err("thisComp.layer('Ghost').position").contains("Ghost"));
assert!(f.err("thisComp.layer(99).name").contains("99"));
}
#[test]
fn result_kind_errors() {
let f = fx();
assert!(f.ev(f.a, POS, "5", 0.0).unwrap_err().contains("dimension"));
assert!(f.err("'abc'").contains("number"));
assert!(f.err("var x = 1").contains("undefined"));
assert!(f.err("0 / 0").contains("finite"));
}
#[test]
fn shadowing_layer_attribute_names_is_local() {
let f = fx();
assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0);
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
}
#[test]
fn try_catch_does_not_swallow_host_reads() {
let f = fx();
assert_close!(f.num("try { thisComp.layer('B').transform.position[0] } catch (e) { -1 }", 0.0), 100.0);
assert_close!(f.num("try { thisComp.layer('Ghost').name; 1 } catch (e) { -1 }", 0.0), -1.0);
}
#[test]
fn loops_over_layers() {
let f = fx();
let e = "var s = 0;\nfor (var i = 1; i <= thisComp.numLayers; i++) s += thisComp.layer(i).transform.position[0];\ns";
assert_close!(f.num(e, 0.0), 50.0 + 100.0 + 20.0 + 100.0);
}
#[test]
fn infinite_loops_are_stopped() {
let f = fx();
let e = f.err("while (true) {}");
assert!(!e.is_empty());
}
#[test]
fn renderer_uses_expressions_through_eval_ctx() {
let mut f = fx();
f.set_expr(f.b, ROT, "time * 360");
let host = Expressions;
let comp = f.p.comp(f.cid).unwrap();
let b = comp.layer(f.b).unwrap();
let prop = b.props.prop(ROT).unwrap();
let mut ctx = EvalCtx::new(&f.p, f.cid, comp, secs(0.25));
assert_eq!(ctx.value(b, prop), Value::Scalar(0.0), "no host: keyframed value");
ctx.expr = Some(&host);
assert_eq!(ctx.value(b, prop), Value::Scalar(90.0));
}
#[test]
fn disabled_expression_is_ignored() {
let mut f = fx();
f.set_expr(f.b, ROT, "time * 360");
f.layer_mut(f.b).props.prop_mut(ROT).unwrap().expr.as_mut().unwrap().enabled = false;
assert_eq!(eval_property(&f.p, f.cid, f.b, ROT, 0.25).unwrap(), Value::Scalar(0.0));
}
// ---- render & performance ---------------------------------------------------------------------
#[test]
fn expression_drives_rotation_in_a_rendered_frame() {
let mut p = Project::default();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
// A 80×10 horizontal bar at the centre; rotating 90° makes it vertical.
let mut bar = solid(&mut p, &comp, "Bar", [1.0, 1.0, 1.0], 80, 10);
bar.props.prop_mut(ROT).unwrap().expr = Some(Expression { text: "time * 360".into(), enabled: true });
p.comp_mut(cid).unwrap().layers.push(bar);
let host = Expressions;
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
let still = r.comp_frame(cid, Tick::ZERO);
assert!(still.get(85, 50)[3] > 0.99 && still.get(50, 85)[3] < 0.01, "horizontal at t=0");
let no_expr = r.comp_frame(cid, secs(0.25));
assert!(no_expr.get(85, 50)[3] > 0.99, "without a host the bar stays put");
r.expr = Some(&host);
let img = r.comp_frame(cid, secs(0.25));
assert!(img.get(50, 85)[3] > 0.99, "vertical at t=0.25 (90°)");
assert!(img.get(85, 50)[3] < 0.01);
}
#[test]
fn hundred_wiggles_are_fast() {
let mut p = Project::default();
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(10.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
for i in 0..100 {
let mut l = solid(&mut p, &comp, &format!("L{i}"), [1.0, 1.0, 1.0], 10, 10);
l.props.prop_mut(POS).unwrap().expr = Some(Expression { text: "wiggle(2, 50)".into(), enabled: true });
p.comp_mut(cid).unwrap().layers.push(l);
}
let host = Expressions;
let c = p.comp(cid).unwrap();
let frame = |t: f64| {
let mut ctx = EvalCtx::new(&p, cid, c, secs(t));
ctx.expr = Some(&host);
c.layers.iter().map(|l| ctx.value(l, l.props.prop(POS).unwrap()).as_vec3()[0]).sum::<f64>()
};
frame(0.0); // warm up: runtime + compile
let n = 20;
let start = Instant::now();
let mut acc = 0.0;
for i in 0..n {
acc += frame(i as f64 / 30.0);
}
let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})");
// Generous bound so unoptimised CI builds pass; release is far below.
assert!(per_frame < 100.0, "{per_frame} ms/frame");
}
#[test]
fn error_messages_point_at_the_users_line() {
let f = fx();
assert_eq!(f.err("var a = 1;\n\nthisComp.layer('Ghost').position"), "Error at line 3: Layer named \"Ghost\" does not exist");
assert_eq!(f.err("var a = 1;\nfoo.bar + a"), "Error at line 2: ReferenceError: foo is not defined");
// An unfinished last line is reported on that line, not on the hidden wrapper.
assert!(f.err("5 +").starts_with("Error at line 1: SyntaxError"));
}