ANM-5: sampleImage, data footage (JSON/CSV/TSV) with footage().sourceData, thisProject, error list, Expression Language menu

sampleImage renders the sampled layer through the evaluating renderer's footage source
(EvalCtx::footage), cached per thread and frame. File > Import reads .json/.csv/.tsv as data
footage kept in the project. expr.errors collects failing expressions for the error bar;
expr.languageMenu lists the categorised Expression Language menu. Marker keys expose
protectedRegion and cue points; colorDepth, timeToFeetAndFrames, comp.activeCamera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:58:04 -04:00
parent abd39f5fd8
commit 6d0d7de922
26 changed files with 951 additions and 25 deletions

View File

@ -0,0 +1,375 @@
//! Expression tools: the error list behind the expression error bar, and the Expression
//! Language menu (the ⓕ button next to an expression), written from the public Expression
//! Language Reference.
use effectcraft_project::{ItemId, Layer, Property};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde::Serialize;
use serde_json::{Value, json};
use super::{CommandSpec, comp_id, f_p};
use crate::{EngineError, Result, Session, query};
/// One failing expression.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ExprError {
pub comp: u64,
pub layer: u64,
#[serde(rename = "layerIndex")]
pub layer_index: usize,
#[serde(rename = "layerName")]
pub layer_name: String,
pub prop: u64,
/// Match path (`transform/opacity`).
pub path: String,
/// Property name path as shown in the timeline (`Transform/Opacity`).
pub name: String,
pub message: String,
/// The expression is disabled (it failed to compile).
pub disabled: bool,
}
impl ExprError {
/// The error bar's text: `Error at line 1 in property 'Opacity' of layer 1 ('Solid') in comp 'Main'. …`
pub fn describe(&self, comp_name: &str) -> String {
let prop = self.name.rsplit('/').next().unwrap_or(&self.name);
format!("{} — property '{}' of layer {} ('{}') in comp '{}'", self.message, prop, self.layer_index, self.layer_name, comp_name)
}
}
/// Every failing expression of comp `cid` at comp time `t`: evaluation errors of enabled
/// expressions and the syntax errors that disabled others.
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
let mut out = vec![];
for (i, l) in comp.layers.iter().enumerate() {
let mut props: Vec<&Property> = vec![];
l.props.walk("", &mut |_, p| {
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
props.push(p);
}
});
for p in props {
let e = p.expr.as_ref().expect("filtered");
let msg = if e.enabled {
match &ctx.expr {
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
None => None,
}
} else {
s.expr_check.and_then(|check| check(&e.text).err())
};
if let Some(message) = msg {
out.push(error_of(cid, i, l, p, message, !e.enabled));
}
}
}
out
}
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
ExprError {
comp: cid.0,
layer: l.id.0,
layer_index: i + 1,
layer_name: l.name.clone(),
prop: p.uid,
path: super::match_path_of(&l.props, p.uid).unwrap_or_default(),
name: l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone()),
message,
disabled,
}
}
fn errors_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let name = s.project.item(cid).map(|i| i.name.clone()).ok_or(EngineError::NoComp)?;
let list = errors(s, cid, t);
let text: Vec<String> = list.iter().map(|e| e.describe(&name)).collect();
Ok(json!({"comp": cid.0, "time": t.seconds(), "count": list.len(), "errors": list, "text": text}))
}
/// The Expression Language menu: (category, [(label, inserted text)]). Submenus are nested
/// with `▸` in the category name.
pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
vec![
(
"Global",
vec![
("comp(name)", "comp(\"name\")"),
("footage(name)", "footage(\"name\")"),
("thisComp", "thisComp"),
("thisLayer", "thisLayer"),
("thisProperty", "thisProperty"),
("thisProject", "thisProject"),
("time", "time"),
("colorDepth", "colorDepth"),
("posterizeTime(framesPerSecond)", "posterizeTime(framesPerSecond)"),
("value", "value"),
],
),
(
"Vector Math",
vec![
("add(vec1, vec2)", "add(vec1, vec2)"),
("sub(vec1, vec2)", "sub(vec1, vec2)"),
("mul(vec, amount)", "mul(vec, amount)"),
("div(vec, amount)", "div(vec, amount)"),
("clamp(value, limit1, limit2)", "clamp(value, limit1, limit2)"),
("dot(vec1, vec2)", "dot(vec1, vec2)"),
("cross(vec1, vec2)", "cross(vec1, vec2)"),
("normalize(vec)", "normalize(vec)"),
("length(vec)", "length(vec)"),
("length(point1, point2)", "length(point1, point2)"),
("lookAt(fromPoint, atPoint)", "lookAt(fromPoint, atPoint)"),
],
),
(
"Random Numbers",
vec![
("seedRandom(offset, timeless=false)", "seedRandom(offset, timeless=false)"),
("random()", "random()"),
("random(maxValOrArray)", "random(maxValOrArray)"),
("random(minValOrArray, maxValOrArray)", "random(minValOrArray, maxValOrArray)"),
("gaussRandom()", "gaussRandom()"),
("gaussRandom(maxValOrArray)", "gaussRandom(maxValOrArray)"),
("gaussRandom(minValOrArray, maxValOrArray)", "gaussRandom(minValOrArray, maxValOrArray)"),
("noise(valOrArray)", "noise(valOrArray)"),
],
),
(
"Interpolation",
vec![
("linear(t, value1, value2)", "linear(t, value1, value2)"),
("linear(t, tMin, tMax, value1, value2)", "linear(t, tMin, tMax, value1, value2)"),
("ease(t, value1, value2)", "ease(t, value1, value2)"),
("ease(t, tMin, tMax, value1, value2)", "ease(t, tMin, tMax, value1, value2)"),
("easeIn(t, value1, value2)", "easeIn(t, value1, value2)"),
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
],
),
(
"Color Conversion",
vec![
("rgbToHsl(rgbaArray)", "rgbToHsl(rgbaArray)"),
("hslToRgb(hslaArray)", "hslToRgb(hslaArray)"),
("hexToRgb(hexString)", "hexToRgb(hexString)"),
],
),
("Other Math", vec![("degreesToRadians(degrees)", "degreesToRadians(degrees)"), ("radiansToDegrees(radians)", "radiansToDegrees(radians)")]),
(
"JavaScript Math",
vec![
("Math.cos(value)", "Math.cos(value)"),
("Math.sin(value)", "Math.sin(value)"),
("Math.atan2(y, x)", "Math.atan2(y, x)"),
("Math.sqrt(value)", "Math.sqrt(value)"),
("Math.abs(value)", "Math.abs(value)"),
("Math.round(value)", "Math.round(value)"),
("Math.floor(value)", "Math.floor(value)"),
("Math.min(value1, value2)", "Math.min(value1, value2)"),
("Math.max(value1, value2)", "Math.max(value1, value2)"),
("Math.PI", "Math.PI"),
],
),
(
"Comp",
vec![
("layer(index)", "layer(index)"),
("layer(name)", "layer(\"name\")"),
("layer(otherLayer, relIndex)", "layer(otherLayer, relIndex)"),
("marker", "marker"),
("numLayers", "numLayers"),
("activeCamera", "activeCamera"),
("width", "width"),
("height", "height"),
("duration", "duration"),
("frameDuration", "frameDuration"),
("pixelAspect", "pixelAspect"),
("name", "name"),
("bgColor", "bgColor"),
("displayStartTime", "displayStartTime"),
],
),
(
"Footage",
vec![
("width", "width"),
("height", "height"),
("duration", "duration"),
("frameDuration", "frameDuration"),
("pixelAspect", "pixelAspect"),
("name", "name"),
("sourceText", "sourceText"),
("sourceData", "sourceData"),
("dataValue(dataPath)", "dataValue(dataPath)"),
("dataKeyCount", "dataKeyCount"),
],
),
(
"Layer ▸ Sub-objects",
vec![
("source", "source"),
("effect(name)", "effect(\"name\")"),
("effect(index)", "effect(index)"),
("mask(name)", "mask(\"name\")"),
("mask(index)", "mask(index)"),
],
),
(
"Layer ▸ General",
vec![
("width", "width"),
("height", "height"),
("index", "index"),
("parent", "parent"),
("hasParent", "hasParent"),
("inPoint", "inPoint"),
("outPoint", "outPoint"),
("startTime", "startTime"),
("hasVideo", "hasVideo"),
("enabled", "enabled"),
("active", "active"),
],
),
(
"Layer ▸ Properties",
vec![
("anchorPoint", "anchorPoint"),
("position", "position"),
("scale", "scale"),
("rotation", "rotation"),
("opacity", "opacity"),
("marker", "marker"),
("name", "name"),
],
),
(
"Layer ▸ Space Transforms",
vec![
("toComp(point, t = time)", "toComp(point, t = time)"),
("fromComp(point, t = time)", "fromComp(point, t = time)"),
("toWorld(point, t = time)", "toWorld(point, t = time)"),
("fromWorld(point, t = time)", "fromWorld(point, t = time)"),
("toCompVec(vec, t = time)", "toCompVec(vec, t = time)"),
("fromCompVec(vec, t = time)", "fromCompVec(vec, t = time)"),
("toWorldVec(vec, t = time)", "toWorldVec(vec, t = time)"),
("fromWorldVec(vec, t = time)", "fromWorldVec(vec, t = time)"),
],
),
(
"Layer ▸ Other",
vec![
("sourceRectAtTime(t = time, includeExtents = false)", "sourceRectAtTime(t = time, includeExtents = false)"),
("sampleImage(point, radius = [.5, .5], postEffect = true, t = time)", "sampleImage(point, radius = [.5, .5], postEffect = true, t = time)"),
],
),
(
"Property",
vec![
("value", "value"),
("valueAtTime(t)", "valueAtTime(t)"),
("velocity", "velocity"),
("velocityAtTime(t)", "velocityAtTime(t)"),
("speed", "speed"),
("speedAtTime(t)", "speedAtTime(t)"),
("wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
("temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
("smooth(width = .2, samples = 5, t = time)", "smooth(width = .2, samples = 5, t = time)"),
("loopIn(type = \"cycle\", numKeyframes = 0)", "loopIn(type = \"cycle\", numKeyframes = 0)"),
("loopOut(type = \"cycle\", numKeyframes = 0)", "loopOut(type = \"cycle\", numKeyframes = 0)"),
("loopInDuration(type = \"cycle\", duration = 0)", "loopInDuration(type = \"cycle\", duration = 0)"),
("loopOutDuration(type = \"cycle\", duration = 0)", "loopOutDuration(type = \"cycle\", duration = 0)"),
("key(index)", "key(index)"),
("key(markerName)", "key(markerName)"),
("nearestKey(t)", "nearestKey(t)"),
("numKeys", "numKeys"),
("name", "name"),
("propertyIndex", "propertyIndex"),
],
),
(
"Path Property",
vec![
("points(t = time)", "points(t = time)"),
("inTangents(t = time)", "inTangents(t = time)"),
("outTangents(t = time)", "outTangents(t = time)"),
("isClosed()", "isClosed()"),
(
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
),
],
),
(
"Text ▸ Style",
vec![
("text.sourceText.style", "text.sourceText.style"),
("getStyleAt(charIndex, t = time)", "getStyleAt(charIndex, t = time)"),
("createStyle()", "createStyle()"),
("setFontSize(value)", "setFontSize(value)"),
("setFillColor(value)", "setFillColor(value)"),
("setText(value)", "setText(value)"),
],
),
("Text ▸ Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]),
("Key", vec![("value", "value"), ("time", "time"), ("index", "index")]),
(
"Marker Key",
vec![
("duration", "duration"),
("comment", "comment"),
("chapter", "chapter"),
("url", "url"),
("frameTarget", "frameTarget"),
("eventCuePoint", "eventCuePoint"),
("cuePointName", "cuePointName"),
("parameters", "parameters"),
("protectedRegion", "protectedRegion"),
],
),
(
"Time Conversion",
vec![
(
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
),
("framesToTime(frames, fps = 1.0 / thisComp.frameDuration)", "framesToTime(frames, fps = 1.0 / thisComp.frameDuration)"),
(
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
),
(
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
),
(
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
),
],
),
]
}
fn menu_cmd(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!(
language_menu()
.into_iter()
.map(|(c, items)| json!({"category": c, "items": items.into_iter().map(|(l, t)| json!({"label": l, "text": t})).collect::<Vec<_>>()}))
.collect::<Vec<_>>()
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("expr.errors", "Expression errors (the error bar)", "{comp?, time? (s)}", errors_cmd),
query!("expr.languageMenu", "Expression Language menu", "{}", menu_cmd),
]
}

View File

@ -84,12 +84,23 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::String(x)) => vec![x.clone()],
_ => return Err(bad("file.import", "missing `paths`")),
};
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
let mut ids = vec![];
let mut errors = vec![];
let mut probed = vec![];
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
for path in &paths {
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
if let Some(f) = data_footage(s, path) {
match f {
Ok(f) => probed.push((path.clone(), f)),
Err(e) => errors.push(format!("{path}: {e}")),
}
continue;
}
let Some(importer) = s.importer.clone() else {
errors.push(format!("{path}: media import is not available in this build"));
continue;
};
match importer.probe(path) {
Ok(mut f) => {
// Settings ▸ Import ▸ Sequence Footage frames per second.
@ -122,6 +133,25 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"items": ids, "errors": errors}))
}
/// Extensions imported as data footage.
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
pub(crate) fn data_footage(s: &Session, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
Some((|| {
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
if ext == "json" {
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
}
Ok(effectcraft_project::Footage { path: path.to_string(), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text), ..Default::default() })
})())
}
fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Project Settings", None, |proj, _| {
if let Some(b) = str_p(p, "bitDepth") {

View File

@ -239,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
(LayerSource::Footage { item }, (f.width, f.height), d)
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => {
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
}
@ -505,7 +508,8 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
let compensate = b_p(p, "compensate").unwrap_or(true);
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() };
let ectx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};

