Merge main into #1387; clean

This commit is contained in:
bflatastic
2026-10-30 05:46:52 +07:00
124 changed files with 4777 additions and 432 deletions
+20 -5
View File
@@ -10,7 +10,7 @@
use effectcraft_geom::{Mat3, Mat4};
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property};
use effectcraft_render::{EvalCtx, source_size};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
/// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`).
@@ -299,7 +299,17 @@ 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, footage: self.ctx.footage })
let same_comp = c == self.ctx.comp_id.0;
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,
property_overrides: if same_comp { self.ctx.property_overrides } else { &[] },
media_overrides: if same_comp { self.ctx.media_overrides } else { &[] },
})
}
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
let layer = self.layer(c, l)?;
@@ -372,7 +382,7 @@ impl<'a> Resolver<'a> {
Req::LayerInfo { comp, layer } => {
let c = self.comp(*comp)?;
let l = self.layer(*comp, *layer)?;
let (mut w, mut h) = source_size(project, l);
let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l);
if w == 0 || h == 0 {
(w, h) = (c.width, c.height);
}
@@ -467,7 +477,7 @@ impl<'a> Resolver<'a> {
}
}
_ => {
let (w, h) = source_size(project, l);
let (w, h) = ctx.source_size(l);
[0.0, 0.0, w as f64, h as f64]
}
};
@@ -501,7 +511,12 @@ impl<'a> Resolver<'a> {
Req::SourceInfo { comp, layer } => {
let c = self.comp(*comp)?;
let l = self.layer(*comp, *layer)?;
let it = project.item(l.source.item()?)?;
let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?;
let item = match &l.source {
LayerSource::Footage { item } => ctx.media_item(l, *item),
_ => l.source.item()?,
};
let it = project.item(item)?;
let (kind, aspect, frame, duration) = match &it.kind {
ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()),
ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()),
+12 -2
View File
@@ -17,7 +17,8 @@
//! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`,
//! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the
//! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/
//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`).
//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`),
//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have.
//! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time /
//! `seedRandom`), so frames render identically on every thread and platform.
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
@@ -162,7 +163,16 @@ fn evaluate_out(
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
budget_left()?;
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let ctx = EvalCtx {
project: ctx.project,
comp_id: ctx.comp_id,
comp: ctx.comp,
time: ctx.time,
expr: Some(host),
footage: ctx.footage,
property_overrides: ctx.property_overrides,
media_overrides: ctx.media_overrides,
};
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() } };
+54
View File
@@ -208,6 +208,60 @@ function ease() { return __interp(__ease, arguments); }
function easeIn() { return __interp(__easeIn, arguments); }
function easeOut() { return __interp(__easeOut, arguments); }
// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1
// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection
// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four
// finite numbers is linear, never an error.
function __cubicBezier(curve) {
var x1, y1, x2, y2;
if (Array.isArray(curve) && curve.length === 4) {
x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3];
}
if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' ||
!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) {
return __lin;
}
x1 = Math.max(0, Math.min(1, x1));
x2 = Math.max(0, Math.min(1, x2));
function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; }
function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); }
function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; }
function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); }
return function (x) {
if (x <= 0) return 0;
if (x >= 1) return 1;
var u = x;
for (var i = 0; i < 8; i++) {
var f = bx(u) - x;
var d = dbx(u);
if (Math.abs(d) < 1e-12) break;
// Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x
// alone can still mean a visible error in y (f * dy/dx estimates it).
if (Math.abs(f * dby(u) / d) < 1e-9) return by(u);
var n = u - f / d;
if (!(n >= 0 && n <= 1)) break;
u = n;
}
var lo = 0, hi = 1;
for (var j = 0; j < 60; j++) {
var mid = (lo + hi) / 2;
if (bx(mid) < x) lo = mid; else hi = mid;
}
return by((lo + hi) / 2);
};
}
// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is
// the sixth / fourth argument. Read it by position, not from the end: when it is missing or
// unusable, the interpolation falls back to linear without consuming the last value.
function bezier() {
var a = arguments, n = a.length;
if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]);
if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]);
if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]);
return __interp(__lin, [a[0], a[1], a[2]]);
}
// ---------------------------------------------------------------------------------------------
// Random numbers and noise.
+1
View File
@@ -677,6 +677,7 @@ pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) ->
let p0 = src.para(0);
d.set_paras(vec![p0; d.para_count()]);
d.stroke_over_fill = src.stroke_over_fill;
d.line_join = src.line_join;
}
}
for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() {
+124 -2
View File
@@ -314,8 +314,78 @@ fn interpolation_with_arrays() {
assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]);
}
// ---- object model -----------------------------------------------------------------------------
#[test]
fn cubic_bezier_curves() {
let f = fx();
// ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³.
assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0));
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0);
// Symmetric ease-in-out: slower at both ends, faster in the middle.
let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}");
// Control points on the diagonal are linear.
assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0);
// t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers.
assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0);
assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
}
#[test]
fn cubic_bezier_short_form_arrays_and_fallbacks() {
let f = fx();
// Short form: no tMin / tMax.
assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5);
assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]);
// y outside 0..1 overshoots, as CSS allows.
let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0);
assert!(over > 100.0, "overshoot: {over}");
// A curve that is not four finite numbers falls back to linear instead of throwing.
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0);
// A missing or unusable curve must not consume the last value: read the curve by position.
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0);
let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap();
assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0]));
// Exactly four values are a curve; extra ones mean the curve is malformed, so linear.
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0);
// x control points outside 0..1 are clamped, as CSS requires.
let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0);
assert!(v.is_finite(), "clamped curve: {v}");
}
#[test]
fn cubic_bezier_is_accurate_near_the_endpoints() {
/// The value the curve reaches at `x`, by bisection on its monotone x(u).
fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 {
let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u;
let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u;
let (mut lo, mut hi) = (0.0f64, 1.0f64);
for _ in 0..100 {
let mid = 0.5 * (lo + hi);
if bx(mid) < x {
lo = mid;
} else {
hi = mid;
}
}
by(0.5 * (lo + hi))
}
// Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the
// thousands), so the solver stops on the estimated y error instead.
let f = fx();
for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] {
let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0);
let want = reference(c[0], c[1], c[2], c[3], x);
assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}");
}
}
// ---- object model -----------------------------------------------------------------------------
#[test]
fn comp_attributes() {
let f = fx();
@@ -908,6 +978,49 @@ fn source_rect_at_time() {
assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}");
}
#[test]
fn source_dimensions_follow_media_replacement() {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let footage = |p: &mut Project, name: &str, width, height| {
p.add_item(
name,
Label::SeaFoam,
None,
ItemKind::Footage(effectcraft_project::Footage {
path: name.into(),
kind: effectcraft_project::FootageKind::Still,
width,
height,
has_video: true,
..Default::default()
}),
)
};
let original = footage(&mut p, "original.png", 100, 80);
let replacement = footage(&mut p, "replacement.png", 30, 40);
let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None);
l.props.prop_mut(ROT).unwrap().expr =
Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true });
let lid = l.id;
p.comp_mut(cid).unwrap().layers.push(l);
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let media = effectcraft_render::MediaOverride::new(lid, replacement);
let ctx = EvalCtx {
project: &p,
comp_id: cid,
comp: c,
time: Tick::ZERO,
expr: Some(&Expressions),
footage: None,
property_overrides: &[],
media_overrides: &[media],
};
assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0));
}
// ---- colour -----------------------------------------------------------------------------------
#[test]
@@ -1117,7 +1230,16 @@ 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), footage: None };
let ctx = EvalCtx {
project: p,
comp_id: cid,
comp: c,
time: Tick::ZERO,
expr: Some(&Expressions),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
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();
+2
View File
@@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[
"linear(time, 0, 1, 0, 100)",
"ease(time, 0, 2, [0, 0], [100, 50])",
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
"bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])",
"bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])",
"clamp(value * 2, 0, 100)",
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
"normalize([3, 4])[0] * degreesToRadians(180)",