Files
effectcraft/crates/effects/src/generate.rs
T
vangel76andClaude Opus 5.5 d7854b84a3 4-Color Gradient: weights 1 / (d² + Blend²), as After Effects
After Effects weighs each of the four points by its inverse squared
distance softened by Blend squared, 1 / (d^2 + Blend^2) in pixels: fitted to
AE 2026 renders of the default gradient at Blend 10, 100 and 300 and case
904 E16 at Blend 50 (power 2.00 at every Blend, softening 0 / 2367 / 9817 /
90127 = about Blend^2; mean error 0.27/255, max 0.9). EffectCraft used
1 / (d^2 / (100 Blend))^1.5, in which Blend cancels out: E16 was 34/255 off
on 99 % of the grid, now 0 grid points off, max 1/255, CPU and GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:16:02 +02:00

582 lines
26 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).
pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(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])
}
#[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);
}
}