View File

@ -126,7 +126,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
}
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() };
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
.layers
.iter()

View File

@ -9,6 +9,7 @@ mod comp_more;
mod edit;
mod effect;
pub mod essential;
pub mod expr_tools;
mod file;
mod file_more;
mod frontend;
@ -138,6 +139,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(liquify::specs());
v.extend(settings::specs());
v.extend(essential::specs());
v.extend(expr_tools::specs());
v.extend(stubs::specs());
v
})

View File

@ -557,7 +557,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref() };
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}

View File

@ -29,7 +29,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let fx = layer.effects()?;
let index = fx.groups().position(|g| g.uid == fx_uid)?;
let g = fx.groups().nth(index)?;
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);

View File

@ -393,7 +393,7 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (comp.width as f64, comp.height as f64);
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
let f = cam.forward();
let lights: Vec<Value> = comp
@ -535,7 +535,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let mut out = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let m = ctx.world_matrix(l);

View File

@ -206,7 +206,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr };
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video

View File

@ -52,6 +52,93 @@ pub enum Req {
/// 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.
@ -192,7 +279,7 @@ impl<'a> Resolver<'a> {
}
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 })
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)?;
@ -345,6 +432,36 @@ impl<'a> Resolver<'a> {
};
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) => {
@ -363,6 +480,17 @@ impl<'a> Resolver<'a> {
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(),

View File

@ -28,6 +28,7 @@ pub mod host;
mod noise;
pub mod rewrite;
mod runtime;
mod sample;
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemId, Layer, LayerId, Project, Property};
@ -112,7 +113,7 @@ fn evaluate_out(
if depth >= MAX_DEPTH {
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host) };
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } };

