M9.6: text animation depth (selectors, animator properties, per-char 3D, path text, presets)

Range selector complete (units, based on, modes, shapes, smoothness, ease
high/low, randomize), Wiggly and Expression selectors combined by mode;
all AE animator properties incl. line anchor, character offset/value,
skew axis, tracking type, fill/stroke HSB+opacity, blur; More Options
(anchor grouping, fill & stroke order, inter-character blending); Path
Options (text on a mask path); per-character 3D rendered as planes in
the 3D compositor; text animation commands and original presets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 00:01:19 -04:00
1 parent d73f199f38
commit c5cf189e1a
24 files changed
+2912 -284

No files matched your search

+1
View File
@@ -25,3 +25,4 @@ boa_engine = { workspace = true, features = ["js"] }
[dev-dependencies]
effectcraft-color = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-text = { workspace = true }
+45 -1
View File
@@ -53,6 +53,31 @@ impl ExprHost for Expressions {
None => Ok(value.clone()),
}
}
fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> {
let Some(e) = prop.expr.as_ref().filter(|_| prop.has_expression()) else {
let v = prop.value_at(layer.layer_time(ctx.time)).components();
return Ok([v.first().copied().unwrap_or(100.0), v.get(1).copied().unwrap_or(100.0), v.get(2).copied().unwrap_or(100.0)]);
};
let value = prop.value_at(layer.layer_time(ctx.time));
let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, [index as f64 + 1.0, total as f64], selector)?;
let num = |o: &runtime::Out| match o {
runtime::Out::Num(x) => Some(*x),
runtime::Out::Bool(b) => Some(if *b { 100.0 } else { 0.0 }),
_ => None,
};
let r = match &out {
runtime::Out::Arr(items) => {
let g = |i: usize| items.get(i).and_then(num).or_else(|| items.first().and_then(num));
[g(0), g(1), g(2)]
}
o => [num(o); 3],
};
match r {
[Some(a), Some(b), Some(c)] if a.is_finite() && b.is_finite() && c.is_finite() => Ok([a, b, c]),
_ => Err("Error: expression selector amount must be a number or an array of numbers".into()),
}
}
}
/// Pops the evaluation frame on every exit path.
@@ -66,6 +91,23 @@ impl Drop for FrameGuard {
/// 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 out = evaluate_out(host, ctx, layer, prop, value, text, [1.0, 1.0], [100.0; 3])?;
runtime::to_value(&out, value)
}
/// Run an expression and return the raw script result. `text_sel` / `selector` are the text
/// Expression Selector's `[textIndex, textTotal]` and `selectorValue`.
#[allow(clippy::too_many_arguments)]
fn evaluate_out(
host: &dyn ExprHost,
ctx: &EvalCtx,
layer: &Layer,
prop: &Property,
value: &Value,
text: &str,
text_sel: [f64; 2],
selector: [f64; 3],
) -> Result<runtime::Out, 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());
@@ -84,6 +126,8 @@ pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Proper
uid: prop.uid,
index: ctx.comp.index_of(layer.id).unwrap_or(0) as f64,
frame_duration: ctx.comp.frame_duration().seconds(),
text_sel,
selector,
};
// Answer the requests nearly every expression makes up front.
let mut frame = Frame::default();
@@ -95,7 +139,7 @@ pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Proper
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::Done(r) => return r,
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 {
+8 -1
View File
@@ -11,13 +11,20 @@ var __S = null;
var __rc = 0;
var __seed = 0;
var __timeless = false;
// Text Expression Selector inputs (1, 1, [100, 100, 100] outside a selector).
var textIndex = 1;
var textTotal = 1;
var selectorValue = [100, 100, 100];
// 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) {
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
time = t;
value = v;
textIndex = ti;
textTotal = tt;
selectorValue = sv;
__S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {} };
__rc = 0;
__seed = 0;
+6
View File
@@ -49,6 +49,9 @@ pub struct Begin<'a> {
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.
@@ -228,6 +231,9 @@ impl Runtime {
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))));
+57
View File
@@ -942,3 +942,60 @@ fn error_messages_point_at_the_users_line() {
// 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"));
}
/// Text Expression Selector: Amount evaluated per unit with textIndex / textTotal /
/// selectorValue.
#[test]
fn text_expression_selector_values() {
let mut p = Project::default();
let comp = Comp::new(400, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut l = build::layer(&mut p, &comp, "T", LayerSource::Text, (400, 200), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "ABCD".into(), size: 40.0, ..Default::default() }));
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let props = build::text_anim_props(&mut ids, "opacity", false);
let mut a = build::text_animator(&mut ids, "Animator 1", props);
let sel = build::expression_selector(&mut ids, "Expression Selector 1");
a.sub_mut("selectors").unwrap().children.push(sel.into());
p.next_id = next;
l.props.group_mut("text/animators").unwrap().children.push(a.into());
let lid = l.id;
p.comp_mut(cid).unwrap().layers.push(l);
let sel_of = |p: &Project| {
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions) };
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
let lay = effectcraft_text::layout_doc(&doc);
let anim = l.props.group("text/animators/#1").unwrap();
effectcraft_render::text::animator_selection(&ctx, l, anim, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect::<Vec<_>>()
};
// Default: selectorValue * textIndex / textTotal, intersected with the full range selector.
assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.75, 1.0]);
let set_expr = |p: &mut Project, text: &str| {
let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap();
l.props.prop_mut("text/animators/#1/selectors/#2/amount").unwrap().expr = Some(Expression { text: text.into(), enabled: true });
};
// Range selector covering the first half → selectorValue 0 for the second half.
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(50.0);
assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.0, 0.0]);
// Plain numbers, conditionals on textIndex (1-based) and arrays all work.
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(100.0);
set_expr(&mut p, "textIndex % 2 == 0 ? 100 : 0");
assert_eq!(sel_of(&p), vec![0.0, 1.0, 0.0, 1.0]);
set_expr(&mut p, "[50, 0, 0]");
assert_eq!(sel_of(&p), vec![0.5; 4]);
// Mode Add after the range: 1 + v.
let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap();
l.props.prop_mut("text/animators/#1/selectors/#2/mode").unwrap().value = Value::Enum(0);
set_expr(&mut p, "-textIndex / textTotal * 100");
assert_eq!(sel_of(&p), vec![0.75, 0.5, 0.25, 0.0]);
// Errors fall back to the static amount (100%).
set_expr(&mut p, "nope(");
assert_eq!(sel_of(&p), vec![1.0; 4]);
// Outside a selector the expression still evaluates (textIndex = textTotal = 1).
set_expr(&mut p, build::EXPRESSION_SELECTOR_DEFAULT);
let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap();
assert_eq!(v, Value::Vec3([100.0; 3]));
}