mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Align every effect's display name, category, parameter names, order, twirl-down groups, popup options, units, defaults and ranges with the public After Effects effect reference, and implement controls that were ignored or missing where cheap. One commit because the categories share the registry, migration tables and GPU kernels (only green states). Infrastructure - effects::migrate: saved instances are upgraded on project open (Session::replace_project → upgrade_effects): renamed ids (PARAM_ID_ALIASES), params moved into twirl-down groups, popup values remapped by label (POPUP_LABEL_ALIASES), refreshed names/UI hints, added params created with legacy_default values that keep old renders. - flatten_params also keys nested props by match path (group/param), so effects declare grouped params with `group/param` ids; layer-source companions sit next to grouped layer params. - lookup() resolves old effect names (EFFECT_NAME_ALIASES). - Registry-wide test: unique ids/names, non-empty names, defaults in range, popup defaults within options, value kinds match UI. - docs/effects.md generated from the registry (catalog.rs) with GPU / 32-bpc badges and per-effect status; a test keeps it current. Batches - Blur & Sharpen, Channel, 3D Channel, Perspective, Text, Utility: Camera Lens Blur iris/highlight groups, Compound Blur layer, Camera- Shake Deblur method/strength, Invert full channel list (CPU HLS/YIQ), Set Channels/Set Matte layers, Channel Combiner targets, 3D Channel Extract clamp/invert. - Color Correction: temporal smoothing + scene detect for Auto Color/ Contrast/Levels and Shadow/Highlight, Brightness & Contrast legacy switch, Change to Color tolerance group, Leave Color, Photo Filter presets, Exposure channels, Levels clip popups, Colorama phase source, Color Link sample list, Lumetri groups (CPU + GPU kernels). - Distort: Bulge, Displacement Map layer/behaviour, Magnify, Optics Compensation resize, Ripple conversion, Transform sampling, Turbulent Displace types/pinning/evolution, Wave Warp types/pinning, Twirl and Ripple radius fixes. - Generate: 4-Color Gradient, Checkerboard, Grid, Circle size/feather/ blending, Advanced Lightning types, Cell Pattern, Radio Waves, Fractal palettes, Scribble/Vegas names. - Keying, Matte, Obsolete: Color Difference Key partials/views, Unmult controls, Key Light groups, Refine Soft/Hard Matte, Roto Brush groups, Mocha shape moved to Matte, Spill Suppressor accuracy, Basic/Path Text groups. - Noise & Grain, Immersive Video, Audio, Time: Fractal/Turbulent Noise full control set (noise3.rs, GPU), grain look groups, Remove Grain, VR effects, Compressor/Gate/Distortion controls, Timewarp groups, VR Sphere to Plane name. - Simulation: AE twirl-down groups for all simulations, Foam strength, Particle Playground barrel radius and mapper targets. - Stylize, Transition: Glow looping/dimensions/alpha (CPU + GPU), Mosaic averaging, Cartoon groups, Roughen Edges evolution, Texturize layer, Gradient Wipe layer, Radial Wipe both, Card Wipe options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1940 lines
86 KiB
Rust
1940 lines
86 KiB
Rust
//! 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 {
|
||
/// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices).
|
||
over_julia: bool,
|
||
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
|
||
}
|
||
|
||
/// Fractal Set Choice options.
|
||
pub(crate) const FRACTAL_SETS: [&str; 6] =
|
||
["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"];
|
||
/// Fractal Palette options.
|
||
pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"];
|
||
/// Fractal Oversample Method options.
|
||
pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"];
|
||
|
||
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("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY"));
|
||
let mmag = pr.f("mandelbrot/mandelbrotMagnification");
|
||
let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01);
|
||
let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY"));
|
||
let jmag = pr.f("julia/juliaMagnification");
|
||
let jlim = pr.f("julia/juliaEscapeLimit").max(1.01);
|
||
let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY"));
|
||
let overlay = pr.b("fractalColor/overlay");
|
||
let transparency = pr.b("fractalColor/transparency");
|
||
let transparent_inside = transparency || overlay;
|
||
let palette = pr.e("fractalColor/palette");
|
||
let hue = (pr.f("fractalColor/hue") / 360.0) as f32;
|
||
let steps = pr.f("fractalColor/cycleSteps").max(1.0);
|
||
let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0;
|
||
let edge = pr.b("fractalColor/edgeHighlight");
|
||
let every = pr.e("highQualitySettings/samplingMethod") == 1;
|
||
let factor = pr.f("highQualitySettings/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]);
|
||
// FRACTAL_SETS order.
|
||
let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse };
|
||
let sets: (FracSet, Option<FracSet>) = match set_choice {
|
||
1 => (set(false, true, false), None),
|
||
2 => (set(false, false, true), None),
|
||
3 => (set(false, true, true), None),
|
||
4 => (set(true, false, false), None),
|
||
5 => (set(true, true, false), None),
|
||
_ => (set(false, false, 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 {
|
||
let z0 = if s.over_julia { jc } else { (0.0, 0.0) };
|
||
escape(z0, (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 {
|
||
// Solid Color: only the inside of the set is drawn (Transparency flips the sides).
|
||
if palette == 3 {
|
||
let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5);
|
||
return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] };
|
||
}
|
||
let Some(n) = n else {
|
||
return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] };
|
||
};
|
||
let u = n / steps + cyc_off;
|
||
let band = (u.rem_euclid(1.0)) as f32;
|
||
let (r, g, bl) = match palette {
|
||
// Hue Wheel: the full hue circle at full saturation and brightness.
|
||
1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5),
|
||
// Black And White: alternating bands.
|
||
2 => {
|
||
let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 };
|
||
(v, v, v)
|
||
}
|
||
// Lightness Gradient: black → hue → white, the hue moving 45° each cycle.
|
||
_ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 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("edgeOptions/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("strokeOptions/curviness") / 100.0;
|
||
let curv_var = pr.f("strokeOptions/curvinessVariation") / 100.0;
|
||
let spacing = (pr.f("strokeOptions/spacing") * sc).max(0.75).max(width * 0.25);
|
||
let spacing_var = pr.f("strokeOptions/spacingVariation") / 100.0;
|
||
let overlap = pr.f("strokeOptions/pathOverlap") / 100.0;
|
||
let overlap_var = pr.f("strokeOptions/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;
|
||
// Shorter Contours Have Fewer Segments: segment count scales with contour length.
|
||
let fewer = pr.e("stroke") == 0 && pr.e("shorterContoursHave") == 1;
|
||
let longest = paths.iter().map(|(p, c)| poly_length(p, *c)).fold(0.0, f64::max);
|
||
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 nseg = if fewer && longest > 0.0 { ((nseg as f64 * len / longest).round() as usize).max(1) } else { nseg };
|
||
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 ¢ers {
|
||
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(®ion, s, s) } else { region.clone() }
|
||
}
|
||
2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true),
|
||
3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false),
|
||
4 => {
|
||
let r = pr.f("strokeWidth") * sc;
|
||
let out = morph_frac(®ion, r * 0.5, true);
|
||
let inn = morph_frac(®ion, r * 0.5, false);
|
||
out.zip_map(&inn, |a, b| (a - b).max(0.0))
|
||
}
|
||
_ => gauss_plane(®ion, 0.5, 0.5),
|
||
};
|
||
if pr.e("stroke") == 0 {
|
||
// Antialias: keep the hard interior, soften only the boundary.
|
||
cov = cov.zip_map(®ion, |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(&FRACTAL_SETS)),
|
||
p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])),
|
||
p("mandelbrot/mandelbrotX", "X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)),
|
||
p("mandelbrot/mandelbrotY", "Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)),
|
||
p("mandelbrot/mandelbrotMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)),
|
||
p("mandelbrot/mandelbrotEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)),
|
||
p("julia/juliaX", "X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
|
||
p("julia/juliaY", "Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
|
||
p("julia/juliaMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)),
|
||
p("julia/juliaEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)),
|
||
p("postInversionOffset/postInversionX", "X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
|
||
p("postInversionOffset/postInversionY", "Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)),
|
||
check("fractalColor/overlay", "Overlay", false),
|
||
check("fractalColor/transparency", "Transparency", false),
|
||
p("fractalColor/palette", "Palette", Value::Enum(0), popup(&FRACTAL_PALETTES)),
|
||
p("fractalColor/hue", "Hue", num(0.0), ang()),
|
||
p("fractalColor/cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)),
|
||
p("fractalColor/cycleOffset", "Cycle Offset", num(0.0), ang()),
|
||
check("fractalColor/edgeHighlight", "Edge Highlight", false),
|
||
p("highQualitySettings/samplingMethod", "Oversample Method", Value::Enum(0), popup(&FRACTAL_OVERSAMPLE)),
|
||
p("highQualitySettings/samplingFactor", "Oversample 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("edgeOptions/edgeWidth", "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("strokeOptions/curviness", "Curviness", num(50.0), pct()),
|
||
p("strokeOptions/curvinessVariation", "Curviness Variation", num(50.0), pct()),
|
||
p("strokeOptions/spacing", "Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)),
|
||
p("strokeOptions/spacingVariation", "Spacing Variation", num(50.0), pct()),
|
||
p("strokeOptions/pathOverlap", "Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||
p("strokeOptions/pathOverlapVariation", "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", 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)),
|
||
p("shorterContoursHave", "Image Contours: Shorter Contours Have", Value::Enum(0), popup(&["Same Number of Segments", "Fewer Segments"])),
|
||
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: ¶ms, 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", &[("fractalColor/transparency", Value::Bool(true))], img);
|
||
assert!(t.img.data.iter().any(|p| p[3] == 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn fractal_palettes_and_set_choices() {
|
||
let img = Image::new(64, 48);
|
||
// Solid Color: only the inside of the set is drawn; Transparency flips it.
|
||
let solid = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3))], img.clone());
|
||
assert!(solid.img.data.iter().any(|p| p[3] == 0.0) && solid.img.data.iter().any(|p| p[3] > 0.99 && p[0] > 0.9));
|
||
let flipped = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3)), ("fractalColor/transparency", Value::Bool(true))], img.clone());
|
||
for (a, b) in solid.img.data.iter().zip(&flipped.img.data) {
|
||
assert!((a[3] > 0.5) != (b[3] > 0.5) || (a[3] - 0.5).abs() < 0.49);
|
||
}
|
||
// Black And White: escaped pixels are pure black or white (away from supersampled edges).
|
||
let bw = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(2)), ("highQualitySettings/samplingFactor", num(1.0))], img.clone());
|
||
assert!(bw.img.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && (p[0] == 0.0 || p[0] == 1.0)));
|
||
// Mandelbrot Over Julia depends on the Julia centre.
|
||
let o1 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(0.3))], img.clone());
|
||
let o2 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(-0.2))], img.clone());
|
||
assert_ne!(o1.img.data, o2.img.data);
|
||
let plain = run("ec.generate.fractal", &[("julia/juliaX", num(0.3))], img);
|
||
assert_ne!(o1.img.data, plain.img.data);
|
||
}
|
||
|
||
#[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)), ("edgeOptions/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);
|
||
}
|
||
}
|