mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
Merge pull request #1874 from vangel76/rfx-masks-shapes
Mask feather, Repeater opacity and Trim Paths on rectangles render as in After Effects
This commit is contained in:
@@ -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();
|
||||
|
||||
+61
-5
@@ -130,13 +130,38 @@ pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
|
||||
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 max_rad = size[0].abs().min(size[1].abs()) / 2.0;
|
||||
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 max_rad = w.min(h) / 2.0;
|
||||
// `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness.
|
||||
let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) };
|
||||
if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) }
|
||||
let rad = if roundness.is_finite() && !max_rad.is_nan() { roundness.clamp(0.0, max_rad) } 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 {
|
||||
@@ -369,6 +394,37 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
|
||||
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 rect_with_nan_size_does_not_panic() {
|
||||
let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0);
|
||||
|
||||
@@ -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]
|
||||
|
||||
+239
-19
@@ -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;
|
||||
@@ -60,40 +60,213 @@ 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;
|
||||
/// 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 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;
|
||||
|
||||
/// 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 box radii in grid pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Axis {
|
||||
step: usize,
|
||||
pad: usize,
|
||||
/// Radii of the box passes (Smooth: three; Linear: one, the others 0).
|
||||
radii: [usize; 3],
|
||||
}
|
||||
|
||||
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 };
|
||||
let none = Axis { step: 1, pad: 0, radii: [0; 3] };
|
||||
if f <= 0.0 {
|
||||
return none;
|
||||
}
|
||||
let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad };
|
||||
if linear {
|
||||
// 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);
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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<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);
|
||||
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();
|
||||
// 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
|
||||
// (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 = 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]), 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 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 {
|
||||
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)
|
||||
};
|
||||
cov = Mask::from_alpha(&b);
|
||||
if blurred {
|
||||
// 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);
|
||||
}
|
||||
Some(cov)
|
||||
if grid.is_some() {
|
||||
cov = crop(&cov, gx, gy, w, h);
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
/// The layer's enabled masks at the context time, flattened to polylines in layer space (for
|
||||
@@ -133,7 +306,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];
|
||||
@@ -169,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::<usize>() * 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +396,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
}
|
||||
for i in order {
|
||||
let m = step(i as f64 + offset);
|
||||
let t = if n > 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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -16,6 +16,7 @@ mod frame_blend;
|
||||
mod guides;
|
||||
mod mask_blur;
|
||||
mod mask_expansion;
|
||||
mod mask_feather;
|
||||
mod motion_tile;
|
||||
mod nested;
|
||||
mod paint;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! 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));
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 = 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, 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 box kernel gives {want}");
|
||||
}
|
||||
assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge");
|
||||
// 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));
|
||||
// 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 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() <= 2, "({x}, {y}): {got:?}, the box coverage {c:.4} gives {want:?}");
|
||||
}
|
||||
// 2. After Effects: within 2 levels per channel.
|
||||
for k in 0..3 {
|
||||
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:?}"));
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user