mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
FxHost::param_at mapped the requested layer time to comp time, and EvalCtx::value mapped it back. Under a fractional stretch (33.3 %, 133.33 %) that round trip truncates a tick early, so a Hold key at 0.5 s read its previous value at the step starting there: a Birth Rate cut to 0 at 0.5 s bore one more step of particles (six). EvalCtx::value_at_layer_time reads the keyframes at the given layer time exactly, and still runs an expression at the comp time. EvalCtx::value now uses it, and param_at reads through it. EffectHost::param_is_static says when a parameter has no keyframes and no expression. CC Particle World then skips sampling its Birth Rate at every step, since it's the same throughout.
343 lines
15 KiB
Rust
343 lines
15 KiB
Rust
//! Property evaluation (keyframes + expressions) and layer transforms.
|
|
//!
|
|
//! Keyframe times are stored in **layer time** (they move with the layer), so evaluating a
|
|
//! property at comp time `t` first maps `t` through the layer's start time and stretch.
|
|
|
|
use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::{Comp, ItemId, Layer, LayerId, LayerSource, Project, PropGroup, Property};
|
|
use effectcraft_time::Tick;
|
|
|
|
/// Expression evaluation hook (implemented by `effectcraft-expr`).
|
|
pub trait ExprHost: Send + Sync {
|
|
/// Evaluate the expression of `prop` (on `layer`, comp time `t`), given the keyframed value.
|
|
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String>;
|
|
/// Evaluate a text Expression Selector's Amount expression (on `prop`) for one unit:
|
|
/// `textIndex` (1-based), `textTotal` and `selectorValue` (percent, per dimension) are
|
|
/// defined. Returns the amount in percent per dimension.
|
|
fn eval_text_selector(
|
|
&self,
|
|
_ctx: &EvalCtx,
|
|
_layer: &Layer,
|
|
_prop: &Property,
|
|
_index: usize,
|
|
_total: usize,
|
|
_selector: [f64; 3],
|
|
) -> Result<[f64; 3], String> {
|
|
Err("no expression engine".into())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct EvalCtx<'a> {
|
|
pub project: &'a Project,
|
|
pub comp_id: ItemId,
|
|
pub comp: &'a Comp,
|
|
/// 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, footage: None }
|
|
}
|
|
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
|
|
EvalCtx { time, ..*self }
|
|
}
|
|
/// Value of a property at the context time (keyframes, then expression).
|
|
pub fn value(&self, layer: &Layer, prop: &Property) -> Value {
|
|
// Separated Position reads as the combination of X/Y/Z Position.
|
|
if prop.match_id == "position"
|
|
&& let Some(tr) = layer.transform()
|
|
&& let Some(px) = tr.get("positionX")
|
|
&& tr.get("position").is_some_and(|p| p.uid == prop.uid)
|
|
{
|
|
let z = tr.get("positionZ").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0);
|
|
let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0);
|
|
return Value::Vec3([self.value(layer, px).as_f64(), y, z]);
|
|
}
|
|
self.value_at_layer_time(layer, prop, layer.layer_time(self.time))
|
|
}
|
|
/// [`Self::value`] with the keyframes read at layer time `lt` exactly, rather than at the
|
|
/// context time mapped through the layer's start and stretch (which rounds under a
|
|
/// fractional stretch: a Hold key at `lt` could read its previous value). An expression
|
|
/// still runs at the context time.
|
|
pub fn value_at_layer_time(&self, layer: &Layer, prop: &Property, lt: Tick) -> Value {
|
|
let v = prop.value_at(lt);
|
|
if prop.has_expression()
|
|
&& let Some(h) = self.expr
|
|
{
|
|
return h.eval(self, layer, prop, &v).unwrap_or(v);
|
|
}
|
|
v
|
|
}
|
|
pub fn group_value(&self, layer: &Layer, g: &PropGroup, m: &str) -> Option<Value> {
|
|
g.get(m).map(|p| self.value(layer, p))
|
|
}
|
|
pub fn f(&self, layer: &Layer, g: &PropGroup, m: &str, d: f64) -> f64 {
|
|
self.group_value(layer, g, m).map(|v| v.as_f64()).unwrap_or(d)
|
|
}
|
|
pub fn v2(&self, layer: &Layer, g: &PropGroup, m: &str, d: [f64; 2]) -> [f64; 2] {
|
|
self.group_value(layer, g, m).map(|v| v.as_vec2()).unwrap_or(d)
|
|
}
|
|
pub fn v3(&self, layer: &Layer, g: &PropGroup, m: &str, d: [f64; 3]) -> [f64; 3] {
|
|
self.group_value(layer, g, m).map(|v| v.as_vec3()).unwrap_or(d)
|
|
}
|
|
pub fn color(&self, layer: &Layer, g: &PropGroup, m: &str) -> [f32; 4] {
|
|
self.group_value(layer, g, m).map(|v| v.as_color()).unwrap_or([1.0; 4])
|
|
}
|
|
pub fn e(&self, layer: &Layer, g: &PropGroup, m: &str) -> u32 {
|
|
self.group_value(layer, g, m).map(|v| v.as_enum()).unwrap_or(0)
|
|
}
|
|
pub fn b(&self, layer: &Layer, g: &PropGroup, m: &str) -> bool {
|
|
self.group_value(layer, g, m).map(|v| v.as_bool()).unwrap_or(false)
|
|
}
|
|
/// Layer Position, honouring Separate Dimensions (X/Y/Z Position properties).
|
|
pub fn position(&self, layer: &Layer, tr: &PropGroup) -> [f64; 3] {
|
|
match tr.get("positionX") {
|
|
Some(px) => [self.value(layer, px).as_f64(), self.f(layer, tr, "positionY", 0.0), self.f(layer, tr, "positionZ", 0.0)],
|
|
None => self.v3(layer, tr, "position", [0.0; 3]),
|
|
}
|
|
}
|
|
/// Source time of a layer at the context time: Time Remap's value when enabled, else the
|
|
/// (stretch-aware) layer time.
|
|
pub fn source_time(&self, layer: &Layer) -> Tick {
|
|
if let Some(tr) = layer.props.get("timeRemap") {
|
|
return Tick::from_seconds_f64(self.value(layer, tr).as_f64());
|
|
}
|
|
// Responsive Design — Time: a time-stretched precomp keeps its protected regions at
|
|
// their original speed.
|
|
if let LayerSource::Comp { item } = layer.source
|
|
&& (layer.stretch - 100.0).abs() > 1e-9
|
|
&& let Some(nc) = self.project.comp(item)
|
|
&& let Some(t) = responsive_source_time(nc, layer.stretch / 100.0, (self.time - layer.start_time).seconds())
|
|
{
|
|
return t;
|
|
}
|
|
layer.layer_time(self.time)
|
|
}
|
|
/// The time a precomp layer's nested comp is shown at: [`Self::source_time`], floored to
|
|
/// the nested comp's own frames when it preserves its frame rate (Composition Settings ▸
|
|
/// Preserve frame rate when nested or in render queue).
|
|
pub fn nested_time(&self, layer: &Layer) -> Tick {
|
|
let t = self.source_time(layer);
|
|
match layer.source {
|
|
LayerSource::Comp { item } => match self.project.comp(item) {
|
|
Some(nc) if nc.preserve_frame_rate => nc.frame_rate.tick_of(crate::frame_position(t, nc.frame_rate).0),
|
|
_ => t,
|
|
},
|
|
_ => t,
|
|
}
|
|
}
|
|
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
|
|
self.comp.layer(id)
|
|
}
|
|
|
|
/// Local (parent-space) transform of a layer.
|
|
pub fn local_matrix(&self, layer: &Layer) -> Mat4 {
|
|
self.local_matrix_par(layer, 1.0)
|
|
}
|
|
|
|
/// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps
|
|
/// with another pixel aspect) is stretched horizontally by this factor in the comp.
|
|
pub fn par_ratio(&self, layer: &Layer) -> f64 {
|
|
let par = match &layer.source {
|
|
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) {
|
|
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
|
|
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
|
|
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
|
|
_ => 1.0,
|
|
},
|
|
_ => 1.0,
|
|
};
|
|
let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 };
|
|
if r.is_finite() && r > 0.0 && (r - 1.0).abs() > 1e-9 { r } else { 1.0 }
|
|
}
|
|
|
|
/// [`Self::local_matrix`] with the source's pixel aspect folded into the scale (`par`; 1 for
|
|
/// a parent's matrix, which children don't inherit).
|
|
fn local_matrix_par(&self, layer: &Layer, par: f64) -> Mat4 {
|
|
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
|
|
let three = layer.is_3d();
|
|
let pos = self.position(layer, tr);
|
|
let rz = self.f(layer, tr, "rotation", 0.0);
|
|
if layer.is_camera() || layer.is_light() {
|
|
// Children of cameras/lights follow their position and full rotation.
|
|
return Mat4::translate(Vec3::from(pos)) * crate::three_d::camera::rig_rotation(self, layer);
|
|
}
|
|
let anchor = self.v3(layer, tr, "anchor", [0.0; 3]);
|
|
let mut scale = self.v3(layer, tr, "scale", [100.0; 3]);
|
|
scale[0] *= par;
|
|
if !three {
|
|
scale[2] = 100.0;
|
|
let a = Vec3::from([anchor[0], anchor[1], 0.0]);
|
|
let p = Vec3::from([pos[0], pos[1], 0.0]);
|
|
let rz = rz + crate::three_d::camera::auto_orient_2d(self, layer);
|
|
return Mat4::layer_3d(a, p, Vec3::from(scale), Vec3::ZERO, vec3(0.0, 0.0, rz));
|
|
}
|
|
let rx = self.f(layer, tr, "rotationX", 0.0);
|
|
let ry = self.f(layer, tr, "rotationY", 0.0);
|
|
let p = Vec3::from(pos);
|
|
// Auto-orient (along path / towards camera) replaces Orientation.
|
|
let orient = crate::three_d::camera::auto_orient_3d(self, layer, p)
|
|
.unwrap_or_else(|| Mat4::orientation(Vec3::from(self.v3(layer, tr, "orientation", [0.0; 3]))));
|
|
Mat4::translate(p)
|
|
* orient
|
|
* Mat4::rotate_z(rz)
|
|
* Mat4::rotate_y(ry)
|
|
* Mat4::rotate_x(rx)
|
|
* Mat4::scale(Vec3::from(scale) / 100.0)
|
|
* Mat4::translate(-Vec3::from(anchor))
|
|
}
|
|
|
|
/// Layer space → comp (world) space, including parents.
|
|
pub fn world_matrix(&self, layer: &Layer) -> Mat4 {
|
|
let mut m = self.local_matrix_par(layer, self.par_ratio(layer));
|
|
let mut cur = layer.parent;
|
|
let mut guard = 0;
|
|
while let Some(pid) = cur {
|
|
guard += 1;
|
|
if guard > 64 {
|
|
break;
|
|
}
|
|
let Some(p) = self.comp.layer(pid) else { break };
|
|
m = self.local_matrix(p) * m;
|
|
cur = p.parent;
|
|
}
|
|
m
|
|
}
|
|
|
|
pub fn opacity(&self, layer: &Layer) -> f64 {
|
|
layer.transform().map(|tr| self.f(layer, tr, "opacity", 100.0)).unwrap_or(100.0) / 100.0
|
|
}
|
|
|
|
/// The comp's active camera at this time: (view-projection to comp pixels, eye, zoom).
|
|
pub fn camera(&self) -> (Mat4, Vec3, f64) {
|
|
let c = crate::three_d::active_camera(self);
|
|
(c.projection(self.comp.width as f64, self.comp.height as f64), c.eye, c.zoom)
|
|
}
|
|
|
|
/// Layer space → comp pixel space as a 2D projective matrix, plus camera-space depth of the
|
|
/// layer's anchor (for 3D sorting). 2D layers ignore the camera.
|
|
pub fn layer_to_comp(&self, layer: &Layer) -> (Mat3, f64) {
|
|
let world = self.world_matrix(layer);
|
|
if layer.is_3d() {
|
|
let (cam, _, _) = self.camera();
|
|
let full = cam * world;
|
|
let anchor = layer.transform().map(|tr| self.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
|
|
let a = world.apply(Vec3::from(anchor));
|
|
let depth = cam.0[3][0] * a.x + cam.0[3][1] * a.y + cam.0[3][2] * a.z + cam.0[3][3];
|
|
(full.plane_to_mat3(), depth)
|
|
} else {
|
|
let m = &world.0;
|
|
(Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0)
|
|
}
|
|
}
|
|
/// [`effect_bounds`] of a layer of this comp.
|
|
pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) {
|
|
effect_bounds(self.project, self.comp, layer)
|
|
}
|
|
/// Effect space (see [`effect_bounds`]) → comp matrix.
|
|
pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 {
|
|
let o = self.effect_bounds(layer).1;
|
|
self.layer_to_comp(layer).0 * Mat3::translate(vec2(o[0], o[1]))
|
|
}
|
|
}
|
|
|
|
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
|
|
/// clamped to the comp, in seconds.
|
|
pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> {
|
|
let d = comp.duration.seconds();
|
|
let mut v: Vec<(f64, f64)> = comp
|
|
.markers
|
|
.iter()
|
|
.filter(|m| m.protected && m.duration > Tick::ZERO)
|
|
.map(|m| (m.time.seconds().clamp(0.0, d), (m.time + m.duration).seconds().clamp(0.0, d)))
|
|
.filter(|(a, b)| b > a)
|
|
.collect();
|
|
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
|
let mut out: Vec<(f64, f64)> = vec![];
|
|
for (a, b) in v {
|
|
match out.last_mut() {
|
|
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
|
_ => out.push((a, b)),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Source time of a precomp time-stretched by `stretch` (1 = 100%), `elapsed` comp seconds
|
|
/// after the layer's start, with Responsive Design — Time: protected regions play at their
|
|
/// original speed and the unprotected parts absorb the whole stretch. `None` when the comp has
|
|
/// no protected regions or the stretch can't be absorbed (reversed, or shorter than the
|
|
/// protected regions), so the plain stretch applies.
|
|
pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option<Tick> {
|
|
let regions = protected_regions(comp);
|
|
if regions.is_empty() || stretch <= 0.0 {
|
|
return None;
|
|
}
|
|
let d = comp.duration.seconds();
|
|
let lp: f64 = regions.iter().map(|(a, b)| b - a).sum();
|
|
let lu = d - lp;
|
|
let k = if lu > 1e-9 { (d * stretch - lp) / lu } else { 1.0 };
|
|
if k <= 1e-9 {
|
|
return None;
|
|
}
|
|
if elapsed < 0.0 {
|
|
return Some(Tick::from_seconds_f64(elapsed / k));
|
|
}
|
|
// Segments in source order: (start, end, protected).
|
|
let mut segs = vec![];
|
|
let mut at = 0.0;
|
|
for (a, b) in ®ions {
|
|
if *a > at {
|
|
segs.push((at, *a, false));
|
|
}
|
|
segs.push((*a, *b, true));
|
|
at = *b;
|
|
}
|
|
if d > at {
|
|
segs.push((at, d, false));
|
|
}
|
|
let mut pos = 0.0;
|
|
for (a, b, prot) in segs {
|
|
let speed = if prot { 1.0 } else { k };
|
|
let len = (b - a) * speed;
|
|
if elapsed < pos + len {
|
|
return Some(Tick::from_seconds_f64(a + (elapsed - pos) / speed));
|
|
}
|
|
pos += len;
|
|
}
|
|
Some(Tick::from_seconds_f64(d + (elapsed - pos) / k))
|
|
}
|
|
|
|
/// A layer's effect bounds ([`effectcraft_effects::EffectCtx::layer_size`]) and their top-left
|
|
/// corner in layer coordinates: the source rectangle at (0, 0), or for layers without one
|
|
/// (shape, text) a comp-sized rectangle centred on the layer's origin, which their content
|
|
/// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer
|
|
/// space for effect points: a shape layer's default effect point is its bounds' centre).
|
|
pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) {
|
|
match source_size(project, layer) {
|
|
(0, _) => {
|
|
let (w, h) = (comp.width as f64, comp.height as f64);
|
|
([w, h], [-w / 2.0, -h / 2.0])
|
|
}
|
|
(w, h) => ([w as f64, h as f64], [0.0; 2]),
|
|
}
|
|
}
|
|
|
|
/// Size of a layer's source in layer pixels.
|
|
pub fn source_size(project: &Project, layer: &Layer) -> (u32, u32) {
|
|
match &layer.source {
|
|
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => {
|
|
project.item(*item).and_then(|i| i.dimensions()).unwrap_or((0, 0))
|
|
}
|
|
LayerSource::Null => (100, 100),
|
|
_ => (0, 0),
|
|
}
|
|
}
|