mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -06:00
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>
582 lines
26 KiB
Rust
582 lines
26 KiB
Rust
//! 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);
|
||
}
|
||
}
|