View File

@ -18,6 +18,7 @@ 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, __DOC = 10;
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14;
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
time = t;
@ -29,6 +30,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
__rc = 0;
__seed = 0;
__timeless = false;
__projectInfo = null;
}
function __finish(r) {
@ -269,6 +271,18 @@ function timeToTimecode(t, base, isDuration) {
return (neg ? '-' : '') + h + ':' + __pad(mm) + ':' + __pad(ss) + ':' + __pad(ff);
}
function timeToFeetAndFrames(t, fps, framesPerFoot, isDuration) {
if (t === undefined) t = time + thisComp.displayStartTime;
if (fps === undefined) fps = 1 / __S.fd;
if (framesPerFoot === undefined) framesPerFoot = 16;
var neg = t < 0;
var f = Math.floor(Math.abs(t) * fps + 1e-6);
var feet = Math.floor(f / framesPerFoot), fr = f % framesPerFoot;
var s = String(fr);
while (s.length < 2) s = '0' + s;
return (neg ? '-' : '') + feet + '+' + s;
}
function timeToCurrentFormat(t, fps) {
if (fps === undefined) fps = Math.round(1 / __S.fd);
return timeToTimecode(t, fps);
@ -371,6 +385,14 @@ Comp.prototype = {
get displayStartTime() { return this.__info[7]; },
get bgColor() { return this.__info[8]; },
get marker() { return new Markers(this.__c, -1); },
// The topmost enabled camera active at the current time.
get activeCamera() {
for (var i = 1; i <= this.numLayers; i++) {
var l = this.layer(i);
if (l.__info[10] === 'Camera' && l.active) return l;
}
__err('Composition "' + this.name + '" has no active camera');
},
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
var id = __h(__LAYER, this.__c, a);
@ -386,6 +408,68 @@ function comp(name) {
return new Comp(id);
}
// Footage (data-driven animation) and the project ------------------------------------------------
function Footage(info) { this.__i = info; this.__d = null; }
Footage.prototype = {
__isFootage: true,
get name() { return this.__i[0]; },
get width() { return this.__i[1]; },
get height() { return this.__i[2]; },
get duration() { return this.__i[3]; },
get frameDuration() { return this.__i[4]; },
get pixelAspect() { return this.__i[5]; },
get ntscDropFrame() { return false; },
// The data file's text (JSON / CSV / TSV), or "" for media.
get sourceText() { return this.__i[7] === null ? '' : this.__i[7]; },
get __data() {
if (this.__d !== null) return this.__d;
var j = __h(__FDATA, this.__i[0]);
if (j === null) __err('Footage "' + this.name + '" has no data (or it could not be parsed)');
return (this.__d = JSON.parse(j));
},
// JSON: the parsed document; CSV/TSV: one object per row keyed by the header row.
get sourceData() { return this.__data.data; },
// CSV/TSV cell by [column, row] (0-based, rows after the header).
dataValue: function (idx) {
idx = __v(idx);
var rows = this.__data.rows;
if (rows === null) {
// JSON: a path of keys / indexes.
var v = this.__data.data;
for (var i = 0; i < idx.length; i++) {
if (v === null || v === undefined) __err('dataValue: no value at ' + JSON.stringify(idx));
v = v[idx[i]];
}
return v;
}
var r = rows[idx[1]];
if (r === undefined || r[idx[0]] === undefined) __err('dataValue: no cell at column ' + idx[0] + ', row ' + idx[1]);
return r[idx[0]];
},
get dataKeyCount() { var r = this.__data.rows; return r === null ? 0 : r.length; },
// The column names (CSV/TSV header row).
get dataKeyNames() { return this.__data.header || []; },
toString: function () { return '[object Footage]'; },
};
function footage(name) {
var info = __h(__FOOT, String(name));
if (info === null) __err('Footage named "' + name + '" does not exist');
return new Footage(info);
}
var __projectInfo = null;
// The project's bits per channel (8, 16 or 32).
__global('colorDepth', function () { return thisProject.bitsPerChannel; });
var thisProject = {
get __info() { return __projectInfo || (__projectInfo = __h(__PROJ)); },
get fullPath() { return ''; },
get bitsPerChannel() { return this.__info[0]; },
get linearBlending() { return this.__info[1]; },
toString: function () { return '[object Project]'; },
};
// Layer -----------------------------------------------------------------------------------------
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
@ -464,7 +548,14 @@ Layer.prototype = {
var r = __h(__RECT, this.__c, this.__l, t === undefined ? time : t, !!includeExtents);
return { top: r[0], left: r[1], width: r[2], height: r[3] };
},
sampleImage: function () { return [0, 0, 0, 0]; },
// Average straight RGBA of the layer's pixels in `point ± radius` (layer space).
sampleImage: function (point, radius, postEffect, t) {
point = __v(point);
radius = radius === undefined ? [0.5, 0.5] : __v(radius);
if (!Array.isArray(radius)) radius = [radius, radius];
return __h(__SAMPLE, this.__c, this.__l, point[0], point[1], radius[0], radius.length > 1 ? radius[1] : radius[0],
postEffect === undefined ? true : !!__v(postEffect), t === undefined ? time : __v(t));
},
toString: function () { return '[object Layer]'; },
};
@ -817,7 +908,12 @@ Markers.prototype = {
}
if (!(n >= 1 && n <= list.length)) __err('Marker index ' + n + ' out of range');
var m = list[n - 1];
return { time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n };
var params = {};
for (var k = 0; k < m[8].length; k++) params[m[8][k][0]] = m[8][k][1];
return {
time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n,
frameTarget: m[5], eventCuePoint: m[6], cuePointName: m[7], parameters: params, protectedRegion: m[9],
};
},
nearestKey: function (t) {
var list = this.__list;

View File

@ -145,6 +145,19 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
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| {

119
crates/expr/src/sample.rs Normal file
View File

@ -0,0 +1,119 @@
//! `sampleImage`: read a layer's rendered pixels from an expression.
//!
//! The layer is rendered at full resolution through the renderer that is evaluating the
//! expression (its footage source; [`EvalCtx::footage`]): source → masks → effects for
//! `postEffect = true`, the bare source otherwise, in layer space (before the transform). Buffers
//! are cached per thread for the frame, so many expressions sampling the same layer at the same
//! time render it once.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_project::{ItemId, Layer};
use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer};
use effectcraft_time::Tick;
/// A rendered layer buffer: straight-from-the-renderer premultiplied pixels with the layer
/// space → buffer pixel mapping.
#[derive(Clone)]
struct Sampled {
img: Arc<effectcraft_render::Image>,
offset: [f64; 2],
scale: f64,
}
type Key = (usize, u64, u64, u64, bool, u64);
thread_local! {
static CACHE: RefCell<HashMap<Key, Option<Sampled>>> = RefCell::new(HashMap::new());
/// Nested sampleImage renders (a sampled layer whose expressions sample again).
static DEPTH: RefCell<usize> = const { RefCell::new(0) };
}
const MAX_CACHE: usize = 32;
const MAX_DEPTH: usize = 2;
/// A fingerprint of the layer's definition (so edits never reuse stale pixels).
fn fingerprint(layer: &Layer) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{layer:?}").hash(&mut h);
h.finish()
}
fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Option<Sampled> {
let depth = DEPTH.with(|d| *d.borrow());
if depth >= MAX_DEPTH {
return None;
}
let key: Key = (ctx.project as *const _ as usize, comp.0, layer.id.0, t.to_bits(), post, fingerprint(layer));
if let Some(hit) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
return hit;
}
DEPTH.with(|d| *d.borrow_mut() += 1);
struct Pop;
impl Drop for Pop {
fn drop(&mut self) {
DEPTH.with(|d| *d.borrow_mut() -= 1);
}
}
let _pop = Pop;
let footage: &dyn effectcraft_render::FootageSource = ctx.footage.unwrap_or(&NoFootage);
let mut r = Renderer::new(ctx.project, footage, RenderOpts { motion_blur: false, ..Default::default() });
r.expr = ctx.expr;
let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?;
let out = if post {
r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
} else {
r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale })
};
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() >= MAX_CACHE {
c.clear();
}
c.insert(key, out.clone());
});
out
}
/// The average colour (straight RGBA, 0..1) of `layer`'s pixels in the box `point ± radius`
/// (layer pixels) at comp time `t`. Transparent outside the layer.
pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2], radius: [f64; 2], post: bool, t: f64) -> Option<[f32; 4]> {
if !layer.source.is_av() {
return Some([0.0; 4]);
}
let Some(b) = render(ctx, comp, layer, post, t) else { return Some([0.0; 4]) };
let to_px = |x: f64, y: f64| (x * b.scale + b.offset[0], y * b.scale + b.offset[1]);
let (rx, ry) = (radius[0].abs() * b.scale, radius[1].abs() * b.scale);
let (cx, cy) = to_px(point[0], point[1]);
// Pixels whose centres (at +0.5) fall in the box; at least the one under the point.
let (x0, x1, y0, y1) = if rx < 0.5 && ry < 0.5 {
let (px, py) = (cx.floor() as i64, cy.floor() as i64);
(px, px, py, py)
} else {
let lo = |c: f64, r: f64| (c - r - 0.5).ceil() as i64;
let hi = |c: f64, r: f64| (c + r - 0.5).floor() as i64;
(lo(cx, rx), hi(cx, rx).max(lo(cx, rx)), lo(cy, ry), hi(cy, ry).max(lo(cy, ry)))
};
let mut acc = [0.0f64; 4];
let mut n = 0.0;
for y in y0..=y1.min(y0 + 4096) {
for x in x0..=x1.min(x0 + 4096) {
let p = b.img.get(x, y);
for k in 0..4 {
acc[k] += p[k] as f64;
}
n += 1.0;
}
}
if n == 0.0 {
return Some([0.0; 4]);
}
let a = acc[3] / n;
if a <= 1e-9 {
return Some([0.0; 4]);
}
Some([(acc[0] / n / a) as f32, (acc[1] / n / a) as f32, (acc[2] / n / a) as f32, a as f32])
}

