Files
effectcraft/crates/expr/src/host.rs
T
Brandon ThomasandClaude Opus 5.5 f78435927b M9.9: text style runs + paragraph settings
TextDoc gains character style runs (font, size, colours, tracking, leading,
baseline shift, h/v scale, tsume, faux styles, caps, super/subscript, kerning
metrics/optical/manual, ligatures) and per-paragraph settings (justification
with last-line modes, indents, space before/after, direction, composer,
hanging punctuation), serde-compatible with single-style documents. Layout
handles mixed runs on a line (shared baseline, largest leading), every-line
composer, editing geometry (carets, hit testing, selection quads) and basic
vertical type. Renderer colours per run. Expressions get the sourceText style
API (style, getStyleAt, createStyle, setters with character ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:34:13 -04:00

374 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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> },
/// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as
/// `layer.setText` keys), or null for other properties.
Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
}
/// A text document as the style API's JSON.
pub fn doc_json(d: &effectcraft_keyframe::TextDoc) -> String {
use effectcraft_keyframe::text_doc::{char_style_json, para_style_json};
let runs: Vec<serde_json::Value> = d.runs().iter().map(|r| serde_json::json!({"len": r.len, "style": char_style_json(&r.style)})).collect();
let paras: Vec<serde_json::Value> = d.paras().iter().map(para_style_json).collect();
serde_json::json!({"doc": d, "text": d.text, "runs": runs, "paras": paras}).to_string()
}
/// 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::Doc { 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) };
match v {
Value::Text(d) => Resp::Str(doc_json(&d)),
Value::Str(s) => Resp::Str(doc_json(&effectcraft_keyframe::TextDoc::plain(&s))),
_ => Resp::Null,
}
}
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(),
)
}
})
}
}