Merge main into M13.5 long-tail polish

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 00:25:09 -04:00
co-authored by Claude Opus 5.5
177 changed files with 25734 additions and 510 deletions
+129 -1
View File
@@ -52,6 +52,93 @@ pub enum Req {
/// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as
/// `layer.setText` keys), or null for other properties.
Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
/// `footage(name)`: `[name, width, height, duration, frameDuration, pixelAspect, kind,
/// sourceText|null, dataFormat|null]` or null.
Footage { name: String },
/// A data footage item's parsed data as JSON `{data, rows, header}` (`rows`/`header` for
/// CSV/TSV), or null.
FootageData { name: String },
/// `sampleImage`: the average straight `[r, g, b, a]` of the layer's pixels in the box
/// `point ± radius` (layer space) at comp time `t`, after masks and effects when `post`.
Sample { comp: u64, layer: u64, x: Secs, y: Secs, rx: Secs, ry: Secs, post: bool, t: Secs },
/// `thisProject`: `[bitsPerChannel, linearBlending, workingSpace]`.
Project,
}
/// Parse CSV/TSV text (RFC 4180 quoting) into rows of cells.
pub fn parse_delimited(text: &str, sep: char) -> Vec<Vec<String>> {
let mut rows = vec![];
let mut row = vec![];
let mut cell = String::new();
let mut quoted = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if quoted {
match c {
'"' if chars.peek() == Some(&'"') => {
chars.next();
cell.push('"');
}
'"' => quoted = false,
_ => cell.push(c),
}
continue;
}
match c {
'"' if cell.is_empty() => quoted = true,
c if c == sep => row.push(std::mem::take(&mut cell)),
'\r' => {}
'\n' => {
row.push(std::mem::take(&mut cell));
rows.push(std::mem::take(&mut row));
}
_ => cell.push(c),
}
}
if !cell.is_empty() || !row.is_empty() {
row.push(cell);
rows.push(row);
}
rows.retain(|r| !(r.len() == 1 && r[0].trim().is_empty()));
rows
}
/// A CSV/TSV cell as JSON: numbers become numbers.
fn cell_json(c: &str) -> serde_json::Value {
let t = c.trim();
match t.parse::<f64>() {
Ok(x) if x.is_finite() && !t.is_empty() => serde_json::json!(x),
_ => serde_json::json!(c),
}
}
/// The data of a data footage item as `{data, rows, header}` JSON (see [`Req::FootageData`]).
pub fn footage_data_json(f: &effectcraft_project::Footage) -> Option<String> {
let text = f.data.as_deref()?;
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
Some(match f.data_format()? {
"json" => {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
serde_json::json!({"data": v, "rows": null, "header": null}).to_string()
}
fmt => {
let rows = parse_delimited(text, if fmt == "tsv" { '\t' } else { ',' });
let (header, body) = match rows.split_first() {
Some((h, b)) => (h.clone(), b.to_vec()),
None => (vec![], vec![]),
};
let objects: Vec<serde_json::Value> = body
.iter()
.map(|r| {
serde_json::Value::Object(
header.iter().enumerate().map(|(i, k)| (k.clone(), r.get(i).map(|c| cell_json(c)).unwrap_or(serde_json::Value::Null))).collect(),
)
})
.collect();
let cells: Vec<Vec<serde_json::Value>> = body.iter().map(|r| r.iter().map(|c| cell_json(c)).collect()).collect();
serde_json::json!({"data": objects, "rows": cells, "header": header}).to_string()
}
})
}
/// A text document as the style API's JSON.
@@ -192,7 +279,7 @@ impl<'a> Resolver<'a> {
}
fn ctx_at(&self, c: u64, t: f64) -> Option<EvalCtx<'a>> {
let comp = self.comp(c)?;
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr })
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage })
}
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
let layer = self.layer(c, l)?;
@@ -345,6 +432,36 @@ impl<'a> Resolver<'a> {
};
Resp::nums(r)
}
Req::Footage { name } => {
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
let ItemKind::Footage(f) = &it.kind else { return None };
Resp::List(vec![
Resp::Str(it.name.clone()),
Resp::Num(f.width as f64),
Resp::Num(f.height as f64),
Resp::Num(f.duration.seconds()),
Resp::Num(f.frame_rate.frame_duration().seconds()),
Resp::Num(f.pixel_aspect),
Resp::Str(it.type_name().into()),
f.data.clone().map_or(Resp::Null, Resp::Str),
f.data_format().map_or(Resp::Null, |x| Resp::Str(x.into())),
])
}
Req::FootageData { name } => {
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
let ItemKind::Footage(f) = &it.kind else { return None };
Resp::Str(footage_data_json(f)?)
}
Req::Project => Resp::List(vec![
Resp::Num(project.settings.bit_depth.bits() as f64),
Resp::Bool(project.settings.blend_linear || project.settings.linearize),
Resp::Str(project.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default()),
]),
Req::Sample { comp, layer, x, y, rx, ry, post, t } => {
let l = self.layer(*comp, *layer)?;
let f = |b: &Secs| f64::from_bits(*b);
Resp::nums(crate::sample::sample_image(self.ctx, ItemId(*comp), l, [f(x), f(y)], [f(rx), f(ry)], *post, f(t))?.map(|v| v as f64))
}
Req::Markers { comp, layer } => {
let marks: Vec<(f64, &effectcraft_project::Marker)> = match layer {
Some(l) => {
@@ -363,6 +480,17 @@ impl<'a> Resolver<'a> {
Resp::Str(m.comment.clone()),
Resp::Str(m.chapter.clone()),
Resp::Str(m.url.clone()),
Resp::Str(m.frame_target.clone()),
Resp::Bool(m.cue_point.as_ref().is_some_and(|c| !c.navigation)),
Resp::Str(m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default()),
Resp::List(
m.cue_point
.iter()
.flat_map(|c| c.params.iter())
.map(|(k, v)| Resp::List(vec![Resp::Str(k.clone()), Resp::Str(v.clone())]))
.collect(),
),
Resp::Bool(m.protected),
])
})
.collect(),
+2 -1
View File
@@ -28,6 +28,7 @@ pub mod host;
mod noise;
pub mod rewrite;
mod runtime;
mod sample;
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemId, Layer, LayerId, Project, Property};
@@ -112,7 +113,7 @@ fn evaluate_out(
if depth >= MAX_DEPTH {
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host) };
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } };
+98 -2
View File
@@ -18,6 +18,7 @@ var selectorValue = [100, 100, 100];
// Request ops (must match runtime.rs).
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14;
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
time = t;
@@ -29,6 +30,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
__rc = 0;
__seed = 0;
__timeless = false;
__projectInfo = null;
}
function __finish(r) {
@@ -269,6 +271,18 @@ function timeToTimecode(t, base, isDuration) {
return (neg ? '-' : '') + h + ':' + __pad(mm) + ':' + __pad(ss) + ':' + __pad(ff);
}
function timeToFeetAndFrames(t, fps, framesPerFoot, isDuration) {
if (t === undefined) t = time + thisComp.displayStartTime;
if (fps === undefined) fps = 1 / __S.fd;
if (framesPerFoot === undefined) framesPerFoot = 16;
var neg = t < 0;
var f = Math.floor(Math.abs(t) * fps + 1e-6);
var feet = Math.floor(f / framesPerFoot), fr = f % framesPerFoot;
var s = String(fr);
while (s.length < 2) s = '0' + s;
return (neg ? '-' : '') + feet + '+' + s;
}
function timeToCurrentFormat(t, fps) {
if (fps === undefined) fps = Math.round(1 / __S.fd);
return timeToTimecode(t, fps);
@@ -371,6 +385,14 @@ Comp.prototype = {
get displayStartTime() { return this.__info[7]; },
get bgColor() { return this.__info[8]; },
get marker() { return new Markers(this.__c, -1); },
// The topmost enabled camera active at the current time.
get activeCamera() {
for (var i = 1; i <= this.numLayers; i++) {
var l = this.layer(i);
if (l.__info[10] === 'Camera' && l.active) return l;
}
__err('Composition "' + this.name + '" has no active camera');
},
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
var id = __h(__LAYER, this.__c, a);
@@ -386,6 +408,68 @@ function comp(name) {
return new Comp(id);
}
// Footage (data-driven animation) and the project ------------------------------------------------
function Footage(info) { this.__i = info; this.__d = null; }
Footage.prototype = {
__isFootage: true,
get name() { return this.__i[0]; },
get width() { return this.__i[1]; },
get height() { return this.__i[2]; },
get duration() { return this.__i[3]; },
get frameDuration() { return this.__i[4]; },
get pixelAspect() { return this.__i[5]; },
get ntscDropFrame() { return false; },
// The data file's text (JSON / CSV / TSV), or "" for media.
get sourceText() { return this.__i[7] === null ? '' : this.__i[7]; },
get __data() {
if (this.__d !== null) return this.__d;
var j = __h(__FDATA, this.__i[0]);
if (j === null) __err('Footage "' + this.name + '" has no data (or it could not be parsed)');
return (this.__d = JSON.parse(j));
},
// JSON: the parsed document; CSV/TSV: one object per row keyed by the header row.
get sourceData() { return this.__data.data; },
// CSV/TSV cell by [column, row] (0-based, rows after the header).
dataValue: function (idx) {
idx = __v(idx);
var rows = this.__data.rows;
if (rows === null) {
// JSON: a path of keys / indexes.
var v = this.__data.data;
for (var i = 0; i < idx.length; i++) {
if (v === null || v === undefined) __err('dataValue: no value at ' + JSON.stringify(idx));
v = v[idx[i]];
}
return v;
}
var r = rows[idx[1]];
if (r === undefined || r[idx[0]] === undefined) __err('dataValue: no cell at column ' + idx[0] + ', row ' + idx[1]);
return r[idx[0]];
},
get dataKeyCount() { var r = this.__data.rows; return r === null ? 0 : r.length; },
// The column names (CSV/TSV header row).
get dataKeyNames() { return this.__data.header || []; },
toString: function () { return '[object Footage]'; },
};
function footage(name) {
var info = __h(__FOOT, String(name));
if (info === null) __err('Footage named "' + name + '" does not exist');
return new Footage(info);
}
var __projectInfo = null;
// The project's bits per channel (8, 16 or 32).
__global('colorDepth', function () { return thisProject.bitsPerChannel; });
var thisProject = {
get __info() { return __projectInfo || (__projectInfo = __h(__PROJ)); },
get fullPath() { return ''; },
get bitsPerChannel() { return this.__info[0]; },
get linearBlending() { return this.__info[1]; },
toString: function () { return '[object Project]'; },
};
// Layer -----------------------------------------------------------------------------------------
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
@@ -464,7 +548,14 @@ Layer.prototype = {
var r = __h(__RECT, this.__c, this.__l, t === undefined ? time : t, !!includeExtents);
return { top: r[0], left: r[1], width: r[2], height: r[3] };
},
sampleImage: function () { return [0, 0, 0, 0]; },
// Average straight RGBA of the layer's pixels in `point ± radius` (layer space).
sampleImage: function (point, radius, postEffect, t) {
point = __v(point);
radius = radius === undefined ? [0.5, 0.5] : __v(radius);
if (!Array.isArray(radius)) radius = [radius, radius];
return __h(__SAMPLE, this.__c, this.__l, point[0], point[1], radius[0], radius.length > 1 ? radius[1] : radius[0],
postEffect === undefined ? true : !!__v(postEffect), t === undefined ? time : __v(t));
},
toString: function () { return '[object Layer]'; },
};
@@ -817,7 +908,12 @@ Markers.prototype = {
}
if (!(n >= 1 && n <= list.length)) __err('Marker index ' + n + ' out of range');
var m = list[n - 1];
return { time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n };
var params = {};
for (var k = 0; k < m[8].length; k++) params[m[8][k][0]] = m[8][k][1];
return {
time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n,
frameTarget: m[5], eventCuePoint: m[6], cuePointName: m[7], parameters: params, protectedRegion: m[9],
};
},
nearestKey: function (t) {
var list = this.__list;
+13
View File
@@ -145,6 +145,19 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
t: secs(num(args, 4, ctx)?),
pre: arg(args, 5).to_boolean(),
},
11 => Req::Footage { name: string(args, 1, ctx)? },
12 => Req::FootageData { name: string(args, 1, ctx)? },
13 => Req::Sample {
comp: id(args, 1, ctx)?,
layer: id(args, 2, ctx)?,
x: secs(num(args, 3, ctx)?),
y: secs(num(args, 4, ctx)?),
rx: secs(num(args, 5, ctx)?),
ry: secs(num(args, 6, ctx)?),
post: arg(args, 7).to_boolean(),
t: secs(num(args, 8, ctx)?),
},
14 => Req::Project,
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
};
let found = FRAMES.with(|f| {
+119
View File
@@ -0,0 +1,119 @@
//! `sampleImage`: read a layer's rendered pixels from an expression.
//!
//! The layer is rendered at full resolution through the renderer that is evaluating the
//! expression (its footage source; [`EvalCtx::footage`]): source → masks → effects for
//! `postEffect = true`, the bare source otherwise, in layer space (before the transform). Buffers
//! are cached per thread for the frame, so many expressions sampling the same layer at the same
//! time render it once.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_project::{ItemId, Layer};
use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer};
use effectcraft_time::Tick;
/// A rendered layer buffer: straight-from-the-renderer premultiplied pixels with the layer
/// space → buffer pixel mapping.
#[derive(Clone)]
struct Sampled {
img: Arc<effectcraft_render::Image>,
offset: [f64; 2],
scale: f64,
}
type Key = (usize, u64, u64, u64, bool, u64);
thread_local! {
static CACHE: RefCell<HashMap<Key, Option<Sampled>>> = RefCell::new(HashMap::new());
/// Nested sampleImage renders (a sampled layer whose expressions sample again).
static DEPTH: RefCell<usize> = const { RefCell::new(0) };
}
const MAX_CACHE: usize = 32;
const MAX_DEPTH: usize = 2;
/// A fingerprint of the layer's definition (so edits never reuse stale pixels).
fn fingerprint(layer: &Layer) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{layer:?}").hash(&mut h);
h.finish()
}
fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Option<Sampled> {
let depth = DEPTH.with(|d| *d.borrow());
if depth >= MAX_DEPTH {
return None;
}
let key: Key = (ctx.project as *const _ as usize, comp.0, layer.id.0, t.to_bits(), post, fingerprint(layer));
if let Some(hit) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
return hit;
}
DEPTH.with(|d| *d.borrow_mut() += 1);
struct Pop;
impl Drop for Pop {
fn drop(&mut self) {
DEPTH.with(|d| *d.borrow_mut() -= 1);
}
}
let _pop = Pop;
let footage: &dyn effectcraft_render::FootageSource = ctx.footage.unwrap_or(&NoFootage);
let mut r = Renderer::new(ctx.project, footage, RenderOpts { motion_blur: false, ..Default::default() });
r.expr = ctx.expr;
let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?;
let out = if post {
r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
} else {
r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale })
};
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() >= MAX_CACHE {
c.clear();
}
c.insert(key, out.clone());
});
out
}
/// The average colour (straight RGBA, 0..1) of `layer`'s pixels in the box `point ± radius`
/// (layer pixels) at comp time `t`. Transparent outside the layer.
pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2], radius: [f64; 2], post: bool, t: f64) -> Option<[f32; 4]> {
if !layer.source.is_av() {
return Some([0.0; 4]);
}
let Some(b) = render(ctx, comp, layer, post, t) else { return Some([0.0; 4]) };
let to_px = |x: f64, y: f64| (x * b.scale + b.offset[0], y * b.scale + b.offset[1]);
let (rx, ry) = (radius[0].abs() * b.scale, radius[1].abs() * b.scale);
let (cx, cy) = to_px(point[0], point[1]);
// Pixels whose centres (at +0.5) fall in the box; at least the one under the point.
let (x0, x1, y0, y1) = if rx < 0.5 && ry < 0.5 {
let (px, py) = (cx.floor() as i64, cy.floor() as i64);
(px, px, py, py)
} else {
let lo = |c: f64, r: f64| (c - r - 0.5).ceil() as i64;
let hi = |c: f64, r: f64| (c + r - 0.5).floor() as i64;
(lo(cx, rx), hi(cx, rx).max(lo(cx, rx)), lo(cy, ry), hi(cy, ry).max(lo(cy, ry)))
};
let mut acc = [0.0f64; 4];
let mut n = 0.0;
for y in y0..=y1.min(y0 + 4096) {
for x in x0..=x1.min(x0 + 4096) {
let p = b.img.get(x, y);
for k in 0..4 {
acc[k] += p[k] as f64;
}
n += 1.0;
}
}
if n == 0.0 {
return Some([0.0; 4]);
}
let a = acc[3] / n;
if a <= 1e-9 {
return Some([0.0; 4]);
}
Some([(acc[0] / n / a) as f32, (acc[1] / n / a) as f32, (acc[2] / n / a) as f32, a as f32])
}
+77 -1
View File
@@ -1019,7 +1019,7 @@ fn text_expression_selector_values() {
let sel_of = |p: &Project| {
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions) };
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None };
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
let lay = effectcraft_text::layout_doc(&doc);
let anim = l.props.group("text/animators/#1").unwrap();
@@ -1051,3 +1051,79 @@ fn text_expression_selector_values() {
let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap();
assert_eq!(v, Value::Vec3([100.0; 3]));
}
/// `sampleImage` reads the layer's rendered pixels (layer space, straight colour) after masks
/// and effects, or the bare source with `postEffect = false`; the radius averages a box.
#[test]
fn sample_image_reads_rendered_pixels() {
let f = fx();
let code = |c: &str| format!("var c = {c}; c[0] * 1000 + c[1] * 100 + c[2] * 10 + c[3]");
// B: 40×40 blue solid.
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([20, 20])"), 0.0), 11.0);
// Half the box is outside the layer: alpha 0.5, colour still pure blue (straight).
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([0, 20], [1, 1])"), 0.0), 10.5);
// A: red with a mask covering 5..15: outside the mask it's transparent after masks, red before.
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2])"), 0.0), 0.0);
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2], [0.5, 0.5], false)"), 0.0), 1001.0);
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([10, 10])"), 0.0), 1001.0);
// The result has four values; outside the layer it's transparent.
assert_close!(f.num("thisComp.layer(\"B\").sampleImage([20, 20]).length", 0.0), 4.0);
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
}
fn data_item(p: &mut Project, name: &str, text: &str) {
let f = effectcraft_project::Footage {
path: format!("/data/{name}"),
kind: effectcraft_project::FootageKind::Data,
data: Some(text.into()),
..Default::default()
};
p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f));
}
/// Data-driven animation: `footage(name)` with `sourceData` / `sourceText` / `dataValue` for
/// JSON, CSV and TSV items.
#[test]
fn data_footage_json_csv_tsv() {
let mut f = fx();
data_item(&mut f.p, "stats.json", r#"{"title": "Q3", "items": [{"v": 3}, {"v": 7.5}]}"#);
data_item(&mut f.p, "scores.csv", "name,score\n\"Smith, J\",12\nLee,30.5\n");
data_item(&mut f.p, "table.tsv", "a\tb\n1\t2\n3\t4\n");
assert_close!(f.num("footage(\"stats.json\").sourceData.items[1].v", 0.0), 7.5);
assert_eq!(f.text("footage(\"stats.json\").sourceData.title", 0.0), "Q3");
assert_close!(f.num("footage(\"stats.json\").dataValue([\"items\", 0, \"v\"])", 0.0), 3.0);
assert_close!(f.num("footage(\"scores.csv\").sourceData[1].score", 0.0), 30.5);
assert_eq!(f.text("footage(\"scores.csv\").sourceData[0].name", 0.0), "Smith, J");
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, 0])", 0.0), 12.0);
assert_close!(f.num("footage(\"scores.csv\").dataKeyCount", 0.0), 2.0);
assert_eq!(f.text("footage(\"scores.csv\").dataKeyNames.join('|')", 0.0), "name|score");
assert_close!(f.num("footage(\"table.tsv\").dataValue([1, 1])", 0.0), 4.0);
assert_close!(f.num("footage(\"table.tsv\").sourceText.length", 0.0), 12.0);
assert!(f.err("footage(\"missing.json\").sourceData").contains("does not exist"));
// Animated from data: the value follows the CSV row picked by time.
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, Math.floor(time)])", 1.2), 30.5);
}
#[test]
fn project_and_time_globals() {
let mut f = fx();
assert_close!(f.num("thisProject.bitsPerChannel", 0.0), 8.0);
assert_close!(f.num("colorDepth", 0.0), 8.0);
assert_close!(f.num("thisProject.linearBlending ? 1 : 0", 0.0), 0.0);
assert_eq!(f.text("timeToFeetAndFrames(2)", 0.0), "3+12");
assert_close!(f.num("posterizeTime(4); time", 0.6), 0.5);
// Path API.
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0);
assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0);
// Marker keys carry protected regions and cue points.
{
let a = f.a;
let l = f.layer_mut(a);
l.markers[0].protected = true;
l.markers[0].cue_point = Some(effectcraft_project::CuePoint { name: "go".into(), navigation: false, params: vec![("k".into(), "v".into())] });
}
assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0);
assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov");
}