Files
effectcraft/crates/effects/src/misc.rs
T
vangel76andClaude Opus 5.5 f4fbdc6c25 Glow: the glow covers the layer's blurred alpha, blur sigma 0.384 x radius, as After Effects
With Glow Based On Color Channels, After Effects gives the glow the layer's whole
alpha (blurred), not the thresholded part, and Intensity scales only its colour:
a layer below the threshold darkens what lies under its edge. Fitted to AE 2026
renders of a grey solid over red (cases 524, 807-809): the halo is the same at
Intensity 1 and 3 and with Composite Original Behind or On Top, and its edge is
an erfc step of sigma = 0.384 x Glow Radius at radius 10 and 30 (was radius / 2).

524 (Glow defaults on a 50 % grey solid): EffectCraft had no halo (max 116/255
off at the edge); now red 248/234/208/165/140 at 7.5/5.5/3.5/1.5/0.5 px outside
vs AE 249/235/206/164/140, max 2/255 over the frame, CPU and GPU. The blur
width applies to every glow; alpha-based glows keep their coverage rule
(thresholded alpha, scaled by Intensity). Not matched yet: the brightness of glows above the
threshold (AE about 3.7 x brighter for a white solid, cases 806, 810, 811).

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

827 lines
33 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.
//! Blur & Sharpen, Stylize, Perspective, Channel, Noise and Transition effects.
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, gaussian_blur, hash_noise};
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
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 }
}
/// AE's Blurriness is roughly a radius; our Gaussian uses sigma = blurriness / 2.
fn blur_sigma(blurriness: f64) -> f64 {
blurriness.max(0.0) * 0.5
}
fn dims_xy(dim: u32) -> (f64, f64) {
match dim {
1 => (1.0, 0.0),
2 => (0.0, 1.0),
_ => (1.0, 1.0),
}
}
fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = blur_sigma(ctx.params.f("blurriness")) * b.scale;
if s <= 0.0 {
return b;
}
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
}
b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment);
b
}
fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
if r == 0 {
return b;
}
let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize;
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((r * it) as u32 + 1);
}
b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment);
b
}
fn directional(ctx: &EffectCtx, mut b: Buf) -> Buf {
let len = ctx.params.f("length") * b.scale;
if len < 0.5 {
return b;
}
if !ctx.adjustment {
b.pad(len.ceil() as u32 + 1);
}
b.img = effectcraft_raster::directional_blur(&b.img, ctx.params.f("direction"), len);
b
}
fn radial(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("amount");
let zoom = ctx.params.e("type") == 1;
let c = b.to_px(ctx.params.v2("center"));
b.img = effectcraft_raster::radial_blur(&b.img, c, if zoom { amt / 100.0 } else { amt }, zoom);
b
}
fn unsharp_core(img: &Image, amount: f32, sigma: f64, threshold: f32) -> Image {
let blurred = gaussian_blur(img, sigma, sigma, true);
let mut out = img.clone();
out.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, bl)| {
for c in 0..3 {
let d = o[c] - bl[c];
if d.abs() * 255.0 >= threshold {
o[c] = (o[c] + d * amount).max(0.0);
}
}
});
out
}
fn sharpen(ctx: &EffectCtx, mut b: Buf) -> Buf {
let a = ctx.params.f("amount") as f32 / 50.0;
b.img = unsharp_core(&b.img, a, 1.0 * b.scale, 0.0);
b
}
fn unsharp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let a = ctx.params.f("amount") as f32 / 100.0;
b.img = unsharp_core(&b.img, a, (ctx.params.f("radius") * b.scale).max(0.1), ctx.params.f("threshold") as f32);
b
}
/// Position (0 = Color A, 1 = Color B) in Glow's A & B gradient for brightness `l`: `loops`
/// cycles starting at `phase` (revolutions), shaped by Color Looping (0 Sawtooth A>B,
/// 1 Sawtooth B>A, 2 Triangle A>B>A, 3 Triangle B>A>B) and biased so `mid` maps to the middle.
pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 {
let u = l.clamp(0.0, 1.0) * loops.max(0.0) + phase;
let mut t = u - u.floor();
if u > 0.0 && t <= 0.0 {
t = 1.0;
}
let tri = 1.0 - (2.0 * t - 1.0).abs();
let t = match looping {
0 => t,
1 => 1.0 - t,
3 => 1.0 - tri,
_ => tri,
};
let m = mid.clamp(0.01, 0.99);
t.max(0.0).powf(0.5f32.ln() / m.ln())
}
/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases
/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's
/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2).
pub fn glow_sigma(radius: f64) -> f64 {
(radius * 0.384).max(0.5)
}
/// Glow Operation options (blend modes the glow is applied with).
pub const GLOW_OPERATIONS: [&str; 17] = [
"Normal",
"Add",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Color Dodge",
"Color Burn",
"Darken",
"Lighten",
"Difference",
"Exclusion",
"Hue",
"Saturation",
"Color",
"Luminosity",
];
/// Glow's Glow Operation as a blend mode (Add for projects saved before it existed).
pub fn glow_operation(ctx: &EffectCtx) -> effectcraft_color::BlendMode {
let name = GLOW_OPERATIONS.get(ctx.params.get("glowOperation").map(|v| v.as_enum()).unwrap_or(1) as usize).copied().unwrap_or("Add");
effectcraft_color::BlendMode::ALL.iter().copied().find(|m| m.label() == name).unwrap_or(effectcraft_color::BlendMode::Add)
}
/// Glow's Arbitrary Map: red, green and blue curves of the glow brightness, as Curves point
/// lists `x,y …` separated by `|` (an empty or missing curve is the identity).
fn glow_map(s: &str) -> Vec<Option<crate::Curve>> {
let mut parts = s.split('|');
(0..3).map(|_| parts.next().and_then(crate::Curve::parse)).collect()
}
fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
let thr = ctx.params.f("threshold") as f32 / 100.0;
let radius = ctx.params.f("radius") * b.scale;
let intensity = ctx.params.f("intensity") as f32;
let alpha_based = ctx.params.e("based") == 0;
let use_colors = ctx.params.e("colors") == 1;
let arbitrary = (ctx.params.e("colors") == 2).then(|| glow_map(ctx.params.s("arbitraryMap")));
let glow_op = glow_operation(ctx);
let looping = ctx.params.e("colorLooping");
let loops = ctx.params.f("colorLoops") as f32;
let phase = (ctx.params.f("colorPhase") / 360.0) as f32;
let mid = ctx.params.f("abMidpoint") as f32 / 100.0;
let ca = ctx.params.color("colorA");
let cb = ctx.params.color("colorB");
let (kx, ky) = dims_xy(ctx.params.e("glowDimensions"));
if !ctx.adjustment {
b.pad((radius * 1.5).ceil() as u32 + 2);
}
// Bright pass.
let mut bright = b.img.clone();
bright.data.par_iter_mut().for_each(|px| {
let a = px[3];
if a <= 0.0 {
*px = [0.0; 4];
return;
}
// Glow Based On: brightness from the colours, or from the alpha channel.
let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) };
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
// Based on the colour channels, the glow's coverage is the layer's whole alpha, below
// the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha).
let ga = if alpha_based { k * a } else { a };
if let Some(map) = &arbitrary {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::<Vec<f32>>();
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
} else if use_colors {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
} else {
*px = [px[0] * k, px[1] * k, px[2] * k, ga];
}
});
let s = glow_sigma(radius);
let blurred = gaussian_blur(&bright, s * kx, s * ky, false);
let mode = ctx.params.e("operation");
b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| {
// Intensity scales the glow's colour; with the colour channels its coverage stays.
let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }];
match mode {
// Behind: the glow shows only where the original is transparent.
1 => {
let k = 1.0 - o[3];
for c in 0..4 {
o[c] += g[c] * k;
}
}
// None: the glow alone.
2 => *o = [g[0], g[1], g[2], g[3]],
// On Top with another Glow Operation: that blend mode.
_ if glow_op != effectcraft_color::BlendMode::Add => *o = effectcraft_color::blend_pixel(glow_op, *o, g, 0.0),
// On Top: add (screen-like add on premultiplied colour; alpha grows by the glow's alpha).
_ => {
for c in 0..3 {
o[c] += g[c];
}
o[3] = (o[3] + g[3] * (1.0 - o[3])).min(1.0);
}
}
o[3] = o[3].clamp(0.0, 1.0);
});
b
}
fn drop_shadow(ctx: &EffectCtx, mut b: Buf) -> Buf {
let color = ctx.params.color("color");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let dir = ctx.params.f("direction").to_radians();
let dist = ctx.params.f("distance") * b.scale;
let soft = ctx.params.f("softness") * b.scale;
let only = ctx.params.b("shadowOnly");
let pad = (dist + soft * 1.5).ceil() as u32 + 2;
if !ctx.adjustment {
b.pad(pad);
}
let (dx, dy) = (dir.sin() * dist, -dir.cos() * dist);
let mut sh = Image::new(b.img.width, b.img.height);
let src = &b.img;
sh.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = src.sample_bilinear(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy)[3] * opacity * color[3];
*px = [color[0] * a, color[1] * a, color[2] * a, a];
}
});
if soft > 0.0 {
sh = gaussian_blur(&sh, soft / 2.0, soft / 2.0, false);
}
if !only {
sh.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(s, o)| {
let k = 1.0 - o[3];
for c in 0..4 {
s[c] = o[c] + s[c] * k;
}
});
}
b.img = sh;
b
}
/// Invert's Channel options (After Effects order): RGB / HLS / YIQ groups, each followed by its
/// single channels, then Alpha.
pub const INVERT_CHANNELS: [&str; 13] =
["RGB", "Red", "Green", "Blue", "HLS", "Hue", "Lightness", "Saturation", "YIQ", "Luminance", "In Phase Chrominance", "Quadrature Chrominance", "Alpha"];
/// Index of "Alpha" in [`INVERT_CHANNELS`].
pub const INVERT_ALPHA: u32 = 12;
fn rgb_to_yiq(c: [f32; 3]) -> [f32; 3] {
[0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2], 0.596 * c[0] - 0.274 * c[1] - 0.322 * c[2], 0.211 * c[0] - 0.523 * c[1] + 0.312 * c[2]]
}
fn yiq_to_rgb(c: [f32; 3]) -> [f32; 3] {
[c[0] + 0.956 * c[1] + 0.621 * c[2], c[0] - 0.272 * c[1] - 0.647 * c[2], c[0] - 1.106 * c[1] + 1.703 * c[2]]
}
/// Invert straight colour `s` in the colour space / channel `ch` (an [`INVERT_CHANNELS`] index
/// other than Alpha).
fn invert_color(ch: u32, s: [f32; 3]) -> [f32; 3] {
match ch {
1 => [1.0 - s[0], s[1], s[2]],
2 => [s[0], 1.0 - s[1], s[2]],
3 => [s[0], s[1], 1.0 - s[2]],
4..=7 => {
let (mut h, mut sat, mut l) = effectcraft_color::rgb_to_hsl(s[0], s[1], s[2]);
if matches!(ch, 4 | 5) {
h = 1.0 - h;
}
if matches!(ch, 4 | 6) {
l = 1.0 - l;
}
if matches!(ch, 4 | 7) {
sat = 1.0 - sat;
}
let (r, g, b) = effectcraft_color::hsl_to_rgb(h, sat, l);
[r, g, b]
}
8..=11 => {
// Y inverts around 1, the (signed) chrominance axes around 0.
let mut q = rgb_to_yiq(s);
if matches!(ch, 8 | 9) {
q[0] = 1.0 - q[0];
}
if matches!(ch, 8 | 10) {
q[1] = -q[1];
}
if matches!(ch, 8 | 11) {
q[2] = -q[2];
}
yiq_to_rgb(q).map(|v| v.max(0.0))
}
_ => [1.0 - s[0], 1.0 - s[1], 1.0 - s[2]],
}
}
fn invert(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ch = ctx.params.e("channel");
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.data.par_iter_mut().for_each(|px| {
let a = px[3];
if ch == INVERT_ALPHA {
let na = 1.0 - a;
let k = if a > 0.0 { na / a } else { 0.0 };
let inv = [px[0] * k, px[1] * k, px[2] * k, na];
for c in 0..4 {
px[c] += (inv[c] - px[c]) * blend;
}
return;
}
if a <= 0.0 {
return;
}
let s = [px[0] / a, px[1] / a, px[2] / a];
let o = invert_color(ch, s);
for c in 0..3 {
px[c] = (s[c] + (o[c] - s[c]) * blend) * a;
}
});
b
}
fn mosaic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hb = ctx.params.f("horizontal").round().max(1.0) as usize;
let vb = ctx.params.f("vertical").round().max(1.0) as usize;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let cw = (w as f64 / hb as f64).max(1.0);
let ch = (h as f64 / vb as f64).max(1.0);
let src = b.img.clone();
if ctx.params.b("sharpColors") {
// Each tile takes the colour of the pixel at its centre.
b.img.rows_mut().for_each(|(y, row)| {
let cy = ((y as f64 / ch).floor() + 0.5) * ch;
for (x, px) in row.iter_mut().enumerate() {
let cx = ((x as f64 / cw).floor() + 0.5) * cw;
*px = src.get_clamped(cx as i64, cy as i64);
}
});
return b;
}
// Each tile takes the average colour of its region.
let (nx, ny) = ((w as f64 / cw).ceil() as usize, (h as f64 / ch).ceil() as usize);
let mut sums = vec![[0.0f64; 5]; nx * ny];
for y in 0..h {
let ty = ((y as f64 / ch) as usize).min(ny - 1);
for x in 0..w {
let tx = ((x as f64 / cw) as usize).min(nx - 1);
let p = src.get(x as i64, y as i64);
let s = &mut sums[ty * nx + tx];
for c in 0..4 {
s[c] += p[c] as f64;
}
s[4] += 1.0;
}
}
b.img.rows_mut().for_each(|(y, row)| {
let ty = ((y as f64 / ch) as usize).min(ny - 1);
for (x, px) in row.iter_mut().enumerate() {
let tx = ((x as f64 / cw) as usize).min(nx - 1);
let s = sums[ty * nx + tx];
*px = [0, 1, 2, 3].map(|c| (s[c] / s[4].max(1.0)) as f32);
}
});
b
}
fn posterize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let lv = ctx.params.f("level").round().clamp(2.0, 255.0) as f32 - 1.0;
b.img.map_straight(|c| c.map(|v| (v.clamp(0.0, 1.0) * lv).round() / lv));
b
}
fn threshold(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = ctx.params.f("level") as f32 / 255.0;
b.img.map_straight(|c| {
let v = if luminance(c[0], c[1], c[2]) >= t { 1.0 } else { 0.0 };
[v, v, v]
});
b
}
fn find_edges(ctx: &EffectCtx, mut b: Buf) -> Buf {
let inv = ctx.params.b("invert");
let blend = ctx.params.f("blend") as f32 / 100.0;
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let l = |dx: i64, dy: i64| {
let p = src.get_clamped(x as i64 + dx, y as i64 + dy);
luminance(p[0], p[1], p[2])
};
let gx = l(1, -1) + 2.0 * l(1, 0) + l(1, 1) - l(-1, -1) - 2.0 * l(-1, 0) - l(-1, 1);
let gy = l(-1, 1) + 2.0 * l(0, 1) + l(1, 1) - l(-1, -1) - 2.0 * l(0, -1) - l(1, -1);
let e = (gx * gx + gy * gy).sqrt().min(1.0);
let v = if inv { e } else { 1.0 - e };
let a = px[3];
*px = blend_with(*px, [v * a, v * a, v * a, a], blend);
}
});
b
}
/// Blend With Original: `orig` over the effect result `fx` by `k` (0 = effect only).
fn blend_with(orig: [f32; 4], fx: [f32; 4], k: f32) -> [f32; 4] {
[0, 1, 2, 3].map(|c| fx[c] + (orig[c] - fx[c]) * k)
}
fn emboss(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ang = ctx.params.f("direction").to_radians();
let relief = ctx.params.f("relief") * b.scale;
let contrast = ctx.params.f("contrast") as f32 / 100.0;
let blend = ctx.params.f("blend") as f32 / 100.0;
let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = src.sample_bilinear_clamped(x as f64 + 0.5 + dx, y as f64 + 0.5 + dy);
let c = src.sample_bilinear_clamped(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy);
let d = luminance(a[0], a[1], a[2]) - luminance(c[0], c[1], c[2]);
let v = (0.5 + d * contrast).clamp(0.0, 1.0);
let al = px[3];
*px = blend_with(*px, [v * al, v * al, v * al, al], blend);
}
});
b
}
fn noise(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("amount") as f32 / 100.0;
let color = ctx.params.b("color");
// Old saves lack the switch: clipping was always on.
let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool());
let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed;
let w = b.img.width;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = px[3];
if a <= 0.0 {
continue;
}
let n = |k: u32| (hash_noise(x as u32 + k * w, y as u32, frame_seed) - 0.5) * amt;
let (r, g, bl) = if color {
(n(0), n(1), n(2))
} else {
let v = n(0);
(v, v, v)
};
if clip {
px[0] = (px[0] + r * a).clamp(0.0, a);
px[1] = (px[1] + g * a).clamp(0.0, a);
px[2] = (px[2] + bl * a).clamp(0.0, a);
} else {
// Unclipped: values past black / white wrap around (integer overflow look).
for (c, n) in [r, g, bl].into_iter().enumerate() {
px[c] = ((px[c] / a + n).rem_euclid(1.0)) * a;
}
}
}
});
b
}
fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let ang = ctx.params.f("angle").to_radians();
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let (w, h) = (b.img.width as f64, b.img.height as f64);
// Direction the wipe travels: angle 90° wipes left→right.
let (dx, dy) = (ang.sin(), -ang.cos());
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)];
let proj: Vec<f64> = corners.iter().map(|(x, y)| x * dx + y * dy).collect();
let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max));
let edge = lo - feather + (hi - lo + 2.0 * feather) * done;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let d = (x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy;
let k = ((d - edge) / feather + 0.5).clamp(0.0, 1.0) as f32;
for c in px.iter_mut() {
*c *= k;
}
}
});
b
}
fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let start = ctx.params.f("startAngle");
let c = b.to_px(ctx.params.v2("center"));
let dir = ctx.params.e("wipe");
let feather = (ctx.params.f("feather")).max(0.01);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0);
// Both: the wipe opens in both directions from the start angle at once.
let (a, edge) = match dir {
1 => (360.0 - a, done * 360.0),
2 => (a.min(360.0 - a), done * 180.0),
_ => (a, done * 360.0),
};
let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32;
for ch in px.iter_mut() {
*ch *= k;
}
}
});
b
}
fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let ang = ctx.params.f("direction").to_radians();
let width = (ctx.params.f("width") * b.scale).max(1.0);
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let (dx, dy) = (ang.cos(), ang.sin());
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width);
let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32;
for c in px.iter_mut() {
*c *= k;
}
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let dims = || popup(&["Horizontal and Vertical", "Horizontal", "Vertical"]);
vec![
spec(
"ec.blur.gaussian",
"Gaussian Blur",
"Blur & Sharpen",
vec![
p("blurriness", "Blurriness", num(0.0), slider(0.0, 3000.0, 0.0, 50.0, 1)),
p("dimensions", "Blur Dimensions", Value::Enum(0), dims()),
p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox),
],
gaussian,
),
spec(
"ec.blur.fastbox",
"Fast Box Blur",
"Blur & Sharpen",
vec![
p("radius", "Blur Radius", num(0.0), slider(0.0, 3000.0, 0.0, 50.0, 1)),
p("iterations", "Iterations", num(3.0), slider(1.0, 50.0, 1.0, 5.0, 0)),
p("dimensions", "Blur Dimensions", Value::Enum(0), dims()),
p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox),
],
box_blur,
),
spec(
"ec.blur.directional",
"Directional Blur",
"Blur & Sharpen",
vec![p("direction", "Direction", num(0.0), ParamUi::Angle), p("length", "Blur Length", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1))],
directional,
),
spec(
"ec.blur.radial",
"Radial Blur",
"Blur & Sharpen",
vec![
p("amount", "Amount", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("type", "Type", Value::Enum(0), popup(&["Spin", "Zoom"])),
// Our blur is always fully anti-aliased; the choice is kept for compatibility.
p("antialiasing", "Antialiasing (Best Quality)", Value::Enum(0), popup(&["Low", "High"])),
],
radial,
),
spec("ec.blur.sharpen", "Sharpen", "Blur & Sharpen", vec![p("amount", "Sharpen Amount", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 0))], sharpen),
spec(
"ec.blur.unsharp",
"Unsharp Mask",
"Blur & Sharpen",
vec![
p("amount", "Amount", num(50.0), slider(0.0, 500.0, 0.0, 500.0, 0)),
p("radius", "Radius", num(1.0), slider(0.1, 500.0, 0.1, 50.0, 1)),
p("threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
],
unsharp,
),
spec(
"ec.stylize.glow",
"Glow",
"Stylize",
vec![
p("based", "Glow Based On", Value::Enum(1), popup(&["Alpha Channel", "Color Channels"])),
p("threshold", "Glow Threshold", num(60.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("radius", "Glow Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("intensity", "Glow Intensity", num(1.0), slider(0.0, 255.0, 0.0, 4.0, 1)),
p("operation", "Composite Original", Value::Enum(0), popup(&["On Top", "Behind", "None"])),
p("glowOperation", "Glow Operation", Value::Enum(1), popup(&GLOW_OPERATIONS)),
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors", "Arbitrary Map"])),
// Arbitrary Map: "red points | green points | blue points" (Curves format).
p("arbitraryMap", "Arbitrary Map", Value::Str(String::new()), ParamUi::Hidden),
p("colorLooping", "Color Looping", Value::Enum(2), popup(&["Sawtooth A>B", "Sawtooth B>A", "Triangle A>B>A", "Triangle B>A>B"])),
p("colorLoops", "Color Loops", num(1.0), slider(1.0, 127.0, 1.0, 10.0, 1)),
p("colorPhase", "Color Phase", num(0.0), ParamUi::Angle),
p("abMidpoint", "A & B Midpoint", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("colorA", "Color A", col(1.0, 1.0, 1.0), ParamUi::Color),
p("colorB", "Color B", col(0.0, 0.0, 0.0), ParamUi::Color),
p("glowDimensions", "Glow Dimensions", Value::Enum(0), dims()),
],
glow,
),
spec(
"ec.stylize.mosaic",
"Mosaic",
"Stylize",
vec![
p("horizontal", "Horizontal Blocks", num(10.0), slider(1.0, 4000.0, 1.0, 200.0, 0)),
p("vertical", "Vertical Blocks", num(10.0), slider(1.0, 4000.0, 1.0, 200.0, 0)),
p("sharpColors", "Sharp Colors", Value::Bool(false), ParamUi::Checkbox),
],
mosaic,
),
spec("ec.stylize.posterize", "Posterize", "Stylize", vec![p("level", "Level", num(6.0), slider(2.0, 255.0, 2.0, 32.0, 0))], posterize),
spec("ec.stylize.threshold", "Threshold", "Stylize", vec![p("level", "Level", num(128.0), slider(0.0, 255.0, 0.0, 255.0, 0))], threshold),
spec(
"ec.stylize.findedges",
"Find Edges",
"Stylize",
vec![p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0))],
find_edges,
),
spec(
"ec.stylize.emboss",
"Emboss",
"Stylize",
vec![
p("direction", "Direction", num(45.0), ParamUi::Angle),
p("relief", "Relief", num(1.5), slider(0.0, 10.0, 0.0, 10.0, 2)),
p("contrast", "Contrast", num(100.0), slider(0.0, 500.0, 0.0, 500.0, 0)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
],
emboss,
),
spec(
"ec.perspective.dropshadow",
"Drop Shadow",
"Perspective",
vec![
p("color", "Shadow Color", col(0.0, 0.0, 0.0), ParamUi::Color),
p("opacity", "Opacity", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("direction", "Direction", num(135.0), ParamUi::Angle),
p("distance", "Distance", num(5.0), slider(0.0, 32000.0, 0.0, 120.0, 1)),
p("softness", "Softness", num(0.0), slider(0.0, 1000.0, 0.0, 250.0, 1)),
p("shadowOnly", "Shadow Only", Value::Bool(false), ParamUi::Checkbox),
],
drop_shadow,
),
spec(
"ec.channel.invert",
"Invert",
"Channel",
vec![
p("channel", "Channel", Value::Enum(0), popup(&INVERT_CHANNELS)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
],
invert,
),
spec(
"ec.noise.noise",
"Noise",
"Noise & Grain",
vec![
p("amount", "Amount of Noise", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("color", "Use Color Noise", Value::Bool(true), ParamUi::Checkbox),
p("clip", "Clip Result Values", Value::Bool(true), ParamUi::Checkbox),
],
noise,
),
spec(
"ec.transition.linearwipe",
"Linear Wipe",
"Transition",
vec![
p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("angle", "Wipe Angle", num(90.0), ParamUi::Angle),
p("feather", "Feather", num(0.0), slider(0.0, 32000.0, 0.0, 500.0, 1)),
],
linear_wipe,
),
spec(
"ec.transition.radialwipe",
"Radial Wipe",
"Transition",
vec![
p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("startAngle", "Start Angle", num(0.0), ParamUi::Angle),
p("center", "Wipe Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("wipe", "Wipe", Value::Enum(0), popup(&["Clockwise", "Counterclockwise", "Both"])),
p("feather", "Feather", num(0.0), slider(0.0, 360.0, 0.0, 50.0, 1)),
],
radial_wipe,
),
spec(
"ec.transition.venetian",
"Venetian Blinds",
"Transition",
vec![
p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("direction", "Direction", num(90.0), ParamUi::Angle),
p("width", "Width", num(30.0), slider(2.0, 1000.0, 2.0, 200.0, 0)),
p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
venetian,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
/// A bright square on mid grey.
fn scene() -> effectcraft_raster::Image {
let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]);
for y in 8..16 {
for x in 8..16 {
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
img
}
/// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below
/// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the
/// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3
/// and with Composite Original On Top or Behind; its width follows Glow Radius.
#[test]
fn glow_halo_matches_after_effects() {
let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]);
// AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge
// (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red.
let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)];
let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)];
for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])]
{
let set = [
("based", Value::Enum(1)),
("threshold", num(60.0)),
("radius", num(radius)),
("intensity", num(intensity)),
("operation", Value::Enum(operation)),
];
let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default());
for &(d, red) in profile {
// Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0.
let (bx, by) = o.to_px([-d, 60.0]);
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
let ae = 1.0 - red / 255.0;
assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]);
assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}");
}
}
}
#[test]
fn glow_operation_and_arbitrary_map() {
// Pixels addressed in layer space (Glow pads its buffer).
struct Out(Buf);
impl Out {
fn get(&self, x: i64, y: i64) -> [f32; 4] {
self.0.img.get(x + self.0.offset[0] as i64, y + self.0.offset[1] as i64)
}
}
let run = |vals: &[(&str, Value)]| Out(run_fx("ec.stylize.glow", vals, scene(), 0.0, EffectEnv::default()));
let add = run(&[]);
assert_eq!(GLOW_OPERATIONS[1], "Add");
// Multiply darkens around the square where Add brightens.
let mul = run(&[("glowOperation", Value::Enum(2))]);
assert!(add.get(6, 12)[0] > 0.31 && mul.get(6, 12)[0] <= 0.3 + 1e-4, "{:?} {:?}", add.get(6, 12), mul.get(6, 12));
assert_eq!(
glow_operation(&EffectCtx {
params: &crate::Params::default(),
time: 0.0,
layer_size: [1.0; 2],
seed: 0,
adjustment: false,
env: Default::default()
}),
effectcraft_color::BlendMode::Add
);
// Arbitrary Map: the glow takes the mapped colour (here pure red for every brightness).
let red = run(&[("colors", Value::Enum(2)), ("arbitraryMap", Value::Str("0,1 1,1|0,0 1,0|0,0 1,0".into())), ("operation", Value::Enum(2))]);
let p = red.get(6, 12);
assert!(p[0] > 0.05 && p[1] < 1e-4 && p[2] < 1e-4, "{p:?}");
}
}