Files
effectcraft/crates/effects/src/generate.rs
T
Claude 80b9aacea7 G1: Turbulent Displace Evolution changes the output (#943)
Turbulent Displace read its noise through value_noise, whose quintic fade also
eased the evolution axis. Near every whole revolution the pattern hardly moved:
Evolution 0 and 37 degrees (or 360 and 400) differed by well under a hundredth of
the frame, so different Evolution values looked identical, and the motion sped up
and slowed down within each revolution (the #1816 symptom).

Add value_noise_linear_z (x and y keep the fade, z is linear) on the CPU and in the
WGSL kernels and use it for Turbulent Displace on both paths. value_noise is
unchanged, so the other effects and the pinned simulation goldens are unaffected.

Tests: CPU test that non-equivalent Evolution values (fractional, past 360, past
720) all differ and that Cycle Evolution still matches 0/360; unit test of the
linear z axis; GPU-vs-CPU cases at small and large Evolution.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYXhNN7W7nB6sKjwwU7CDM
2026-10-27 15:34:34 -05:00

615 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generate and Noise effects that synthesise images.
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Px;
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
/// Checkerboard / Grid "Size From" options.
pub(crate) const SIZE_FROM: [&str; 3] = ["Corner Point", "Width Slider", "Width & Height Sliders"];
fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category, params, render, gpu: false, float: true }
}
/// Blend a generated straight colour `g` (with alpha `ga`) over the original pixel.
fn put(px: &mut Px, g: [f32; 3], ga: f32, blend_orig: f32) {
let g2 = [g[0] * ga, g[1] * ga, g[2] * ga, ga];
let k = 1.0 - blend_orig;
for c in 0..4 {
px[c] = px[c] * blend_orig + g2[c] * k;
}
}
fn gradient_ramp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = b.to_px(ctx.params.v2("start"));
let e = b.to_px(ctx.params.v2("end"));
let c0 = ctx.params.color("startColor");
let c1 = ctx.params.color("endColor");
let radial = ctx.params.e("shape") == 1;
let blend = ctx.params.f("blend") as f32 / 100.0;
let scatter = ctx.params.f("scatter") as f32 / 512.0;
let (dx, dy) = (e.0 - s.0, e.1 - s.1);
let len2 = (dx * dx + dy * dy).max(1e-9);
let seed = ctx.seed;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (vx, vy) = (x as f64 + 0.5 - s.0, y as f64 + 0.5 - s.1);
let mut t = if radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 } as f32;
if scatter > 0.0 {
t += (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * scatter;
}
let t = t.clamp(0.0, 1.0);
let c = [c0[0] + (c1[0] - c0[0]) * t, c0[1] + (c1[1] - c0[1]) * t, c0[2] + (c1[2] - c0[2]) * t];
// The ramp fills only where the layer has alpha (#1063), so it keeps the source alpha.
let a = px[3];
put(px, c, a, blend);
}
});
b
}
/// Blending Mode options of the shape generators (Checkerboard, Grid, Circle): "None" shows the
/// generated pattern only; the others composite it onto the layer like layer blending modes.
pub(crate) const GEN_BLEND_MODES: [&str; 20] = [
"None",
"Normal",
"Add",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Color Dodge",
"Color Burn",
"Darken",
"Lighten",
"Difference",
"Exclusion",
"Hue",
"Saturation",
"Color",
"Luminosity",
"Stencil Alpha",
"Silhouette Alpha",
];
pub fn gen_mode(i: u32) -> Option<BlendMode> {
Some(match i {
0 => return None,
2 => BlendMode::Add,
3 => BlendMode::Multiply,
4 => BlendMode::Screen,
5 => BlendMode::Overlay,
6 => BlendMode::SoftLight,
7 => BlendMode::HardLight,
8 => BlendMode::ColorDodge,
9 => BlendMode::ColorBurn,
10 => BlendMode::Darken,
11 => BlendMode::Lighten,
12 => BlendMode::Difference,
13 => BlendMode::Exclusion,
14 => BlendMode::Hue,
15 => BlendMode::Saturation,
16 => BlendMode::Color,
17 => BlendMode::Luminosity,
18 => BlendMode::StencilAlpha,
19 => BlendMode::SilhouetteAlpha,
_ => BlendMode::Normal,
})
}
/// Composite a generated premultiplied pixel `g` onto the original `o` with blending mode
/// index `mode` (into [`GEN_BLEND_MODES`]).
#[inline]
pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px {
match gen_mode(mode) {
None => g,
Some(m) => blend_pixel(m, o, g, 0.5),
}
}
/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point
/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100
/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error
/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0.
pub fn four_color_softening(blend: f64, scale: f64) -> f64 {
let b = blend.max(0.0) * scale;
(b * b).max(1e-6)
}
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pts: Vec<((f64, f64), [f32; 4])> =
(1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect();
let k = four_color_softening(ctx.params.f("blend"), b.scale);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
let seed = ctx.seed;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let mut acc = [0.0f64; 3];
let mut wsum = 0.0;
for ((qx, qy), c) in &pts {
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
let w = 1.0 / (d2 + k);
for i in 0..3 {
acc[i] += c[i] as f64 * w;
}
wsum += w;
}
let mut c = acc.map(|v| (v / wsum) as f32);
if jitter > 0.0 {
// Dither against banding: a few code values of noise.
let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0);
c = c.map(|v| v + n);
}
// None keeps the layer's alpha (#1381), as the Gradient Ramp does: the gradient fills
// a shape or text, not its bounding box.
let a = if gen_mode(mode).is_none() { op * px[3] } else { op };
*px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode);
}
});
b
}
/// Cell size of Checkerboard / Grid from Size From (Corner Point, Width Slider, Width &
/// Height Sliders), in pixels.
fn cell_size(ctx: &EffectCtx, b: &Buf, anchor: (f64, f64)) -> (f64, f64) {
let (w, h) = match ctx.params.e("sizeFrom") {
0 => {
let c = b.to_px(ctx.params.v2("corner"));
((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs())
}
1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale),
_ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale),
};
(w.max(1.0), h.max(1.0))
}
fn checkerboard(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
// Feather softens the transitions; 0 keeps a half-pixel antialiased edge.
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
// Signed distance (in feather units) to the nearest cell edge, positive in even cells.
let axis = |p: f64, size: f64, f: f64| -> f64 {
let u = p / size;
let fr = u - u.floor();
let d = fr.min(1.0 - fr) * size;
let s = if (u.floor() as i64).rem_euclid(2) == 0 { 1.0 } else { -1.0 };
s * (d / (f * 0.5)).min(1.0)
};
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let sx = axis(x as f64 + 0.5 - anchor.0, w, fw);
let sy = axis(y as f64 + 0.5 - anchor.1, h, fh);
let cov = ((0.5 + 0.5 * sx * sy) as f32).clamp(0.0, 1.0) * op;
*px = gen_blend(*px, [c[0] * cov, c[1] * cov, c[2] * cov, cov], mode);
}
});
b
}
fn grid(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
let border = (ctx.params.f("border") * b.scale).max(0.0);
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let invert = ctx.params.b("invertGrid");
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let gx = (x as f64 + 0.5 - anchor.0).rem_euclid(w);
let gy = (y as f64 + 0.5 - anchor.1).rem_euclid(h);
let (dx, dy) = (gx.min(w - gx), gy.min(h - gy));
let cx = ((border / 2.0 - dx) / fw + 0.5).clamp(0.0, 1.0);
let cy = ((border / 2.0 - dy) / fh + 0.5).clamp(0.0, 1.0);
let mut cov = if border <= 0.0 { 0.0 } else { cx.max(cy) as f32 };
if invert {
cov = 1.0 - cov;
}
let a = cov * op;
*px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode);
}
});
b
}
/// Circle Edge options.
pub(crate) const CIRCLE_EDGES: [&str; 5] = ["None", "Edge Radius", "Thickness", "Thickness * Radius", "Thickness & Feather * Radius"];
fn circle(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
let r = ctx.params.f("radius") * b.scale;
let thick = ctx.params.f("thickness").max(0.0);
let mut f_out = ctx.params.f("feather/feather").max(0.0) * b.scale;
let mut f_in = ctx.params.f("feather/featherInner").max(0.0) * b.scale;
// Inner radius of the ring (None = solid disk).
let inner = match ctx.params.e("edge") {
1 => Some(ctx.params.f("edgeRadius") * b.scale),
2 => Some(r - thick * b.scale),
3 => Some(r - thick / 100.0 * r),
4 => {
f_out = ctx.params.f("feather/feather").max(0.0) / 100.0 * r;
f_in = ctx.params.f("feather/featherInner").max(0.0) / 100.0 * r;
Some(r - thick / 100.0 * r)
}
_ => None,
};
let (ro, ri) = match inner {
Some(i) => (r.max(i), Some(r.min(i).max(0.0))),
None => (r, None),
};
let (f_out, f_in) = (f_out.max(0.5), f_in.max(0.5));
let color = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
let invert = ctx.params.b("invert");
let mode = ctx.params.e("blendingMode");
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let d = ((x as f64 + 0.5 - c.0).powi(2) + (y as f64 + 0.5 - c.1).powi(2)).sqrt();
let mut cov = ((ro - d) / f_out + 0.5).clamp(0.0, 1.0) as f32;
if let Some(ri) = ri {
cov *= ((d - ri) / f_in + 0.5).clamp(0.0, 1.0) as f32;
}
if invert {
cov = 1.0 - cov;
}
let a = cov * op;
*px = gen_blend(*px, [color[0] * a, color[1] * a, color[2] * a, a], mode);
}
});
b
}
// ---- value noise / fBm (our own lattice noise with quintic fade) ----
#[inline]
pub(crate) fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
let mut h =
(ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
h = h.wrapping_mul(0x297a_2d39);
h ^= h >> 15;
(h & 0xffff) as f32 / 65535.0
}
#[inline]
pub(crate) fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z
/// eases in and out of every integer.
pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
value_noise_z(x, y, z, seed, true)
}
/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic
/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible
/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate.
pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 {
value_noise_z(x, y, z, seed, false)
}
fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 {
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { z - z0 });
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed);
let a = l(l(c(0, 0, 0), c(1, 0, 0), fx), l(c(0, 1, 0), c(1, 1, 0), fx), fy);
let b = l(l(c(0, 0, 1), c(1, 0, 1), fx), l(c(0, 1, 1), c(1, 1, 1), fx), fy);
l(a, b, fz)
}
// Fractal Noise lives in `noise3` (with Turbulent Noise).
pub fn specs() -> Vec<EffectSpec> {
let pt = |x, y| Value::Vec2([x, y]);
vec![
spec(
"ec.generate.gradientramp",
"Gradient Ramp",
"Generate",
vec![
p("start", "Start of Ramp", pt(0.5, 0.0), ParamUi::Point),
p("startColor", "Start Color", col(0.0, 0.0, 0.0), ParamUi::Color),
p("end", "End of Ramp", pt(0.5, 1.0), ParamUi::Point),
p("endColor", "End Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("shape", "Ramp Shape", Value::Enum(0), popup(&["Linear Ramp", "Radial Ramp"])),
p("scatter", "Ramp Scatter", num(0.0), slider(0.0, 512.0, 0.0, 512.0, 1)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
gradient_ramp,
),
spec(
"ec.generate.fourcolor",
"4-Color Gradient",
"Generate",
vec![
p("positionsColors/point1", "Point 1", pt(0.1, 0.1), ParamUi::Point),
p("positionsColors/color1", "Color 1", col(1.0, 1.0, 0.0), ParamUi::Color),
p("positionsColors/point2", "Point 2", pt(0.9, 0.1), ParamUi::Point),
p("positionsColors/color2", "Color 2", col(0.0, 1.0, 0.0), ParamUi::Color),
p("positionsColors/point3", "Point 3", pt(0.1, 0.9), ParamUi::Point),
p("positionsColors/color3", "Color 3", col(1.0, 0.0, 1.0), ParamUi::Color),
p("positionsColors/point4", "Point 4", pt(0.9, 0.9), ParamUi::Point),
p("positionsColors/color4", "Color 4", col(0.0, 0.0, 1.0), ParamUi::Color),
p("blend", "Blend", num(100.0), slider(1.0, 1000.0, 1.0, 1000.0, 1)),
p("jitter", "Jitter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES[..18])),
],
four_color,
),
spec(
"ec.generate.checkerboard",
"Checkerboard",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), ParamUi::Point),
p("sizeFrom", "Size From", Value::Enum(1), popup(&SIZE_FROM)),
p("corner", "Corner", pt(0.1, 0.1), ParamUi::Point),
p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("feather/featherWidth", "Width", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("feather/featherHeight", "Height", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES)),
],
checkerboard,
),
spec(
"ec.generate.grid",
"Grid",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), ParamUi::Point),
p("sizeFrom", "Size From", Value::Enum(1), popup(&SIZE_FROM)),
p("corner", "Corner", pt(0.1, 0.1), ParamUi::Point),
p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("border", "Border", num(2.0), slider(0.0, 400.0, 0.0, 40.0, 1)),
p("feather/featherWidth", "Width", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("feather/featherHeight", "Height", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("invertGrid", "Invert Grid", Value::Bool(false), ParamUi::Checkbox),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES)),
],
grid,
),
spec(
"ec.generate.circle",
"Circle",
"Generate",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("radius", "Radius", num(75.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("edge", "Edge", Value::Enum(0), popup(&CIRCLE_EDGES)),
p("edgeRadius", "Edge Radius", num(0.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("thickness", "Thickness", num(10.0), slider(0.0, 4000.0, 0.0, 100.0, 1)),
p("feather/feather", "Feather Outer Edge", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("feather/featherInner", "Feather Inner Edge", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("invert", "Invert Circle", Value::Bool(false), ParamUi::Checkbox),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES)),
],
circle,
),
]
}
#[cfg(test)]
mod generator_tests {
use super::*;
use crate::{EffectEnv, run_fx};
use effectcraft_raster::Image;
fn red() -> Image {
Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0])
}
/// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the
/// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it).
#[test]
fn linear_z_value_noise_moves_at_a_constant_rate() {
for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] {
let a = value_noise_linear_z(x, y, 0.0, seed);
let b = value_noise_linear_z(x, y, 1.0, seed);
for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] {
let want = a + (b - a) * z;
let got = value_noise_linear_z(x, y, z, seed);
assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}");
}
// Same lattice values at whole z, and x / y are untouched.
assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed));
}
// The eased version still has the old shape: at z = 0.25 it lags the linear one.
let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5));
let eased = value_noise(0.3, 0.7, 0.25, 5);
assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3);
}
#[test]
fn gradient_ramp_stays_inside_the_layer_alpha() {
// #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape.
let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = if i % 40 < 10 { 0.0 } else { 1.0 };
*p = p.map(|c| c * a);
}
for blend in [0.0, 50.0] {
let o = run_fx("ec.generate.gradientramp", &[("blend", num(blend))], img.clone(), 0.0, EffectEnv::default());
for (src, out) in img.data.iter().zip(&o.img.data) {
assert!((src[3] - out[3]).abs() < 1e-6, "blend {blend}: alpha {} became {}", src[3], out[3]);
assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "blend {blend}: premultiplied colour above alpha");
}
assert!(o.img.data.iter().any(|p| p[3] > 0.99), "blend {blend}: the ramp is drawn inside the shape");
}
}
/// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 %
/// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10
/// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5).
#[test]
fn four_color_blend_matches_after_effects() {
let pts = [
("positionsColors/point1", [19.2, 10.8]),
("positionsColors/point2", [172.8, 10.8]),
("positionsColors/point3", [19.2, 97.2]),
("positionsColors/point4", [172.8, 97.2]),
];
let cols = [
("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]),
("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]),
("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]),
("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]),
];
let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [
(
10.0,
[
(30, 20, [249.0, 240.0, 15.0]),
(150, 30, [20.0, 208.0, 47.0]),
(60, 90, [213.0, 56.0, 199.0]),
(100, 10, [115.0, 177.0, 78.0]),
(10, 50, [238.0, 145.0, 110.0]),
],
),
(
30.0,
[
(30, 20, [236.0, 214.0, 41.0]),
(150, 30, [30.0, 189.0, 66.0]),
(60, 90, [203.0, 67.0, 188.0]),
(100, 10, [117.0, 173.0, 82.0]),
(10, 50, [233.0, 140.0, 115.0]),
],
),
];
for (blend, samples) in ae {
let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect();
vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v))));
vals.push(("blend", num(blend)));
let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default());
for (x, y, want) in samples {
let p = o.img.get(x, y);
for c in 0..3 {
assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]);
}
}
}
}
#[test]
fn four_color_none_stays_inside_the_layer_alpha() {
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = [0.0, 0.5, 1.0][i % 40 / 14];
*p = p.map(|c| c * a);
}
for op in [100.0, 50.0] {
let o = run_fx("ec.generate.fourcolor", &[("opacity", num(op))], img.clone(), 0.0, EffectEnv::default());
let k = op as f32 / 100.0;
for (src, out) in img.data.iter().zip(&o.img.data) {
assert!((src[3] * k - out[3]).abs() < 1e-6, "opacity {op}: alpha {} became {}", src[3], out[3]);
assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "opacity {op}: premultiplied colour above alpha");
}
assert!(o.img.data.iter().any(|p| p[1] > 0.1), "opacity {op}: the gradient replaces the layer's colour");
}
}
#[test]
fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() {
let set = [("width", num(8.0)), ("height", num(2.0))];
// None (default): the off squares are transparent; Width Slider makes 8×8 squares.
let o = run_fx("ec.generate.checkerboard", &set, red(), 0.0, EffectEnv::default());
assert!(o.img.get(3, 3)[3] > 0.99 && o.img.get(3, 3)[1] > 0.99, "{:?}", o.img.get(3, 3));
assert!(o.img.get(11, 3)[3] < 0.01);
assert!(o.img.get(3, 6)[3] > 0.99, "height ignored with Width Slider");
// Width & Height Sliders: 8×2 cells; Normal keeps the layer under the off squares.
let o = run_fx(
"ec.generate.checkerboard",
&[set[0].clone(), set[1].clone(), ("sizeFrom", Value::Enum(2)), ("blendingMode", Value::Enum(1))],
red(),
0.0,
EffectEnv::default(),
);
assert!(o.img.get(3, 2)[0] > 0.99 && o.img.get(3, 2)[1] < 0.01, "second row of cells is off");
// Corner Point: cell size from the anchor-corner rectangle.
let o = run_fx("ec.generate.checkerboard", &[("sizeFrom", Value::Enum(0)), ("corner", Value::Vec2([4.0, 4.0]))], red(), 0.0, EffectEnv::default());
assert!(o.img.get(1, 1)[3] > 0.99 && o.img.get(5, 1)[3] < 0.01);
}
#[test]
fn grid_invert_and_stencil() {
let set = [("width", num(10.0)), ("border", num(2.0))];
let o = run_fx("ec.generate.grid", &set, red(), 0.0, EffectEnv::default());
assert!(o.img.get(0, 5)[3] > 0.9, "line at the anchor");
assert!(o.img.get(5, 5)[3] < 0.01, "cell interior is empty with None");
let o = run_fx("ec.generate.grid", &[set[0].clone(), set[1].clone(), ("invertGrid", Value::Bool(true))], red(), 0.0, EffectEnv::default());
assert!(o.img.get(5, 5)[3] > 0.99 && o.img.get(0, 5)[3] < 0.1);
// Stencil Alpha: the layer shows only through the grid lines.
let o = run_fx("ec.generate.grid", &[set[0].clone(), set[1].clone(), ("blendingMode", Value::Enum(18))], red(), 0.0, EffectEnv::default());
assert!(o.img.get(0, 5)[0] > 0.9 && o.img.get(5, 5)[3] < 0.01);
}
#[test]
fn circle_edge_makes_a_ring() {
let c = ("center", Value::Vec2([16.0, 16.0]));
let disk = run_fx("ec.generate.circle", &[c.clone(), ("radius", num(10.0))], red(), 0.0, EffectEnv::default());
assert!(disk.img.get(16, 16)[3] > 0.99 && disk.img.get(30, 16)[3] < 0.01);
let ring = run_fx(
"ec.generate.circle",
&[c.clone(), ("radius", num(10.0)), ("edge", Value::Enum(2)), ("thickness", num(3.0))],
red(),
0.0,
EffectEnv::default(),
);
assert!(ring.img.get(16, 16)[3] < 0.01, "hole in the middle");
assert!(ring.img.get(24, 16)[3] > 0.99, "{:?}", ring.img.get(24, 16));
let er =
run_fx("ec.generate.circle", &[c, ("radius", num(10.0)), ("edge", Value::Enum(1)), ("edgeRadius", num(5.0))], red(), 0.0, EffectEnv::default());
assert!(er.img.get(16, 16)[3] < 0.01 && er.img.get(23, 16)[3] > 0.99);
}
#[test]
fn four_color_blending_mode_and_opacity() {
let pts: Vec<(&str, Value)> = (1..=4)
.map(|i| match i {
1 => ("positionsColors/point1", Value::Vec2([0.0, 0.0])),
2 => ("positionsColors/point2", Value::Vec2([32.0, 0.0])),
3 => ("positionsColors/point3", Value::Vec2([0.0, 32.0])),
_ => ("positionsColors/point4", Value::Vec2([32.0, 32.0])),
})
.collect();
let mut half = pts.clone();
half.push(("opacity", num(50.0)));
// None: the gradient alone at half opacity.
let o = run_fx("ec.generate.fourcolor", &half, red(), 0.0, EffectEnv::default());
assert!((o.img.get(16, 16)[3] - 0.5).abs() < 1e-3);
// Normal: over the opaque layer.
half.push(("blendingMode", Value::Enum(1)));
let o = run_fx("ec.generate.fourcolor", &half, red(), 0.0, EffectEnv::default());
assert!((o.img.get(16, 16)[3] - 1.0).abs() < 1e-3);
}
}