Files
effectcraft/crates/effects/src/generate3.rs
T
Brandon ThomasandClaude Opus 5.5 08a48cc1e3 M9.7: effect controls widgets (EFF-1/EFF-2)
Angle dial with revolutions (0x+45.0°), point crosshair + on-viewer effect
point controls via a small viewer hook, colour eyedropper sampling the
composited frame, Curves graph editor and Levels histogram/triangles bound
to their params, layer params with a Source/Masks/Effects & Masks companion,
mask (Path) popups, keyframe navigator, drag-to-reorder effect headers.

Engine: effect.copy / effect.paste / effect.reset; effect.remove takes the
selection; Edit > Copy/Paste/Duplicate/Clear act on selected effects; all
undoable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:05:17 -04:00

1889 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generate effects, batch 3: Fractal (Mandelbrot/Julia), mask-driven Scribble and Stroke,
//! Vegas (animated segments along image contours or masks), Write-on, Paint Bucket (flood
//! fill), Eyedropper Fill, CC Glue Gun, CC Threads, and the audio visualisers Audio Spectrum
//! and Audio Waveform (fed by the host's audio).
//!
//! Also home of the shared anti-aliased segment rasteriser ([`raster_segs`]) used by the text
//! effects.
use std::f64::consts::PI;
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul};
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: "Generate", params, render, gpu: false, float: true }
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
// ---------------------------------------------------------------- shared rasterisation
/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Seg {
pub a: [f64; 2],
pub b: [f64; 2],
pub r: f64,
pub v: f32,
}
/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction
/// of the radius that is fully opaque; the rest falls off smoothly.
pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane {
let mut out = Plane::new(w, h);
if w == 0 || h == 0 || segs.is_empty() {
return out;
}
let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); h];
for (i, s) in segs.iter().enumerate() {
let pad = s.r + 1.0;
let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize;
let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64);
if y1 <= 0.0 || y0 >= h {
continue;
}
for row in buckets.iter_mut().take(y1 as usize).skip(y0) {
row.push(i as u32);
}
}
let hard = hardness.clamp(0.0, 1.0);
out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let py = y as f64 + 0.5;
for &i in &buckets[y] {
let s = segs[i as usize];
let pad = s.r + 1.0;
let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize;
let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w);
let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]);
let l2 = dx * dx + dy * dy;
let inner = s.r * hard - 0.5;
let outer = s.r + 0.5;
for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) {
let px = x as f64 + 0.5;
let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt();
if d >= outer {
continue;
}
let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v;
if c > *o {
*o = c;
}
}
}
});
out
}
/// Segments along a polyline (buffer px).
pub(crate) fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec<Seg>) {
let n = pts.len();
if n == 1 {
out.push(Seg { a: pts[0], b: pts[0], r, v });
}
if n < 2 {
return;
}
let segs = if closed { n } else { n - 1 };
for i in 0..segs {
out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v });
}
}
/// Portion `[s0, s1]` (arc length) of a polyline.
pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> {
let n = pts.len();
let mut out = Vec::new();
if n < 2 || s1 <= s0 {
return out;
}
let segs = if closed { n } else { n - 1 };
let mut acc = 0.0;
for i in 0..segs {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
let (e0, e1) = (acc, acc + l);
acc = e1;
if e1 < s0 || e0 > s1 || l <= 0.0 {
continue;
}
let at = |s: f64| {
let t = ((s - e0) / l).clamp(0.0, 1.0);
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
};
let p0 = at(s0.max(e0));
if out.last() != Some(&p0) {
out.push(p0);
}
out.push(at(s1.min(e1)));
}
out
}
/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal
/// Original Image.
pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) {
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
let a = (c * opacity * color[3]).clamp(0.0, 1.0);
let s = [color[0] * a, color[1] * a, color[2] * a, a];
*px = match style {
1 => s,
2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a],
_ => over(*px, s),
};
});
}
#[inline]
fn over(dst: Px, src: Px) -> Px {
let k = 1.0 - src[3];
[src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k]
}
/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels.
pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> {
let m = ctx.env.masks.get(idx.checked_sub(1)?)?;
if m.points.is_empty() {
return None;
}
Some((m.points.iter().map(|&q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed, m.inverted))
}
/// All masks mapped to buffer pixels.
fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> {
(1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect()
}
/// Chaikin corner cutting (open polyline, endpoints kept).
fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> {
let mut cur = pts.to_vec();
for _ in 0..iters {
if cur.len() < 3 {
return cur;
}
let mut nx = Vec::with_capacity(cur.len() * 2);
nx.push(cur[0]);
for w in cur.windows(2) {
let (a, b) = (w[0], w[1]);
nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
}
nx.push(*cur.last().unwrap_or(&[0.0; 2]));
cur = nx;
}
cur
}
// ---------------------------------------------------------------- Fractal
#[derive(Clone, Copy)]
struct FracSet {
julia: bool,
inverse: bool,
}
/// Smooth escape count, or `None` inside the set.
fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option<f64> {
let (mut x, mut y) = z0;
let lim2 = limit * limit;
for i in 0..max_iter {
let (nx, ny) = match power {
3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y),
4 => {
let (a, b) = (x * x - y * y, 2.0 * x * y);
(a * a - b * b, 2.0 * a * b)
}
_ => (x * x - y * y, 2.0 * x * y),
};
x = nx + c.0;
y = ny + c.1;
let m2 = x * x + y * y;
if m2 > lim2 {
let lz = m2.ln() * 0.5;
let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln();
return Some((i as f64 + 1.0 - nu).max(0.0));
}
}
None
}
fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let set_choice = pr.e("setChoice");
let power = 2 + pr.e("equation").min(2);
let mc = (pr.f("mandelbrotX"), pr.f("mandelbrotY"));
let mmag = pr.f("mandelbrotMagnification");
let mlim = pr.f("mandelbrotEscapeLimit").max(1.01);
let jc = (pr.f("juliaX"), pr.f("juliaY"));
let jmag = pr.f("juliaMagnification");
let jlim = pr.f("juliaEscapeLimit").max(1.01);
let post = (pr.f("postInversionX"), pr.f("postInversionY"));
let overlay = pr.b("overlay");
let transparent_inside = pr.b("transparency") || overlay;
let palette = pr.e("palette");
let hue = (pr.f("hue") / 360.0) as f32;
let steps = pr.f("cycleSteps").max(1.0);
let cyc_off = pr.f("cycleOffset") / 360.0;
let edge = pr.b("edgeHighlight");
let every = pr.e("samplingMethod") == 1;
let factor = pr.f("samplingFactor").round().clamp(1.0, 9.0) as usize;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let lw = ctx.layer_size[0].max(1.0);
let lh = ctx.layer_size[1].max(1.0);
let inv = 1.0 / b.scale.max(1e-9);
let (off_x, off_y) = (b.offset[0], b.offset[1]);
let sets: (FracSet, Option<FracSet>) = match set_choice {
1 => (FracSet { julia: false, inverse: true }, None),
2 => (FracSet { julia: false, inverse: false }, Some(FracSet { julia: false, inverse: true })),
3 => (FracSet { julia: false, inverse: true }, Some(FracSet { julia: false, inverse: false })),
4 => (FracSet { julia: true, inverse: false }, None),
5 => (FracSet { julia: true, inverse: true }, None),
_ => (FracSet { julia: false, inverse: false }, None),
};
let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32;
let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option<f64> {
let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) };
let span = 3.5 / 2f64.powf(mag);
let mut u = (lx - lw * 0.5) / lw * span + center.0;
let mut v = (ly - lh * 0.5) / lw * span + center.1;
if s.inverse {
let d = (u * u + v * v).max(1e-12);
u = u / d + post.0;
v = -v / d + post.1;
}
if s.julia { escape((u, v), mc, power, lim, iter_for(mag)) } else { escape((0.0, 0.0), (u, v), power, lim, iter_for(mag)) }
};
let eval = |lx: f64, ly: f64| -> Option<f64> {
let a = eval_set(sets.0, lx, ly);
match (a, sets.1) {
(None, Some(s2)) => eval_set(s2, lx, ly),
_ => a,
}
};
let shade = |n: Option<f64>| -> Px {
let Some(n) = n else {
return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] };
};
let band = ((n / steps + cyc_off).rem_euclid(1.0)) as f32;
let (r, g, bl) = match palette {
1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5),
2 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.3 + 0.4 * (band * 4.0).fract()),
_ => hsl_to_rgb(hue, 0.85, band.clamp(0.0, 1.0)),
};
let mut c = [r, g, bl];
if edge && n.fract() < 0.12 {
c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6];
}
[c[0], c[1], c[2], 1.0]
};
// First pass: one sample per pixel.
let mut first: Vec<Option<f64>> = vec![None; w * h];
first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
*v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv);
}
});
let src = if overlay { Some(b.img.clone()) } else { None };
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let here = first[y * w + x];
let needs_ss = factor > 1
&& (every || {
let mut diff = false;
for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] {
let (nx, ny) = (x as i64 + dx, y as i64 + dy);
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
continue;
}
let o = first[ny as usize * w + nx as usize];
diff |= match (here, o) {
(Some(a), Some(c)) => (a - c).abs() > steps * 0.25,
(None, None) => false,
_ => true,
};
}
diff
});
let c = if needs_ss {
let mut acc = [0.0f32; 4];
for sy in 0..factor {
for sx in 0..factor {
let fx = x as f64 + (sx as f64 + 0.5) / factor as f64;
let fy = y as f64 + (sy as f64 + 0.5) / factor as f64;
let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv));
let a = s[3];
for k in 0..3 {
acc[k] += s[k] * a;
}
acc[3] += a;
}
}
let n = (factor * factor) as f32;
[acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n]
} else {
let s = shade(here);
[s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]]
};
*px = match &src {
Some(o) => over(o.data[y * w + x], c),
None => c,
};
}
});
b
}
// ---------------------------------------------------------------- Scribble
/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`.
#[allow(clippy::too_many_arguments)]
fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) {
out.clear();
for (pts, _closed, inverted) in polys {
let m = pts.len();
if m < 2 {
continue;
}
// Crossings: (t along d, |sin| of crossing angle).
let mut xs: Vec<(f64, f64)> = Vec::new();
for i in 0..m {
let (a, b) = (pts[i], pts[(i + 1) % m]);
let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s);
if (na > 0.0) == (nb > 0.0) {
continue;
}
let t = na / (na - nb);
let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
let el = (ex * ex + ey * ey).sqrt().max(1e-9);
let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2);
xs.push((q[0] * d[0] + q[1] * d[1], sin));
}
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
// Signed area decides which side is "left".
let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum();
let left_is_inside = area < 0.0;
let mode = match fill {
4 => {
if left_is_inside {
2
} else {
3
}
}
5 => {
if left_is_inside {
3
} else {
2
}
}
f => f,
};
let mode = if *inverted && mode == 0 { 6 } else { mode };
for (k, &(t, sin)) in xs.iter().enumerate() {
let entering = k % 2 == 0;
let l = ew / sin;
match mode {
0 => {
if entering && k + 1 < xs.len() {
out.push((t, xs[k + 1].0));
}
}
1 => out.push((t - l * 0.5, t + l * 0.5)),
2 => out.push(if entering { (t, t + l) } else { (t - l, t) }),
3 => out.push(if entering { (t - l, t) } else { (t, t + l) }),
_ => {}
}
}
if mode == 6 {
// Inverted Inside: outside of the polygon within the bounds.
let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew;
let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew;
let mut cur = lo;
for pair in xs.chunks(2) {
out.push((cur, pair[0].0));
cur = pair.get(1).map(|v| v.0).unwrap_or(hi);
}
out.push((cur, hi));
}
}
out.sort_by(|a, b| a.0.total_cmp(&b.0));
// Merge overlaps.
let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len());
for &(a, b) in out.iter() {
if let Some(last) = merged.last_mut()
&& a <= last.1
{
last.1 = last.1.max(b);
continue;
}
merged.push((a, b));
}
*out = merged;
}
fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let which = pr.e("scribble");
let polys: Vec<_> = if which == 0 { mask_px(ctx, &b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, &b) };
let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect();
let style = match pr.e("composite") {
0 => 0,
2 => 2,
_ => 1,
};
let (w, h) = (b.img.width as usize, b.img.height as usize);
if polys.is_empty() {
if style == 1 {
b.img = Image::new(b.img.width, b.img.height);
}
return b;
}
let sc = b.scale;
let fill = pr.e("fillType");
let ew = pr.f("edgeWidth").max(0.1) * sc;
let ang = pr.f("angle").to_radians();
let d = [ang.cos(), ang.sin()];
let n = [-d[1], d[0]];
let width = (pr.f("strokeWidth") * sc).max(0.1);
let curv = pr.f("curviness") / 100.0;
let curv_var = pr.f("curvinessVariation") / 100.0;
let spacing = (pr.f("spacing") * sc).max(0.75).max(width * 0.25);
let spacing_var = pr.f("spacingVariation") / 100.0;
let overlap = pr.f("pathOverlap") / 100.0;
let overlap_var = pr.f("pathOverlapVariation") / 100.0;
let start = pr.f("start").clamp(0.0, 100.0) / 100.0;
let end = pr.f("end").clamp(0.0, 100.0) / 100.0;
let sequential = pr.b("fillPathsSequentially");
let wiggle = pr.e("wiggleType");
let wps = pr.f("wigglesPerSecond").max(0.0);
let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed;
let (step, frac) = match wiggle {
0 => (0u32, 0.0f32),
1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0),
_ => {
let u = (ctx.time * wps).max(0.0);
(u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32))
}
};
let rnd = |i: u32, k: u32| {
let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0;
if frac > 0.0 {
let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0;
a + (c - a) * frac
} else {
a
}
};
let groups: Vec<Vec<(Vec<[f64; 2]>, bool, bool)>> =
if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() };
// Per group a zig-zag polyline (in buffer px).
let mut lines: Vec<Vec<Vec<[f64; 2]>>> = Vec::new();
let mut runs = Vec::new();
for (gi, g) in groups.iter().enumerate() {
let mut nmin = f64::INFINITY;
let mut nmax = f64::NEG_INFINITY;
for (pts, _, _) in g {
for q in pts {
let v = q[0] * n[0] + q[1] * n[1];
nmin = nmin.min(v);
nmax = nmax.max(v);
}
}
nmin -= ew;
nmax += ew;
// Chains of zig-zags: each run joins the nearest chain that ended on the previous
// scan line (so separate regions get separate strokes).
let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new();
let mut s = nmin + spacing * 0.5;
let mut li = 0u32;
let reach = spacing * 3.0 + ew;
while s < nmax && li < 20_000 {
scan_runs(g, fill, ew, d, n, s, &mut runs);
for (ri, &(a, bb)) in runs.iter().enumerate() {
let k = (gi as u32) << 20 | ri as u32;
let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45);
let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45);
let (a, bb) = (a - ov, bb + ov2);
if bb <= a {
continue;
}
let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s];
let (pa, pb) = (at(a), at(bb));
let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt();
let best = chains
.iter()
.enumerate()
.filter(|(_, c)| li > 0 && c.1 == li - 1)
.map(|(i, c)| {
let last = *c.0.last().unwrap_or(&pa);
(i, dist(last, pa).min(dist(last, pb)))
})
.filter(|&(_, dd)| dd < reach)
.min_by(|x, y| x.1.total_cmp(&y.1));
match best {
Some((ci, _)) => {
let last = *chains[ci].0.last().unwrap_or(&pa);
let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) };
// Curved turn: an extra point pushed past the edge.
let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0);
let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing];
let ch = &mut chains[ci];
ch.0.extend([mid, p0, p1]);
ch.1 = li;
}
None => chains.push((vec![pa, pb], li)),
}
}
s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25);
li += 1;
}
lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect());
}
let r = width * 0.5;
let mut segs = Vec::new();
for group in &lines {
let lens: Vec<f64> = group.iter().map(|l| poly_length(l, false)).collect();
let total: f64 = lens.iter().sum();
let (g0, g1) = (total * start, total * end);
let mut acc = 0.0;
for (l, len) in group.iter().zip(lens) {
let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len));
acc += len;
poly_segs(&part, false, r, 1.0, &mut segs);
}
}
let cov = raster_segs(w, h, &segs, 0.9);
let color = pr.color("color");
paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style);
b
}
// ---------------------------------------------------------------- Stroke
fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let all = pr.b("allMasks");
let polys: Vec<_> = if all { all_masks_px(ctx, &b) } else { mask_px(ctx, &b, pr.f("path").round() as usize).into_iter().collect() };
let style = pr.e("paintStyle");
let (w, h) = (b.img.width as usize, b.img.height as usize);
let size = (pr.f("brushSize") * b.scale).max(0.1);
let r = size * 0.5;
let hard = pr.f("brushHardness") / 100.0;
let start = pr.f("start").clamp(0.0, 100.0) / 100.0;
let end = pr.f("end").clamp(0.0, 100.0) / 100.0;
let spacing = pr.f("spacing") / 100.0;
let sequential = pr.b("strokeSequentially");
let lens: Vec<f64> = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect();
let total: f64 = lens.iter().sum();
let mut segs = Vec::new();
let mut acc = 0.0;
for ((pts, closed, _), &len) in polys.iter().zip(&lens) {
let (s0, s1) = if all && sequential {
let (g0, g1) = (total * start, total * end);
((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len))
} else {
(len * start, len * end)
};
acc += len;
if s1 <= s0 {
continue;
}
if spacing <= 0.5 {
let part = trim_poly(pts, *closed, s0, s1);
poly_segs(&part, false, r, 1.0, &mut segs);
} else {
let step = (size * spacing).max(0.5);
let mut s = s0;
while s <= s1 + 1e-9 && segs.len() < 200_000 {
let (q, _) = crate::util::poly_point_at(pts, *closed, s);
segs.push(Seg { a: q, b: q, r, v: 1.0 });
s += step;
}
}
}
let cov = raster_segs(w, h, &segs, hard);
paint(&mut b.img, &cov, pr.color("color"), (pr.f("opacity") / 100.0) as f32, style);
b
}
// ---------------------------------------------------------------- Vegas
/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag).
fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> {
use std::collections::HashMap;
let (w, h) = (pl.w, pl.h);
if w < 2 || h < 2 {
return Vec::new();
}
// Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1.
let hk = |x: usize, y: usize| 2 * (y * w + x) as u64;
let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1;
let mut pos: HashMap<u64, [f64; 2]> = HashMap::new();
let mut adj: HashMap<u64, Vec<u64>> = HashMap::new();
let v = |x: usize, y: usize| pl.data[y * w + x];
let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 };
for y in 0..h - 1 {
for x in 0..w - 1 {
let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1));
let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3;
if idx == 0 || idx == 15 {
continue;
}
// Edge points (pixel centres at +0.5).
let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]);
let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]);
let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]);
let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]);
let pairs: &[(usize, usize)] = match idx {
1 | 14 => &[(3, 0)],
2 | 13 => &[(0, 1)],
3 | 12 => &[(3, 1)],
4 | 11 => &[(1, 2)],
5 => &[(3, 0), (1, 2)],
6 | 9 => &[(0, 2)],
7 | 8 => &[(3, 2)],
10 => &[(0, 1), (2, 3)],
_ => &[],
};
let e = [top, right, bottom, left];
for &(i, j) in pairs {
pos.insert(e[i].0, e[i].1);
pos.insert(e[j].0, e[j].1);
adj.entry(e[i].0).or_default().push(e[j].0);
adj.entry(e[j].0).or_default().push(e[i].0);
}
}
}
let mut keys: Vec<u64> = adj.keys().copied().collect();
keys.sort_unstable();
// Start open chains at endpoints (degree 1) first, then closed loops.
keys.sort_by_key(|k| adj[k].len() != 1);
let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new();
let mut out = Vec::new();
for &k0 in &keys {
for &first in &adj[&k0] {
if used.contains(&(k0.min(first), k0.max(first))) {
continue;
}
let mut chain = vec![pos[&k0]];
let (mut prev, mut cur) = (k0, first);
used.insert((prev.min(cur), prev.max(cur)));
let mut closed = false;
loop {
chain.push(pos[&cur]);
if cur == k0 {
closed = true;
chain.pop();
break;
}
let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n))));
match next {
Some(n) => {
used.insert((cur.min(n), cur.max(n)));
prev = cur;
cur = n;
}
None => break,
}
}
if chain.len() >= 2 {
out.push((chain, closed));
}
}
}
out
}
fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let sc = b.scale;
let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 {
mask_px(ctx, &b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default()
} else {
let src = layer_or_self(ctx, &b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1);
let ch = pr.e("channel");
let inv = pr.b("invertInput");
let mut pl = Plane::from_image(&src, |px| {
let (c, a) = unpremul(px);
let v = match ch {
1 => c[0],
2 => c[1],
3 => c[2],
4 => a,
_ => luminance(c[0], c[1], c[2]) * a,
};
if inv { 1.0 - v } else { v }
});
let blur = pr.f("preBlur") * sc;
if blur > 0.05 {
pl = gauss_plane(&pl, blur, blur);
}
let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32);
// Simplify by tolerance (drop points closer than tol × size).
let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1;
if tol > 0.0 {
for (c, _) in cs.iter_mut() {
let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len());
for &q in c.iter() {
if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) {
keep.push(q);
}
}
*c = keep;
}
}
cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1)));
if pr.e("renderMode") == 1 {
let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1;
cs.into_iter().skip(sel).take(1).collect()
} else {
cs
}
};
let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize;
let seg_len = pr.f("length").clamp(0.0, 1.0);
let bunched = pr.e("segmentDistribution") == 0;
let rot = pr.f("rotation") / 360.0;
let random_phase = pr.b("randomPhase");
let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed;
let width = (pr.f("width") * sc).max(0.1);
let hard = pr.f("hardness").clamp(0.0, 1.0);
let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99));
let mut segs = Vec::new();
for (ci, (pts, closed)) in paths.iter().enumerate() {
let len = poly_length(pts, *closed);
if len <= 0.0 {
continue;
}
let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 };
let each = len / nseg as f64 * seg_len;
for i in 0..nseg {
let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 };
let s0 = (base + phase).rem_euclid(1.0) * len;
// Sub-sample the segment so opacity can ramp along it.
let sub = 12usize;
let mut prev: Option<[f64; 2]> = None;
for k in 0..=sub {
let f = k as f64 / sub as f64;
let mut s = s0 + each * f;
if *closed {
s = s.rem_euclid(len);
} else if s > len {
break;
}
let (q, _) = crate::util::poly_point_at(pts, *closed, s);
let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 };
if let Some(pv) = prev
&& ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0
{
segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) });
}
prev = Some(q);
}
}
}
let cov = raster_segs(w, h, &segs, hard);
let color = pr.color("color");
match pr.e("blendMode") {
1 => paint(&mut b.img, &cov, color, 1.0, 0),
2 => {
b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
let a = c * color[3];
*px = over([color[0] * a, color[1] * a, color[2] * a, a], *px);
});
}
3 => paint(&mut b.img, &cov, color, 1.0, 2),
_ => paint(&mut b.img, &cov, color, 1.0, 1),
}
b
}
// ---------------------------------------------------------------- Write-on / Glue Gun
/// Brush dab history: only the brush position at the current time is known to an effect
/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along
/// the current position — a single dab when the brush is static. Deterministic.
fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let q = b.to_px(pr.v2("brushPosition"));
let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05);
let cov = raster_segs(w, h, &[Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], pr.f("brushHardness") / 100.0);
paint(&mut b.img, &cov, pr.color("color"), (pr.f("brushOpacity") / 100.0) as f32, pr.e("paintStyle"));
b
}
fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let q = b.to_px(pr.v2("brushPosition"));
let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5);
let density = pr.f("density").max(0.0);
let refl = pr.f("reflection").to_radians();
let strength = (pr.f("strength") / 100.0) as f32;
let glue_only = pr.e("style") == 1;
let src = b.img.clone();
let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53];
let beads = (density.round() as usize).clamp(1, 32);
let seed = ctx.seed;
let centers: Vec<([f64; 2], f64)> = (0..beads)
.map(|i| {
let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI;
let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 };
([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64))
})
.collect();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
// Height field: union of hemispheres.
let mut best = (0.0f64, [0.0f64; 2], 1.0f64);
for &(c, r) in &centers {
let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r);
let d2 = dx * dx + dy * dy;
if d2 < 1.0 {
let hgt = (1.0 - d2).sqrt();
if hgt > best.0 {
best = (hgt, [dx, dy], r);
}
}
}
if best.0 <= 0.0 {
if glue_only {
*px = [0.0; 4];
}
continue;
}
let nrm = [best.1[0], best.1[1], best.0];
let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0);
let spec = ndl.powi(24) as f32;
// Refraction: look through the bead.
let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64);
let edge = smoothstep(0.0, 0.15, best.0 as f32);
let shade = 0.65 + 0.35 * ndl as f32;
let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)];
let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)];
let base = if glue_only { [0.0; 4] } else { *px };
*px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3));
px[3] = px[3].clamp(0.0, 1.0);
}
});
b
}
// ---------------------------------------------------------------- Paint Bucket
fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let (w, h) = (b.img.width as usize, b.img.height as usize);
if w == 0 || h == 0 {
return b;
}
let fp = b.to_px(pr.v2("fillPoint"));
let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64);
let sel = pr.e("fillSelector");
let tol = (pr.f("tolerance") / 255.0) as f32;
let key = |p: Px| -> [f32; 4] {
let (c, a) = unpremul(p);
match sel {
1 => [c[0], c[1], c[2], 0.0],
2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }],
3 | 4 => [0.0, 0.0, 0.0, a],
_ => p,
}
};
let mut region = Plane::new(w, h);
if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h {
let seed = key(b.img.data[sy as usize * w + sx as usize]);
let within = |i: usize| {
let k = key(b.img.data[i]);
if sel == 2 {
return k[3] == seed[3];
}
(0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6)
};
let mut seen = vec![false; w * h];
let mut stack = vec![(sx as usize, sy as usize)];
while let Some((x, y)) = stack.pop() {
let row = y * w;
if seen[row + x] || !within(row + x) {
continue;
}
let mut l = x;
while l > 0 && !seen[row + l - 1] && within(row + l - 1) {
l -= 1;
}
let mut r = x;
while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) {
r += 1;
}
for i in l..=r {
seen[row + i] = true;
region.data[row + i] = 1.0;
if y > 0 && !seen[row - w + i] {
stack.push((i, y - 1));
}
if y + 1 < h && !seen[row + w + i] {
stack.push((i, y + 1));
}
}
}
}
if pr.b("viewThreshold") {
b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]);
return b;
}
let sc = b.scale;
let mut cov = match pr.e("stroke") {
1 => {
let s = pr.f("featherSoftness") * sc * 0.5;
if s > 0.05 { gauss_plane(&region, s, s) } else { region.clone() }
}
2 => morph_frac(&region, pr.f("spreadRadius") * sc, true),
3 => morph_frac(&region, pr.f("spreadRadius") * sc, false),
4 => {
let r = pr.f("strokeWidth") * sc;
let out = morph_frac(&region, r * 0.5, true);
let inn = morph_frac(&region, r * 0.5, false);
out.zip_map(&inn, |a, b| (a - b).max(0.0))
}
_ => gauss_plane(&region, 0.5, 0.5),
};
if pr.e("stroke") == 0 {
// Antialias: keep the hard interior, soften only the boundary.
cov = cov.zip_map(&region, |a, r| if r > 0.0 { a.max(0.5) } else { a });
}
let color = pr.color("color");
let op = (pr.f("opacity") / 100.0) as f32;
let mode = pr.e("blendingMode");
b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
let k = (c * op * color[3]).clamp(0.0, 1.0);
if k <= 0.0 {
if mode == 9 {
*px = [0.0; 4];
}
return;
}
let (dc, da) = unpremul(*px);
let fc = [color[0], color[1], color[2]];
let mixed = match mode {
1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]],
2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]],
3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])],
4..=7 => {
let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]);
let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]);
let (r, g, bl) = match mode {
4 => hsl_to_rgb(fh, ds, dl),
5 => hsl_to_rgb(dh, fs, dl),
6 => hsl_to_rgb(fh, fs, dl),
_ => hsl_to_rgb(dh, ds, fl),
};
[r, g, bl]
}
_ => fc,
};
*px = match mode {
8 => over(premul(fc, k), *px),
9 => premul(fc, k),
0 => over(*px, premul(fc, k)),
_ => {
if da <= 1e-6 {
return;
}
let c3 = crate::util::lerp3(dc, mixed, k);
premul(c3, da)
}
};
});
b
}
// ---------------------------------------------------------------- Eyedropper Fill
fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let (w, h) = (b.img.width as i64, b.img.height as i64);
let c = b.to_px(pr.v2("samplePoint"));
let r = (pr.f("sampleRadius") * b.scale).max(0.0);
let mode = pr.e("averagePixelColors");
let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64);
let ri = r.ceil() as i64;
let mut acc = [0.0f64; 4];
let mut n = 0.0f64;
for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) {
for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) {
if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 {
continue;
}
let px = b.img.data[(y * w + x) as usize];
match mode {
0 => {
if px[3] > 1e-4 {
let (s, _) = unpremul(px);
for k in 0..3 {
acc[k] += s[k] as f64;
}
acc[3] += 1.0;
n += 1.0;
}
}
1 => {
let (s, _) = unpremul(px);
for k in 0..3 {
acc[k] += s[k] as f64;
}
acc[3] += 1.0;
n += 1.0;
}
2 => {
for k in 0..3 {
acc[k] += px[k] as f64;
}
acc[3] += 1.0;
n += 1.0;
}
_ => {
for k in 0..4 {
acc[k] += px[k] as f64;
}
n += 1.0;
}
}
}
}
let fill: Px = if n > 0.0 {
if mode == 3 {
let a = (acc[3] / n) as f32;
let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] };
[s[0], s[1], s[2], a]
} else {
[(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0]
}
} else {
[0.0, 0.0, 0.0, 0.0]
};
let keep_alpha = pr.b("maintainOriginalAlpha");
let blend = (pr.f("blendWithOriginal") / 100.0) as f32;
b.img.data.par_iter_mut().for_each(|px| {
let a = if keep_alpha { px[3] } else { fill[3] };
let f = premul([fill[0], fill[1], fill[2]], a);
*px = crate::util::lerp4(f, *px, blend);
});
b
}
// ---------------------------------------------------------------- CC Threads
fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let sc = b.scale;
let tw = (pr.f("width") * sc).max(1.0);
let th = (pr.f("height") * sc).max(1.0);
let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64;
let ang = pr.f("direction").to_radians();
let (sn, cs) = ang.sin_cos();
let c = b.to_px(pr.v2("center"));
let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0);
let shadow = (pr.f("shadowing") / 100.0) as f32;
let texture = (pr.f("texture") / 100.0) as f32;
let src = b.img.clone();
let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
let u = dx * cs + dy * sn;
let v = -dx * sn + dy * cs;
let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64);
let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64);
// Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the
// band |fu-0.5| < coverage/2.
let in_h = (fv - 0.5).abs() < coverage * 0.5;
let in_v = (fu - 0.5).abs() < coverage * 0.5;
// Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps.
let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps;
let which = match (in_h, in_v) {
(true, true) => Some(h_top),
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
};
let Some(horizontal) = which else {
*px = [0.0; 4];
continue;
};
// Thread colour: the source sampled along the thread centre line.
let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) };
let s = src.sample_bilinear_clamped(sx, sy);
// Round profile across the thread + darkening where it dives under its neighbour.
let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) };
let along = if horizontal { fu } else { fv };
let profile = (1.0 - across * across).max(0.0).sqrt() as f32;
let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0;
let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 };
let fib =
if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 };
let k = (shade * fib).max(0.0);
*px = [s[0] * k, s[1] * k, s[2] * k, s[3]];
}
});
b
}
// ---------------------------------------------------------------- Audio Spectrum / Waveform
/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two).
pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) {
let n = re.len();
if n < 2 {
return;
}
let mut j = 0;
for i in 1..n {
let mut bit = n >> 1;
while j & bit != 0 {
j ^= bit;
bit >>= 1;
}
j |= bit;
if i < j {
re.swap(i, j);
im.swap(i, j);
}
}
let mut len = 2;
while len <= n {
let ang = -2.0 * PI / len as f64;
let (ws, wc) = ang.sin_cos();
for start in (0..n).step_by(len) {
let (mut cr, mut ci) = (1.0, 0.0);
for k in 0..len / 2 {
let (a, bb) = (start + k, start + k + len / 2);
let tr = re[bb] * cr - im[bb] * ci;
let ti = re[bb] * ci + im[bb] * cr;
re[bb] = re[a] - tr;
im[bb] = im[a] - ti;
re[a] += tr;
im[a] += ti;
let ncr = cr * wc - ci * ws;
ci = cr * ws + ci * wc;
cr = ncr;
}
}
len <<= 1;
}
}
const AUDIO_RATE: u32 = 48_000;
/// Fetch mono (or one channel) samples of the audio layer around the current time.
fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option<Vec<f64>> {
let id = ctx.params.get("audioLayer")?.as_layer()?;
let host = ctx.env.host?;
let dur = (dur_ms / 1000.0).max(0.001);
let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16);
let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5;
let s = host.audio(id, start, frames, AUDIO_RATE)?;
Some(
(0..frames)
.map(|i| {
let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64);
match channel {
1 => l,
2 => r,
_ => (l + r) * 0.5,
}
})
.collect(),
)
}
/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at
/// parameter u∈[0,1] → (point, unit normal).
struct Baseline {
pts: Vec<[f64; 2]>,
closed: bool,
len: f64,
polar: bool,
center: [f64; 2],
radius: f64,
}
impl Baseline {
fn new(ctx: &EffectCtx, b: &Buf) -> Baseline {
let pr = ctx.params;
let polar = pr.b("usePolarPath");
let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) {
Some((p, c, _)) if !polar => (p, c),
_ => {
let s = b.to_px(pr.v2("startPoint"));
let e = b.to_px(pr.v2("endPoint"));
(vec![[s.0, s.1], [e.0, e.1]], false)
}
};
let len = poly_length(&pts, closed);
let center = pts[0];
let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 };
Baseline { pts, closed, len, polar, center, radius }
}
fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) {
if self.polar {
let a = u * 2.0 * PI - PI * 0.5;
let (s, c) = a.sin_cos();
return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]);
}
let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len);
(q, [d[1], -d[0]])
}
}
/// Draw line/dot marks with core (inside colour) and soft halo (outside colour).
fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) {
let (w, h) = (b.img.width as usize, b.img.height as usize);
let core = raster_segs(w, h, segs_core, 1.0);
let halo_segs: Vec<Seg> = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect();
let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() };
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, hl) = (core.data[i], halo.data[i]);
let a = c.max(hl);
let base = if composite { *px } else { [0.0; 4] };
if a <= 0.0 {
*px = base;
return;
}
let t = if a > 0.0 { c / a } else { 0.0 };
let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t);
*px = over(base, premul(col, a));
});
}
fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0) else { return b };
let n = samples.len().next_power_of_two().max(64);
let mut re = vec![0.0; n];
let mut im = vec![0.0; n];
let m = samples.len();
for (i, s) in samples.iter().enumerate() {
// Hann window.
re[i] = s * (0.5 - 0.5 * (2.0 * PI * i as f64 / (m.max(2) - 1) as f64).cos());
}
fft(&mut re, &mut im);
let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize;
let f0 = pr.f("startFrequency").max(1.0);
let f1 = pr.f("endFrequency").max(f0 + 1.0);
let hz_per_bin = AUDIO_RATE as f64 / n as f64;
let mag = |f: f64| {
let k = ((f / hz_per_bin).round() as usize).min(n / 2);
(re[k] * re[k] + im[k] * im[k]).sqrt() * 4.0 / m as f64
};
let max_h = pr.f("maximumHeight") * b.scale;
let thick = (pr.f("thickness") * b.scale).max(0.1);
let soft = pr.f("softness") / 100.0;
let disp = pr.e("displayOptions");
let side = pr.e("sideOptions");
let base = Baseline::new(ctx, &b);
let mut tops = Vec::with_capacity(bands);
let mut segs = Vec::new();
for i in 0..bands {
let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 };
let fa = f0 * (f1 / f0).powf(i as f64 / bands as f64);
let fb = f0 * (f1 / f0).powf((i + 1) as f64 / bands as f64);
let mut v = 0.0f64;
let k = 4;
for j in 0..k {
v = v.max(mag(fa + (fb - fa) * j as f64 / k as f64));
}
let db = (20.0 * v.max(1e-9).log10() + 60.0) / 60.0;
let hgt = db.clamp(0.0, 1.0) * max_h;
let (q, nrm) = base.at(u);
let (ua, ub) = match side {
0 => (0.0, hgt),
1 => (-hgt, 0.0),
_ => (-hgt, hgt),
};
let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua];
let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub];
match disp {
0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }),
_ => tops.push((pa, pb)),
}
}
if disp == 1 {
for w2 in tops.windows(2) {
segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 });
if side == 2 {
segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 });
}
}
} else if disp == 2 {
for &(pa, pb) in &tops {
segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 });
if side == 2 {
segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 });
}
}
}
draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal"));
b
}
fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions")) else { return b };
let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize;
let max_h = pr.f("maximumHeight") * b.scale;
let thick = (pr.f("thickness") * b.scale).max(0.1);
let soft = pr.f("softness") / 100.0;
let disp = pr.e("displayOptions");
let base = Baseline::new(ctx, &b);
let m = samples.len();
let mut pts = Vec::with_capacity(count);
let mut segs = Vec::new();
for i in 0..count {
let u = i as f64 / (count - 1) as f64;
let a = ((u * (m - 1) as f64) as usize).min(m - 1);
let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1);
// Peak with sign of the window for a stable digital display.
let (mut lo, mut hi) = (0.0f64, 0.0f64);
for s in &samples[a..=bnd] {
lo = lo.min(*s);
hi = hi.max(*s);
}
let (q, nrm) = base.at(u);
match disp {
0 => segs.push(Seg {
a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h],
b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h],
r: thick * 0.5,
v: 1.0,
}),
_ => {
let v = samples[a] * max_h;
pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]);
}
}
}
if disp == 1 {
poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs);
} else if disp == 2 {
segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 }));
}
draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal"));
b
}
// ---------------------------------------------------------------- registry
pub fn specs() -> Vec<EffectSpec> {
let ang = || ParamUi::Angle;
let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox);
let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]);
let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), ParamUi::Mask);
let display = || popup(&["Digital", "Analog lines", "Analog dots"]);
vec![
spec(
"ec.generate.fractal",
"Fractal",
vec![
p(
"setChoice",
"Set Choice",
Value::Enum(0),
popup(&["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Inverse", "Mandelbrot Inverse Over Mandelbrot", "Julia", "Julia Inverse"]),
),
p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])),
p("mandelbrotX", "Mandelbrot: X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)),
p("mandelbrotY", "Mandelbrot: Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)),
p("mandelbrotMagnification", "Mandelbrot: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)),
p("mandelbrotEscapeLimit", "Mandelbrot: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)),
p("juliaX", "Julia: X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
p("juliaY", "Julia: Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
p("juliaMagnification", "Julia: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)),
p("juliaEscapeLimit", "Julia: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)),
p("postInversionX", "Post-Inversion Offset: X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
p("postInversionY", "Post-Inversion Offset: Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
check("overlay", "Overlay", false),
check("transparency", "Transparency", false),
p("palette", "Palette", Value::Enum(0), popup(&["Lightness Bands", "Hue Bands", "Hue/Lightness Bands"])),
p("hue", "Hue", num(0.0), ang()),
p("cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)),
p("cycleOffset", "Cycle Offset", num(0.0), ang()),
check("edgeHighlight", "Edge Highlight", false),
p("samplingMethod", "Sampling Method", Value::Enum(0), popup(&["Edge Detect", "Every Pixel"])),
p("samplingFactor", "Sampling Factor", num(2.0), slider(1.0, 9.0, 1.0, 9.0, 0)),
],
fractal,
),
spec(
"ec.generate.scribble",
"Scribble",
vec![
p("scribble", "Scribble", Value::Enum(0), popup(&["Single Mask", "All Masks", "All Masks Using Modes"])),
mask_idx("mask", "Mask", 1.0),
p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])),
p("edgeWidth", "Edge Options: Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), pct()),
p("angle", "Angle", num(45.0), ang()),
p("strokeWidth", "Stroke Width", num(2.0), slider(0.1, 50.0, 0.1, 50.0, 1)),
p("curviness", "Stroke Options: Curviness", num(50.0), pct()),
p("curvinessVariation", "Stroke Options: Curviness Variation", num(50.0), pct()),
p("spacing", "Stroke Options: Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)),
p("spacingVariation", "Stroke Options: Spacing Variation", num(50.0), pct()),
p("pathOverlap", "Stroke Options: Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("pathOverlapVariation", "Stroke Options: Path Overlap Variation", num(50.0), pct()),
p("start", "Start", num(0.0), pct()),
p("end", "End", num(100.0), pct()),
check("fillPathsSequentially", "Fill Paths Sequentially", true),
p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])),
p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)),
p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
p("composite", "Composite", Value::Enum(1), popup(&["On Original Image", "On Transparent", "Reveal Original Image"])),
],
scribble,
),
spec(
"ec.generate.stroke",
"Stroke",
vec![
mask_idx("path", "Path", 1.0),
check("allMasks", "All Masks", false),
check("strokeSequentially", "Stroke Sequentially", true),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("brushSize", "Brush Size", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
p("brushHardness", "Brush Hardness", num(75.0), pct()),
p("opacity", "Opacity", num(100.0), pct()),
p("start", "Start", num(0.0), pct()),
p("end", "End", num(100.0), pct()),
p("spacing", "Spacing", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("paintStyle", "Paint Style", Value::Enum(0), paint_style()),
],
stroke,
),
spec(
"ec.generate.vegas",
"Vegas",
vec![
p("stroke", "Stroke", Value::Enum(0), popup(&["Image Contours", "Mask/Path"])),
p("inputLayer", "Image Contours: Input Layer", Value::Layer(None), ParamUi::Layer),
check("invertInput", "Image Contours: Invert Input", false),
p("ifLayerSizesDiffer", "Image Contours: If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])),
p("channel", "Image Contours: Channel", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Alpha"])),
p("threshold", "Image Contours: Threshold", num(50.0), pct()),
p("preBlur", "Image Contours: Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("tolerance", "Image Contours: Tolerance", num(0.01), slider(0.0, 1.0, 0.0, 0.1, 3)),
p("renderMode", "Image Contours: Render Mode", Value::Enum(0), popup(&["All Contours", "Selected Contour"])),
p("selectedContour", "Image Contours: Selected Contour", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)),
mask_idx("path", "Mask/Path: Path", 1.0),
p("segments", "Segments: Segments", num(32.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
p("length", "Segments: Length", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("segmentDistribution", "Segments: Segment Distribution", Value::Enum(0), popup(&["Bunched", "Even"])),
p("rotation", "Segments: Rotation", num(0.0), ang()),
check("randomPhase", "Segments: Random Phase", false),
p("randomSeed", "Segments: Random Seed", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)),
p("blendMode", "Rendering: Blend Mode", Value::Enum(1), popup(&["Transparent", "Over", "Under", "Stencil"])),
p("color", "Rendering: Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("width", "Rendering: Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
p("hardness", "Rendering: Hardness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("startOpacity", "Rendering: Start Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("midpointOpacity", "Rendering: Mid-point Opacity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("midpointPosition", "Rendering: Mid-point Position", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("endOpacity", "Rendering: End Opacity", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
],
vegas,
),
spec(
"ec.generate.writeon",
"Write-on",
vec![
p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("brushSize", "Brush Size", num(6.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
p("brushHardness", "Brush Hardness", num(75.0), pct()),
p("brushOpacity", "Brush Opacity", num(100.0), pct()),
p("strokeLength", "Stroke Length (secs)", num(0.0), slider(0.0, 30.0, 0.0, 30.0, 1)),
p("brushSpacing", "Brush Spacing (secs)", num(0.001), slider(0.001, 5.0, 0.001, 5.0, 3)),
p("paintTimeProperties", "Paint Time Properties", Value::Enum(0), popup(&["None", "Opacity", "Color"])),
p("brushTimeProperties", "Brush Time Properties", Value::Enum(0), popup(&["None", "Size", "Hardness", "Size & Hardness"])),
p("paintStyle", "Paint Style", Value::Enum(0), paint_style()),
],
write_on,
),
spec(
"ec.generate.paintbucket",
"Paint Bucket",
vec![
p("fillPoint", "Fill Point", pt(0.5, 0.5), ParamUi::Point),
p("fillSelector", "Fill Selector", Value::Enum(0), popup(&["Color & Alpha", "Straight Color", "Transparency", "Opacity", "Alpha Channel"])),
p("tolerance", "Tolerance", num(10.0), slider(0.0, 255.0, 0.0, 100.0, 1)),
check("viewThreshold", "View Threshold", false),
p("stroke", "Stroke", Value::Enum(0), popup(&["Antialias", "Feather", "Spread", "Choke", "Stroke"])),
p("featherSoftness", "Feather Softness", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
p("spreadRadius", "Spread Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("strokeWidth", "Stroke Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), pct()),
p(
"blendingMode",
"Blending Mode",
Value::Enum(0),
popup(&["Normal", "Add", "Multiply", "Screen", "Hue", "Saturation", "Color", "Luminosity", "Behind", "Fill Only"]),
),
],
paint_bucket,
),
spec(
"ec.generate.eyedropperfill",
"Eyedropper Fill",
vec![
p("samplePoint", "Sample Point", pt(0.5, 0.5), ParamUi::Point),
p("sampleRadius", "Sample Radius", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("averagePixelColors", "Average Pixel Colors", Value::Enum(0), popup(&["Skip Empty", "All", "All Premultiplied", "Including Alpha"])),
check("maintainOriginalAlpha", "Maintain Original Alpha", false),
p("blendWithOriginal", "Blend With Original", num(0.0), pct()),
],
eyedropper_fill,
),
spec(
"ec.generate.ccgluegun",
"CC Glue Gun",
vec![
p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point),
p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p("density", "Density", num(5.0), slider(0.0, 32.0, 0.0, 32.0, 1)),
p("timeSpan", "Time Span (sec)", num(1.0), slider(0.0, 60.0, 0.0, 10.0, 2)),
p("reflection", "Reflection", num(45.0), ang()),
p("strength", "Strength", num(100.0), pct()),
p("style", "Style", Value::Enum(0), popup(&["Over Layer", "Glue Only"])),
],
glue_gun,
),
spec(
"ec.generate.ccthreads",
"CC Threads",
vec![
p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
p("overlaps", "Overlaps", num(1.0), slider(1.0, 20.0, 1.0, 10.0, 0)),
p("direction", "Direction", num(0.0), ang()),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("coverage", "Coverage", num(80.0), pct()),
p("shadowing", "Shadowing", num(50.0), pct()),
p("texture", "Texture", num(10.0), pct()),
],
threads,
),
spec(
"ec.generate.audiospectrum",
"Audio Spectrum",
vec![
p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer),
p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point),
p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point),
mask_idx("path", "Path", 0.0),
check("usePolarPath", "Use Polar Path", false),
p("startFrequency", "Start Frequency", num(20.0), slider(1.0, 20000.0, 1.0, 2000.0, 0)),
p("endFrequency", "End Frequency", num(2000.0), slider(1.0, 20000.0, 1.0, 10000.0, 0)),
p("frequencyBands", "Frequency bands", num(64.0), slider(1.0, 4096.0, 1.0, 256.0, 0)),
p("maximumHeight", "Maximum Height", num(50.0), slider(0.0, 10000.0, 0.0, 500.0, 1)),
p("audioDuration", "Audio Duration (milliseconds)", num(90.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)),
p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)),
p("thickness", "Thickness", num(3.0), slider(0.0, 1000.0, 0.0, 50.0, 2)),
p("softness", "Softness", num(50.0), pct()),
p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color),
check("blendOverlappingColors", "Blend Overlapping Colors", true),
p("hueInterpolation", "Hue Interpolation", num(0.0), ang()),
check("dynamicHuePhase", "Dynamic Hue Phase", false),
check("colorSymmetry", "Color Symmetry", false),
p("displayOptions", "Display Options", Value::Enum(0), display()),
p("sideOptions", "Side Options", Value::Enum(0), popup(&["Side A", "Side B", "Side A & B"])),
check("durationAveraging", "Duration Averaging", false),
check("compositeOnOriginal", "Composite On Original", false),
],
audio_spectrum,
),
spec(
"ec.generate.audiowaveform",
"Audio Waveform",
vec![
p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer),
p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point),
p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point),
mask_idx("path", "Path", 0.0),
check("usePolarPath", "Use Polar Path", false),
p("displayedSamples", "Displayed Samples", num(200.0), slider(2.0, 4096.0, 2.0, 1000.0, 0)),
p("maximumHeight", "Maximum Height", num(100.0), slider(0.0, 10000.0, 0.0, 500.0, 1)),
p("audioDuration", "Audio Duration (milliseconds)", num(200.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)),
p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)),
p("thickness", "Thickness", num(2.0), slider(0.0, 1000.0, 0.0, 50.0, 2)),
p("softness", "Softness", num(10.0), pct()),
p("randomSeed", "Random Seed (Analog)", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)),
p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color),
p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Mono", "Left", "Right"])),
p("displayOptions", "Display Options", Value::Enum(1), display()),
check("compositeOnOriginal", "Composite On Original", false),
],
audio_waveform,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, EffectHost, LayerPixels, MaskShape, Params};
/// Run with point defaults scaled to the layer size.
pub(crate) fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf {
let s = crate::find(id).unwrap();
let ls = [img.width as f64, img.height as f64];
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for p in &s.params {
if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) {
params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
}
}
for (k, v) in set {
assert!(params.values.contains_key(*k), "{id}: unknown {k}");
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: ls, seed: 3, adjustment: false, env };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}
fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf {
run_env(id, set, img, 0.5, EffectEnv::default())
}
fn square_mask() -> Vec<MaskShape> {
vec![MaskShape { name: "Mask 1".into(), points: vec![[16.0, 16.0], [48.0, 16.0], [48.0, 48.0], [16.0, 48.0]], closed: true, inverted: false }]
}
fn alpha_sum(img: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 {
let mut s = 0.0;
for y in y0..y1 {
for x in x0..x1 {
s += img.data[y * img.width as usize + x][3];
}
}
s
}
#[test]
fn fractal_deterministic_and_has_inside_set() {
let img = Image::new(64, 48);
let a = run("ec.generate.fractal", &[], img.clone());
let b = run("ec.generate.fractal", &[], img.clone());
assert_eq!(a.img.data, b.img.data);
// The default view contains the main cardioid (black, opaque) and escaped (coloured) points.
assert!(a.img.data.iter().any(|p| p[3] > 0.99 && p[0] + p[1] + p[2] < 0.01));
assert!(a.img.data.iter().any(|p| p[0] + p[1] + p[2] > 0.3));
let t = run("ec.generate.fractal", &[("transparency", Value::Bool(true))], img);
assert!(t.img.data.iter().any(|p| p[3] == 0.0));
}
#[test]
fn scribble_fills_mask_and_animates() {
let masks = square_mask();
let env = EffectEnv { masks: &masks, ..Default::default() };
let img = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]);
let a = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env);
let b = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env);
assert_eq!(a.img.data, b.img.data);
let inside = alpha_sum(&a.img, 18, 18, 46, 46);
let outside = alpha_sum(&a.img, 0, 0, 10, 64);
assert!(inside > 100.0 && outside < 1.0, "{inside} {outside}");
let c = run_env("ec.generate.scribble", &[], img.clone(), 1.5, env);
assert_ne!(a.img.data, c.img.data, "jumpy wiggle changes over time");
let s = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 0.5, env);
let s2 = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 3.5, env);
assert_eq!(s.img.data, s2.img.data, "static wiggle is constant");
let half = run_env("ec.generate.scribble", &[("end", num(50.0))], img, 0.5, env);
assert!(alpha_sum(&half.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64));
}
#[test]
fn scribble_edge_types() {
let masks = square_mask();
let env = EffectEnv { masks: &masks, ..Default::default() };
let img = Image::new(64, 64);
let o = run_env("ec.generate.scribble", &[("fillType", Value::Enum(3)), ("edgeWidth", num(6.0))], img, 0.5, env);
// Outside edge: paint near the border outside, nothing in the centre.
assert!(alpha_sum(&o.img, 26, 26, 38, 38) < 0.5);
assert!(alpha_sum(&o.img, 8, 8, 16, 56) > 5.0);
}
#[test]
fn stroke_draws_along_mask() {
let masks = square_mask();
let env = EffectEnv { masks: &masks, ..Default::default() };
let img = Image::new(64, 64);
let a = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("paintStyle", Value::Enum(1))], img.clone(), 0.0, env);
assert!(a.img.get(32, 16)[3] > 0.9, "on the top edge");
assert!(a.img.get(32, 32)[3] < 0.01, "centre empty");
let b = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("end", num(25.0))], img.clone(), 0.0, env);
assert!(b.img.get(32, 16)[3] > 0.9 && b.img.get(48, 32)[3] < 0.01, "first quarter only");
let d = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("spacing", num(300.0))], img, 0.0, env);
assert!(alpha_sum(&d.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64), "spaced dabs");
// No masks: unchanged.
let src = Image::filled(8, 8, [0.2, 0.3, 0.4, 1.0]);
assert_eq!(run("ec.generate.stroke", &[], src.clone()).img.data, src.data);
}
#[test]
fn vegas_traces_contours() {
let mut img = Image::new(64, 64);
for y in 16..48 {
for x in 16..48 {
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
let a = run(
"ec.generate.vegas",
&[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))],
img.clone(),
);
let b = run(
"ec.generate.vegas",
&[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))],
img.clone(),
);
assert_eq!(a.img.data, b.img.data);
assert!(alpha_sum(&a.img, 14, 14, 50, 50) > 50.0, "edges drawn");
assert!(alpha_sum(&a.img, 26, 26, 38, 38) < 0.5, "interior untouched");
let r = run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("rotation", num(90.0))], img);
assert_ne!(r.img.data, a.img.data);
}
#[test]
fn contours_of_square_are_closed() {
let mut pl = Plane::new(20, 20);
for y in 5..15 {
for x in 5..15 {
pl.data[y * 20 + x] = 1.0;
}
}
let c = contours(&pl, 0.5);
assert_eq!(c.len(), 1);
assert!(c[0].1);
let l = poly_length(&c[0].0, true);
assert!((l - 40.0).abs() < 4.0, "{l}");
}
#[test]
fn write_on_and_glue_gun_draw_at_brush() {
let img = Image::new(32, 32);
let a = run("ec.generate.writeon", &[], img.clone());
assert!(a.img.get(16, 16)[3] > 0.9 && a.img.get(2, 2)[3] == 0.0);
assert_eq!(a.img.data, run("ec.generate.writeon", &[], img.clone()).img.data);
let src = Image::filled(32, 32, [0.5, 0.2, 0.1, 1.0]);
let g = run("ec.generate.ccgluegun", &[], src.clone());
assert_eq!(g.img.data, run("ec.generate.ccgluegun", &[], src.clone()).img.data);
assert_ne!(g.img.get(16, 16), src.get(16, 16));
assert_eq!(g.img.get(0, 0), src.get(0, 0));
}
#[test]
fn paint_bucket_fills_enclosed_region_only() {
let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]);
for i in 8..24 {
for &(x, y) in &[(i, 8), (i, 23), (8, i), (23, i)] {
img.set(x, y, [0.0, 0.0, 0.0, 1.0]);
}
}
let out = run("ec.generate.paintbucket", &[], img.clone());
let inside = out.img.get(16, 16);
assert!(inside[0] > 0.99 && inside[1] < 0.01, "{inside:?}");
assert_eq!(out.img.get(2, 2), [1.0, 1.0, 1.0, 1.0], "outside untouched");
assert_eq!(out.img.get(8, 16), [0.0, 0.0, 0.0, 1.0], "border untouched");
assert_eq!(out.img.data, run("ec.generate.paintbucket", &[], img).img.data);
}
#[test]
fn eyedropper_fill_uses_sample() {
let mut img = Image::filled(16, 16, [0.0, 0.0, 1.0, 1.0]);
img.set(8, 8, [1.0, 0.0, 0.0, 1.0]);
let out = run("ec.generate.eyedropperfill", &[], img.clone());
assert!(out.img.data.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0]));
let blended = run("ec.generate.eyedropperfill", &[("blendWithOriginal", num(100.0))], img.clone());
assert_eq!(blended.img.data, img.data);
}
#[test]
fn threads_weave_is_deterministic_and_gappy() {
let src = Image::filled(64, 64, [0.4, 0.6, 0.2, 1.0]);
let a = run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src.clone());
assert_eq!(a.img.data, run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src).img.data);
assert!(a.img.data.iter().any(|p| p[3] == 0.0), "gaps between threads");
assert!(a.img.data.iter().any(|p| p[3] > 0.99), "threads");
}
struct Tone;
impl EffectHost for Tone {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
Some(
(0..frames)
.flat_map(|i| {
let t = start + i as f64 / rate as f64;
let v = (2.0 * PI * 440.0 * t).sin() as f32 * 0.8;
[v, v]
})
.collect(),
)
}
}
#[test]
fn audio_effects_without_audio_pass_through() {
let src = Image::filled(16, 16, [0.3, 0.3, 0.3, 1.0]);
for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] {
assert_eq!(run(id, &[], src.clone()).img.data, src.data, "{id}");
}
}
#[test]
fn audio_effects_draw_with_host() {
let host = Tone;
let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() };
let img = Image::new(96, 64);
for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] {
let a = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env);
let b = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env);
assert_eq!(a.img.data, b.img.data, "{id}");
assert!(alpha_sum(&a.img, 0, 0, 96, 64) > 10.0, "{id} drew nothing");
}
}
#[test]
fn fft_finds_tone() {
let n = 256;
let mut re: Vec<f64> = (0..n).map(|i| (2.0 * PI * 8.0 * i as f64 / n as f64).cos()).collect();
let mut im = vec![0.0; n];
fft(&mut re, &mut im);
let mags: Vec<f64> = (0..n / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect();
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
assert_eq!(peak, 8);
}
}