effectcraft/crates/expr/src/host.rs

502 lines
22 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 },
/// `footage(name)`: `[name, width, height, duration, frameDuration, pixelAspect, kind,
/// sourceText|null, dataFormat|null]` or null.
Footage { name: String },
/// A data footage item's parsed data as JSON `{data, rows, header}` (`rows`/`header` for
/// CSV/TSV), or null.
FootageData { name: String },
/// `sampleImage`: the average straight `[r, g, b, a]` of the layer's pixels in the box
/// `point ± radius` (layer space) at comp time `t`, after masks and effects when `post`.
Sample { comp: u64, layer: u64, x: Secs, y: Secs, rx: Secs, ry: Secs, post: bool, t: Secs },
/// `thisProject`: `[bitsPerChannel, linearBlending, workingSpace]`.
Project,
}
/// Parse CSV/TSV text (RFC 4180 quoting) into rows of cells.
pub fn parse_delimited(text: &str, sep: char) -> Vec<Vec<String>> {
let mut rows = vec![];
let mut row = vec![];
let mut cell = String::new();
let mut quoted = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if quoted {
match c {
'"' if chars.peek() == Some(&'"') => {
chars.next();
cell.push('"');
}
'"' => quoted = false,
_ => cell.push(c),
}
continue;
}
match c {
'"' if cell.is_empty() => quoted = true,
c if c == sep => row.push(std::mem::take(&mut cell)),
'\r' => {}
'\n' => {
row.push(std::mem::take(&mut cell));
rows.push(std::mem::take(&mut row));
}
_ => cell.push(c),
}
}
if !cell.is_empty() || !row.is_empty() {
row.push(cell);
rows.push(row);
}
rows.retain(|r| !(r.len() == 1 && r[0].trim().is_empty()));
rows
}
/// A CSV/TSV cell as JSON: numbers become numbers.
fn cell_json(c: &str) -> serde_json::Value {
let t = c.trim();
match t.parse::<f64>() {
Ok(x) if x.is_finite() && !t.is_empty() => serde_json::json!(x),
_ => serde_json::json!(c),
}
}
/// The data of a data footage item as `{data, rows, header}` JSON (see [`Req::FootageData`]).
pub fn footage_data_json(f: &effectcraft_project::Footage) -> Option<String> {
let text = f.data.as_deref()?;
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
Some(match f.data_format()? {
"json" => {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
serde_json::json!({"data": v, "rows": null, "header": null}).to_string()
}
fmt => {
let rows = parse_delimited(text, if fmt == "tsv" { '\t' } else { ',' });
let (header, body) = match rows.split_first() {
Some((h, b)) => (h.clone(), b.to_vec()),
None => (vec![], vec![]),
};
let objects: Vec<serde_json::Value> = body
.iter()
.map(|r| {
serde_json::Value::Object(
header.iter().enumerate().map(|(i, k)| (k.clone(), r.get(i).map(|c| cell_json(c)).unwrap_or(serde_json::Value::Null))).collect(),
)
})
.collect();
let cells: Vec<Vec<serde_json::Value>> = body.iter().map(|r| r.iter().map(|c| cell_json(c)).collect()).collect();
serde_json::json!({"data": objects, "rows": cells, "header": header}).to_string()
}
})
}
/// 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, footage: self.ctx.footage })
}
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::Footage { name } => {
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
let ItemKind::Footage(f) = &it.kind else { return None };
Resp::List(vec![
Resp::Str(it.name.clone()),
Resp::Num(f.width as f64),
Resp::Num(f.height as f64),
Resp::Num(f.duration.seconds()),
Resp::Num(f.frame_rate.frame_duration().seconds()),
Resp::Num(f.pixel_aspect),
Resp::Str(it.type_name().into()),
f.data.clone().map_or(Resp::Null, Resp::Str),
f.data_format().map_or(Resp::Null, |x| Resp::Str(x.into())),
])
}
Req::FootageData { name } => {
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
let ItemKind::Footage(f) = &it.kind else { return None };
Resp::Str(footage_data_json(f)?)
}
Req::Project => Resp::List(vec![
Resp::Num(project.settings.bit_depth.bits() as f64),
Resp::Bool(project.settings.blend_linear || project.settings.linearize),
Resp::Str(project.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default()),
]),
Req::Sample { comp, layer, x, y, rx, ry, post, t } => {
let l = self.layer(*comp, *layer)?;
let f = |b: &Secs| f64::from_bits(*b);
Resp::nums(crate::sample::sample_image(self.ctx, ItemId(*comp), l, [f(x), f(y)], [f(rx), f(ry)], *post, f(t))?.map(|v| v as f64))
}
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()),
Resp::Str(m.frame_target.clone()),
Resp::Bool(m.cue_point.as_ref().is_some_and(|c| !c.navigation)),
Resp::Str(m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default()),
Resp::List(
m.cue_point
.iter()
.flat_map(|c| c.params.iter())
.map(|(k, v)| Resp::List(vec![Resp::Str(k.clone()), Resp::Str(v.clone())]))
.collect(),
),
Resp::Bool(m.protected),
])
})
.collect(),
)
}
})
}
}