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:
parent
abd39f5fd8
commit
6d0d7de922
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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() } };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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`]).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue