mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
M8: expressions engine (boa) with AE object model
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
808cd490cd
commit
4ce50e00a0
@@ -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;
|
||||
Reference in New Issue
Block a user