View File

@ -1019,7 +1019,7 @@ fn text_expression_selector_values() {
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 ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None };
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();
@ -1051,3 +1051,79 @@ fn text_expression_selector_values() {
let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap();
assert_eq!(v, Value::Vec3([100.0; 3]));
}
/// `sampleImage` reads the layer's rendered pixels (layer space, straight colour) after masks
/// and effects, or the bare source with `postEffect = false`; the radius averages a box.
#[test]
fn sample_image_reads_rendered_pixels() {
let f = fx();
let code = |c: &str| format!("var c = {c}; c[0] * 1000 + c[1] * 100 + c[2] * 10 + c[3]");
// B: 40×40 blue solid.
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([20, 20])"), 0.0), 11.0);
// Half the box is outside the layer: alpha 0.5, colour still pure blue (straight).
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([0, 20], [1, 1])"), 0.0), 10.5);
// A: red with a mask covering 5..15: outside the mask it's transparent after masks, red before.
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2])"), 0.0), 0.0);
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2], [0.5, 0.5], false)"), 0.0), 1001.0);
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([10, 10])"), 0.0), 1001.0);
// The result has four values; outside the layer it's transparent.
assert_close!(f.num("thisComp.layer(\"B\").sampleImage([20, 20]).length", 0.0), 4.0);
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
}
fn data_item(p: &mut Project, name: &str, text: &str) {
let f = effectcraft_project::Footage {
path: format!("/data/{name}"),
kind: effectcraft_project::FootageKind::Data,
data: Some(text.into()),
..Default::default()
};
p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f));
}
/// Data-driven animation: `footage(name)` with `sourceData` / `sourceText` / `dataValue` for
/// JSON, CSV and TSV items.
#[test]
fn data_footage_json_csv_tsv() {
let mut f = fx();
data_item(&mut f.p, "stats.json", r#"{"title": "Q3", "items": [{"v": 3}, {"v": 7.5}]}"#);
data_item(&mut f.p, "scores.csv", "name,score\n\"Smith, J\",12\nLee,30.5\n");
data_item(&mut f.p, "table.tsv", "a\tb\n1\t2\n3\t4\n");
assert_close!(f.num("footage(\"stats.json\").sourceData.items[1].v", 0.0), 7.5);
assert_eq!(f.text("footage(\"stats.json\").sourceData.title", 0.0), "Q3");
assert_close!(f.num("footage(\"stats.json\").dataValue([\"items\", 0, \"v\"])", 0.0), 3.0);
assert_close!(f.num("footage(\"scores.csv\").sourceData[1].score", 0.0), 30.5);
assert_eq!(f.text("footage(\"scores.csv\").sourceData[0].name", 0.0), "Smith, J");
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, 0])", 0.0), 12.0);
assert_close!(f.num("footage(\"scores.csv\").dataKeyCount", 0.0), 2.0);
assert_eq!(f.text("footage(\"scores.csv\").dataKeyNames.join('|')", 0.0), "name|score");
assert_close!(f.num("footage(\"table.tsv\").dataValue([1, 1])", 0.0), 4.0);
assert_close!(f.num("footage(\"table.tsv\").sourceText.length", 0.0), 12.0);
assert!(f.err("footage(\"missing.json\").sourceData").contains("does not exist"));
// Animated from data: the value follows the CSV row picked by time.
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, Math.floor(time)])", 1.2), 30.5);
}
#[test]
fn project_and_time_globals() {
let mut f = fx();
assert_close!(f.num("thisProject.bitsPerChannel", 0.0), 8.0);
assert_close!(f.num("colorDepth", 0.0), 8.0);
assert_close!(f.num("thisProject.linearBlending ? 1 : 0", 0.0), 0.0);
assert_eq!(f.text("timeToFeetAndFrames(2)", 0.0), "3+12");
assert_close!(f.num("posterizeTime(4); time", 0.6), 0.5);
// Path API.
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0);
assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0);
// Marker keys carry protected regions and cue points.
{
let a = f.a;
let l = f.layer_mut(a);
l.markers[0].protected = true;
l.markers[0].cue_point = Some(effectcraft_project::CuePoint { name: "go".into(), navigation: false, params: vec![("k".into(), "v".into())] });
}
assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0);
assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov");
}

