mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -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>
750 lines
30 KiB
Rust
750 lines
30 KiB
Rust
//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card
|
|
//! Wipe (2D cards), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
|
|
//!
|
|
//! Every transition is the identity at 0 % completion. Angles are clockwise from "up".
|
|
|
|
use effectcraft_color::luminance;
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::ParamUi;
|
|
use effectcraft_raster::Px;
|
|
use rayon::prelude::*;
|
|
|
|
use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul};
|
|
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider};
|
|
|
|
fn spec(id: &'static str, name: &'static str, params: Vec<ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
|
EffectSpec { id, name, category: "Transition", params, render, gpu: false, float: true }
|
|
}
|
|
|
|
fn completion(ctx: &EffectCtx) -> f64 {
|
|
(ctx.params.f("completion") / 100.0).clamp(0.0, 1.0)
|
|
}
|
|
|
|
#[inline]
|
|
fn scale_px(px: &mut Px, k: f32) {
|
|
for c in px.iter_mut() {
|
|
*c *= k;
|
|
}
|
|
}
|
|
|
|
/// Resample `other` into `b`'s pixel grid with placement `mode` (Gradient Wipe's Gradient
|
|
/// Placement, Texturize's Texture Placement): 0 tile from the top-left, 1 centre once,
|
|
/// 2 stretch to fit.
|
|
pub(crate) fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image {
|
|
let (ls, os) = (ctx.layer_size, other.size);
|
|
let inv = 1.0 / b.scale.max(1e-9);
|
|
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
|
|
let (px, py) = ((x as f64 + 0.5 - b.offset[0]) * inv, (y as f64 + 0.5 - b.offset[1]) * inv);
|
|
let (qx, qy) = match mode {
|
|
0 if os[0] >= 1.0 && os[1] >= 1.0 => (px.rem_euclid(os[0]), py.rem_euclid(os[1])),
|
|
1 => (px + (os[0] - ls[0]) * 0.5, py + (os[1] - ls[1]) * 0.5),
|
|
_ if ls[0] > 0.0 && ls[1] > 0.0 => (px * os[0] / ls[0], py * os[1] / ls[1]),
|
|
_ => (px, py),
|
|
};
|
|
other.buf.img.sample_bilinear(qx * other.buf.scale + other.buf.offset[0], qy * other.buf.scale + other.buf.offset[1])
|
|
})
|
|
}
|
|
|
|
/// Largest distance from `c` to a corner of the buffer.
|
|
fn max_corner_dist(b: &Buf, c: (f64, f64), metric: impl Fn(f64, f64) -> f64) -> f64 {
|
|
let (w, h) = (b.img.width as f64, b.img.height as f64);
|
|
[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| metric(x - c.0, y - c.1)).fold(1e-6, f64::max)
|
|
}
|
|
|
|
fn block_dissolve(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let done = completion(ctx) as f32;
|
|
if done <= 0.0 {
|
|
return b;
|
|
}
|
|
let bw = (ctx.params.f("blockWidth") * b.scale).max(1e-3);
|
|
let bh = (ctx.params.f("blockHeight") * b.scale).max(1e-3);
|
|
let feather = ctx.params.f("feather") * b.scale;
|
|
let (ox, oy) = (b.offset[0], b.offset[1]);
|
|
let seed = ctx.seed ^ 0xb10c;
|
|
let mut mask = Plane::new(b.img.width as usize, b.img.height as usize);
|
|
let w = mask.w;
|
|
mask.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
|
let by = ((y as f64 + 0.5 - oy) / bh).floor() as i64;
|
|
for (x, m) in row.iter_mut().enumerate() {
|
|
let bx = ((x as f64 + 0.5 - ox) / bw).floor() as i64;
|
|
let r = hash1(bx as u32, by as u32, seed);
|
|
*m = if r >= done { 1.0 } else { 0.0 };
|
|
}
|
|
});
|
|
if feather > 0.0 {
|
|
mask = gauss_plane(&mask, feather * 0.5, feather * 0.5);
|
|
}
|
|
b.img.mul_mask(&mask.data);
|
|
b
|
|
}
|
|
|
|
fn gradient_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let done = completion(ctx) as f32;
|
|
if done <= 0.0 {
|
|
return b;
|
|
}
|
|
let soft = (ctx.params.f("softness") / 100.0) as f32;
|
|
let invert = ctx.params.b("invert");
|
|
// The gradient layer (the layer itself when none is chosen), placed over this one.
|
|
let grad = match ctx.layer_param("gradientLayer", false) {
|
|
Some(o) => place_layer(ctx, &b, &o, ctx.params.e("gradientPlacement")),
|
|
None => b.img.clone(),
|
|
};
|
|
b.img.data.par_iter_mut().zip(grad.data.par_iter()).for_each(|(px, g)| {
|
|
let (c, _) = unpremul(*g);
|
|
let mut t = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
|
if invert {
|
|
t = 1.0 - t;
|
|
}
|
|
let k = if done >= 1.0 {
|
|
0.0
|
|
} else if soft > 0.0 {
|
|
((t - (done * (1.0 + soft) - soft)) / soft).clamp(0.0, 1.0)
|
|
} else if t >= done {
|
|
1.0
|
|
} else {
|
|
0.0
|
|
};
|
|
scale_px(px, k);
|
|
});
|
|
b
|
|
}
|
|
|
|
#[inline]
|
|
fn cross(a: (f64, f64), b: (f64, f64)) -> f64 {
|
|
a.0 * b.1 - a.1 * b.0
|
|
}
|
|
|
|
fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let outer = ctx.params.f("outerRadius").max(0.0) * b.scale;
|
|
if outer <= 0.0 {
|
|
return b;
|
|
}
|
|
let n = ctx.params.f("points").round().clamp(3.0, 64.0) as usize;
|
|
let inner = if ctx.params.b("useInnerRadius") { ctx.params.f("innerRadius").max(0.0) * b.scale } else { outer };
|
|
let star = ctx.params.b("useInnerRadius");
|
|
let rot = ctx.params.f("rotation").to_radians();
|
|
let feather = ctx.params.f("feather") * b.scale;
|
|
let c = b.to_px(ctx.params.v2("center"));
|
|
let m = if star { n * 2 } else { n };
|
|
let step = std::f64::consts::TAU / m as f64;
|
|
let verts: Vec<(f64, f64)> = (0..m)
|
|
.map(|i| {
|
|
let r = if star && i % 2 == 1 { inner } else { outer };
|
|
let a = rot + i as f64 * step;
|
|
(r * a.sin(), -r * a.cos())
|
|
})
|
|
.collect();
|
|
b.img.rows_mut().for_each(|(y, row)| {
|
|
for (x, px) in row.iter_mut().enumerate() {
|
|
let d = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
|
let r = d.0.hypot(d.1);
|
|
let rb = if r < 1e-9 {
|
|
outer.min(inner)
|
|
} else {
|
|
let a = (d.0.atan2(-d.1) - rot).rem_euclid(std::f64::consts::TAU);
|
|
let i = ((a / step).floor() as usize).min(m - 1);
|
|
let (vi, vj) = (verts[i], verts[(i + 1) % m]);
|
|
let e = (vj.0 - vi.0, vj.1 - vi.1);
|
|
let dir = (d.0 / r, d.1 / r);
|
|
let den = cross(dir, e);
|
|
if den.abs() < 1e-12 { outer } else { cross(vi, e) / den }
|
|
};
|
|
let k = if feather > 0.0 {
|
|
((r - rb) / feather + 0.5).clamp(0.0, 1.0)
|
|
} else if r >= rb {
|
|
1.0
|
|
} else {
|
|
0.0
|
|
};
|
|
scale_px(px, k as f32);
|
|
}
|
|
});
|
|
b
|
|
}
|
|
|
|
fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let done = completion(ctx);
|
|
if done <= 0.0 {
|
|
return b;
|
|
}
|
|
let tw = (ctx.params.f("transitionWidth") / 100.0).clamp(0.01, 1.0);
|
|
let rows = ctx.params.f("rows").round().clamp(1.0, 1000.0) as i64;
|
|
// Rows & Columns: Independent, or Columns Follows Rows.
|
|
let cols = if ctx.params.e("rowsAndColumns") == 1 { rows } else { ctx.params.f("columns").round().clamp(1.0, 1000.0) as i64 };
|
|
let card_scale = ctx.params.f("cardScale").max(0.01);
|
|
let axis = ctx.params.e("flipAxis");
|
|
let order = ctx.params.e("flipOrder");
|
|
let back_self = ctx.params.e("backLayer") == 1;
|
|
let jitter = ctx.params.f("timingRandomness").clamp(0.0, 1.0);
|
|
let seed = ctx.params.f("randomSeed") as u32 ^ 0xca7d;
|
|
let (x0, y0, w, h) = layer_rect(ctx, &b);
|
|
let (cw, ch) = (w / cols as f64, h / rows as f64);
|
|
let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 };
|
|
let ny = |j: i64| if rows > 1 { j as f64 / (rows - 1) as f64 } else { 0.0 };
|
|
// Flip Order "Gradient": cards flip first where the gradient layer (default: this layer) is dark.
|
|
let grad = (order == 8).then(|| match ctx.layer_param("gradientLayer", false) {
|
|
Some(o) => place_layer(ctx, &b, &o, 2),
|
|
None => b.img.clone(),
|
|
});
|
|
let src = b.img.clone();
|
|
b.img = remap(&src, false, |x, y| {
|
|
let (lx, ly) = (x - x0, y - y0);
|
|
if lx < 0.0 || ly < 0.0 || lx >= w || ly >= h {
|
|
return Some((x, y));
|
|
}
|
|
let i = ((lx / cw).floor() as i64).min(cols - 1);
|
|
let j = ((ly / ch).floor() as i64).min(rows - 1);
|
|
let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch);
|
|
let mut o = match order {
|
|
1 => 1.0 - nx(i),
|
|
2 => ny(j),
|
|
3 => 1.0 - ny(j),
|
|
4 => (nx(i) + ny(j)) * 0.5,
|
|
5 => (1.0 - nx(i) + ny(j)) * 0.5,
|
|
6 => (nx(i) + 1.0 - ny(j)) * 0.5,
|
|
7 => 1.0 - (nx(i) + ny(j)) * 0.5,
|
|
8 => {
|
|
let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]);
|
|
let (c, _) = unpremul(g);
|
|
luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64
|
|
}
|
|
_ => nx(i),
|
|
};
|
|
if jitter > 0.0 {
|
|
o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter;
|
|
}
|
|
let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0);
|
|
// Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1).
|
|
let (mut dx, mut dy) = ((x - ccx) / card_scale, (y - ccy) / card_scale);
|
|
if dx.abs() > cw * 0.5 + 1e-9 || dy.abs() > ch * 0.5 + 1e-9 {
|
|
return None;
|
|
}
|
|
if p <= 0.0 {
|
|
return Some((ccx + dx, ccy + dy));
|
|
}
|
|
let cos = (p * std::f64::consts::PI).cos();
|
|
if cos.abs() < 1e-4 || (cos < 0.0 && !back_self) {
|
|
return None;
|
|
}
|
|
let around_x = match axis {
|
|
1 => false,
|
|
2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5,
|
|
_ => true,
|
|
};
|
|
if around_x {
|
|
dy /= cos;
|
|
if dy.abs() > ch * 0.5 {
|
|
return None;
|
|
}
|
|
} else {
|
|
dx /= cos;
|
|
if dx.abs() > cw * 0.5 {
|
|
return None;
|
|
}
|
|
}
|
|
Some((ccx + dx, ccy + dy))
|
|
});
|
|
b
|
|
}
|
|
|
|
fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let done = completion(ctx);
|
|
if done <= 0.0 {
|
|
return b;
|
|
}
|
|
let c = b.to_px(ctx.params.v2("center"));
|
|
let rot = ctx.params.f("rotation").to_radians();
|
|
let border = ctx.params.f("border").max(0.0) * b.scale;
|
|
let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0);
|
|
let shape = ctx.params.e("shape");
|
|
let reverse = ctx.params.b("reverse");
|
|
let (_, _, w, _) = layer_rect(ctx, &b);
|
|
let cs = (w / tiles).max(1.0);
|
|
let (s, co) = rot.sin_cos();
|
|
let metric = |dx: f64, dy: f64| match shape {
|
|
0 => dx.abs(),
|
|
2 => dx.abs().max(dy.abs()),
|
|
_ => dx.hypot(dy),
|
|
};
|
|
let maxd = max_corner_dist(&b, c, |dx, dy| {
|
|
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
|
metric(qx, qy)
|
|
});
|
|
let spread = 0.25;
|
|
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 (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
|
let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs);
|
|
let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0);
|
|
if reverse {
|
|
o = 1.0 - o;
|
|
}
|
|
let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0);
|
|
if p <= 0.0 {
|
|
continue;
|
|
}
|
|
let half = cs * 0.5 * (1.0 - p);
|
|
let m = (qx - cx).abs().max((qy - cy).abs());
|
|
let k = if border > 0.0 {
|
|
((half - m) / border + 0.5).clamp(0.0, 1.0)
|
|
} else if m < half {
|
|
1.0
|
|
} else {
|
|
0.0
|
|
};
|
|
scale_px(px, k as f32);
|
|
}
|
|
});
|
|
b
|
|
}
|
|
|
|
fn radial_scale_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let done = completion(ctx);
|
|
if done <= 0.0 {
|
|
return b;
|
|
}
|
|
let c = b.to_px(ctx.params.v2("center"));
|
|
let reverse = ctx.params.b("reverse");
|
|
let maxd = max_corner_dist(&b, c, f64::hypot);
|
|
let hole = done * maxd;
|
|
let src = b.img.clone();
|
|
b.img = remap(&src, false, |x, y| {
|
|
let (dx, dy) = (x - c.0, y - c.1);
|
|
let r = dx.hypot(dy);
|
|
if reverse {
|
|
let lim = (1.0 - done) * maxd;
|
|
if r >= lim || lim <= 1e-9 {
|
|
return None;
|
|
}
|
|
let k = maxd / lim;
|
|
Some((c.0 + dx * k, c.1 + dy * k))
|
|
} else {
|
|
if r <= hole || maxd - hole <= 1e-9 {
|
|
return None;
|
|
}
|
|
let rs = (r - hole) * maxd / (maxd - hole);
|
|
Some((c.0 + dx / r * rs, c.1 + dy / r * rs))
|
|
}
|
|
});
|
|
b
|
|
}
|
|
|
|
fn scale_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let stretch = ctx.params.f("stretch").max(0.0);
|
|
if stretch <= 0.0 {
|
|
return b;
|
|
}
|
|
let c = b.to_px(ctx.params.v2("center"));
|
|
let a = ctx.params.f("direction").to_radians();
|
|
let dir = (a.sin(), -a.cos());
|
|
let src = b.img.clone();
|
|
b.img = remap(&src, true, |x, y| {
|
|
let u = (x - c.0) * dir.0 + (y - c.1) * dir.1;
|
|
if u <= 0.0 {
|
|
return Some((x, y));
|
|
}
|
|
let back = u - u / (1.0 + stretch);
|
|
Some((x - dir.0 * back, y - dir.1 * back))
|
|
});
|
|
b
|
|
}
|
|
|
|
fn light_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|
let done = completion(ctx);
|
|
if done <= 0.0 {
|
|
return b;
|
|
}
|
|
let c = b.to_px(ctx.params.v2("center"));
|
|
let intensity = ctx.params.f("intensity") as f32 / 100.0;
|
|
let shape = ctx.params.e("shape");
|
|
let a = ctx.params.f("direction").to_radians();
|
|
let (s, co) = a.sin_cos();
|
|
let from_source = ctx.params.b("colorFromSource");
|
|
let color = ctx.params.color("color");
|
|
let reverse = ctx.params.b("reverse");
|
|
let metric = move |dx: f64, dy: f64| {
|
|
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
|
match shape {
|
|
0 => qx.abs(),
|
|
2 => qx.abs().max(qy.abs()),
|
|
_ => qx.hypot(qy),
|
|
}
|
|
};
|
|
let maxd = max_corner_dist(&b, c, metric);
|
|
let edge = if reverse { (1.0 - done) * maxd } else { done * maxd };
|
|
let band = (maxd * 0.06).max(2.0);
|
|
let ramp = (done * 20.0).min(1.0) as f32 * ((1.0 - done) * 20.0).min(1.0) as f32;
|
|
b.img.rows_mut().for_each(|(y, row)| {
|
|
for (x, px) in row.iter_mut().enumerate() {
|
|
let m = metric(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
|
let visible = if reverse { m < edge } else { m > edge };
|
|
if !visible {
|
|
*px = [0.0; 4];
|
|
continue;
|
|
}
|
|
let g = (-((m - edge) / band).powi(2)).exp() as f32 * intensity * ramp;
|
|
if g <= 0.0 {
|
|
continue;
|
|
}
|
|
let (sc, a) = unpremul(*px);
|
|
let lc = if from_source { sc } else { [color[0], color[1], color[2]] };
|
|
for i in 0..3 {
|
|
px[i] += lc[i] * g * a;
|
|
}
|
|
}
|
|
});
|
|
b
|
|
}
|
|
|
|
pub fn specs() -> Vec<EffectSpec> {
|
|
let pt = |x, y| Value::Vec2([x, y]);
|
|
let done = || p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0));
|
|
// The CC transitions call it plain "Completion".
|
|
let cc_done = || p("completion", "Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1));
|
|
vec![
|
|
spec(
|
|
"ec.transition.blockdissolve",
|
|
"Block Dissolve",
|
|
vec![
|
|
done(),
|
|
p("blockWidth", "Block Width", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)),
|
|
p("blockHeight", "Block Height", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)),
|
|
p("feather", "Feather", num(0.0), slider(0.0, 400.0, 0.0, 50.0, 1)),
|
|
],
|
|
block_dissolve,
|
|
),
|
|
spec(
|
|
"ec.transition.gradientwipe",
|
|
"Gradient Wipe",
|
|
vec![
|
|
done(),
|
|
p("softness", "Transition Softness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
|
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
|
|
p("gradientPlacement", "Gradient Placement", Value::Enum(2), popup(&["Tile Gradient", "Center Gradient", "Stretch Gradient to Fit"])),
|
|
p("invert", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox),
|
|
],
|
|
gradient_wipe,
|
|
),
|
|
spec(
|
|
"ec.transition.iriswipe",
|
|
"Iris Wipe",
|
|
vec![
|
|
p("center", "Iris Center", pt(0.5, 0.5), ParamUi::Point),
|
|
p("points", "Iris Points", num(6.0), slider(6.0, 32.0, 6.0, 32.0, 0)),
|
|
p("outerRadius", "Outer Radius", num(0.0), slider(0.0, 32000.0, 0.0, 2000.0, 1)),
|
|
p("useInnerRadius", "Use Inner Radius", Value::Bool(false), ParamUi::Checkbox),
|
|
p("innerRadius", "Inner Radius", num(0.0), slider(0.0, 32000.0, 0.0, 2000.0, 1)),
|
|
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
|
|
p("feather", "Feather", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
|
],
|
|
iris_wipe,
|
|
),
|
|
spec(
|
|
"ec.transition.cardwipe",
|
|
"Card Wipe",
|
|
vec![
|
|
done(),
|
|
p("transitionWidth", "Transition Width", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)),
|
|
p("backLayer", "Back Layer", Value::Enum(0), popup(&["None", "Self"])),
|
|
p("rowsAndColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])),
|
|
p("rows", "Rows", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
|
|
p("columns", "Columns", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
|
|
p("cardScale", "Card Scale", num(1.0), slider(0.01, 10.0, 0.01, 2.0, 2)),
|
|
p("flipAxis", "Flip Axis", Value::Enum(0), popup(&["X", "Y", "Random"])),
|
|
p(
|
|
"flipOrder",
|
|
"Flip Order",
|
|
Value::Enum(0),
|
|
popup(&[
|
|
"Left to Right",
|
|
"Right to Left",
|
|
"Top to Bottom",
|
|
"Bottom to Top",
|
|
"Top Left to Bottom Right",
|
|
"Top Right to Bottom Left",
|
|
"Bottom Left to Top Right",
|
|
"Bottom Right to Top Left",
|
|
"Gradient",
|
|
]),
|
|
),
|
|
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
|
|
p("timingRandomness", "Timing Randomness", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
|
p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
|
|
],
|
|
card_wipe,
|
|
),
|
|
spec(
|
|
"ec.transition.ccgridwipe",
|
|
"CC Grid Wipe",
|
|
vec![
|
|
cc_done(),
|
|
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
|
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
|
|
p("border", "Border", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
|
p("tiles", "Tiles", num(10.0), slider(1.0, 500.0, 1.0, 50.0, 1)),
|
|
p("shape", "Shape", Value::Enum(1), popup(&["Doors", "Radial", "Rectangular"])),
|
|
p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox),
|
|
],
|
|
grid_wipe,
|
|
),
|
|
spec(
|
|
"ec.transition.ccradialscalewipe",
|
|
"CC Radial ScaleWipe",
|
|
vec![cc_done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox)],
|
|
radial_scale_wipe,
|
|
),
|
|
spec(
|
|
"ec.transition.ccscalewipe",
|
|
"CC Scale Wipe",
|
|
vec![
|
|
p("stretch", "Stretch", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 2)),
|
|
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
|
p("direction", "Direction", num(90.0), ParamUi::Angle),
|
|
],
|
|
scale_wipe,
|
|
),
|
|
spec(
|
|
"ec.transition.cclightwipe",
|
|
"CC Light Wipe",
|
|
vec![
|
|
cc_done(),
|
|
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
|
p("intensity", "Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
|
|
p("shape", "Shape", Value::Enum(1), popup(&["Doors", "Round", "Square"])),
|
|
p("direction", "Direction", num(0.0), ParamUi::Angle),
|
|
p("colorFromSource", "Color from Source", Value::Bool(false), ParamUi::Checkbox),
|
|
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
|
p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox),
|
|
],
|
|
light_wipe,
|
|
),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Params, find};
|
|
use effectcraft_raster::Image;
|
|
|
|
fn run(id: &str, img: &Image, set: &[(&str, Value)]) -> Image {
|
|
let s = find(id).unwrap();
|
|
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
|
for ps in &s.params {
|
|
if let (ParamUi::Point, Value::Vec2(f)) = (&ps.ui, &ps.default) {
|
|
params.values.insert(ps.id.to_string(), Value::Vec2([f[0] * img.width as f64, f[1] * img.height as f64]));
|
|
}
|
|
}
|
|
for (k, v) in set {
|
|
params.values.insert(k.to_string(), v.clone());
|
|
}
|
|
let ctx =
|
|
EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false, env: Default::default() };
|
|
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
|
}
|
|
|
|
fn ramp(w: u32, h: u32) -> Image {
|
|
let mut img = Image::new(w, h);
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let v = (x as f32 + 0.5) / w as f32;
|
|
img.set(x, y, [v, v, v, 1.0]);
|
|
}
|
|
}
|
|
img
|
|
}
|
|
|
|
fn alpha_sum(img: &Image) -> f32 {
|
|
img.data.iter().map(|p| p[3]).sum()
|
|
}
|
|
|
|
#[test]
|
|
fn transitions_are_identity_at_zero_and_clear_at_full() {
|
|
let img = ramp(40, 30);
|
|
let full = alpha_sum(&img);
|
|
for id in [
|
|
"ec.transition.blockdissolve",
|
|
"ec.transition.gradientwipe",
|
|
"ec.transition.cardwipe",
|
|
"ec.transition.ccgridwipe",
|
|
"ec.transition.ccradialscalewipe",
|
|
"ec.transition.cclightwipe",
|
|
] {
|
|
assert_eq!(run(id, &img, &[]), img, "{id} at 0%");
|
|
let out = run(id, &img, &[("completion", num(100.0))]);
|
|
assert!(alpha_sum(&out) < full * 0.02, "{id} at 100%: {}", alpha_sum(&out));
|
|
let half = alpha_sum(&run(id, &img, &[("completion", num(50.0))]));
|
|
assert!(half > 0.0 && half < full, "{id} at 50%: {half}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gradient_wipe_removes_dark_pixels_first() {
|
|
let img = ramp(40, 4);
|
|
let out = run("ec.transition.gradientwipe", &img, &[("completion", num(50.0))]);
|
|
assert_eq!(out.get(5, 1)[3], 0.0);
|
|
assert_eq!(out.get(35, 1)[3], 1.0);
|
|
let inv = run("ec.transition.gradientwipe", &img, &[("completion", num(50.0)), ("invert", Value::Bool(true))]);
|
|
assert_eq!(inv.get(5, 1)[3], 1.0);
|
|
assert_eq!(inv.get(35, 1)[3], 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn iris_wipe_opens_a_polygon_hole() {
|
|
let img = Image::filled(60, 60, [1.0, 1.0, 1.0, 1.0]);
|
|
assert_eq!(run("ec.transition.iriswipe", &img, &[]), img);
|
|
let out = run("ec.transition.iriswipe", &img, &[("outerRadius", num(20.0))]);
|
|
assert_eq!(out.get(30, 30)[3], 0.0);
|
|
assert_eq!(out.get(30, 12)[3], 0.0, "vertex points up");
|
|
assert_eq!(out.get(2, 2)[3], 1.0);
|
|
// A hexagon's flat side is closer than its vertex: cos(30°)·20 ≈ 17.3 px.
|
|
assert_eq!(out.get(30 + 18, 30)[3], 1.0);
|
|
// Star with a small inner radius leaves the space between points opaque.
|
|
let star = run("ec.transition.iriswipe", &img, &[("outerRadius", num(20.0)), ("useInnerRadius", Value::Bool(true)), ("innerRadius", num(5.0))]);
|
|
assert_eq!(star.get(30, 15)[3], 0.0);
|
|
assert_eq!(star.get(30 + 12, 30)[3], 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn block_dissolve_is_binary_per_block() {
|
|
let img = Image::filled(40, 40, [1.0, 1.0, 1.0, 1.0]);
|
|
let out = run("ec.transition.blockdissolve", &img, &[("completion", num(50.0)), ("blockWidth", num(10.0)), ("blockHeight", num(10.0))]);
|
|
for by in 0..4 {
|
|
for bx in 0..4 {
|
|
let a = out.get(bx * 10, by * 10)[3];
|
|
for y in 0..10 {
|
|
for x in 0..10 {
|
|
assert_eq!(out.get(bx * 10 + x, by * 10 + y)[3], a);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let n = alpha_sum(&out) / 100.0;
|
|
assert!(n > 2.0 && n < 14.0, "{n}");
|
|
}
|
|
|
|
#[test]
|
|
fn card_wipe_flips_cards_in_order() {
|
|
let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]);
|
|
let out = run("ec.transition.cardwipe", &img, &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0))]);
|
|
// Left cards are gone, right cards untouched.
|
|
assert_eq!(out.get(2, 20)[3], 0.0);
|
|
assert_eq!(out.get(77, 20), [1.0, 0.0, 0.0, 1.0]);
|
|
let back = run(
|
|
"ec.transition.cardwipe",
|
|
&img,
|
|
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backLayer", Value::Enum(1))],
|
|
);
|
|
assert_eq!(back.get(2, 20)[3], 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn radial_scale_wipe_pushes_content_outward() {
|
|
let img = ramp(41, 41);
|
|
let out = run("ec.transition.ccradialscalewipe", &img, &[("completion", num(30.0))]);
|
|
assert_eq!(out.get(20, 20)[3], 0.0);
|
|
assert!(out.get(0, 0)[3] > 0.0);
|
|
let rev = run("ec.transition.ccradialscalewipe", &img, &[("completion", num(30.0)), ("reverse", Value::Bool(true))]);
|
|
assert!(rev.get(20, 20)[3] > 0.0);
|
|
assert_eq!(rev.get(0, 0)[3], 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn scale_wipe_smears_past_the_center_line() {
|
|
let img = ramp(40, 4);
|
|
assert_eq!(run("ec.transition.ccscalewipe", &img, &[]), img);
|
|
let out = run("ec.transition.ccscalewipe", &img, &[("stretch", num(1000.0))]);
|
|
// direction 90° → to the right of centre, everything samples (almost) the centre column.
|
|
assert_eq!(out.get(10, 1), img.get(10, 1));
|
|
let centre = img.get(20, 1)[0];
|
|
assert!((out.get(38, 1)[0] - centre).abs() < 0.03, "{}", out.get(38, 1)[0]);
|
|
}
|
|
|
|
#[test]
|
|
fn radial_wipe_both_opens_symmetrically() {
|
|
let img = Image::filled(41, 41, [1.0, 1.0, 1.0, 1.0]);
|
|
let both = run("ec.transition.radialwipe", &img, &[("completion", num(50.0)), ("wipe", Value::Enum(2))]);
|
|
// Start angle 0 = up: half done in both directions clears the whole top half…
|
|
assert_eq!(both.get(5, 5)[3], 0.0);
|
|
assert_eq!(both.get(35, 5)[3], 0.0);
|
|
// …and leaves the bottom half.
|
|
assert_eq!(both.get(5, 35)[3], 1.0);
|
|
assert_eq!(both.get(35, 35)[3], 1.0);
|
|
let cw = run("ec.transition.radialwipe", &img, &[("completion", num(50.0))]);
|
|
assert_eq!(cw.get(35, 35)[3], 0.0, "clockwise covers the right half");
|
|
assert_eq!(cw.get(5, 5)[3], 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn card_wipe_columns_follow_rows_scale_and_gradient_order() {
|
|
let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]);
|
|
// Card Scale 0.5 leaves gaps between untouched cards.
|
|
let out = run("ec.transition.cardwipe", &img, &[("completion", num(1.0)), ("rows", num(1.0)), ("columns", num(4.0)), ("cardScale", num(0.5))]);
|
|
assert_eq!(out.get(1, 20)[3], 0.0);
|
|
assert_eq!(out.get(70, 20)[3], 1.0);
|
|
// Columns Follows Rows: 2 rows → 2 columns, so at 50 % with a narrow transition the left
|
|
// half (first column) has flipped away while the right half remains.
|
|
let out = run(
|
|
"ec.transition.cardwipe",
|
|
&img,
|
|
&[("completion", num(50.0)), ("rows", num(2.0)), ("columns", num(40.0)), ("rowsAndColumns", Value::Enum(1)), ("transitionWidth", num(10.0))],
|
|
);
|
|
assert_eq!(out.get(30, 10)[3], 0.0);
|
|
assert_eq!(out.get(50, 10)[3], 1.0);
|
|
// Gradient order (self as gradient): dark cards flip first.
|
|
let ramp = ramp(80, 4);
|
|
let out = run(
|
|
"ec.transition.cardwipe",
|
|
&ramp,
|
|
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("flipOrder", Value::Enum(8)), ("transitionWidth", num(10.0))],
|
|
);
|
|
assert_eq!(out.get(2, 2)[3], 0.0);
|
|
assert_eq!(out.get(77, 2)[3], 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn gradient_wipe_uses_the_gradient_layer_with_placement() {
|
|
struct Host(Image);
|
|
impl crate::EffectHost for Host {
|
|
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
|
Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] })
|
|
}
|
|
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
|
None
|
|
}
|
|
}
|
|
// A flat layer wiped by a 10-px left-to-right ramp layer.
|
|
let img = Image::filled(40, 4, [1.0, 1.0, 1.0, 1.0]);
|
|
let host = Host(ramp(10, 4));
|
|
let s = find("ec.transition.gradientwipe").unwrap();
|
|
let wipe = |placement: u32| {
|
|
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
|
params.values.insert("completion".into(), num(50.0));
|
|
params.values.insert("gradientLayer".into(), Value::Layer(Some(1)));
|
|
params.values.insert("gradientPlacement".into(), Value::Enum(placement));
|
|
let env = crate::EffectEnv { host: Some(&host), ..Default::default() };
|
|
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 4.0], seed: 7, adjustment: false, env };
|
|
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
|
};
|
|
// Stretched: one ramp across the layer.
|
|
let st = wipe(2);
|
|
assert_eq!(st.get(5, 1)[3], 0.0);
|
|
assert_eq!(st.get(35, 1)[3], 1.0);
|
|
// Tiled: the ramp repeats every 10 px.
|
|
let tl = wipe(0);
|
|
assert_eq!(tl.get(11, 1)[3], 0.0);
|
|
assert_eq!(tl.get(18, 1)[3], 1.0);
|
|
assert_eq!(tl.get(31, 1)[3], 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn grid_wipe_shrinks_tiles_from_center() {
|
|
let img = Image::filled(100, 100, [1.0, 1.0, 1.0, 1.0]);
|
|
let out = run("ec.transition.ccgridwipe", &img, &[("completion", num(30.0))]);
|
|
assert_eq!(out.get(50, 50)[3], 0.0, "centre tiles are gone first");
|
|
assert_eq!(out.get(1, 1)[3], 1.0, "corners last");
|
|
}
|
|
}
|