Merge expressions engine (boa)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e2ba01a11f
File diff suppressed because it is too large
Load Diff
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¤t,
|
||||
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;
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
@ -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)"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}") }
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
Loading…
Reference in New Issue