M0.1–M0.7: workspace, time/geom/color/keyframe/project/raster/path/text/effects/render crates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 14:05:20 -04:00
co-authored by Claude Opus 5.5
parent 9f19158a59
commit 808cd490cd
76 changed files with 12319 additions and 3 deletions
+177
View File
@@ -0,0 +1,177 @@
//! 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, look_at, 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>;
}
#[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>,
}
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 }
}
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 {
let lt = layer.layer_time(self.time);
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)
}
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 {
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
let three = layer.is_3d();
let pos = self.v3(layer, tr, "position", [0.0; 3]);
let rz = self.f(layer, tr, "rotation", 0.0);
if layer.is_camera() || layer.is_light() {
return Mat4::translate(Vec3::from(pos)) * Mat4::rotate_z(rz);
}
let anchor = self.v3(layer, tr, "anchor", [0.0; 3]);
let mut scale = self.v3(layer, tr, "scale", [100.0; 3]);
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]);
return Mat4::layer_3d(a, p, Vec3::from(scale), Vec3::ZERO, vec3(0.0, 0.0, rz));
}
let o = self.v3(layer, tr, "orientation", [0.0; 3]);
let rx = self.f(layer, tr, "rotationX", 0.0);
let ry = self.f(layer, tr, "rotationY", 0.0);
Mat4::layer_3d(Vec3::from(anchor), Vec3::from(pos), Vec3::from(scale), Vec3::from(o), vec3(rx, ry, rz))
}
/// Layer space → comp (world) space, including parents.
pub fn world_matrix(&self, layer: &Layer) -> Mat4 {
let mut m = self.local_matrix(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 camera at this time: (view-projection to comp pixels, eye position).
pub fn camera(&self) -> (Mat4, Vec3, f64) {
let (w, h) = (self.comp.width as f64, self.comp.height as f64);
if let Some(cam) = self.comp.active_camera(self.time)
&& let Some(tr) = cam.transform()
{
let pos = Vec3::from(self.v3(cam, tr, "position", [w / 2.0, h / 2.0, -1000.0]));
let zoom = cam.props.sub("cameraOptions").map(|g| self.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0);
let o = self.v3(cam, tr, "orientation", [0.0; 3]);
let rx = self.f(cam, tr, "rotationX", 0.0);
let ry = self.f(cam, tr, "rotationY", 0.0);
let rz = self.f(cam, tr, "rotation", 0.0);
// Extra camera rotation is applied in camera space (inverse, because it rotates the eye).
let extra = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).transpose();
let view = if let Some(poi) = tr.get("poi").map(|p| self.value(cam, p).as_vec3()) {
look_at(pos, Vec3::from(poi), extra)
} else {
look_at(pos, pos + vec3(0.0, 0.0, 1.0), extra)
};
let mut view = view;
// Parented cameras: apply the parent's world matrix inverse.
if let Some(pid) = cam.parent
&& let Some(p) = self.comp.layer(pid)
&& let Some(inv) = self.world_matrix(p).inverse()
{
view = view * inv;
let _ = pos;
}
return (effectcraft_geom::camera_matrix(w, h, pos, view, zoom), pos, zoom);
}
let zoom = effectcraft_geom::default_camera_zoom(w);
let pos = vec3(w / 2.0, h / 2.0, -zoom);
let view = look_at(pos, vec3(w / 2.0, h / 2.0, 0.0), Mat4::IDENTITY);
(effectcraft_geom::camera_matrix(w, h, pos, view, zoom), pos, 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)
}
}
}
/// 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),
}
}
+333
View File
@@ -0,0 +1,333 @@
//! The EffectCraft compositor (CPU reference path).
//!
//! `Renderer::comp_frame` renders a composition at a time:
//! bottom-to-top over visible layers → **source** (solid, footage, text, shapes, precomp) →
//! **masks** → **effects** → **transform** (2D affine or 3D projective through the active camera,
//! with motion blur sub-samples) → **track matte** → **blend** with the layer's mode and opacity.
//! Adjustment layers run their effects on everything below, limited by their own bounds/masks.
pub mod eval;
pub mod masks;
pub mod shapes;
pub mod text;
use std::sync::Arc;
use effectcraft_color::BlendMode;
use effectcraft_effects::{Buf, EffectCtx, Params};
use effectcraft_geom::{Mat3, vec2};
pub use eval::{EvalCtx, ExprHost, source_size};
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling};
use effectcraft_raster::{Image, WarpOpts, composite_warp};
use effectcraft_time::Tick;
use rayon::prelude::*;
/// Supplies decoded footage frames (implemented by the media layer).
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
}
/// No footage available (renders footage layers as transparent).
pub struct NoFootage;
impl FootageSource for NoFootage {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
}
#[derive(Clone, Copy, Debug)]
pub struct RenderOpts {
/// Output scale relative to comp pixels (1 = Full, 0.5 = Half…).
pub scale: f64,
pub motion_blur: bool,
/// Render guide layers (viewer only).
pub guides: bool,
/// Draft quality: fewer motion-blur samples, bilinear only.
pub draft: bool,
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false }
}
}
pub struct Renderer<'a> {
pub project: &'a Project,
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
pub opts: RenderOpts,
/// Current nesting depth (precomp recursion guard).
depth: usize,
}
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
Renderer { project, footage, expr: None, opts, depth: 0 }
}
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr }
}
/// Render a composition at comp time `t` (transparent background, comp size × scale).
pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image {
let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) };
let s = self.opts.scale;
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
let mut canvas = Image::new(w, h);
if self.depth > 16 {
return canvas;
}
let ctx = self.ctx(comp_id, comp, t);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
// Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first).
let visible: Vec<&Layer> = comp
.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.collect();
let mut i = 0;
while i < visible.len() {
if visible[i].is_3d() {
let mut j = i;
while j < visible.len() && visible[j].is_3d() {
j += 1;
}
let mut run: Vec<(&Layer, f64)> = visible[i..j].iter().map(|l| (*l, ctx.layer_to_comp(l).1)).collect();
run.sort_by(|a, b| b.1.total_cmp(&a.1));
for (l, _) in run {
self.draw_layer(&ctx, l, &mut canvas);
}
i = j;
} else {
self.draw_layer(&ctx, visible[i], &mut canvas);
i += 1;
}
}
if !self.project.settings.bit_depth.is_float() {
canvas.clamp01();
}
canvas
}
/// Evaluate an effect instance's parameters.
fn effect_params(&self, ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup) -> Params {
let mut p = Params::default();
for c in &g.children {
if let Node::Prop(pr) = c {
p.values.insert(pr.match_id.clone(), ctx.value(layer, pr));
}
}
p
}
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool) -> Buf {
if !layer.switches.effects {
return buf;
}
let Some(fx) = layer.effects() else { return buf };
let lt = layer.layer_time(ctx.time);
let size = source_size(self.project, layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
for g in fx.groups() {
if !g.enabled {
continue;
}
let GroupKind::Effect { effect } = &g.kind else { continue };
let Some(spec) = effectcraft_effects::find(effect) else { continue };
let params = self.effect_params(ctx, layer, g);
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment };
buf = effectcraft_effects::apply(spec, &ectx, buf);
}
buf
}
/// Source pixels of a layer in layer space (before masks/effects).
fn source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let s = self.opts.scale;
match &layer.source {
LayerSource::Solid { item } => {
let ItemKind::Solid(sol) = &self.project.item(*item)?.kind else { return None };
let w = ((sol.width as f64 * s).ceil() as u32).max(1);
let h = ((sol.height as f64 * s).ceil() as u32).max(1);
if layer.switches.adjustment {
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
}
let c = sol.color;
Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s })
}
LayerSource::Comp { item } => {
if self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 };
let lt = layer.layer_time(ctx.time);
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
}
LayerSource::Footage { item } => {
let it = self.project.item(*item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
if !f.has_video {
return None;
}
let lt = layer.layer_time(ctx.time);
let img = self.footage.frame(*item, f, lt)?;
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
if s < 0.75 {
let w = ((f.width as f64 * s).round() as u32).max(1);
let h = ((f.height as f64 * s).round() as u32).max(1);
return Some(Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s });
}
Some(Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k })
}
LayerSource::Text => Some(text::render(ctx, layer, s)),
LayerSource::Shape => layer.props.sub("contents").map(|c| shapes::render(ctx, layer, c, s)),
_ => None,
}
}
/// Fully processed layer buffer (source → masks → effects).
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let mut buf = self.source(ctx, layer)?;
masks::apply(ctx, layer, &mut buf);
Some(self.apply_effects(ctx, layer, buf, false))
}
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
if self.opts.draft || layer.switches.quality == Quality::Draft {
effectcraft_raster::Sampling::Bilinear
} else if layer.switches.sampling == Sampling::Bicubic {
effectcraft_raster::Sampling::Bicubic
} else {
effectcraft_raster::Sampling::Bilinear
}
}
/// Buffer pixel → output pixel matrix for the layer at the context time.
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
let s = self.opts.scale;
let (l2c, _) = ctx.layer_to_comp(layer);
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
let samples = if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur {
if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
} else {
1
};
let opts = WarpOpts { sampling: self.sampling(layer), opacity, mode, seed: layer.id.0 as u32, clip: None };
if samples <= 1 {
let m = self.buf_matrix(ctx, layer, buf);
composite_warp(target, &buf.img, &m, &opts);
return;
}
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
let phase = ctx.comp.shutter_phase / 360.0;
let mut acc = Image::new(target.width, target.height);
let accum: Vec<Image> = (0..samples)
.into_par_iter()
.map(|i| {
let f = phase + angle * i as f64 / (samples - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let m = self.buf_matrix(&sub, layer, buf);
let mut img = Image::new(target.width, target.height);
composite_warp(&mut img, &buf.img, &m, &WarpOpts { mode: BlendMode::Normal, opacity: 1.0, ..opts });
img
})
.collect();
let k = 1.0 / samples as f32;
for img in &accum {
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, b)| {
for c in 0..4 {
a[c] += b[c] * k;
}
});
}
target.blend_from(&acc, mode, opacity, layer.id.0 as u32);
}
fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
let opacity = ctx.opacity(layer) as f32;
if opacity <= 0.0 && !layer.switches.adjustment {
return;
}
if layer.switches.adjustment {
self.draw_adjustment(ctx, layer, canvas, opacity);
return;
}
let Some(buf) = self.layer_buf(ctx, layer) else { return };
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
let preserve = layer.preserve_transparency;
if matte.is_none() && !preserve {
self.place(ctx, layer, &buf, canvas, layer.blend_mode, opacity);
return;
}
// Render in isolation, then matte / preserve transparency, then blend.
let mut iso = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &buf, &mut iso, BlendMode::Normal, 1.0);
if let Some((m, kind)) = matte {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = self.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
}
iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| {
let k = match kind {
MatteKind::Alpha => q[3],
MatteKind::AlphaInverted => 1.0 - q[3],
MatteKind::Luma => effectcraft_color::luminance(q[0], q[1], q[2]),
MatteKind::LumaInverted => 1.0 - effectcraft_color::luminance(q[0], q[1], q[2]),
}
.clamp(0.0, 1.0);
for c in p.iter_mut() {
*c *= k;
}
});
}
if preserve {
iso.data.par_iter_mut().zip(canvas.data.par_iter()).for_each(|(p, q)| {
for c in p.iter_mut() {
*c *= q[3];
}
});
}
canvas.blend_from(&iso, layer.blend_mode, opacity, layer.id.0 as u32);
}
/// Adjustment layer: effects applied to the comp below, limited to the layer's footprint.
fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) {
let Some(mut foot) = self.source(ctx, layer) else { return };
masks::apply(ctx, layer, &mut foot);
let mut matte = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
let adjusted = self.apply_effects(ctx, layer, below, true);
if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height {
return;
}
canvas.data.par_iter_mut().zip(adjusted.img.data.par_iter()).zip(matte.data.par_iter()).for_each(|((c, a), m)| {
let k = m[3] * opacity;
for i in 0..4 {
c[i] += (a[i] - c[i]) * k;
}
});
}
}
/// Convenience: render one frame of a comp with no footage source.
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
}
#[cfg(test)]
mod tests;
+70
View File
@@ -0,0 +1,70 @@
//! Layer masks: path coverage with expansion, feather, opacity, invert, combined by mode.
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::Value;
use effectcraft_path::{FillRule, StrokeStyle};
use effectcraft_project::{GroupKind, Layer, MaskMode};
use effectcraft_raster::{Mask, gaussian_blur};
use rayon::prelude::*;
use crate::eval::EvalCtx;
/// Coverage of one mask in buffer pixels (before mode/opacity/invert).
fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None };
let path = effectcraft_path::to_kurbo(&sp);
let (w, h) = (buf.img.width, buf.img.height);
let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale));
let mut cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero);
let exp = ctx.f(layer, g, "expansion", 0.0);
if exp.abs() > 0.01 {
let ring = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, &m, w, h);
cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| {
*c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) };
});
}
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
if feather[0] > 0.0 || feather[1] > 0.0 {
let img = cov.to_image();
let b = gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false);
cov = Mask::from_alpha(&b);
}
Some(cov)
}
/// Apply the layer's masks to its buffer.
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) {
let Some(masks) = layer.masks() else { return };
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect();
if list.is_empty() {
return;
}
// Masks may extend past the source: grow the buffer to cover them when feather/expansion pads.
let n = (buf.img.width * buf.img.height) as usize;
let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. });
let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n];
for g in list {
let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue };
let Some(mut cov) = coverage(ctx, layer, g, buf) else { continue };
let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
cov.data.par_iter_mut().for_each(|c| {
if inverted {
*c = 1.0 - *c;
}
*c *= op;
});
acc.par_iter_mut().zip(cov.data.par_iter()).for_each(|(a, &c)| {
*a = match mode {
MaskMode::Add => *a + c - *a * c,
MaskMode::Subtract => *a * (1.0 - c),
MaskMode::Intersect => *a * c,
MaskMode::Lighten => a.max(c),
MaskMode::Darken => a.min(c),
MaskMode::Difference => (*a - c).abs(),
MaskMode::None => *a,
};
});
}
buf.img.mul_mask(&acc);
}
+284
View File
@@ -0,0 +1,284 @@
//! Shape-layer contents: groups, parametric paths, fills, strokes, gradients and path operators,
//! with the After Effects stacking semantics (operators and paint items act on the paths above
//! them in the same group; items higher in the list draw on top).
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use rayon::prelude::*;
use crate::eval::EvalCtx;
#[derive(Clone, Debug)]
enum Paint {
Fill { color: [f32; 4], rule: FillRule },
Stroke { color: [f32; 4], style: StrokeStyle },
Gradient { g: Gradient, radial: bool, start: [f64; 2], end: [f64; 2], rule: FillRule },
}
#[derive(Clone, Debug)]
struct Draw {
paint: Paint,
paths: Vec<BezPath>,
/// Group-space → layer-space for gradients and stroke scaling.
xf: Mat3,
opacity: f32,
}
fn mat_paths(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
effectcraft_path::transform(paths, m)
}
fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [0.0; 2]);
let scale = ctx.v2(layer, tr, "scale", [100.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let skew = ctx.f(layer, tr, "skew", 0.0);
let skew_axis = ctx.f(layer, tr, "skewAxis", 0.0);
let m = Mat3::translate(vec2(pos[0], pos[1]))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(-skew, skew_axis)
* Mat3::scale(vec2(scale[0] / 100.0, scale[1] / 100.0))
* Mat3::translate(vec2(-anchor[0], -anchor[1]));
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
}
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
}
/// Collect draws and accumulated paths of a contents group (in group space).
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Vec<BezPath>) {
let mut draws: Vec<Draw> = Vec::new();
let mut acc: Vec<BezPath> = Vec::new();
for node in &contents.children {
let Node::Group(g) = node else { continue };
if !g.enabled {
continue;
}
match g.match_id.as_str() {
"rect" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)));
}
"ellipse" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::ellipse(size, pos));
}
"star" => {
let star = ctx.e(layer, g, "type") == 0;
acc.push(effectcraft_path::polystar(
star,
ctx.f(layer, g, "points", 5.0),
ctx.v2(layer, g, "position", [0.0; 2]),
ctx.f(layer, g, "rotation", 0.0),
ctx.f(layer, g, "innerRadius", 50.0),
ctx.f(layer, g, "outerRadius", 100.0),
ctx.f(layer, g, "innerRoundness", 0.0),
ctx.f(layer, g, "outerRoundness", 0.0),
));
}
"path" => {
if let Some(Value::Path(p)) = ctx.group_value(layer, g, "path") {
acc.push(effectcraft_path::to_kurbo(&p));
}
}
"fill" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
draws.push(Draw { paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
}
"stroke" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
});
let style = StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
};
draws.push(Draw { paint: Paint::Stroke { color: c, style }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
}
"gfill" => {
let gr = match ctx.group_value(layer, g, "colors") {
Some(Value::Gradient(gr)) => gr,
_ => Gradient::default(),
};
draws.push(Draw {
paint: Paint::Gradient {
g: gr,
radial: ctx.e(layer, g, "type") == 1,
start: ctx.v2(layer, g, "start", [0.0; 2]),
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
rule: rule(ctx, layer, g),
},
paths: acc.clone(),
xf: Mat3::IDENTITY,
opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32,
});
}
"trim" => {
let (s, e, o) = (ctx.f(layer, g, "start", 0.0), ctx.f(layer, g, "end", 100.0), ctx.f(layer, g, "offset", 0.0));
acc = effectcraft_path::ops::trim(&acc, s, e, o);
for d in &mut draws {
// Paint items above the trim are not affected (AE semantics).
let _ = d;
}
}
"pucker" => {
acc = effectcraft_path::ops::pucker_bloat(&acc, ctx.f(layer, g, "amount", 0.0));
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
let (cd, cp) = collect(ctx, layer, inner);
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
for mut d in cd {
d.paths = mat_paths(&d.paths, &m);
d.xf = m * d.xf;
d.opacity *= op;
draws.push(d);
}
acc.extend(mat_paths(&cp, &m));
}
"repeater" => {
let copies = ctx.f(layer, g, "copies", 3.0).max(0.0);
let offset = ctx.f(layer, g, "offset", 0.0);
let n = copies.ceil() as usize;
let Some(tr) = g.sub("transform") else { continue };
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [100.0, 0.0]);
let scale = ctx.v2(layer, tr, "scale", [100.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let so = (ctx.f(layer, tr, "startOpacity", 100.0) / 100.0) as f32;
let eo = (ctx.f(layer, tr, "endOpacity", 100.0) / 100.0) as f32;
let step = |k: f64| {
Mat3::translate(vec2(pos[0] * k, pos[1] * k))
* Mat3::translate(vec2(anchor[0], anchor[1]))
* Mat3::rotate_deg(rot * k)
* Mat3::scale(vec2((scale[0] / 100.0).powf(k), (scale[1] / 100.0).powf(k)))
* Mat3::translate(vec2(-anchor[0], -anchor[1]))
};
let base_draws = std::mem::take(&mut draws);
let base_acc = std::mem::take(&mut acc);
let above = ctx.e(layer, g, "composite") == 1;
let mut order: Vec<usize> = (0..n).collect();
if !above {
order.reverse();
}
for i in order {
let k = i as f64 + offset;
let m = step(k);
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
let opk = so + (eo - so) * t;
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
for d in &base_draws {
let mut d = d.clone();
d.paths = mat_paths(&d.paths, &m);
d.xf = m * d.xf;
d.opacity *= opk * partial;
draws.push(d);
}
acc.extend(mat_paths(&base_acc, &m));
}
}
_ => {}
}
}
(draws, acc)
}
fn composite_coverage(img: &mut Image, cov: &Mask, color: [f32; 4], opacity: f32) {
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
let a = c * opacity * color[3];
if a <= 0.0 {
return;
}
let k = 1.0 - a;
px[0] = color[0] * a + px[0] * k;
px[1] = color[1] * a + px[1] * k;
px[2] = color[2] * a + px[2] * k;
px[3] = a + px[3] * k;
});
}
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
let (draws, _) = collect(ctx, layer, contents);
let mut bounds: Option<kurbo::Rect> = None;
for d in &draws {
if let Some(b) = effectcraft_path::bounds(&d.paths) {
let grow = match &d.paint {
Paint::Stroke { style, .. } => style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0,
_ => 1.0,
};
let b = b.inflate(grow, grow);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
// Keep buffers sane for runaway geometry.
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
let mut img = Image::new(w, h);
for d in draws.iter().rev() {
if d.opacity <= 0.0 || d.paths.is_empty() {
continue;
}
match &d.paint {
Paint::Fill { color, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Stroke { color, style } => {
// Strokes scale with their group transform.
let mut st = style.clone();
st.width *= d.xf.mean_scale();
let cov = effectcraft_path::stroke_coverage(&d.paths, &st, &m, w, h);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Gradient { g, radial, start, end, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
let op = d.opacity;
img.data.par_chunks_mut(w as usize).zip(cov.data.par_chunks(w as usize)).enumerate().for_each(|(y, (row, crow))| {
for x in 0..row.len() {
let c = crow[x];
if c <= 0.0 {
continue;
}
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
let (vx, vy) = (p.x - sx, p.y - sy);
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
let col = g.sample(t);
let a = c * op * col[3];
let k = 1.0 - a;
let px = &mut row[x];
px[0] = col[0] * a + px[0] * k;
px[1] = col[1] * a + px[1] * k;
px[2] = col[2] * a + px[2] * k;
px[3] = a + px[3] * k;
}
});
}
}
}
Buf { img, offset, scale: s }
}
+162
View File
@@ -0,0 +1,162 @@
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, MatteKind, Project, Solid, TrackMatte};
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
fn setup() -> (Project, ItemId, Comp) {
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("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> effectcraft_project::Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
}
#[test]
fn solid_fills_comp() {
let (mut p, cid, comp) = setup();
let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(199, 99), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn position_keyframes_move_layer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 1.0, 0.0], 20, 20);
let pos = l.props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([180.0, 50.0, 0.0]))];
p.comp_mut(cid).unwrap().layers.push(l);
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 50)[3] < 0.01);
let b = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01);
}
#[test]
fn opacity_and_blend_modes() {
let (mut p, cid, comp) = setup();
let bottom = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
let mut top = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
top.blend_mode = BlendMode::Multiply;
let c = p.comp_mut(cid).unwrap();
c.layers.push(top);
c.layers.push(bottom);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!((img.get(50, 50)[0] - 0.25).abs() < 1e-4);
}
#[test]
fn half_resolution() {
let (mut p, cid, comp) = setup();
let l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 0.5);
assert_eq!((img.width, img.height), (100, 50));
assert!((img.get(50, 25)[3] - 1.0).abs() < 1e-4);
}
#[test]
fn mask_cuts_layer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(50, 50)[3] > 0.99);
assert!(img.get(150, 50)[3] < 0.01);
}
#[test]
fn track_matte_alpha() {
let (mut p, cid, comp) = setup();
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
matte.switches.video = false;
let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
fill.track_matte = Some(TrackMatte { layer: matte.id, kind: MatteKind::Alpha });
let c = p.comp_mut(cid).unwrap();
c.layers.push(matte);
c.layers.push(fill);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(50, 50)[2] > 0.99);
assert!(img.get(150, 50)[3] < 0.01);
}
#[test]
fn shape_layer_draws_fill_and_stroke() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [40.0, 40.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let stroke = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, stroke, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let centre = img.get(100, 50);
assert!(centre[0] > 0.99 && centre[1] < 0.01, "{centre:?}");
let edge = img.get(80, 50);
assert!(edge[1] > 0.5, "stroke on top {edge:?}");
}
#[test]
fn text_layer_renders_glyphs() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 60.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 80.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let covered = img.data.iter().filter(|px| px[3] > 0.5).count();
assert!(covered > 300, "{covered}");
}
#[test]
fn precomp_and_3d_render() {
let (mut p, cid, comp) = setup();
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let s = solid(&mut p, &inner, [1.0, 0.5, 0.0], 100, 100);
p.comp_mut(iid).unwrap().layers.push(s);
let mut pre = build::layer(&mut p, &comp, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.switches.three_d = true;
p.comp_mut(cid).unwrap().layers.push(pre);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
// A 3D layer at z = 0 with the default camera looks exactly like 2D.
let c = img.get(100, 50);
assert!((c[0] - 1.0).abs() < 1e-3 && (c[1] - 0.5).abs() < 1e-3, "{c:?}");
assert!(img.get(10, 50)[3] < 0.01);
}
#[test]
fn effects_run_in_pipeline() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
let spec = effectcraft_effects::find("ec.color.tint").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), "Tint", [50.0, 50.0]);
p.next_id = next;
if let Some(pr) = g.prop_mut("white") {
pr.value = Value::Color([0.0, 1.0, 0.0, 1.0]);
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let c = img.get(100, 50);
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
+276
View File
@@ -0,0 +1,276 @@
//! Text layers: Source Text layout + text animators (range selectors) → per-character outlines.
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::Image;
use effectcraft_text::{TextLayout, layout_doc};
use crate::eval::EvalCtx;
/// Evaluated per-character animation state.
#[derive(Clone, Copy, Debug)]
pub struct CharXf {
pub offset: [f64; 2],
pub anchor: [f64; 2],
pub scale: [f64; 2],
pub rotation: f64,
pub skew: f64,
pub opacity: f64,
pub fill: Option<[f32; 4]>,
pub fill_k: f32,
pub stroke: Option<[f32; 4]>,
pub stroke_k: f32,
pub stroke_width: f64,
pub tracking: f64,
}
impl Default for CharXf {
fn default() -> Self {
CharXf { offset: [0.0; 2], anchor: [0.0; 2], scale: [100.0; 2], rotation: 0.0, skew: 0.0, opacity: 100.0, fill: None, fill_k: 0.0, stroke: None, stroke_k: 0.0, stroke_width: 0.0, tracking: 0.0 }
}
}
fn shape_value(shape: u32, f: f64) -> f64 {
let f = f.clamp(0.0, 1.0);
match shape {
1 => f, // ramp up
2 => 1.0 - f, // ramp down
3 => 1.0 - (2.0 * f - 1.0).abs(), // triangle
4 => (1.0 - (2.0 * f - 1.0).powi(2)).sqrt(), // round
5 => {
let t = 1.0 - (2.0 * f - 1.0).abs();
t * t * (3.0 - 2.0 * t)
} // smooth
_ => 1.0,
}
}
/// Selection amount (0..1) of unit `i` of `n` for a range selector.
fn range_amount(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, i: usize, n: usize) -> f64 {
let adv = sel.sub("advanced");
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
let shape = adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0);
let amount = adv.map(|a| ctx.f(layer, a, "amount", 100.0)).unwrap_or(100.0) / 100.0;
let n = n.max(1) as f64;
let (mut s, mut e, o) = if units_index {
(ctx.f(layer, sel, "start", 0.0) / n, ctx.f(layer, sel, "end", n) / n, ctx.f(layer, sel, "offset", 0.0) / n)
} else {
(ctx.f(layer, sel, "start", 0.0) / 100.0, ctx.f(layer, sel, "end", 100.0) / 100.0, ctx.f(layer, sel, "offset", 0.0) / 100.0)
};
if s > e {
std::mem::swap(&mut s, &mut e);
}
s += o;
e += o;
let (a, b) = (i as f64 / n, (i as f64 + 1.0) / n);
let v = if shape == 0 {
// Square: the fraction of this unit covered by the range (smooth partial selection).
((b.min(e) - a.max(s)) / (b - a)).clamp(0.0, 1.0)
} else {
if e - s <= 1e-9 {
return 0.0;
}
let c = (a + b) * 0.5;
if c < s || c > e { 0.0 } else { shape_value(shape, (c - s) / (e - s)) }
};
v * amount
}
/// Evaluate animators for every glyph.
pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &TextLayout) -> Vec<CharXf> {
let mut out = vec![CharXf::default(); lay.glyphs.len()];
let Some(anims) = text.sub("animators") else { return out };
for anim in anims.groups().filter(|g| g.enabled) {
let Some(props) = anim.sub("properties") else { continue };
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p));
let pos = get("position").map(|v| v.as_vec2());
let anchor = get("anchor").map(|v| v.as_vec2());
let scale = get("scale").map(|v| v.as_vec2());
let rot = get("rotation").map(|v| v.as_f64());
let skew = get("skew").map(|v| v.as_f64());
let op = get("opacity").map(|v| v.as_f64());
let fill = get("fillColor").map(|v| v.as_color());
let stroke = get("strokeColor").map(|v| v.as_color());
let sw = get("strokeWidth").map(|v| v.as_f64());
let tracking = get("tracking").map(|v| v.as_f64());
for (gi, g) in lay.glyphs.iter().enumerate() {
let mut k = 1.0;
for sel in &sels {
let based = sel.sub("advanced").map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0);
let (i, n) = match based {
1 => {
if g.is_space {
k = 0.0;
continue;
}
(g.char_index_no_space, lay.chars_no_space)
}
2 => (g.word_index, lay.words),
3 => (g.line_index, lay.lines),
_ => (g.char_index, lay.chars),
};
k *= range_amount(ctx, layer, sel, i, n);
}
if k <= 0.0 {
continue;
}
let c = &mut out[gi];
if let Some(p) = pos {
c.offset[0] += p[0] * k;
c.offset[1] += p[1] * k;
}
if let Some(a) = anchor {
c.anchor[0] += a[0] * k;
c.anchor[1] += a[1] * k;
}
if let Some(s) = scale {
c.scale[0] *= 1.0 + (s[0] / 100.0 - 1.0) * k;
c.scale[1] *= 1.0 + (s[1] / 100.0 - 1.0) * k;
}
if let Some(r) = rot {
c.rotation += r * k;
}
if let Some(s) = skew {
c.skew += s * k;
}
if let Some(o) = op {
c.opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(f) = fill {
c.fill = Some(f);
c.fill_k = (c.fill_k + k as f32).min(1.0);
}
if let Some(f) = stroke {
c.stroke = Some(f);
c.stroke_k = (c.stroke_k + k as f32).min(1.0);
}
if let Some(w) = sw {
c.stroke_width += w * k;
}
if let Some(t) = tracking {
c.tracking += t * k;
}
}
}
out
}
/// The Source Text value at the current time.
pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option<TextDoc> {
let text = layer.props.sub("text")?;
match ctx.group_value(layer, text, "sourceText")? {
Value::Text(t) => Some(*t),
Value::Str(s) => Some(TextDoc { text: s, ..Default::default() }),
_ => None,
}
}
/// Outlines of all glyphs in layer space with their paint (for text → shapes and hit testing).
pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
let Some(doc) = source_text(ctx, layer) else { return vec![] };
let Some(text) = layer.props.sub("text") else { return vec![] };
let lay = layout_doc(&doc);
let xfs = char_transforms(ctx, layer, text, &lay);
let mut track = 0.0;
let mut out = Vec::with_capacity(lay.glyphs.len());
let mut last_line = usize::MAX;
for (g, x) in lay.glyphs.iter().zip(&xfs) {
if g.line_index != last_line {
track = 0.0;
last_line = g.line_index;
}
let pivot = [g.origin.x + g.advance / 2.0 + track, g.origin.y];
track += x.tracking / 1000.0 * doc.size;
if g.is_space || g.path.elements().is_empty() {
continue;
}
let m = Mat3::translate(vec2(pivot[0] + x.offset[0], pivot[1] + x.offset[1]))
* Mat3::rotate_deg(x.rotation)
* Mat3::skew_deg(-x.skew, 0.0)
* Mat3::scale(vec2(x.scale[0] / 100.0, x.scale[1] / 100.0))
* Mat3::translate(vec2(-g.advance / 2.0 - x.anchor[0], -x.anchor[1]));
out.push((effectcraft_path::transform(std::slice::from_ref(&g.path), &m).remove(0), *x));
}
out
}
fn lerp4(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
[a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k, a[3] + (b[3] - a[3]) * k]
}
/// Render a text layer into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
let Some(doc) = source_text(ctx, layer) else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } };
let glyphs = glyph_paths(ctx, layer);
let stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 };
let mut bounds: Option<kurbo::Rect> = None;
for (p, x) in &glyphs {
if let Some(b) = effectcraft_path::bounds(std::slice::from_ref(p)) {
let b = b.inflate(stroke_w + x.stroke_width + 2.0, stroke_w + x.stroke_width + 2.0);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } };
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
for (p, x) in &glyphs {
let op = (x.opacity / 100.0).clamp(0.0, 1.0) as f32;
if op <= 0.0 {
continue;
}
let Some(gb) = effectcraft_path::bounds(std::slice::from_ref(p)) else { continue };
let sw = stroke_w + x.stroke_width;
let gb = gb.inflate(sw + 1.0, sw + 1.0);
let x0 = ((gb.x0 * s + offset[0]).floor() as i64).max(0);
let y0 = ((gb.y0 * s + offset[1]).floor() as i64).max(0);
let x1 = ((gb.x1 * s + offset[0]).ceil() as i64).min(w as i64);
let y1 = ((gb.y1 * s + offset[1]).ceil() as i64).min(h as i64);
if x1 <= x0 || y1 <= y0 {
continue;
}
let (gw, gh) = ((x1 - x0) as u32, (y1 - y0) as u32);
let m = Mat3::translate(vec2(-x0 as f64, -y0 as f64)) * base;
let fill_col = x.fill.map(|f| lerp4(doc.fill, f, x.fill_k)).unwrap_or(doc.fill);
let stroke_col = x.stroke.map(|f| lerp4(doc.stroke, f, x.stroke_k)).unwrap_or(doc.stroke);
let mut layers: Vec<(effectcraft_raster::Mask, [f32; 4])> = Vec::new();
let fill_cov = doc.apply_fill.then(|| (effectcraft_path::fill_coverage(std::slice::from_ref(p), &m, gw, gh, FillRule::NonZero), fill_col));
let stroke_cov = (sw > 0.0).then(|| {
let st = StrokeStyle { width: sw, join: effectcraft_path::Join::Round, ..Default::default() };
(effectcraft_path::stroke_coverage(std::slice::from_ref(p), &st, &m, gw, gh), stroke_col)
});
if doc.stroke_over_fill {
layers.extend(fill_cov);
layers.extend(stroke_cov);
} else {
layers.extend(stroke_cov);
layers.extend(fill_cov);
}
for (cov, col) in layers {
for yy in 0..gh as usize {
let row = (y0 as usize + yy) * w as usize + x0 as usize;
for xx in 0..gw as usize {
let c = cov.data[yy * gw as usize + xx];
if c <= 0.0 {
continue;
}
let a = c * op * col[3];
let px = &mut img.data[row + xx];
let k = 1.0 - a;
px[0] = col[0] * a + px[0] * k;
px[1] = col[1] * a + px[1] * k;
px[2] = col[2] * a + px[2] * k;
px[3] = a + px[3] * k;
}
}
}
}
Buf { img, offset, scale: s }
}