M0.1–M0.7: workspace, time/geom/color/keyframe/project/raster/path/text/effects/render crates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 14:05:20 -04:00
co-authored by Claude Opus 5.5
parent 9f19158a59
commit 808cd490cd
76 changed files with 12319 additions and 3 deletions
+455
View File
@@ -0,0 +1,455 @@
//! Color Correction effects.
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Color Correction", params, render, gpu: false, float: true }
}
fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf {
let black = ctx.params.color("black");
let white = ctx.params.color("white");
let amt = ctx.params.f("amount") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l];
mix(c, t, amt)
});
b
}
fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hi = ctx.params.color("highlights");
let mid = ctx.params.color("midtones");
let sh = ctx.params.color("shadows");
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let t = if l < 0.5 { mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0) } else { mix([mid[0], mid[1], mid[2]], [hi[0], hi[1], hi[2]], (l - 0.5) * 2.0) };
mix(c, t, blend)
});
b
}
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
let br = ctx.params.f("brightness") as f32 / 100.0;
let ct = ctx.params.f("contrast") as f32 / 100.0;
let k = if ct >= 0.0 { 1.0 / (1.0 - ct * 0.99) } else { 1.0 + ct };
b.img.map_straight(|c| c.map(|v| ((v + br - 0.5) * k + 0.5).max(0.0)));
b
}
fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hue = ctx.params.f("hue") as f32 / 360.0;
let sat = ctx.params.f("saturation") as f32 / 100.0;
let light = ctx.params.f("lightness") as f32 / 100.0;
let colorize = ctx.params.b("colorize");
let ch = ctx.params.f("colorizeHue") as f32 / 360.0;
let cs = ctx.params.f("colorizeSaturation") as f32 / 100.0;
let cl = ctx.params.f("colorizeLightness") as f32 / 100.0;
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (h, s, l) = if colorize {
(ch, cs, (l + cl * if cl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0))
} else {
let s2 = if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) };
let l2 = if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) };
((h + hue).rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
};
let (r, g, bl) = hsl_to_rgb(h, s, l);
[r, g, bl]
});
b
}
fn levels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ib = ctx.params.f("inBlack") as f32;
let iw = ctx.params.f("inWhite") as f32;
let g = ctx.params.f("gamma").max(0.01) as f32;
let ob = ctx.params.f("outBlack") as f32;
let ow = ctx.params.f("outWhite") as f32;
let clip = !ctx.params.b("noClip");
b.img.map_straight(|c| {
c.map(|v| {
let mut t = (v - ib) / (iw - ib).max(1e-6);
if clip {
t = t.clamp(0.0, 1.0);
}
let t = t.max(0.0).powf(1.0 / g);
ob + (ow - ob) * t
})
});
b
}
fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
let e = 2f32.powf(ctx.params.f("exposure") as f32);
let off = ctx.params.f("offset") as f32;
let g = ctx.params.f("gamma").max(0.01) as f32;
b.img.map_straight(|c| {
c.map(|v| {
let lin = effectcraft_color::srgb_to_linear(v.max(0.0));
let o = (lin * e + off).max(0.0).powf(1.0 / g);
effectcraft_color::linear_to_srgb(o)
})
});
b
}
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")].map(|v| v as f32 / 100.0);
let tint_on = ctx.params.b("tint");
let tc = ctx.params.color("tintColor");
b.img.map_straight(|c| {
let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]);
// Interpolate the six hue weights around the colour wheel.
let hp = h * 6.0;
let i = hp.floor() as usize % 6;
let f = hp - hp.floor();
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
let base = luminance(c[0], c[1], c[2]);
let gray = (base + (wt - 0.5) * s * 0.6).max(0.0);
if tint_on {
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
[r, g, bl]
} else {
[gray, gray, gray]
}
});
b
}
fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = ctx.params.color("color");
let invert = ctx.params.b("invert");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
b.img.data.iter_mut().for_each(|px| {
let a = if invert { 1.0 - px[3] } else { px[3] };
let f = [c[0] * a, c[1] * a, c[2] * a];
for i in 0..3 {
px[i] += (f[i] - px[i]) * opacity;
}
if invert {
px[3] += (a - px[3]) * opacity;
}
});
b
}
fn change_to_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let from = ctx.params.color("from");
let to = ctx.params.color("to");
let tol = ctx.params.f("tolerance") as f32 / 100.0;
let soft = (ctx.params.f("softness") as f32 / 100.0).max(1e-3);
let (fh, fs, fl) = rgb_to_hsl(from[0], from[1], from[2]);
let (th, ts, tl) = rgb_to_hsl(to[0], to[1], to[2]);
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let dh = ((h - fh + 0.5).rem_euclid(1.0) - 0.5).abs() * 2.0;
let d = dh.max((s - fs).abs() * 0.5).max((l - fl).abs() * 0.5);
let k = (1.0 - ((d - tol) / soft).clamp(0.0, 1.0)).clamp(0.0, 1.0);
let (r, g, bl) = hsl_to_rgb((h + (th - fh)).rem_euclid(1.0), (s + (ts - fs)).clamp(0.0, 1.0), (l + (tl - fl)).clamp(0.0, 1.0));
mix(c, [r, g, bl], k)
});
b
}
fn leave_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let keep = ctx.params.color("color");
let amt = ctx.params.f("amount") as f32 / 100.0;
let tol = ctx.params.f("tolerance") as f32 / 100.0;
let (kh, _, _) = rgb_to_hsl(keep[0], keep[1], keep[2]);
b.img.map_straight(|c| {
let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]);
let dh = ((h - kh + 0.5).rem_euclid(1.0) - 0.5).abs() * 2.0;
let keepk = if s > 0.05 && dh <= tol { 1.0 } else { (1.0 - (dh - tol) * 8.0).clamp(0.0, 1.0) * s.min(1.0) };
let g = luminance(c[0], c[1], c[2]);
mix(c, [g, g, g], amt * (1.0 - keepk))
});
b
}
fn photo_filter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = ctx.params.color("color");
let d = ctx.params.f("density") as f32 / 100.0;
let keep = ctx.params.b("preserveLuminosity");
b.img.map_straight(|x| {
let f = [x[0] * c[0], x[1] * c[1], x[2] * c[2]];
let mut o = mix(x, f, d);
if keep {
let l0 = luminance(x[0], x[1], x[2]);
let l1 = luminance(o[0], o[1], o[2]).max(1e-6);
o = o.map(|v| v * l0 / l1);
}
o
});
b
}
fn vibrance(ctx: &EffectCtx, mut b: Buf) -> Buf {
let vib = ctx.params.f("vibrance") as f32 / 100.0;
let sat = ctx.params.f("saturation") as f32 / 100.0;
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let s = (s * (1.0 + sat) + vib * (1.0 - s) * s.min(0.5)).clamp(0.0, 1.0);
let (r, g, bl) = hsl_to_rgb(h, s, l);
[r, g, bl]
});
b
}
fn color_balance(ctx: &EffectCtx, mut b: Buf) -> Buf {
let g = |k: &str| ctx.params.f(k) as f32 / 100.0;
let sh = [g("shadowRed"), g("shadowGreen"), g("shadowBlue")];
let md = [g("midRed"), g("midGreen"), g("midBlue")];
let hl = [g("hiRed"), g("hiGreen"), g("hiBlue")];
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let ws = (1.0 - l * 2.0).clamp(0.0, 1.0);
let wh = (l * 2.0 - 1.0).clamp(0.0, 1.0);
let wm = 1.0 - ws - wh;
let mut o = c;
for i in 0..3 {
o[i] = (c[i] + (sh[i] * ws + md[i] * wm + hl[i] * wh) * 0.5).max(0.0);
}
o
});
b
}
fn channel_mixer(ctx: &EffectCtx, mut b: Buf) -> Buf {
let g = |k: &str| ctx.params.f(k) as f32 / 100.0;
let m = [[g("rr"), g("rg"), g("rb"), g("rc")], [g("gr"), g("gg"), g("gb"), g("gc")], [g("br"), g("bg"), g("bb"), g("bc")]];
let mono = ctx.params.b("monochrome");
b.img.map_straight(|c| {
let row = |r: [f32; 4]| (c[0] * r[0] + c[1] * r[1] + c[2] * r[2] + r[3]).max(0.0);
if mono {
let v = row(m[0]);
[v, v, v]
} else {
[row(m[0]), row(m[1]), row(m[2])]
}
});
b
}
fn gamma_pg(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ch = |n: &str| (ctx.params.f(&format!("{n}Gamma")).max(0.01) as f32, ctx.params.f(&format!("{n}Pedestal")) as f32, ctx.params.f(&format!("{n}Gain")) as f32);
let r = ch("red");
let g = ch("green");
let bl = ch("blue");
b.img.map_straight(|c| {
let f = |v: f32, (gm, pd, gn): (f32, f32, f32)| (pd + (gn - pd) * v.max(0.0).powf(gm)).max(0.0);
[f(c[0], r), f(c[1], g), f(c[2], bl)]
});
b
}
fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf {
let shift = ctx.params.f("phaseShift") as f32 / 360.0;
let cycles = ctx.params.f("cycles").max(0.0) as f32;
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]);
let (r, g, bl) = effectcraft_color::hsv_to_rgb((l * cycles + shift).rem_euclid(1.0), 1.0, 1.0);
mix(c, [r, g, bl], blend)
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let pct = || slider(-100.0, 100.0, -100.0, 100.0, 1);
let mut gpg = Vec::new();
for (n, label) in [("red", "Red"), ("green", "Green"), ("blue", "Blue")] {
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
gpg.push(p(leak(format!("{n}Gamma")), leak(format!("{label} Gamma")), num(1.0), slider(0.1, 10.0, 0.1, 4.0, 2)));
gpg.push(p(leak(format!("{n}Pedestal")), leak(format!("{label} Pedestal")), num(0.0), slider(-2.0, 2.0, -1.0, 1.0, 2)));
gpg.push(p(leak(format!("{n}Gain")), leak(format!("{label} Gain")), num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)));
}
vec![
spec(
"ec.color.tint",
"Tint",
vec![
p("black", "Map Black To", col(0.0, 0.0, 0.0), ParamUi::Color),
p("white", "Map White To", col(1.0, 1.0, 1.0), ParamUi::Color),
p("amount", "Amount to Tint", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
tint,
),
spec(
"ec.color.tritone",
"Tritone",
vec![
p("highlights", "Highlights", col(1.0, 1.0, 1.0), ParamUi::Color),
p("midtones", "Midtones", col(0.5, 0.4, 0.3), ParamUi::Color),
p("shadows", "Shadows", col(0.0, 0.0, 0.0), ParamUi::Color),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
tritone,
),
spec(
"ec.color.brightnesscontrast",
"Brightness & Contrast",
vec![p("brightness", "Brightness", num(0.0), slider(-150.0, 150.0, -150.0, 150.0, 1)), p("contrast", "Contrast", num(0.0), pct())],
brightness_contrast,
),
spec(
"ec.color.huesaturation",
"Hue/Saturation",
vec![
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
p("saturation", "Master Saturation", num(0.0), pct()),
p("lightness", "Master Lightness", num(0.0), pct()),
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
],
hue_saturation,
),
spec(
"ec.color.levels",
"Levels",
vec![
p("inBlack", "Input Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("inWhite", "Input White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)),
p("outBlack", "Output Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("outWhite", "Output White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("noClip", "Don't Clip", Value::Bool(false), ParamUi::Checkbox),
],
levels,
),
spec(
"ec.color.exposure",
"Exposure",
vec![
p("exposure", "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
p("offset", "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)),
p("gamma", "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)),
],
exposure,
),
spec(
"ec.color.blackwhite",
"Black & White",
vec![
p("reds", "Reds", num(40.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("yellows", "Yellows", num(60.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("greens", "Greens", num(40.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("cyans", "Cyans", num(60.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("blues", "Blues", num(20.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("magentas", "Magentas", num(80.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("tint", "Tint", Value::Bool(false), ParamUi::Checkbox),
p("tintColor", "Tint Color", col(0.88, 0.76, 0.6), ParamUi::Color),
],
black_white,
),
EffectSpec {
id: "ec.generate.fill",
name: "Fill",
category: "Generate",
params: vec![
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
render: fill,
gpu: false,
float: true,
},
spec(
"ec.color.changetocolor",
"Change to Color",
vec![
p("from", "From", col(1.0, 0.0, 0.0), ParamUi::Color),
p("to", "To", col(0.0, 0.0, 1.0), ParamUi::Color),
p("tolerance", "Tolerance", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("softness", "Softness", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
change_to_color,
),
spec(
"ec.color.leavecolor",
"Leave Color",
vec![
p("amount", "Amount to Decolor", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("color", "Color To Leave", col(1.0, 0.0, 0.0), ParamUi::Color),
p("tolerance", "Tolerance", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
leave_color,
),
spec(
"ec.color.photofilter",
"Photo Filter",
vec![
p("color", "Color", col(0.93, 0.54, 0.09), ParamUi::Color),
p("density", "Density", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("preserveLuminosity", "Preserve Luminosity", Value::Bool(true), ParamUi::Checkbox),
],
photo_filter,
),
spec("ec.color.vibrance", "Vibrance", vec![p("vibrance", "Vibrance", num(0.0), pct()), p("saturation", "Saturation", num(0.0), pct())], vibrance),
spec(
"ec.color.colorbalance",
"Color Balance",
["shadowRed", "shadowGreen", "shadowBlue", "midRed", "midGreen", "midBlue", "hiRed", "hiGreen", "hiBlue"]
.iter()
.zip([
"Shadow Red Balance",
"Shadow Green Balance",
"Shadow Blue Balance",
"Midtone Red Balance",
"Midtone Green Balance",
"Midtone Blue Balance",
"Highlight Red Balance",
"Highlight Green Balance",
"Highlight Blue Balance",
])
.map(|(id, n)| p(id, n, num(0.0), pct()))
.collect(),
color_balance,
),
spec(
"ec.color.channelmixer",
"Channel Mixer",
{
let ids = ["rr", "rg", "rb", "rc", "gr", "gg", "gb", "gc", "br", "bg", "bb", "bc"];
let names = [
"Red-Red", "Red-Green", "Red-Blue", "Red-Const", "Green-Red", "Green-Green", "Green-Blue", "Green-Const", "Blue-Red", "Blue-Green", "Blue-Blue",
"Blue-Const",
];
let mut v: Vec<_> = ids
.iter()
.zip(names)
.map(|(id, n)| p(id, n, num(if matches!(*id, "rr" | "gg" | "bb") { 100.0 } else { 0.0 }), slider(-200.0, 200.0, -200.0, 200.0, 0)))
.collect();
v.push(p("monochrome", "Monochrome", Value::Bool(false), ParamUi::Checkbox));
v
},
channel_mixer,
),
spec("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", gpg, gamma_pg),
spec(
"ec.color.colorama",
"Colorama",
vec![
p("phaseShift", "Phase Shift", num(0.0), ParamUi::Angle),
p("cycles", "Cycle Repetitions", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("mode", "Get Phase From", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Hue", "Lightness", "Saturation", "Value", "Alpha"])),
],
colorama,
),
]
}
+28
View File
@@ -0,0 +1,28 @@
//! Expression Controls: parameter-only effects whose values expressions read. They pass pixels
//! through unchanged.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
b
}
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>) -> EffectSpec {
EffectSpec { id, name, category: "Expression Controls", params, render: passthrough, gpu: true, float: true }
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec("ec.control.slider", "Slider Control", vec![p("slider", "Slider", num(0.0), slider(-1_000_000.0, 1_000_000.0, 0.0, 100.0, 2))]),
spec("ec.control.angle", "Angle Control", vec![p("angle", "Angle", num(0.0), ParamUi::Angle)]),
spec("ec.control.checkbox", "Checkbox Control", vec![p("checkbox", "Checkbox", Value::Bool(false), ParamUi::Checkbox)]),
spec("ec.control.color", "Color Control", vec![p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color)]),
spec("ec.control.point", "Point Control", vec![p("point", "Point", Value::Vec2([0.5, 0.5]), ParamUi::Point)]),
spec("ec.control.point3d", "3D Point Control", vec![p("point", "3D Point", Value::Vec3([0.0, 0.0, 0.0]), ParamUi::Point3)]),
spec("ec.control.layer", "Layer Control", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)]),
spec("ec.control.dropdown", "Dropdown Menu Control", vec![p("menu", "Menu", Value::Enum(0), popup(&["Item 1", "Item 2", "Item 3"]))]),
]
}
+325
View File
@@ -0,0 +1,325 @@
//! Distort effects: inverse-mapped warps (each output pixel samples the source).
use effectcraft_geom::{Mat3, vec2};
use rayon::prelude::*;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true }
}
/// Resample through `f(x, y) -> source point` (pixel coordinates, centres at +0.5).
fn remap(src: &Image, repeat: bool, f: impl Fn(f64, f64) -> (f64, f64) + Sync) -> Image {
let mut out = Image::new(src.width, src.height);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (sx, sy) = f(x as f64 + 0.5, y as f64 + 0.5);
*px = if repeat { src.sample_bilinear_clamped(sx, sy) } else { src.sample_bilinear(sx, sy) };
}
});
out
}
fn twirl(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ang = ctx.params.f("angle").to_radians();
let r = (ctx.params.f("radius") / 100.0 * b.img.width.min(b.img.height) as f64 * 0.5).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = (x - c.0, y - c.1);
let d = (dx * dx + dy * dy).sqrt();
if d >= r {
return (x, y);
}
let t = 1.0 - d / r;
let a = -ang * t * t;
let (s, co) = a.sin_cos();
(c.0 + dx * co - dy * s, c.1 + dx * s + dy * co)
});
b
}
fn spherize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = (x - c.0, y - c.1);
let d = (dx * dx + dy * dy).sqrt();
if d >= r || d == 0.0 {
return (x, y);
}
let nd = d / r;
let k = (nd.asin() / std::f64::consts::FRAC_PI_2) / nd;
(c.0 + dx * k, c.1 + dy * k)
});
b
}
fn bulge(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rx = (ctx.params.f("hradius") * b.scale).max(1.0);
let ry = (ctx.params.f("vradius") * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
let h = ctx.params.f("height");
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = ((x - c.0) / rx, (y - c.1) / ry);
let d2 = dx * dx + dy * dy;
if d2 >= 1.0 {
return (x, y);
}
let k = 1.0 - h * (1.0 - d2) * 0.5;
(c.0 + dx * rx * k, c.1 + dy * ry * k)
});
b
}
fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let h = ctx.params.f("height") * b.scale;
let w = (ctx.params.f("width") * b.scale).max(1.0);
let dir = ctx.params.f("direction").to_radians();
let speed = ctx.params.f("speed");
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * std::f64::consts::TAU;
let kind = ctx.params.e("waveType");
let (dx, dy) = (dir.sin(), -dir.cos());
if !ctx.adjustment {
b.pad(h.abs().ceil() as u32 + 1);
}
let wave = move |t: f64| -> f64 {
let ph = t.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU;
match kind {
1 => if ph < 0.5 { 1.0 } else { -1.0 },
2 => 1.0 - 4.0 * (ph - 0.5).abs(),
3 => 2.0 * ph - 1.0,
_ => t.sin(),
}
};
b.img = remap(&b.img, false, |x, y| {
// Waves travel along `dir`; displacement is perpendicular to it.
let along = x * dx + y * dy;
let disp = wave(along / w * std::f64::consts::TAU + phase) * h;
(x + dy * disp, y - dx * disp)
});
b
}
fn ripple(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") / 100.0 * b.img.width.max(b.img.height) as f64).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
let w = (ctx.params.f("waveWidth") * b.scale).max(1.0);
let h = ctx.params.f("waveHeight") * b.scale;
let phase = ctx.params.f("phase").to_radians() - ctx.time * ctx.params.f("speed") * std::f64::consts::TAU;
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = (x - c.0, y - c.1);
let d = (dx * dx + dy * dy).sqrt();
if d >= r || d == 0.0 {
return (x, y);
}
let fall = 1.0 - d / r;
let o = (d / w * std::f64::consts::TAU + phase).sin() * h * fall;
(x + dx / d * o, y + dy / d * o)
});
b
}
fn mirror(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
let a = ctx.params.f("angle").to_radians();
let n = (a.cos(), a.sin());
b.img = remap(&b.img, false, |x, y| {
let d = (x - c.0) * n.0 + (y - c.1) * n.1;
if d > 0.0 { (x - 2.0 * d * n.0, y - 2.0 * d * n.1) } else { (x, y) }
});
b
}
fn offset(ctx: &EffectCtx, mut b: Buf) -> Buf {
let shift = ctx.params.v2("shift");
let (w, h) = (b.img.width as f64, b.img.height as f64);
let blend = ctx.params.f("blend") as f32 / 100.0;
let (cx, cy) = (w / 2.0, h / 2.0);
let (sx, sy) = b.to_px(shift);
let orig = b.img.clone();
let mut o = remap(&b.img, false, |x, y| ((x - (sx - cx)).rem_euclid(w), (y - (sy - cy)).rem_euclid(h)));
if blend > 0.0 {
for (p, q) in o.data.iter_mut().zip(&orig.data) {
for i in 0..4 {
p[i] += (q[i] - p[i]) * blend;
}
}
}
b.img = o;
b
}
fn polar(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("interpolation") / 100.0;
let to_polar = ctx.params.e("conversion") == 1;
let (w, h) = (b.img.width as f64, b.img.height as f64);
let (cx, cy) = (w / 2.0, h / 2.0);
let rmax = cx.min(cy);
b.img = remap(&b.img, true, |x, y| {
let (px, py) = if to_polar {
// output is polar: angle → x, radius → y
let dx = x - cx;
let dy = y - cy;
let a = dx.atan2(-dy).rem_euclid(std::f64::consts::TAU);
let r = (dx * dx + dy * dy).sqrt() / rmax;
(a / std::f64::consts::TAU * w, h - r * h)
} else {
let a = x / w * std::f64::consts::TAU;
let r = (h - y) / h * rmax;
(cx + r * a.sin(), cy - r * a.cos())
};
(x + (px - x) * amt, y + (py - y) * amt)
});
b
}
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let uniform = ctx.params.b("uniform");
let sh = ctx.params.f("scaleHeight");
let sw = if uniform { sh } else { ctx.params.f("scaleWidth") };
let skew = ctx.params.f("skew");
let skew_axis = ctx.params.f("skewAxis");
let rot = ctx.params.f("rotation");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(skew, skew_axis)
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1));
let mut out = Image::new(b.img.width, b.img.height);
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, ..Default::default() });
b.img = out;
b
}
fn corner_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (w, h) = (b.layer_w(ctx), b.layer_h(ctx));
let q = ["ul", "ur", "lr", "ll"].map(|k| {
let p = b.to_px(ctx.params.v2(k));
vec2(p.0, p.1)
});
let quad = Mat3::square_to_quad(q);
let o = b.to_px([0.0, 0.0]);
let m = quad * Mat3::scale(vec2(1.0 / (w * b.scale), 1.0 / (h * b.scale))) * Mat3::translate(vec2(-o.0, -o.1));
let mut out = Image::new(b.img.width, b.img.height);
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &Default::default());
b.img = out;
b
}
impl Buf {
fn layer_w(&self, ctx: &EffectCtx) -> f64 {
ctx.layer_size[0].max(1.0)
}
fn layer_h(&self, ctx: &EffectCtx) -> f64 {
ctx.layer_size[1].max(1.0)
}
}
pub fn specs() -> Vec<EffectSpec> {
let pt = |x, y| Value::Vec2([x, y]);
vec![
spec(
"ec.distort.twirl",
"Twirl",
vec![
p("angle", "Angle", num(0.0), ParamUi::Angle),
p("radius", "Twirl Radius", num(30.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("center", "Twirl Center", pt(0.5, 0.5), ParamUi::Point),
],
twirl,
),
spec(
"ec.distort.spherize",
"Spherize",
vec![p("radius", "Radius", num(0.0), slider(0.0, 2500.0, 0.0, 2500.0, 1)), p("center", "Center of Sphere", pt(0.5, 0.5), ParamUi::Point)],
spherize,
),
spec(
"ec.distort.bulge",
"Bulge",
vec![
p("hradius", "Horizontal Radius", num(50.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("vradius", "Vertical Radius", num(50.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("center", "Bulge Center", pt(0.5, 0.5), ParamUi::Point),
p("height", "Bulge Height", num(1.0), slider(-4.0, 4.0, -4.0, 4.0, 2)),
],
bulge,
),
spec(
"ec.distort.wavewarp",
"Wave Warp",
vec![
p("waveType", "Wave Type", Value::Enum(0), popup(&["Sine", "Square", "Triangle", "Sawtooth"])),
p("height", "Wave Height", num(10.0), slider(-1000.0, 1000.0, -100.0, 100.0, 0)),
p("width", "Wave Width", num(40.0), slider(1.0, 10000.0, 1.0, 500.0, 0)),
p("direction", "Direction", num(90.0), ParamUi::Angle),
p("speed", "Wave Speed", num(1.0), slider(-100.0, 100.0, -5.0, 5.0, 1)),
p("phase", "Phase", num(0.0), ParamUi::Angle),
],
wave_warp,
),
spec(
"ec.distort.ripple",
"Ripple",
vec![
p("radius", "Radius", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("center", "Center of Ripple", pt(0.5, 0.5), ParamUi::Point),
p("waveWidth", "Wave Width", num(20.0), slider(1.0, 1000.0, 1.0, 100.0, 1)),
p("waveHeight", "Wave Height", num(10.0), slider(0.0, 400.0, 0.0, 100.0, 1)),
p("speed", "Wave Speed", num(1.0), slider(-15.0, 15.0, -5.0, 5.0, 1)),
p("phase", "Ripple Phase", num(0.0), ParamUi::Angle),
],
ripple,
),
spec("ec.distort.mirror", "Mirror", vec![p("center", "Reflection Center", pt(0.5, 0.5), ParamUi::Point), p("angle", "Reflection Angle", num(0.0), ParamUi::Angle)], mirror),
spec(
"ec.distort.offset",
"Offset",
vec![p("shift", "Shift Center To", pt(0.5, 0.5), ParamUi::Point), p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1))],
offset,
),
spec(
"ec.distort.polar",
"Polar Coordinates",
vec![
p("interpolation", "Interpolation", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("conversion", "Type of Conversion", Value::Enum(0), popup(&["Rect to Polar", "Polar to Rect"])),
],
polar,
),
spec(
"ec.distort.transform",
"Transform",
vec![
p("anchor", "Anchor Point", pt(0.5, 0.5), ParamUi::Point),
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("uniform", "Uniform Scale", Value::Bool(true), ParamUi::Checkbox),
p("scaleHeight", "Scale Height", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)),
p("scaleWidth", "Scale Width", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)),
p("skew", "Skew", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
transform,
),
spec(
"ec.distort.cornerpin",
"Corner Pin",
vec![
p("ul", "Upper Left", pt(0.0, 0.0), ParamUi::Point),
p("ur", "Upper Right", pt(1.0, 0.0), ParamUi::Point),
p("ll", "Lower Left", pt(0.0, 1.0), ParamUi::Point),
p("lr", "Lower Right", pt(1.0, 1.0), ParamUi::Point),
],
corner_pin,
),
]
}
+316
View File
@@ -0,0 +1,316 @@
//! Generate and Noise effects that synthesise images.
use rayon::prelude::*;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Px;
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 }
}
/// 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];
put(px, c, 1.0, blend);
}
});
b
}
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!("point{i}"))), ctx.params.color(&format!("color{i}")))).collect();
let blend = ctx.params.f("blend").max(1.0);
let op = ctx.params.f("opacity") as f32 / 100.0;
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 / (blend * 100.0) + 1e-6).powf(1.5);
for i in 0..3 {
acc[i] += c[i] as f64 * w;
}
wsum += w;
}
let c = acc.map(|v| (v / wsum) as f32);
put(px, c, 1.0, 1.0 - op);
}
});
b
}
fn checkerboard(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let w = (ctx.params.f("width") * b.scale).max(1.0);
let h = (ctx.params.f("height") * b.scale).max(1.0);
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let cx = ((x as f64 + 0.5 - anchor.0) / w).floor() as i64;
let cy = ((y as f64 + 0.5 - anchor.1) / h).floor() as i64;
if (cx + cy).rem_euclid(2) == 0 {
put(px, [c[0], c[1], c[2]], op, 0.0);
}
}
});
b
}
fn grid(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let w = (ctx.params.f("width") * b.scale).max(1.0);
let h = (ctx.params.f("height") * b.scale).max(1.0);
let border = (ctx.params.f("border") * b.scale).max(0.0);
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let fx = (x as f64 + 0.5 - anchor.0).rem_euclid(w);
let fy = (y as f64 + 0.5 - anchor.1).rem_euclid(h);
let d = fx.min(w - fx).min(fy.min(h - fy));
let cov = ((border / 2.0 - d) + 0.5).clamp(0.0, 1.0) as f32;
if cov > 0.0 {
let mut g = *px;
put(&mut g, [c[0], c[1], c[2]], op, 0.0);
for i in 0..4 {
px[i] += (g[i] - px[i]) * cov;
}
}
}
});
b
}
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 feather = (ctx.params.f("feather") * b.scale).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");
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 = ((r - d) / feather + 0.5).clamp(0.0, 1.0) as f32;
if invert {
cov = 1.0 - cov;
}
if cov > 0.0 {
let mut g = *px;
put(&mut g, [color[0], color[1], color[2]], op, 0.0);
for i in 0..4 {
px[i] += (g[i] - px[i]) * cov;
}
}
}
});
b
}
// ---- value noise / fBm (our own lattice noise with quintic fade) ----
#[inline]
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]
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)
}
fn fractal_noise(ctx: &EffectCtx, mut b: Buf) -> Buf {
let kind = ctx.params.e("fractalType");
let contrast = ctx.params.f("contrast") as f32 / 100.0;
let brightness = ctx.params.f("brightness") as f32 / 100.0;
let invert = ctx.params.b("invert");
let scale = (ctx.params.f("scale") * b.scale).max(1.0) as f32;
let off = ctx.params.v2("offset");
let rot = (ctx.params.f("rotation") as f32).to_radians();
let octaves = ctx.params.f("complexity").clamp(1.0, 20.0);
let evo = ctx.params.f("evolution") as f32 / 360.0;
let seed = ctx.params.f("seed") as u32 ^ 0x51ed;
let blend = ctx.params.f("blend") as f32 / 100.0;
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let (sr, cr) = rot.sin_cos();
let n_oct = octaves.ceil() as usize;
let frac = (octaves - octaves.floor()) as f32;
let (ox, oy) = b.to_px(off);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f32 + 0.5 - ox as f32, y as f32 + 0.5 - oy as f32);
let (u, v) = ((dx * cr + dy * sr) / scale, (-dx * sr + dy * cr) / scale);
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
let mut f = 1.0;
for o in 0..n_oct {
let w = if o + 1 == n_oct && frac > 0.0 { frac } else { 1.0 };
let mut n = value_noise(u * f, v * f, evo + o as f32 * 7.31, seed + o as u32);
if kind == 1 {
n = 1.0 - (n * 2.0 - 1.0).abs(); // turbulent / ridged
}
sum += n * amp * w;
norm += amp * w;
amp *= 0.5;
f *= 2.0;
}
let mut val = sum / norm.max(1e-6);
val = ((val - 0.5) * contrast + 0.5 + brightness).clamp(0.0, 1.0);
if invert {
val = 1.0 - val;
}
put(px, [val, val, val], opacity, blend);
}
});
b
}
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("point1", "Point 1", pt(0.1, 0.1), ParamUi::Point),
p("color1", "Color 1", col(1.0, 1.0, 0.0), ParamUi::Color),
p("point2", "Point 2", pt(0.9, 0.1), ParamUi::Point),
p("color2", "Color 2", col(0.0, 1.0, 0.0), ParamUi::Color),
p("point3", "Point 3", pt(0.1, 0.9), ParamUi::Point),
p("color3", "Color 3", col(1.0, 0.0, 1.0), ParamUi::Color),
p("point4", "Point 4", pt(0.9, 0.9), ParamUi::Point),
p("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("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
four_color,
),
spec(
"ec.generate.checkerboard",
"Checkerboard",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), 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("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)),
],
checkerboard,
),
spec(
"ec.generate.grid",
"Grid",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), 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("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)),
],
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("feather", "Feather Outer 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)),
],
circle,
),
spec(
"ec.noise.fractal",
"Fractal Noise",
"Noise & Grain",
vec![
p("fractalType", "Fractal Type", Value::Enum(0), popup(&["Basic", "Turbulent Smooth"])),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("contrast", "Contrast", num(100.0), slider(0.0, 10000.0, 0.0, 400.0, 1)),
p("brightness", "Brightness", num(0.0), slider(-10000.0, 10000.0, -200.0, 200.0, 1)),
p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 20.0, 600.0, 1)),
p("offset", "Offset Turbulence", pt(0.5, 0.5), ParamUi::Point),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("complexity", "Complexity", num(6.0), slider(1.0, 20.0, 1.0, 10.0, 1)),
p("evolution", "Evolution", num(0.0), ParamUi::Angle),
p("seed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
fractal_noise,
),
]
}
+236
View File
@@ -0,0 +1,236 @@
//! The effects library.
//!
//! Every effect is an [`EffectSpec`]: a stable id (`ec.blur.gaussian`), its display name and
//! category (the Effects & Presets tree), typed parameters with defaults and UI hints, and a CPU
//! render function. Effect instances on layers are ordinary property groups (see
//! [`instantiate`]), so parameters animate, take expressions and are addressable like any other
//! property.
mod color_fx;
mod controls;
mod distort;
mod generate;
mod misc;
use std::collections::HashMap;
use std::sync::OnceLock;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
pub use effectcraft_raster::Image;
/// Effect categories in Effects & Presets order.
pub const CATEGORIES: &[&str] = &[
"3D Channel",
"Audio",
"Blur & Sharpen",
"Channel",
"Color Correction",
"Distort",
"Expression Controls",
"Generate",
"Immersive Video",
"Keying",
"Matte",
"Noise & Grain",
"Obsolete",
"Perspective",
"Simulation",
"Stylize",
"Text",
"Time",
"Transition",
"Utility",
];
pub struct ParamSpec {
/// Point defaults are fractions of the layer size (0.5, 0.5 = layer centre).
pub id: &'static str,
pub name: &'static str,
pub default: Value,
pub ui: ParamUi,
}
/// Parameter values evaluated at the current time.
#[derive(Clone, Debug, Default)]
pub struct Params {
pub values: HashMap<String, Value>,
}
impl Params {
pub fn get(&self, id: &str) -> Option<&Value> {
self.values.get(id)
}
pub fn f(&self, id: &str) -> f64 {
self.values.get(id).map(Value::as_f64).unwrap_or(0.0)
}
pub fn v2(&self, id: &str) -> [f64; 2] {
self.values.get(id).map(Value::as_vec2).unwrap_or([0.0; 2])
}
pub fn color(&self, id: &str) -> [f32; 4] {
self.values.get(id).map(Value::as_color).unwrap_or([1.0; 4])
}
pub fn b(&self, id: &str) -> bool {
self.values.get(id).map(Value::as_bool).unwrap_or(false)
}
pub fn e(&self, id: &str) -> u32 {
self.values.get(id).map(Value::as_enum).unwrap_or(0)
}
}
/// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`.
#[derive(Clone, Debug, Default)]
pub struct Buf {
pub img: Image,
pub offset: [f64; 2],
pub scale: f64,
}
impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
return;
}
self.img = self.img.padded(pad);
self.offset[0] += pad as f64;
self.offset[1] += pad as f64;
}
}
/// What an effect gets to render with.
pub struct EffectCtx<'a> {
pub params: &'a Params,
/// Layer time in seconds.
pub time: f64,
/// Layer source size in layer pixels (the effect's "layer bounds").
pub layer_size: [f64; 2],
/// Instance seed (stable per effect instance).
pub seed: u32,
/// Rendering for an adjustment layer (operating on the comp below).
pub adjustment: bool,
}
pub type RenderFn = fn(&EffectCtx, Buf) -> Buf;
pub struct EffectSpec {
pub id: &'static str,
pub name: &'static str,
pub category: &'static str,
pub params: Vec<ParamSpec>,
pub render: RenderFn,
/// Implemented on the GPU path too (shown with a badge).
pub gpu: bool,
/// Supports 32 bpc float (badge).
pub float: bool,
}
pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec {
ParamSpec { id, name, default, ui }
}
pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi {
ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals }
}
pub(crate) fn popup(opts: &[&str]) -> ParamUi {
ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() }
}
pub(crate) fn num(v: f64) -> Value {
Value::Scalar(v)
}
pub(crate) fn col(r: f64, g: f64, b: f64) -> Value {
Value::Color([r, g, b, 1.0])
}
/// All effects, sorted by category then name.
pub fn registry() -> &'static [EffectSpec] {
static R: OnceLock<Vec<EffectSpec>> = OnceLock::new();
R.get_or_init(|| {
let mut v = Vec::new();
v.extend(misc::specs());
v.extend(color_fx::specs());
v.extend(generate::specs());
v.extend(distort::specs());
v.extend(controls::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
v
})
}
pub fn find(id: &str) -> Option<&'static EffectSpec> {
registry().iter().find(|s| s.id == id)
}
/// Find by id or (case-insensitive) display name.
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
}
/// Build the property group for a new instance of an effect.
pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_size: [f64; 2]) -> PropGroup {
let mut g = ids.group(spec.id, instance_name);
g.kind = GroupKind::Effect { effect: spec.id.to_string() };
for ps in &spec.params {
let default = match (&ps.ui, &ps.default) {
(ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]),
_ => ps.default.clone(),
};
let mut pr = Property::new(ids.alloc(), ps.id, ps.name, default).with_ui(ps.ui.clone());
if matches!(ps.ui, ParamUi::Point) {
pr.spatial = true;
}
if matches!(ps.ui, ParamUi::Checkbox | ParamUi::Popup { .. } | ParamUi::Layer) {
pr.hold_only = true;
}
g.children.push(pr.into());
}
g
}
/// Run an effect on a buffer.
pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
(spec.render)(ctx, buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_is_consistent() {
let r = registry();
assert!(r.len() >= 40, "{}", r.len());
let mut ids = std::collections::HashSet::new();
for s in r {
assert!(ids.insert(s.id), "duplicate {}", s.id);
assert!(CATEGORIES.contains(&s.category), "{} in unknown category {}", s.id, s.category);
let mut pids = std::collections::HashSet::new();
for p in &s.params {
assert!(pids.insert(p.id), "{}: duplicate param {}", s.id, p.id);
}
}
}
/// Every effect runs on a small image with its default parameters without panicking and
/// keeps alpha in range.
#[test]
fn all_effects_run_with_defaults() {
let mut img = Image::new(48, 32);
for y in 0..32 {
for x in 0..48 {
let a = if (8..40).contains(&x) && (6..26).contains(&y) { 1.0 } else { 0.0 };
img.set(x, y, [x as f32 / 48.0 * a, y as f32 / 32.0 * a, 0.5 * a, a]);
}
}
for s in registry() {
let params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false };
let out = apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 });
assert!(!out.img.is_empty(), "{}", s.id);
assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id);
}
}
}
+537
View File
@@ -0,0 +1,537 @@
//! 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
}
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 use_colors = ctx.params.e("colors") == 1;
let ca = ctx.params.color("colorA");
let cb = ctx.params.color("colorB");
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;
}
let l = 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 use_colors {
let t = l.clamp(0.0, 1.0);
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, s, false);
let composite_behind = ctx.params.e("operation") == 1;
b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| {
let g = g.map(|v| v * intensity);
if composite_behind {
let k = 1.0 - o[3];
for c in 0..4 {
o[c] += g[c] * k;
}
} else {
// 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
}
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 == 4 {
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 mut o = s;
match ch {
1 => o[0] = 1.0 - s[0],
2 => o[1] = 1.0 - s[1],
3 => o[2] = 1.0 - s[2],
_ => o = [1.0 - s[0], 1.0 - s[1], 1.0 - s[2]],
}
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();
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);
}
});
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 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 = [v * a, v * a, v * a, a];
}
});
b
}
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 (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 = [v * al, v * al, v * al, al];
}
});
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");
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) };
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);
}
});
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);
let a = if dir == 1 { 360.0 - a } else { a };
let edge = 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"])),
],
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("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors"])),
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),
],
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)),
],
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)], 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)),
],
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(&["RGB", "Red", "Green", "Blue", "Alpha"])),
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)],
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,
),
]
}