Files
effectcraft/crates/expr/src/runtime.rs
T
Kristian MyramandClaude Haiku 5.5 4d105f02d4 #809: layer.source and a Layer Control's layer work in expressions
layer.source was undefined in expressions, and a layer from a Layer Control had no transform.
Both now work.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-12 19:19:11 +02:00

659 lines
27 KiB
Rust

//! 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;
/// Loop iterations one script run may make (per function call): a run re-executes once per
/// property it reads, so this bounds the time of every pass.
pub const LOOP_LIMIT: u64 = 1_000_000;
/// The error of an evaluation that ran out of time.
pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate";
/// 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<Req, Resp>,
pub misses: Vec<Req>,
/// What this frame evaluates: an evaluation that needs itself is a cycle.
pub owner: Option<Owner>,
}
/// One expression evaluation: the property (comp, layer, uid), the comp time and the text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Owner {
pub comp: u64,
pub layer: u64,
pub uid: u64,
pub time: effectcraft_time::Tick,
pub text: String,
}
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()) };
/// Script runs since the outermost evaluation on this thread began (nested ones included).
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(target_arch = "wasm32"))]
thread_local! {
/// When the outermost evaluation on this thread began.
pub static STARTED: std::cell::Cell<Option<std::time::Instant>> = const { std::cell::Cell::new(None) };
}
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,
/// 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<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,
},
/// 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<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) }
}
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,
15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? },
_ => 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(LOOP_LIMIT);
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. 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 = match evaluate_in_time(&script, ctx) {
Some(r) => r.and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)),
// Abandoned mid-run: the context is unusable now (see `run`).
None => return Run::Done(Err(TIMED_OUT.into())),
};
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))),
})
}
}
/// Run `script`, giving up (`None`) once the outermost evaluation is out of time
/// ([`crate::MAX_TIME`]). boa's budgeted evaluation yields every few thousand instructions of
/// the script and the functions it calls (not inside callbacks of native functions such as
/// `Array.prototype.map`, which stay bounded by the loop limit per call).
fn evaluate_in_time(script: &Script, ctx: &mut Context) -> Option<JsResult<JsValue>> {
let mut run = std::pin::pin!(script.evaluate_async_with_budget(ctx, 20_000));
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
loop {
if let std::task::Poll::Ready(r) = run.as_mut().poll(&mut cx) {
return Some(r);
}
if out_of_time() {
return None;
}
}
}
#[cfg(test)]
thread_local! {
/// Test hook: this thread's time budget (tests of the run-count budget lift it so that a
/// loaded machine cannot reach the clock first).
pub static TIME_LIMIT: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(crate::MAX_TIME) };
}
#[cfg(not(target_arch = "wasm32"))]
fn max_time() -> std::time::Duration {
#[cfg(test)]
{
TIME_LIMIT.with(|t| t.get())
}
#[cfg(not(test))]
{
crate::MAX_TIME
}
}
/// Whether the outermost evaluation on this thread has used up [`crate::MAX_TIME`].
pub fn out_of_time() -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
STARTED.with(|s| s.get()).is_some_and(|t| t.elapsed() > max_time())
}
#[cfg(target_arch = "wasm32")]
{
false
}
}
/// 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!("__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<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.
///
/// 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 e == TIMED_OUT => {
// Abandoned mid-run: start the next evaluation on a fresh context.
if let Some(old) = r.take() {
drop(ManuallyDrop::into_inner(old));
}
return Run::Done(Err(e));
}
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<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::Style(j) => {
serde_json::from_str::<serde_json::Value>(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 } => {
// A vertex that isn't a number (`[[0, 0], [[1, 2], 0]]`) comes through as NaN, which
// the rasterizer must never see.
if points.iter().chain(ins).chain(outs).flatten().any(|c| !c.is_finite()) {
return Err("Error: path points must be finite numbers".into());
}
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::<TextDoc>(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}") }
}