From b9e859fe3f6fb7a78070228bbb5d20943004a119 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:06:54 +0200 Subject: [PATCH 1/5] Masks: feather reaches into the layer from a mask outside it, as in After Effects The mask coverage was rasterised into the layer-sized buffer and then blurred, so coverage outside the layer counted as 0: a feathered mask past the layer edge faded at that edge, and one just outside the layer gave nothing (the comment said the buffer grew; nothing grew it). A feathered mask is now rasterised on a grid padded by the blur's whole support (3 sigma + 3 for the Gaussian, the box radius for a linear falloff), blurred there and cropped back to the buffer. Wide feathers (sigma > 32 px) use a grid coarser per axis so the blur spans ~16 grid pixels, which keeps the allocation near the buffer's size (feather 1000 on 1920x1080: a 160x134 grid instead of ~20 Mpx); the grid is capped, feathers are clamped and non-finite ones ignored. Unfeathered masks and masks inside the layer give the same coverage as before. The GPU uses these CPU masks (adjustment footprint, masked sources): no GPU change. Tests: case 398 of the .aep corpus rebuilt in the project model (red solid, adjustment layer with a default Gradient Ramp, a 1920x200 mask below the layer, feather 1000) within 3 levels of the Gaussian and within 14 of After Effects' render; a mask right of a solid, a mask larger than the layer (edges stay opaque), linear falloff, half resolution, and huge or broken feathers. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 2 + crates/render/src/masks.rs | 124 +++++++++++++++-- crates/render/src/tests_mask_feather.rs | 178 ++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 crates/render/src/tests_mask_feather.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..8b9671e43 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2420,6 +2420,8 @@ mod tests_mask_blur; #[cfg(test)] mod tests_mask_expansion; #[cfg(test)] +mod tests_mask_feather; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index bb91ef71d..04a6e63e9 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -60,6 +60,86 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu coverage_at(ctx, layer, g, buf) } +/// The largest feather taken, in buffer pixels (larger and non-finite values are clamped). +const MAX_FEATHER_PX: f64 = 1.0e6; +/// Up to this Gaussian sigma (box radius for a linear falloff: twice it) a feather is blurred at +/// the buffer's resolution. A wider one is blurred on a grid coarser per axis by the factor that +/// leaves it about `COARSE_SIGMA` grid pixels (2x for the box radius): a wide blur is smooth, so +/// the coarse grid loses nothing visible, and the padding stays about 50 grid pixels. +const FULL_RES_SIGMA: f64 = 32.0; +const COARSE_SIGMA: f64 = 16.0; +/// The largest padded grid (pixels); a bigger one (only reachable with a huge buffer) falls back +/// to blurring within the buffer. +const MAX_GRID_PX: usize = 1 << 26; +/// Padding for a variable-width feather, which is rasterised at the buffer's resolution. +const MAX_VARIABLE_PAD: usize = 256; + +/// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per +/// grid pixel, `pad` grid pixels on each side of the buffer, and the blur in grid pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Axis { + step: usize, + pad: usize, + /// Gaussian sigma, or the box radius of a linear falloff. + blur: f64, +} + +impl Axis { + /// `feather` in buffer pixels. The pad covers the blur's whole support, so the coverage of + /// the path outside the buffer reaches into it as on an unbounded plane. + fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis { + let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 }; + if f <= 0.0 { + return Axis { step: 1, pad: 0, blur: 0.0 }; + } + if linear { + // One box pass: a straight ramp `feather` pixels wide across the edge. + let r = (f / 2.0).round().max(1.0); + let step = if full_res_only || r <= 2.0 * FULL_RES_SIGMA { 1 } else { (r / (2.0 * COARSE_SIGMA)).ceil() as usize }; + let rg = (r / step as f64).round().max(1.0); + let pad = rg as usize; + Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: rg } + } else { + let sigma = f / 2.0; + let step = if full_res_only || sigma <= FULL_RES_SIGMA { 1 } else { (sigma / COARSE_SIGMA).ceil() as usize }; + let sg = sigma / step as f64; + // Three box passes of radii summing to at most 1.5 * (sqrt(4 sigma^2 + 1) + 1) <= 3 sigma + 3. + let pad = (3.0 * sg).ceil() as usize + 3; + Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: sg } + } + } + + /// Grid pixels for `n` buffer pixels. + fn len(&self, n: u32) -> Option { + (n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?) + } +} + +/// Resamples a grid coverage (`gw` wide) back to the `w x h` buffer: bilinear between the grid +/// pixel centres (an exact copy when both steps are 1). +fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask { + let (gw, gh) = (grid.width as usize, grid.height as usize); + let mut out = Mask::new(w, h, 0.0); + let at = |x: usize, y: usize| grid.data.get(y.saturating_mul(gw).saturating_add(x)).copied().unwrap_or(0.0); + // Grid coordinate of a buffer pixel centre, as (index, fraction) on that axis. + let map = |i: u32, a: Axis, n: usize| -> (usize, usize, f32) { + let u = (i as f64 + 0.5) / a.step as f64 + a.pad as f64 - 0.5; + let u = u.clamp(0.0, n.saturating_sub(1) as f64); + let i0 = u.floor() as usize; + (i0, (i0 + 1).min(n.saturating_sub(1)), (u - i0 as f64) as f32) + }; + let xs: Vec<(usize, usize, f32)> = (0..w).map(|x| map(x, ax, gw)).collect(); + out.data.par_chunks_mut(w.max(1) as usize).enumerate().for_each(|(y, row)| { + let (y0, y1, fy) = map(y as u32, ay, gh); + for (o, &(x0, x1, fx)) in row.iter_mut().zip(&xs) { + let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx; + let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx; + *o = top + (bottom - top) * fy; + } + }); + out +} + fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None }; let path = effectcraft_path::to_kurbo(&sp); @@ -70,29 +150,45 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, // Variable-width feather (Mask Feather tool points): expansion and the per-point feather // are applied together from the signed distance to the path. let variable = !sp.feather.is_empty(); + let feather = ctx.v2(layer, g, "feather", [0.0; 2]); + // The feather blurs the mask's own edge, also where the path lies outside the layer (After + // Effects: a feathered mask just below a layer still fades into it). So a feathered mask is + // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. + // A variable feather scales its point radii by the mean scale: it stays at full resolution. + let (ax, ay) = (Axis::new(feather[0] * buf.scale, linear, variable), Axis::new(feather[1] * buf.scale, linear, variable)); + let blurred = ax.blur > 0.0 || ay.blur > 0.0; + let grid = match (ax.len(w), ay.len(h)) { + (Some(gw), Some(gh)) if blurred && gw.checked_mul(gh).is_some_and(|n| n <= MAX_GRID_PX) => { + let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) else { return None }; + Some((gw, gh, ax, ay)) + } + _ => None, + }; + // Without the grid: blurred within the buffer at its resolution (the behaviour before the padding). + let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) }; + let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0] * buf.scale), unpadded(feather[1] * buf.scale))); + // Buffer pixels -> grid pixels. + let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64)); + let mg = to_grid * m; let mut cov = if variable { - effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + effectcraft_path::feather::variable_feather_coverage(&sp, &mg, gw, gh, exp, linear) } else if exp.abs() > 0.01 { // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a // ring around an uncovered middle (growing) or islands (shrinking). let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0); - effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &mg, gw, gh, FillRule::NonZero) } else { - effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero) }; - let feather = ctx.v2(layer, g, "feather", [0.0; 2]); - if feather[0] > 0.0 || feather[1] > 0.0 { + if blurred { let img = cov.to_image(); - let b = if linear { - // One box pass: a straight ramp `feather` pixels wide across the edge. - let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 }); - box_blur(&img, r(feather[0]), r(feather[1]), 1, false) - } else { - gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false) - }; + let b = if linear { box_blur(&img, gx.blur as usize, gy.blur as usize, 1, false) } else { gaussian_blur(&img, gx.blur, gy.blur, false) }; cov = Mask::from_alpha(&b); } + if grid.is_some() { + cov = crop(&cov, gx, gy, w, h); + } Some(cov) } @@ -133,7 +229,9 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo if list.is_empty() { return false; } - // Masks may extend past the source: grow the buffer to cover them when feather/expansion pads. + // Masks may extend past the source: the buffer keeps its size (pixels outside the layer stay + // empty), but a feathered mask is blurred on a padded grid, so the feather of an edge outside + // the buffer still reaches into it (`coverage_at`). 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]; diff --git a/crates/render/src/tests_mask_feather.rs b/crates/render/src/tests_mask_feather.rs new file mode 100644 index 000000000..52ad60c61 --- /dev/null +++ b/crates/render/src/tests_mask_feather.rs @@ -0,0 +1,178 @@ +//! A feathered mask blurs its own edge, also where the path lies outside the layer: the feather +//! of a mask partly or wholly outside the layer reaches into it (After Effects), instead of the +//! coverage stopping at the layer edge. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, FeatherFalloff, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::render_frame; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> 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) +} + +fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], linear: bool) { + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", rect, MaskMode::Add, [255, 255, 0]); + p.next_id = next; + m.get_mut("feather").unwrap().value = Value::Vec2(feather); + if linear && let GroupKind::Mask { feather_falloff, .. } = &mut m.kind { + *feather_falloff = FeatherFalloff::Linear; + } + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); +} + +/// The standard normal distribution function (Abramowitz and Stegun 7.1.26, error < 2e-7). +fn phi(x: f64) -> f64 { + let z = x.abs() / std::f64::consts::SQRT_2; + let t = 1.0 / (1.0 + 0.327_591_1 * z); + let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); + let erf = 1.0 - poly * (-z * z).exp(); + if x >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +} + +/// Coverage at pixel centre `x` of the span `a..b` blurred by a Gaussian of `sigma` (one axis). +fn span(x: f64, a: f64, b: f64, sigma: f64) -> f64 { + phi((b - x) / sigma) - phi((a - x) / sigma) +} + +/// A white 200×100 solid (32 bpc) with one Add mask. +fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) -> effectcraft_raster::Image { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0; 3], 200, 100); + add_mask(&mut p, &mut l, rect, feather, linear); + p.comp_mut(cid).unwrap().layers.push(l); + render_frame(&p, cid, Tick::ZERO, scale) +} + +#[test] +fn a_feathered_mask_outside_a_solid_reaches_into_it() { + // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = Gaussian sigma 20. + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0); + for x in [150i64, 180, 199] { + let want = span(x as f64 + 0.5, 210.0, 260.0, 20.0) * span(50.5, -50.0, 150.0, 20.0); + let got = img.get(x, 50)[3] as f64; + assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the Gaussian gives {want}"); + } + assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge"); + assert!(img.get(100, 50)[3] < 1e-3); + // Without feather the mask hides the layer. + let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0); + assert!(hard.data.iter().all(|p| p[3] == 0.0)); + // A linear falloff ramps over the feather width: 20 px either side of the edge at x 210. + let lin = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], true, 1.0); + let a = lin.get(199, 50)[3]; + assert!((a - 0.24).abs() < 0.04, "linear: {a}"); + assert!(lin.get(185, 50)[3] < 0.01); + // At half resolution the same layer pixel gets the same coverage. + let half = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 0.5); + assert!((half.get(99, 25)[3] - img.get(198, 50)[3]).abs() < 0.02, "{} {}", half.get(99, 25)[3], img.get(198, 50)[3]); +} + +#[test] +fn a_feathered_mask_larger_than_the_layer_keeps_the_layer_edges() { + // The mask covers the layer and 100 px around it: the layer edges are inside the mask and + // stay opaque (they used to fade, as if the layer edge were the mask edge). + let img = white_with_mask(ShapePath::rect([100.0, 50.0], 400.0, 300.0), [40.0, 40.0], false, 1.0); + for (x, y) in [(0i64, 0i64), (0, 50), (199, 99), (100, 0)] { + assert!(img.get(x, y)[3] > 0.99, "({x}, {y}): {}", img.get(x, y)[3]); + } + // A mask edge inside the layer is feathered as before: half coverage on the edge. + let inner = white_with_mask(ShapePath::rect([100.0, 50.0], 100.0, 400.0), [40.0, 40.0], false, 1.0); + assert!((inner.get(50, 50)[3] - 0.5).abs() < 0.03, "{}", inner.get(50, 50)[3]); + assert!(inner.get(100, 50)[3] > 0.98); +} + +#[test] +fn huge_and_broken_feathers_stay_bounded() { + for f in [1.0e5, 1.0e9, f64::INFINITY, f64::NAN, -5.0] { + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, f], false, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "feather {f}"); + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, 10.0], true, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "linear feather {f}"); + } +} + +/// Case 398 of the .aep corpus, built in EffectCraft's own model: a 1920×1080 comp at 8 bpc, a +/// red solid, above it an adjustment layer of comp size with a Gradient Ramp at its defaults and +/// one Add mask entirely below the layer (1920×200 centred at [960, 1250]) with feather 1000. +fn case_398(with_mask: bool) -> (Project, ItemId) { + let mut p = Project::default(); + assert_eq!(p.settings.bit_depth, BitDepth::Bpc8, "After Effects' default depth"); + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(5.0)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + let mut adj = solid(&mut p, &comp, [1.0; 3], 1920, 1080); + adj.switches.adjustment = true; + let spec = effectcraft_effects::find("ec.generate.gradientramp").unwrap(); + let mut next = p.next_id; + let ramp = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + adj.props.sub_mut("effects").unwrap().children.push(ramp.into()); + if with_mask { + add_mask(&mut p, &mut adj, ShapePath::rect([960.0, 1250.0], 1920.0, 200.0), [1000.0, 1000.0], false); + } + let c = p.comp_mut(cid).unwrap(); + c.layers.push(adj); + c.layers.push(red); + (p, cid) +} + +fn rgb8(img: &effectcraft_raster::Image, x: i64, y: i64) -> [i32; 3] { + let px = img.get(x, y); + [0, 1, 2].map(|c| (px[c].clamp(0.0, 1.0) * 255.0).round() as i32) +} + +#[test] +fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = case_398(true); + let img = render_frame(&p, cid, t, 1.0); + // The ramp alone (no mask): what the adjustment layer mixes in. + let (q, qid) = case_398(false); + let ramp = render_frame(&q, qid, t, 1.0); + // After Effects 2026, 8 bpc, the render of 398 at 1 s. + let ae = [ + ((960, 0), [255, 0, 0]), + ((960, 300), [254, 0, 0]), + ((960, 540), [250, 5, 5]), + ((960, 800), [248, 20, 20]), + ((960, 1000), [252, 40, 40]), + ((960, 1079), [255, 47, 47]), + ((100, 1079), [255, 29, 29]), + ]; + let mut report = Vec::new(); + for ((x, y), want_ae) in ae { + let got = rgb8(&img, x, y); + // 1. The fix: the coverage is the mask's Gaussian (sigma = feather / 2 = 500), blurred from + // the mask edge outside the layer; the result is the red mixed toward the ramp by it. + let c = span(x as f64 + 0.5, 0.0, 1920.0, 500.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 500.0); + let r = ramp.get(x, y); + let want = [0, 1, 2].map(|k| { + let red = if k == 0 { 1.0 } else { 0.0 }; + ((red + (r[k] as f64 - red) * c) * 255.0).round() as i32 + }); + for k in 0..3 { + assert!((got[k] - want[k]).abs() <= 3, "({x}, {y}): {got:?}, the Gaussian coverage {c:.4} gives {want:?}"); + } + // 2. After Effects: the same shape of falloff, the feather reaching ~500 px into the layer. + // Its kernel is narrower than sigma = feather / 2 (the AE values fit sigma ~ 350), so + // near the mask EffectCraft is lower and far from it higher: 12 levels apart at most + // (green at the bottom edge), allowed 14. + for k in 0..3 { + assert!((got[k] - want_ae[k]).abs() <= 14, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); + } + report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}")); + } + println!("398: {}", report.join("; ")); + // The mask covers nothing of the layer itself, but its feather does: the top stays (nearly) red. + assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0); + assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge"); +} From 25de4528042643380577e01ad532561139310d9f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 07:08:53 +0200 Subject: [PATCH 2/5] Masks: feather falloff as After Effects The Smooth feather is three separable box passes of 2 round(0.37 F) + 1 pixels (a quadratic B-spline reaching ~1.11 F either side), fitted to After Effects 2026 renders of feather 100, 300 and 1000 (rows, columns, corner diagonal, case 398): max error under 1/255, where a Gaussian of any width misses by 3/255 or more (it was sigma = F / 2). The coarse grid for wide feathers picks the step whose three windows add up closest to the full-resolution ones. Linear is unchanged (unmeasured). Co-Authored-By: Claude Opus 5.5 --- crates/project/src/props.rs | 3 +- crates/render/src/masks.rs | 88 +++++++++++------ crates/render/src/tests_mask_feather.rs | 121 +++++++++++++++++++----- 3 files changed, 159 insertions(+), 53 deletions(-) diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 6212a4b59..be2468c37 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -205,7 +205,8 @@ pub enum MaskMotionBlur { Off, } -/// How a mask's feather ramps: Smooth (Gaussian) or Linear. +/// How a mask's feather ramps: Smooth (three box passes, an S-shaped ramp of finite reach, as +/// measured in After Effects) or Linear (one box pass: a straight ramp). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FeatherFalloff { #[default] diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 04a6e63e9..f0e0eeee0 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -5,7 +5,7 @@ use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; -use effectcraft_raster::{Mask, box_blur, gaussian_blur}; +use effectcraft_raster::{Mask, box_blur}; use rayon::prelude::*; use crate::eval::EvalCtx; @@ -62,12 +62,20 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu /// The largest feather taken, in buffer pixels (larger and non-finite values are clamped). const MAX_FEATHER_PX: f64 = 1.0e6; -/// Up to this Gaussian sigma (box radius for a linear falloff: twice it) a feather is blurred at -/// the buffer's resolution. A wider one is blurred on a grid coarser per axis by the factor that -/// leaves it about `COARSE_SIGMA` grid pixels (2x for the box radius): a wide blur is smooth, so -/// the coarse grid loses nothing visible, and the padding stays about 50 grid pixels. -const FULL_RES_SIGMA: f64 = 32.0; -const COARSE_SIGMA: f64 = 16.0; +/// Smooth falloff (After Effects' default), measured on After Effects 2026 renders (feather 100, +/// 300 and 1000; red value along rows, columns and the corner diagonal of a feathered rect mask): +/// the edge is blurred separably by three box passes, each `2r + 1` pixels wide with +/// `r = round(SMOOTH_BOX_RADIUS * feather)`. That is a quadratic B-spline reaching about +/// 1.11 x feather either side of the path (finite support; a Gaussian of any width misses the +/// measured profile by 3/255 or more, this kernel by less than 1/255). +const SMOOTH_BOX_RADIUS: f64 = 0.37; +/// Up to this box radius (buffer pixels) a feather is blurred at the buffer's resolution. A wider +/// one is blurred on a grid coarser per axis, about `COARSE_RADIUS` grid pixels per box: a wide +/// blur is smooth, so the coarse grid loses nothing visible, and the padding stays about +/// 3 x `COARSE_RADIUS` grid pixels. (Box passes cost the same at any radius; the coarse grid +/// bounds the padded area, i.e. memory.) +const FULL_RES_RADIUS: usize = 48; +const COARSE_RADIUS: usize = 32; /// The largest padded grid (pixels); a bigger one (only reachable with a huge buffer) falls back /// to blurring within the buffer. const MAX_GRID_PX: usize = 1 << 26; @@ -75,13 +83,13 @@ const MAX_GRID_PX: usize = 1 << 26; const MAX_VARIABLE_PAD: usize = 256; /// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per -/// grid pixel, `pad` grid pixels on each side of the buffer, and the blur in grid pixels. +/// grid pixel, `pad` grid pixels on each side of the buffer, and the box radii in grid pixels. #[derive(Clone, Copy, Debug, PartialEq)] struct Axis { step: usize, pad: usize, - /// Gaussian sigma, or the box radius of a linear falloff. - blur: f64, + /// Radii of the box passes (Smooth: three; Linear: one, the others 0). + radii: [usize; 3], } impl Axis { @@ -89,24 +97,43 @@ impl Axis { /// the path outside the buffer reaches into it as on an unbounded plane. fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis { let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 }; + let none = Axis { step: 1, pad: 0, radii: [0; 3] }; if f <= 0.0 { - return Axis { step: 1, pad: 0, blur: 0.0 }; + return none; } + let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }; if linear { - // One box pass: a straight ramp `feather` pixels wide across the edge. - let r = (f / 2.0).round().max(1.0); - let step = if full_res_only || r <= 2.0 * FULL_RES_SIGMA { 1 } else { (r / (2.0 * COARSE_SIGMA)).ceil() as usize }; - let rg = (r / step as f64).round().max(1.0); - let pad = rg as usize; - Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: rg } - } else { - let sigma = f / 2.0; - let step = if full_res_only || sigma <= FULL_RES_SIGMA { 1 } else { (sigma / COARSE_SIGMA).ceil() as usize }; - let sg = sigma / step as f64; - // Three box passes of radii summing to at most 1.5 * (sqrt(4 sigma^2 + 1) + 1) <= 3 sigma + 3. - let pad = (3.0 * sg).ceil() as usize + 3; - Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: sg } + // Linear falloff (not measured against After Effects; kept as it was): one box pass, + // a straight ramp `feather` pixels wide across the edge. + let r = (f / 2.0).round().max(1.0) as usize; + let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) }; + let rg = ((r as f64 / step as f64).round() as usize).max(1); + return Axis { step, pad: cap(rg), radii: [rg, 0, 0] }; } + let r = (SMOOTH_BOX_RADIUS * f).round() as usize; + if r == 0 { + return none; + } + if full_res_only || r <= FULL_RES_RADIUS { + return Axis { step: 1, pad: cap(3 * r + 1), radii: [r; 3] }; + } + // On a coarse grid the three windows (2 rg + 1 grid pixels each) should still add up to + // 3 (2r + 1) buffer pixels: of a few steps take the one that comes closest, and spread + // the radii over the passes. + let want = 3 * (2 * r + 1); + let s0 = r.div_ceil(COARSE_RADIUS); + let fit = |s: usize| -> (usize, usize) { + let sum = ((want as f64 / s as f64 - 3.0) / 2.0).round().max(0.0) as usize; + let got = (2 * sum + 3).saturating_mul(s); + (sum, got.abs_diff(want)) + }; + let (step, sum) = (s0..=s0 + (s0 / 2).min(8)).map(|s| (s, fit(s))).min_by_key(|&(s, (_, e))| (e, s)).map_or((s0, fit(s0).0), |(s, (sum, _))| (s, sum)); + let radii = [0, 1, 2].map(|i| sum / 3 + usize::from(i < sum % 3)); + Axis { step, pad: sum + 1, radii } + } + + fn blurs(&self) -> bool { + self.radii.iter().any(|&r| r > 0) } /// Grid pixels for `n` buffer pixels. @@ -156,7 +183,7 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. // A variable feather scales its point radii by the mean scale: it stays at full resolution. let (ax, ay) = (Axis::new(feather[0] * buf.scale, linear, variable), Axis::new(feather[1] * buf.scale, linear, variable)); - let blurred = ax.blur > 0.0 || ay.blur > 0.0; + let blurred = ax.blurs() || ay.blurs(); let grid = match (ax.len(w), ay.len(h)) { (Some(gw), Some(gh)) if blurred && gw.checked_mul(gh).is_some_and(|n| n <= MAX_GRID_PX) => { let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) else { return None }; @@ -182,9 +209,14 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero) }; if blurred { - let img = cov.to_image(); - let b = if linear { box_blur(&img, gx.blur as usize, gy.blur as usize, 1, false) } else { gaussian_blur(&img, gx.blur, gy.blur, false) }; - cov = Mask::from_alpha(&b); + // Separable box passes (the corner of a feathered rect is the product of its edges). + let mut img = cov.to_image(); + for (rx, ry) in gx.radii.into_iter().zip(gy.radii) { + if rx > 0 || ry > 0 { + img = box_blur(&img, rx, ry, 1, false); + } + } + cov = Mask::from_alpha(&img); } if grid.is_some() { cov = crop(&cov, gx, gy, w, h); diff --git a/crates/render/src/tests_mask_feather.rs b/crates/render/src/tests_mask_feather.rs index 52ad60c61..db04b1f9f 100644 --- a/crates/render/src/tests_mask_feather.rs +++ b/crates/render/src/tests_mask_feather.rs @@ -26,18 +26,28 @@ fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); } -/// The standard normal distribution function (Abramowitz and Stegun 7.1.26, error < 2e-7). -fn phi(x: f64) -> f64 { - let z = x.abs() / std::f64::consts::SQRT_2; - let t = 1.0 / (1.0 + 0.327_591_1 * z); - let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); - let erf = 1.0 - poly * (-z * z).exp(); - if x >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +/// Coverage at signed distance `d` inside an edge blurred by three box passes `w` pixels wide +/// (the Smooth falloff: an Irwin-Hall n = 3 distribution function, support +-1.5 w). +fn edge(d: f64, w: f64) -> f64 { + let t = (d / w + 1.5).clamp(0.0, 3.0); + if t < 1.0 { + t * t * t / 6.0 + } else if t < 2.0 { + (-2.0 * t * t * t + 9.0 * t * t - 9.0 * t + 3.0) / 6.0 + } else { + 1.0 - (3.0 - t).powi(3) / 6.0 + } } -/// Coverage at pixel centre `x` of the span `a..b` blurred by a Gaussian of `sigma` (one axis). -fn span(x: f64, a: f64, b: f64, sigma: f64) -> f64 { - phi((b - x) / sigma) - phi((a - x) / sigma) +/// The box window of the Smooth falloff for `feather` pixels: `2 round(0.37 feather) + 1`. +fn window(feather: f64) -> f64 { + 2.0 * (0.37 * feather).round() + 1.0 +} + +/// Coverage at pixel centre `x` of the span `a..b` feathered by `feather` (one axis). +fn span(x: f64, a: f64, b: f64, feather: f64) -> f64 { + let w = window(feather); + edge(x - a, w) + edge(b - x, w) - 1.0 } /// A white 200×100 solid (32 bpc) with one Add mask. @@ -54,15 +64,16 @@ fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) #[test] fn a_feathered_mask_outside_a_solid_reaches_into_it() { - // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = Gaussian sigma 20. + // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = three 31 px boxes. let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0); - for x in [150i64, 180, 199] { - let want = span(x as f64 + 0.5, 210.0, 260.0, 20.0) * span(50.5, -50.0, 150.0, 20.0); + for x in [150i64, 170, 180, 199] { + let want = span(x as f64 + 0.5, 210.0, 260.0, 40.0) * span(50.5, -50.0, 150.0, 40.0); let got = img.get(x, 50)[3] as f64; - assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the Gaussian gives {want}"); + assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the box kernel gives {want}"); } assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge"); - assert!(img.get(100, 50)[3] < 1e-3); + // Finite support: nothing beyond 1.5 windows (46.5 px) from the edge. + assert!(img.get(160, 50)[3] == 0.0 && img.get(100, 50)[3] == 0.0); // Without feather the mask hides the layer. let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0); assert!(hard.data.iter().all(|p| p[3] == 0.0)); @@ -151,23 +162,21 @@ fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { let mut report = Vec::new(); for ((x, y), want_ae) in ae { let got = rgb8(&img, x, y); - // 1. The fix: the coverage is the mask's Gaussian (sigma = feather / 2 = 500), blurred from - // the mask edge outside the layer; the result is the red mixed toward the ramp by it. - let c = span(x as f64 + 0.5, 0.0, 1920.0, 500.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 500.0); + // 1. The coverage is the mask's Smooth falloff (three 741 px boxes for feather 1000), + // blurred from the mask edge outside the layer (on the coarse grid); the result is the + // red mixed toward the ramp by it. + let c = span(x as f64 + 0.5, 0.0, 1920.0, 1000.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 1000.0); let r = ramp.get(x, y); let want = [0, 1, 2].map(|k| { let red = if k == 0 { 1.0 } else { 0.0 }; ((red + (r[k] as f64 - red) * c) * 255.0).round() as i32 }); for k in 0..3 { - assert!((got[k] - want[k]).abs() <= 3, "({x}, {y}): {got:?}, the Gaussian coverage {c:.4} gives {want:?}"); + assert!((got[k] - want[k]).abs() <= 2, "({x}, {y}): {got:?}, the box coverage {c:.4} gives {want:?}"); } - // 2. After Effects: the same shape of falloff, the feather reaching ~500 px into the layer. - // Its kernel is narrower than sigma = feather / 2 (the AE values fit sigma ~ 350), so - // near the mask EffectCraft is lower and far from it higher: 12 levels apart at most - // (green at the bottom edge), allowed 14. + // 2. After Effects: within 2 levels per channel. for k in 0..3 { - assert!((got[k] - want_ae[k]).abs() <= 14, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); + assert!((got[k] - want_ae[k]).abs() <= 2, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); } report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}")); } @@ -176,3 +185,67 @@ fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0); assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge"); } + +/// After Effects 2026 (sRGB, 32 bpc): a red 1920x1080 solid with a 1000x600 Add mask centred at +/// [960, 540] (left edge at x 460), feather `f` on both axes; coverage along the row y 540 from +/// x 160 to 760 in steps of 20 (8-bit values / 255). +const AE_FEATHER_100: [(i64, f64); 11] = + [(360, 0.0), (380, 0.016), (400, 0.059), (420, 0.153), (440, 0.31), (460, 0.506), (480, 0.698), (500, 0.855), (520, 0.945), (540, 0.988), (560, 1.0)]; +const AE_FEATHER_300: [(i64, f64); 31] = [ + (160, 0.0), + (180, 0.004), + (200, 0.008), + (220, 0.012), + (240, 0.024), + (260, 0.035), + (280, 0.055), + (300, 0.078), + (320, 0.11), + (340, 0.149), + (360, 0.192), + (380, 0.247), + (400, 0.306), + (420, 0.369), + (440, 0.435), + (460, 0.502), + (480, 0.569), + (500, 0.635), + (520, 0.698), + (540, 0.757), + (560, 0.808), + (580, 0.851), + (600, 0.89), + (620, 0.922), + (640, 0.945), + (660, 0.965), + (680, 0.976), + (700, 0.988), + (720, 0.992), + (740, 0.996), + (760, 1.0), +]; + +#[test] +fn the_smooth_feather_falloff_matches_after_effects() { + for (f, ae) in [(100.0, &AE_FEATHER_100[..]), (300.0, &AE_FEATHER_300[..])] { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + add_mask(&mut p, &mut l, ShapePath::rect([960.0, 540.0], 1000.0, 600.0), [f, f], false); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let mut worst = 0.0f64; + for &(x, want) in ae { + let got = img.get(x, 540)[3] as f64; + // Tolerance 2/255 on the 8-bit After Effects values. + assert!((got - want).abs() <= 2.0 / 255.0, "feather {f}, x {x}: {got:.4}, After Effects {want}"); + worst = worst.max((got - want).abs()); + } + // The corner is the product of its two edges (a separable blur): 0.25 for both feathers. + let corner = img.get(460, 240)[3] as f64; + assert!((corner - 0.251).abs() <= 2.0 / 255.0, "feather {f}: corner {corner}"); + println!("feather {f}: largest deviation from After Effects {:.2}/255", worst * 255.0); + } +} From 710ffd9f7e921320f899ccbab2edd4f63b36be1b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:15:53 +0200 Subject: [PATCH 3/5] Shapes: Repeater copy opacity steps 1 / Copies toward End Opacity, as After Effects After Effects gives Repeater copy i the opacity Start + (End - Start) * i / Copies, so the last copy stops one step short of End Opacity. EffectCraft reached End Opacity at the last copy (i / (Copies - 1)). Measured in AE 2026 (case 709: 3 copies, End Opacity 40): copies at 100 / 80 / 60 % (EffectCraft 100 / 70 / 40); the overlap of the two copies 92 % (EffectCraft 82 %). Now within 2/255 at the sampled pixels. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/shapes.rs | 5 ++++- crates/render/src/shapes/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..641382440 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -395,7 +395,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 1 { i as f32 / (n - 1) as f32 } else { 0.0 }; + // Copy i's opacity moves 1 / Copies of the way from Start to End Opacity per + // copy, so the last copy stops one step short of End Opacity (After Effects + // 2026, case 709: 3 copies, End Opacity 40 give 100 %, 80 %, 60 %). + let t = if copies > 0.0 { (i as f64 / copies) 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 { diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 1dbf67a7a..17591f310 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -150,6 +150,28 @@ fn operators_below_repeater_act_on_copies() { assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img)); } +/// After Effects 2026 (case 709): a 400 × 200 rectangle with Fill, then a Repeater of 3 copies +/// (Position 150, 60, Rotation 10, Scale 90 / 80, End Opacity 40) gives the copies 100 %, 80 % +/// and 60 % opacity (EffectCraft gave 70 % and 40 %). The points are comp pixels where AE shows +/// only the last copy, only the middle one, and both. +#[test] +fn repeater_copy_opacity_matches_after_effects() { + let (p, cid) = shape_comp_sized(1920, 1080, |ids| { + let mut rep = build::shape_repeater(ids, 3.0, [150.0, 60.0]); + set(&mut rep, "transform/rotation", Value::Scalar(10.0)); + set(&mut rep, "transform/scale", Value::Vec2([90.0, 80.0])); + set(&mut rep, "transform/endOpacity", Value::Scalar(40.0)); + let items = vec![build::shape_rect(ids, [400.0, 200.0], [0.0, 0.0], 0.0), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0]), rep]; + vec![build::shape_group(ids, "Group 1", items)] + }); + let img = frame(&p, cid); + // AE's green over its red layer, 153, 153, 204, 235 and 255 of 255. + for (x, y, want) in [(1360, 700, 0.6), (1300, 620, 0.6), (1220, 560, 0.8), (1180, 630, 0.92), (1100, 600, 1.0)] { + let a = alpha(&img, x, y); + assert!((a - want).abs() < 2.0 / 255.0, "({x}, {y}): {a} vs AE {want}"); + } +} + #[test] fn repeater_then_merge_unites_copies() { let (p, cid) = shape_comp(|ids| { From d57b73d4cbe841f795c160b88d7da96ec5533312 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:25:48 +0200 Subject: [PATCH 4/5] Shape rectangles start at the top-right, as After Effects, so Trim Paths cut the same segment After Effects draws a Rectangle Path clockwise from its top-right corner, or with round corners from the end of the top-right arc on the right edge; Trim Paths measures from there (AE 2026, case 930: 400 x 300 rectangles, Trim 0-25 % and 70 / 30). EffectCraft built the path with kurbo, starting at the top-left corner, so trimmed rectangles drew another segment: case 930 was 86 of 5184 grid points off (max 255), now 5 at stroke edges. Start > End was already drawn swapped, as in AE. Convert to Bezier now gives AE's vertex order (top-right first); the engine test follows. Lottie rectangles share the path and now start where lottie players start them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_stubs.rs | 5 ++- crates/path/src/lib.rs | 64 ++++++++++++++++++++++++++++++-- docs/fidelity/README.md | 10 +++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 84321ee7e..b36ad77be 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() { }; let radius = |v: [f64; 2]| v[0].hypot(v[1]); - // A 120 x 80 rectangle is its four corners, joined by straight edges. + // A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right + // corner clockwise as After Effects draws it. let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]})); - assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]); + assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]); assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}"); // Undo brings the parametric shape back. s.execute("edit.undo", json!({})).unwrap(); diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 249eb9fdd..9445da346 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -130,11 +130,36 @@ pub fn from_kurbo(path: &BezPath) -> Vec { out } -/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right). +/// Rectangle path centred at `pos` with corner `roundness`, drawn as After Effects draws it +/// (Trim Paths measures from its first point): clockwise from the top-right corner, or with +/// round corners from the end of the top-right arc on the right edge (After Effects 2026, +/// 400 × 300 rectangles trimmed 0–25 %, case 930). pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { - let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs())); - let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0); - if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) } + let (w, h) = (size[0].abs(), size[1].abs()); + let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0); + let rad = if roundness.is_finite() { roundness.clamp(0.0, w.min(h) / 2.0) } else { 0.0 }; + let mut p = BezPath::new(); + if rad <= 0.0 { + p.move_to((x1, y0)); + p.line_to((x1, y1)); + p.line_to((x0, y1)); + p.line_to((x0, y0)); + p.close_path(); + return p; + } + // Quarter circles as cubics (handle 0.5523 × radius). + let k = rad * 0.552_284_749_8; + p.move_to((x1, y0 + rad)); + p.line_to((x1, y1 - rad)); + p.curve_to((x1, y1 - rad + k), (x1 - rad + k, y1), (x1 - rad, y1)); + p.line_to((x0 + rad, y1)); + p.curve_to((x0 + rad - k, y1), (x0, y1 - rad + k), (x0, y1 - rad)); + p.line_to((x0, y0 + rad)); + p.curve_to((x0, y0 + rad - k), (x0 + rad - k, y0), (x0 + rad, y0)); + p.line_to((x1 - rad, y0)); + p.curve_to((x1 - rad + k, y0), (x1, y0 + rad - k), (x1, y0 + rad)); + p.close_path(); + p } pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { @@ -354,6 +379,37 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec { mod tests { use super::*; + /// After Effects 2026 (case 930): 400 × 300 rectangles at the origin with Trim Paths. Plain: + /// 0–25 % runs from the top-right corner down the right edge and 50 px along the bottom. + /// Round corners 40: from the end of the top-right arc (200, −110) down, round the + /// bottom-right corner, to x 110 on the bottom. Start 70 / End 30 draws 30–70 %. + #[test] + fn rectangles_start_where_after_effects_starts_them() { + let ends = |p: &BezPath| { + let mut first = None; + let mut last = None; + for el in p.elements() { + match *el { + PathEl::MoveTo(q) if first.is_none() => first = Some(q), + PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) | PathEl::QuadTo(_, q) => last = Some(q), + PathEl::ClosePath => {} + } + } + (first.unwrap_or_default(), last.unwrap_or_default()) + }; + let near = |a: Point, b: (f64, f64)| (a.x - b.0).abs() < 1.0 && (a.y - b.1).abs() < 1.0; + let plain = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&plain[0]); + assert!(near(a, (200.0, -150.0)) && near(b, (150.0, 150.0)), "{a:?} {b:?}"); + let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&round[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}"); + // Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left). + let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0); + let (a, b) = ends(&swapped[0]); + assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}"); + } + #[test] fn roundtrip_vertex_model() { let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..fc780016b 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,16 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Rectangle path start point + +Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim +Paths: a plain rectangle starts at its top-right corner and runs clockwise (0–25 % covers the right +edge and 50 px of the bottom); with Roundness 40 it starts where the top-right arc meets the right +edge. Start 70 / End 30 draws 30–70 %. EffectCraft started at the top-left corner (and the rounded +rectangle elsewhere), so trimmed rectangles drew the wrong segment (86 of 5184 grid points off, +now 5 at stroke edges). `effectcraft_path::rect` now draws AE's order, which also gives Convert to +Bezier AE's vertex order. Regression test: `rectangles_start_where_after_effects_starts_them`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 8b715fc22736a9a2434ebf57dc6813e7861fab6a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:18:58 +0200 Subject: [PATCH 5/5] Masks: coarsen the feather grid instead of dropping its padding on huge layers Above MAX_GRID_PX the padded feather grid fell back to the unpadded buffer, so a feathered Add mask just outside a very large layer (e.g. 8192 x 8192, feather 40) covered nothing and the whole layer vanished (CodeRabbit on #1874). The grid is now coarsened by 2, 4, ... until it fits; radii and padding scale with it, so the feather of an edge outside the layer still fades into it. The doc comment on the Linear falloff no longer says it is unchanged: wide linear ramps now go through the coarse grid. Regression tests: a_grid_too_big_is_coarsened_and_keeps_its_padding, a_coarsened_grid_still_feathers_a_mask_outside_the_buffer. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/masks.rs | 132 +++++++++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 21 deletions(-) diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index f0e0eeee0..e6ba0a6fb 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -76,9 +76,13 @@ const SMOOTH_BOX_RADIUS: f64 = 0.37; /// bounds the padded area, i.e. memory.) const FULL_RES_RADIUS: usize = 48; const COARSE_RADIUS: usize = 32; -/// The largest padded grid (pixels); a bigger one (only reachable with a huge buffer) falls back -/// to blurring within the buffer. +/// The largest padded grid (pixels). A grid that would be bigger (a huge buffer) is coarsened +/// (`grid`) until it fits, so the padding, and with it the feather of a mask edge outside the +/// buffer, is never dropped. const MAX_GRID_PX: usize = 1 << 26; +/// The coarsest factor `grid` tries before giving up on the padding (unreachable for any buffer +/// that fits in memory: 1024 x 1024 buffer pixels per grid pixel). +const MAX_COARSEN: usize = 1024; /// Padding for a variable-width feather, which is rasterised at the buffer's resolution. const MAX_VARIABLE_PAD: usize = 256; @@ -103,8 +107,10 @@ impl Axis { } let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }; if linear { - // Linear falloff (not measured against After Effects; kept as it was): one box pass, - // a straight ramp `feather` pixels wide across the edge. + // Linear falloff (not measured against After Effects): one box pass, a straight ramp + // `feather` pixels wide across the edge, as before. Not quite unchanged: a ramp wider + // than about `4 x FULL_RES_RADIUS` pixels is now blurred on the coarse grid and + // resampled bilinearly (a smooth ramp, so the difference is a rounding of the ramp). let r = (f / 2.0).round().max(1.0) as usize; let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) }; let rg = ((r as f64 / step as f64).round() as usize).max(1); @@ -136,6 +142,22 @@ impl Axis { self.radii.iter().any(|&r| r > 0) } + /// The same blur on a grid `k` times coarser: radii and padding scaled down (a blurring + /// axis keeps at least one grid pixel of radius), the padding still covering the support. + fn coarser(self, k: usize) -> Axis { + if k <= 1 { + return self; + } + let blurs = self.blurs(); + let mut radii = self.radii.map(|r| (r as f64 / k as f64).round() as usize); + if blurs && radii.iter().all(|&r| r == 0) { + radii[0] = 1; + } + let support = radii.iter().fold(0usize, |a, &r| a.saturating_add(r)).saturating_add(1); + let pad = if self.pad == 0 { 0 } else { self.pad.div_ceil(k).max(support) }; + Axis { step: self.step.saturating_mul(k), pad, radii } + } + /// Grid pixels for `n` buffer pixels. fn len(&self, n: u32) -> Option { (n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?) @@ -169,36 +191,59 @@ fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask { fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { 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 exp = ctx.f(layer, g, "expansion", 0.0); let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); + let feather = ctx.v2(layer, g, "feather", [0.0; 2]); + let feather = [feather[0] * buf.scale, feather[1] * buf.scale]; + Some(feathered(&sp, &m, (buf.img.width, buf.img.height), exp, feather, linear, MAX_GRID_PX)) +} + +/// The padded grid for a `w x h` buffer: the axes as given, or coarsened together (by 2, 4, …) +/// until the grid has at most `max_px` pixels. `None` without a blur (or, unreachably, when even +/// the coarsest grid is too big). +fn grid(w: u32, h: u32, ax: Axis, ay: Axis, max_px: usize) -> Option<(u32, u32, Axis, Axis)> { + if !(ax.blurs() || ay.blurs()) { + return None; + } + let mut k = 1usize; + while k <= MAX_COARSEN { + let (cx, cy) = (ax.coarser(k), ay.coarser(k)); + if let (Some(gw), Some(gh)) = (cx.len(w), cy.len(h)) + && gw.checked_mul(gh).is_some_and(|n| n <= max_px) + && let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) + { + return Some((gw, gh, cx, cy)); + } + k = k.saturating_mul(2); + } + None +} + +/// Coverage of the mask path `sp` (layer -> buffer `m`) on a `w x h` buffer, expanded by `exp` +/// and feathered by `feather` buffer pixels per axis, on a padded grid of at most `max_px` pixels. +fn feathered(sp: &effectcraft_keyframe::ShapePath, m: &Mat3, (w, h): (u32, u32), exp: f64, feather: [f64; 2], linear: bool, max_px: usize) -> Mask { + let path = effectcraft_path::to_kurbo(sp); // Variable-width feather (Mask Feather tool points): expansion and the per-point feather // are applied together from the signed distance to the path. let variable = !sp.feather.is_empty(); - let feather = ctx.v2(layer, g, "feather", [0.0; 2]); // The feather blurs the mask's own edge, also where the path lies outside the layer (After // Effects: a feathered mask just below a layer still fades into it). So a feathered mask is // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. - // A variable feather scales its point radii by the mean scale: it stays at full resolution. - let (ax, ay) = (Axis::new(feather[0] * buf.scale, linear, variable), Axis::new(feather[1] * buf.scale, linear, variable)); + // A variable feather scales its point radii by the mean scale: it stays at full resolution + // (unless the grid has to be coarsened to fit `max_px`). + let (ax, ay) = (Axis::new(feather[0], linear, variable), Axis::new(feather[1], linear, variable)); let blurred = ax.blurs() || ay.blurs(); - let grid = match (ax.len(w), ay.len(h)) { - (Some(gw), Some(gh)) if blurred && gw.checked_mul(gh).is_some_and(|n| n <= MAX_GRID_PX) => { - let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) else { return None }; - Some((gw, gh, ax, ay)) - } - _ => None, - }; - // Without the grid: blurred within the buffer at its resolution (the behaviour before the padding). + let grid = grid(w, h, ax, ay, max_px); + // Without the grid (no blur, or, unreachably, a grid too big even at the coarsest step): + // blurred within the buffer at its resolution. let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) }; - let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0] * buf.scale), unpadded(feather[1] * buf.scale))); + let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0]), unpadded(feather[1]))); // Buffer pixels -> grid pixels. let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64)); - let mg = to_grid * m; + let mg = to_grid * *m; let mut cov = if variable { - effectcraft_path::feather::variable_feather_coverage(&sp, &mg, gw, gh, exp, linear) + effectcraft_path::feather::variable_feather_coverage(sp, &mg, gw, gh, exp, linear) } else if exp.abs() > 0.01 { // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a @@ -221,7 +266,7 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, if grid.is_some() { cov = crop(&cov, gx, gy, w, h); } - Some(cov) + cov } /// The layer's enabled masks at the context time, flattened to polylines in layer space (for @@ -299,3 +344,48 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo buf.img.mul_mask(&acc); true } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_keyframe::ShapePath; + + /// CodeRabbit on #1874: an 8192 x 8192 layer with a 40 px Smooth feather needs a padded + /// grid larger than `MAX_GRID_PX`; it used to fall back to the unpadded buffer, so an Add mask + /// just outside the layer covered nothing and the whole layer vanished. The grid is now + /// coarsened instead and keeps a pad that covers the blur. + #[test] + fn a_grid_too_big_is_coarsened_and_keeps_its_padding() { + let a = Axis::new(40.0, false, false); + assert!(a.len(8192).zip(a.len(8192)).is_some_and(|(x, y)| x * y > MAX_GRID_PX), "the full-resolution grid is over the limit"); + let (gw, gh, gx, gy) = grid(8192, 8192, a, a, MAX_GRID_PX).expect("a padded grid"); + assert!((gw as usize) * (gh as usize) <= MAX_GRID_PX); + for g in [gx, gy] { + assert!(g.step > 1 && g.pad > 0 && g.blurs(), "{g:?}"); + // The pad covers the blur's support (the three boxes' radii) in buffer pixels. + assert!(g.pad * g.step >= g.radii.iter().sum::() * g.step, "{g:?}"); + } + // A grid that fits is not touched. + assert_eq!(grid(200, 100, a, a, MAX_GRID_PX).map(|g| g.2), Some(a)); + } + + /// The same failure at a testable size: a 200 x 100 buffer, a 40 px feather and a grid limit + /// just below the full-resolution padded grid. The Add mask lies just right of the buffer + /// (x 210..260); its feather still fades into the layer, close to the full-resolution result. + #[test] + fn a_coarsened_grid_still_feathers_a_mask_outside_the_buffer() { + let sp = ShapePath::rect([235.0, 50.0], 50.0, 200.0); + let m = Mat3::IDENTITY; + let full = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, MAX_GRID_PX); + let a = Axis::new(40.0, false, false); + let limit = a.len(200).zip(a.len(100)).map_or(0, |(x, y)| x * y) - 1; + let coarse = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, limit); + let at = |c: &Mask, x: usize, y: usize| c.data.get(y * 200 + x).copied().unwrap_or(-1.0); + assert!(at(&full, 199, 50) > 0.25, "full resolution: {}", at(&full, 199, 50)); + assert!(at(&coarse, 199, 50) > 0.2, "the soft fade survives the coarse grid: {}", at(&coarse, 199, 50)); + for x in [150, 170, 180, 190, 199] { + assert!((at(&coarse, x, 50) - at(&full, x, 50)).abs() < 0.05, "x {x}: coarse {} full {}", at(&coarse, x, 50), at(&full, x, 50)); + } + assert!(at(&coarse, 100, 50) < 0.01, "far from the mask stays clear"); + } +}