//! 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; /// The part of boa's stack-size limit error the runtime recovers from (see [`run`]). const STACK_LIMIT: &str = "maximum stack size"; /// Memoized host answers and pending misses of one expression evaluation. #[derive(Default)] pub struct Frame { pub memo: HashMap, pub misses: Vec, } 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>>> = const { RefCell::new(None) }; /// Evaluation stack (nested when one expression reads another's value). pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; } struct Runtime { ctx: Context, scripts: HashMap>, 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, /// Text Expression Selector `[textIndex, textTotal]` and `selectorValue` (percent). pub text_sel: [f64; 2], pub selector: [f64; 3], } /// Outcome of one script run. pub enum Run { Done(Result), /// 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), Path { points: Vec<[f64; 2]>, ins: Vec<[f64; 2]>, outs: Vec<[f64; 2]>, closed: bool, }, /// A text style object: JSON `{doc: TextDoc | null, ops: [[key, value, start, count], …]}`. Style(String), 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 { arg(args, i).to_number(ctx) } fn id(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { 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 { Ok(arg(args, i).to_string(ctx)?.to_std_string_escaped()) } fn key(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { 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 = 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 { 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) } } 10 => Req::Doc { 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(), }, 11 => Req::Footage { name: string(args, 1, ctx)? }, 12 => Req::FootageData { name: string(args, 1, ctx)? }, 13 => Req::Sample { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, x: secs(num(args, 3, ctx)?), y: secs(num(args, 4, ctx)?), rx: secs(num(args, 5, ctx)?), ry: secs(num(args, 6, ctx)?), post: arg(args, 7).to_boolean(), t: secs(num(args, 8, ctx)?), }, 14 => Req::Project, _ => 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 { 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 = 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 { 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 { 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 { 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); 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 { 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 { 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. Errors are rethrown from the script's own // frame: boa leaves the stack of the inner frames behind when an exception leaves the // script from a nested call (every host request does), and the thread's runtime would // fail every expression with "reached the maximum stack size" after a few hundred. let src = format!("try{{{}\n}}catch(__e){{throw __e}}", 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), JsValue::from(b.text_sel[0]), JsValue::from(b.text_sel[1]), to_js(&crate::host::Resp::List(b.selector.iter().map(|x| crate::host::Resp::Num(*x)).collect()), ctx), ]; 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 { 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!("__style"), ctx).map_err(err)?.to_boolean() { let j = o.get(js_string!("__json"), ctx).map_err(err)?; return Ok(Out::Style(j.as_string().map(|s| s.to_std_string_escaped()).unwrap_or_default())); } if o.get(js_string!("__path"), ctx).map_err(err)?.to_boolean() { let pts = |name: JsString, ctx: &mut Context| -> Result, 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 { 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 (`
`) backtrace /// entry, else the first `:line:col` position. fn find_line(msg: &str) -> Option { 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
")).and_then(position_line) { return Some(l); } position_line(msg) } /// First `:line:col` in `msg`. fn position_line(msg: &str) -> Option { 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. /// /// boa leaves values on its VM stack when an exception unwinds through call frames, and every /// pending host request does that (the request throws out of the object-model functions), so a /// long-lived context eventually fails every run with "maximum stack size". When a run hits that /// limit the context is replaced with a fresh one and the run retried once: each run starts from /// `__begin` with memoized answers, so nothing carries over. A script that really overflows the /// stack fails again on the fresh context and reports the error. pub fn run(text: &str, b: &Begin) -> Run { RUNTIME.with(|r| { let mut r = r.borrow_mut(); for fresh in [false, true] { let out = match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) { Ok(rt) => rt.run(text, b), Err(e) => return Run::Done(Err(e.clone())), }; match out { Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => { // Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit). if let Some(old) = r.take() { drop(ManuallyDrop::into_inner(old)); } } out => return out, } } Run::Done(Err(format!("Error: {STACK_LIMIT}"))) }) } /// 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 { 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 { 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::>().join(","), Out::Undefined | Out::Null => String::new(), Out::Path { .. } => "[object Path]".into(), Out::Style(j) => { serde_json::from_str::(j).ok().map(|v| styled_doc(&v, &effectcraft_keyframe::TextDoc::default()).text).unwrap_or_default() } 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) => match out { Out::Style(j) => { let v: serde_json::Value = serde_json::from_str(j).map_err(|e| format!("Error: bad text style: {e}"))?; Value::Text(Box::new(styled_doc(&v, doc))) } _ => { let mut d = doc.clone(); d.set_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, feather: Vec::new(), }) } _ => return Err("Error: expression result must be a path".into()), }, Value::Layer(_) | Value::Gradient(_) => current.clone(), }) } /// Apply a returned text style (`{doc, ops}`) to the property's own document `own`: the style's /// source document (another layer's, or this one's) gives the formatting, then the setter calls /// apply in order. Without `setText` the text stays `own`'s. pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> effectcraft_keyframe::TextDoc { use effectcraft_keyframe::TextDoc; let mut d = own.clone(); if let Some(src) = v.get("doc").filter(|s| !s.is_null()).and_then(|s| serde_json::from_value::(s.clone()).ok()) { if src.text == own.text { // The same text (this layer's own style, or an identical one): every run carries over. d = TextDoc { box_size: own.box_size, box_pos: own.box_pos, vertical: own.vertical, ..src }; } else { let first = src.style_at(0); d.runs.clear(); d.apply_style_all(|s| *s = first.clone()); let p0 = src.para(0); d.set_paras(vec![p0; d.para_count()]); d.stroke_over_fill = src.stroke_over_fill; } } for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() { let Some(key) = op.get(0).and_then(|k| k.as_str()) else { continue }; let val = op.get(1).cloned().unwrap_or_default(); let start = op.get(2).and_then(|x| x.as_i64()).unwrap_or(-1); let count = op.get(3).and_then(|x| x.as_i64()).unwrap_or(-1); let n = d.char_len(); let range = (start >= 0).then(|| { let a = (start as usize).min(n); a..if count < 0 { n } else { (a + count as usize).min(n) } }); if key == "text" { let s = val.as_str().unwrap_or_default(); match range { Some(r) => d.replace_range(r, s, None), None => d.set_text(s), } continue; } let _ = d.set_attr(key, &val, range); } d } /// 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}") } }