diff --git a/crates/engine/src/commands/expr_tools.rs b/crates/engine/src/commands/expr_tools.rs new file mode 100644 index 00000000..868be63a --- /dev/null +++ b/crates/engine/src/commands/expr_tools.rs @@ -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 { + 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 { + 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 = 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 { + 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::>()})) + .collect::>() + )) +} + +pub fn specs() -> Vec { + vec![ + query!("expr.errors", "Expression errors (the error bar)", "{comp?, time? (s)}", errors_cmd), + query!("expr.languageMenu", "Expression Language menu", "{}", menu_cmd), + ] +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 9e81db54..972e00ea 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -84,12 +84,23 @@ fn import(s: &mut Session, p: &Value) -> Result { 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 { 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> { + 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::(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 { s.edit("Project Settings", None, |proj, _| { if let Some(b) = str_p(p, "bitDepth") { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ac728200..e57b1c0a 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -239,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result { 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 { // 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| -> 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) }; diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs index d8090352..7a0eb86b 100644 --- a/crates/engine/src/commands/layer_time.rs +++ b/crates/engine/src/commands/layer_time.rs @@ -126,7 +126,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { 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() diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 7a053c77..e1b3f15f 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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 }) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 05dc37fa..2f34898c 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -557,7 +557,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { 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; } diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index 00117dad..4ada1dcd 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -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); diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index aea32105..55060d17 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -393,7 +393,7 @@ fn get_3d(s: &mut Session, p: &Value) -> Result { 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 = comp @@ -535,7 +535,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { 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); diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/tracking.rs index c406edb9..6a22fd81 100644 --- a/crates/engine/src/tracking.rs +++ b/crates/engine/src/tracking.rs @@ -206,7 +206,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [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 diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 4b92e224..70093790 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -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> { + 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::() { + 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 { + 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 = 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> = 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> { 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(), diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 323e3c7a..88c1b031 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -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() } }; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 67ddf458..1655f75d 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -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; diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index af7ed9b6..d6138839 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -145,6 +145,19 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult 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| { diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs new file mode 100644 index 00000000..bff5a214 --- /dev/null +++ b/crates/expr/src/sample.rs @@ -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, + offset: [f64; 2], + scale: f64, +} + +type Key = (usize, u64, u64, u64, bool, u64); + +thread_local! { + static CACHE: RefCell>> = RefCell::new(HashMap::new()); + /// Nested sampleImage renders (a sampled layer whose expressions sample again). + static DEPTH: RefCell = 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 { + 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]) +} diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index fd02acef..d75972f7 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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"); +} diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 067e4c8a..f0e0f08b 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -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)) } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index d8906a1a..dccf1aac 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -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 = ["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(); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 7bf67774..aff4d93c 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -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 } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5e7425bb..6699d68d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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`]). diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 47ad6178..b1d94413 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -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, ¶ms); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 0a19d57f..70d56237 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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)); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index e53e268e..5149241e 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -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); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fbd4e5e9..c5795685 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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. diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index edb1132f..31f98bb0 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -56,7 +56,7 @@ pub fn text_target(app: &EffectcraftApp) -> Option { .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); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c9a42698..cd3bf6f9 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d847edb8..76ef92a9 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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));