Files
effectcraft/crates/effects/src/transition.rs
T
Brandon ThomasandClaude Opus 5.5 c8623b1864 M12.7: GPU blur/transition/generate effects (radial, camera lens, CC radial fast, wipes, venetian, cell pattern, checkerboard, grid, 4-color, noise, add grain)
Kernels in fx_generate.wgsl mirror the CPU effects; wipe edges use f64
per-column/per-row terms uploaded as (hi, lo) pairs. CPU helpers shared
with the GPU are now public (camera_lens_spans, gen_mode, pattern_kind,
GrainLook, place_layer). GPU tests in tests_fx_generate.rs.

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

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 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: &params, 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: &params, 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");
}
}