mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 13:52:16 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b9e859fe3f
commit
25de452804
3 files changed
+159
-53
No files matched your search
@@ -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]
|
||||
|
||||
+60
-28
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user