View File

@ -91,7 +91,7 @@ fn scene_project() -> (Project, ItemId) {
fn scene(p: &Project, cid: ItemId) -> Scene {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
}

View File

@ -183,7 +183,8 @@ mod tests {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref() };
let ctx =
effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
ctx.value(layer, layer.props.prop(path).unwrap())
}
@ -221,6 +222,81 @@ mod tests {
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0);
}
/// The expression error bar's list: evaluation errors and syntax errors, with the layer and
/// property they belong to.
#[test]
fn expression_errors_are_collected() {
let mut s = super::session();
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 0);
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "thisComp.layer(\"Nope\").rotation"})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap();
let r = s.execute("expr.errors", json!({})).unwrap();
assert_eq!(r["count"], 2, "{r}");
let errs = r["errors"].as_array().unwrap();
let rot = errs.iter().find(|e| e["path"] == "transform/rotation").unwrap();
assert!(rot["message"].as_str().unwrap().contains("Nope"), "{rot}");
assert_eq!(rot["layerIndex"], 1);
assert_eq!(rot["layerName"], "Box");
assert_eq!(rot["disabled"], false);
let op = errs.iter().find(|e| e["path"] == "transform/opacity").unwrap();
assert_eq!(op["disabled"], true);
assert!(r["text"][0].as_str().unwrap().contains("of layer 1 ('Box') in comp 'C'"), "{r}");
// A user-disabled (but valid) expression is not an error.
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "45"})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "enabled": false})).unwrap();
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 1);
// The Expression Language menu is categorised.
let m = s.execute("expr.languageMenu", json!({})).unwrap();
assert!(m.as_array().unwrap().iter().any(|c| c["category"] == "Footage" && c["items"].as_array().unwrap().iter().any(|i| i["text"] == "sourceData")));
}
/// File ▸ Import of JSON/CSV data files as data footage, read by expressions.
#[test]
fn imported_data_files_drive_expressions() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-data");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("levels.csv"), "label,value\nlow,10\nhigh,90\n").unwrap();
std::fs::write(dir.join("cfg.json"), r#"{"angle": 33}"#).unwrap();
std::fs::write(dir.join("broken.json"), "{nope").unwrap();
let mut s = super::session();
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let paths: Vec<String> = ["levels.csv", "cfg.json", "broken.json"].iter().map(|n| dir.join(n).to_string_lossy().to_string()).collect();
let r = s.execute("file.import", json!({"paths": paths})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}");
let csv = r["items"][0].as_u64().unwrap();
assert_eq!(s.project.item(effectcraft_engine::project::ItemId(csv)).unwrap().type_name(), "Data");
assert!(s.execute("layer.addItem", json!({"item": csv})).is_err(), "data files are not layers");
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "footage(\"levels.csv\").sourceData[1].value"})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "footage(\"cfg.json\").sourceData.angle"})).unwrap();
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 90.0);
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 33.0);
}
/// sampleImage through the renderer: a box's Fill colour follows the solid under it.
#[test]
fn sample_image_drives_a_colour() {
let mut s = super::session();
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"name": "Bg", "color": "#00ff00"})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "Fg", "color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": a, "effect": "Fill"})).unwrap();
s.execute(
"prop.setExpression",
json!({"layer": a, "path": "effects/#1/color", "expression": "thisComp.layer(\"Bg\").sampleImage(thisComp.layer(\"Bg\").fromComp(toComp(anchorPoint)))"}),
)
.unwrap();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts::default());
let px = img.get(50, 50);
assert!(px[1] > 0.99 && px[0] < 0.01, "the box is filled with the sampled green: {px:?}");
}
#[test]
fn expression_syntax_errors_disable_the_expression() {
let mut s = super::session();

View File

@ -36,11 +36,14 @@ pub struct EvalCtx<'a> {
/// Comp time.
pub time: Tick,
pub expr: Option<&'a dyn ExprHost>,
/// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's
/// own source while rendering; `None` renders footage as transparent.
pub footage: Option<&'a dyn crate::FootageSource>,
}
impl<'a> EvalCtx<'a> {
pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> {
EvalCtx { project, comp_id, comp, time, expr: None }
EvalCtx { project, comp_id, comp, time, expr: None, footage: None }
}
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
EvalCtx { time, ..*self }

View File

@ -451,7 +451,7 @@ impl<'a> Renderer<'a> {
}
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr }
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) }
}
/// The accelerator to use for this render, if any (see [`Backend`]).

