mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
Merge main into #1387; clean
This commit is contained in:
+20
-5
@@ -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
@@ -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() } };
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user