mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef74d82902
commit
b9e859fe3f
@@ -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;
|
||||
|
||||
+111
-13
@@ -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<usize> {
|
||||
(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<Mask> {
|
||||
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];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user