mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge main into M13.5 long-tail polish
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+129
-1
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user