Files
effectcraft/crates/effects/src/misc.rs
T
Brandon ThomasandClaude Opus 5.5 248cf6af3a M13.10: visual editors for Lumetri curves, Colorama output cycle, Glow colour map, Reshape correspondence points; scrolling Preview, AE-style comp tabs; web docs
Effect Controls: Lumetri RGB and hue/saturation curve graphs, Colorama's
palette wheel, Glow's colour map strip and Arbitrary Map curves, Reshape
correspondence buttons and viewer handles (all prop.set, one undo step per
gesture, automation ids). Preview panel scrolls; Composition/Timeline tabs
get close, label swatch and viewer lock; Effect Controls tab the layer
swatch; dropdowns elide long text. Fix the browser start (no default disk
cache folder on wasm32). Browser smoke test covers frame workers, waiting
renders and the Media Browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:59:04 -04:00

784 lines
30 KiB
Rust

//! 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 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);
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, k * a];
} 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, k * a];
} else {
*px = px.map(|v| v * k);
}
});
let s = (radius / 2.0).max(0.5);
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)| {
let g = g.map(|v| v * intensity);
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
}
#[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:?}");
}
}