mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 03:57:44 -06:00
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:
co-authored by
Claude Opus 5.5
parent
9f19158a59
commit
808cd490cd
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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: ¶ms, 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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user