View File

@ -303,7 +303,7 @@ fn extruded_text_renders_as_a_mesh() {
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/rotationY", Value::Scalar(40.0));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);

View File

@ -793,7 +793,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref() };
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None };
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));

View File

@ -119,7 +119,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tex = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref() };
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);

View File

@ -69,7 +69,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let project = app.session.project.clone();
let expr = app.session.expr.clone();
let now = app.session.time();
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref() };
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
// Vertical scrolling: content height is known after drawing, so clamp with last frame's.

View File

@ -56,7 +56,7 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?;
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref() };
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None };
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
}
@ -453,7 +453,7 @@ pub fn align_layers(app: &mut EffectcraftApp, op: &str) {
let time = app.session.time();
let mut moves = vec![];
{
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref() };
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) {
let Some(b) = effectcraft_engine::render::content_bounds(&ectx, l) else { continue };
let (m, _) = ectx.layer_to_comp(l);

View File

@ -687,7 +687,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll);
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
let lp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
let gp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;

View File

@ -394,7 +394,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
// The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
@ -891,6 +891,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
comp: app.session.project.comp(cid).unwrap_or(&comp),
time,
expr: app.session.expr.as_deref(),
footage: None,
};
if let Some(m) = l2c(&e2, &l).0.inverse() {
*i2 = m;
@ -1396,6 +1397,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
comp: &comp,
time: app.session.time(),
expr: None,
footage: None,
};
let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY);
let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0));