From 0afa24b88f8179afd08c501a1680336a2bbfd6d5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:01 -0400 Subject: [PATCH] M9.3: Transition effects (CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, CC Twister, CC WarpoMatic) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/transition2.rs | 543 +++++++++++++++++++++++++++++- 1 file changed, 540 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs index 5f33c4b3..2e1738b2 100644 --- a/crates/effects/src/transition2.rs +++ b/crates/effects/src/transition2.rs @@ -1,7 +1,544 @@ -//! Transition effects, batch 2. +//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, +//! CC Twister and CC WarpoMatic. +//! +//! Written from the public descriptions of these transitions (gradient-driven wipes with glass +//! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that +//! reveals a back side); the maths is our own. Completion 0 leaves the layer untouched; +//! completion 100 leaves it fully transparent (or showing the revealed / back-side layer). -use crate::EffectSpec; +use effectcraft_color::rgb_to_hsl; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, fit_layer, gauss_plane, layer_or_self, lerp4, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Transition", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn completion(ctx: &EffectCtx) -> f32 { + (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32 +} + +/// The layer in layer parameter `id` fitted to `b`, if one is chosen. +fn reveal(ctx: &EffectCtx, b: &Buf, id: &str) -> Option { + ctx.layer_param(id, true).map(|o| fit_layer(ctx, b, &o, false)) +} + +const GRAD_PROPS: [&str; 7] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Saturation"]; + +fn prop_plane(img: &Image, prop: u32) -> Plane { + Plane::from_image(img, |p| { + let (c, a) = unpremul(p); + match prop { + 0 => c[0] * a, + 1 => c[1] * a, + 2 => c[2] * a, + 3 => a, + 4 => effectcraft_color::luminance(c[0], c[1], c[2]) * a, + 5 => rgb_to_hsl(c[0], c[1], c[2]).2 * a, + _ => rgb_to_hsl(c[0], c[1], c[2]).1 * a, + } + }) +} + +/// Fraction of a pixel with gradient value `g` that has transitioned, for a soft band of +/// half-width `w`: none at completion 0, all at completion 1. +#[inline] +fn wipe_t(g: f32, c: f32, w: f32) -> f32 { + let w = w.max(1e-3); + let edge = c * (1.0 + 2.0 * w) - w; + 1.0 - smoothstep(edge - w, edge + w, g) +} + +#[inline] +fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) { + let (xi, yi) = (x as i64, y as i64); + ((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5) +} + +// ------------------------------------------------------------------ CC Glass Wipe + +fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let gimg = layer_or_self(ctx, &b, "gradientLayer", true, false); + let mut g = prop_plane(&gimg, 4); + let soft = (ctx.params.f("softness") / 100.0) as f32; + let sm = ctx.params.f("softness") * b.scale * 0.2; + if sm > 0.05 { + g = gauss_plane(&g, sm, sm); + } + let disp = (ctx.params.f("displacementAmount") * b.scale * 4.0) as f32; + let rev = reveal(ctx, &b, "layerToReveal"); + let src = b.img.clone(); + let w = soft * 0.5 + 0.01; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let t = wipe_t(g.get(x, y), c, w); + let band = 4.0 * t * (1.0 - t); + let (gx, gy) = grad(&g, x, y); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let (ox, oy) = ((gx * disp * band) as f64 * 10.0, (gy * disp * band) as f64 * 10.0); + let a = if band > 1e-4 { src.sample_bilinear(fx + ox, fy + oy) } else { src.data[y * src.width as usize + x] }; + let r = match &rev { + Some(r) if band > 1e-4 => r.sample_bilinear(fx - ox, fy - oy), + Some(r) => r.data[y * r.width as usize + x], + None => [0.0; 4], + }; + *px = lerp4(a, r, t); + } + }); + b +} + +// ------------------------------------------------------------------ CC Image Wipe + +fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let gimg = layer_or_self(ctx, &b, "layer", true, false); + let mut g = prop_plane(&gimg, ctx.params.e("property")); + let bl = ctx.params.f("blur") * b.scale * 0.5; + if bl > 0.05 { + g = gauss_plane(&g, bl, bl); + } + if ctx.params.b("inverseGradient") { + g = g.map(|v| 1.0 - v); + } + let mut w = (ctx.params.f("borderSoftness") / 100.0) as f32 * 0.5; + if ctx.params.b("autoSoftness") { + w = w.max(0.02); + } + b.img.data.par_iter_mut().zip(g.data.par_iter()).for_each(|(px, &gv)| { + let k = 1.0 - wipe_t(gv, c, w); + *px = px.map(|v| v * k); + }); + b +} + +// ------------------------------------------------------------------ CC Jaws + +fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let s = b.scale; + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let ang = ctx.params.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let width = (ctx.params.f("width") * s).max(1.0); + let amp = ctx.params.f("height") / 100.0 * width * 0.5; + let shape = ctx.params.e("shape"); + // Far enough at completion 1 that both halves (teeth included) have left the buffer. + let (bw, bh) = (b.img.width as f64, b.img.height as f64); + let vmax = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|&(x, y)| (-(x - cx) * sn + (y - cy) * cs).abs()).fold(0.0, f64::max); + let d = c * (vmax + 2.0 * amp + 2.0); + // Tooth profile in [-amp, amp] along the cut. + let profile = move |u: f64| -> f64 { + let ph = (u / width).rem_euclid(1.0); + match shape { + 0 => amp * (1.0 - 4.0 * (ph - 0.5).abs()), + 1 => { + // RoboJaw: square teeth with sloped flanks. + let t = (1.0 - 4.0 * (ph - 0.5).abs()) * 2.0; + amp * t.clamp(-1.0, 1.0) + } + 2 => { + if ph < 0.5 { + amp + } else { + -amp + } + } + _ => amp * (ph * std::f64::consts::TAU).sin(), + } + }; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // u along the cut, v across it. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let f = profile(u); + let back = |vs: f64| { + let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy); + src.sample_bilinear(sx, sy) + }; + *px = if v + d < f { + back(v + d) + } else if v - d >= f { + back(v - d) + } else { + [0.0; 4] + }; + } + }); + b +} + +// ------------------------------------------------------------------ CC Line Sweep + +fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let s = b.scale; + let ang = ctx.params.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let thick = (ctx.params.f("thickness") * s).max(1.0); + let slant = ctx.params.f("slant") * s; + let flip = ctx.params.b("flipDirection"); + // Projected extents of the buffer along the sweep (u) and across it (v). + let (w, h) = (b.img.width as f64, b.img.height as f64); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let proj = |x: f64, y: f64| (x * cs + y * sn, -x * sn + y * cs); + let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN); + for (x, y) in corners { + let (u, v) = proj(x, y); + u0 = u0.min(u); + u1 = u1.max(u); + v0 = v0.min(v); + v1 = v1.max(v); + } + let nstr = ((v1 - v0) / thick).ceil().max(1.0); + let total = (u1 - u0) + slant.abs() * nstr + 1.0; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (u, v) = proj(x as f64 + 0.5, y as f64 + 0.5); + let k = ((v - v0) / thick).floor(); + let k = if flip { nstr - 1.0 - k } else { k }; + let stagger = if slant >= 0.0 { k } else { nstr - 1.0 - k }; + let front = u0 + c * total - slant.abs() * stagger; + if u < front { + *px = [0.0; 4]; + } + } + }); + b +} + +// ------------------------------------------------------------------ CC Twister + +fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let ang = ctx.params.f("axis").to_radians(); + let (sn, cs) = ang.sin_cos(); + let shading = ctx.params.b("shading"); + let half = (b.img.width as f64).hypot(b.img.height as f64) * 0.5; + let src = b.img.clone(); + let back = reveal(ctx, &b, "backside"); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // u along the twist axis, v across it. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let s01 = ((u / half) * 0.5 + 0.5).clamp(0.0, 1.0); + let th = std::f64::consts::PI * (c * 2.0 - s01).clamp(0.0, 1.0); + let k = th.cos(); + if k.abs() < 1e-4 { + *px = [0.0; 4]; + continue; + } + let vs = v / k; + let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy); + let mut o: Px = if k > 0.0 { + src.sample_bilinear(sx, sy) + } else { + // Back side: the layer itself is seen from behind (mirrored across the axis); a + // Backside layer is mapped so it reads upright when fully flipped. + match &back { + Some(bk) => bk.sample_bilinear(u * cs + vs * sn + cx, u * sn - vs * cs + cy), + None => src.sample_bilinear(sx, sy), + } + }; + if shading { + let l = (0.4 + 0.6 * k.abs()) as f32; + o = [o[0] * l, o[1] * l, o[2] * l, o[3]]; + } + *px = o; + } + }); + b +} + +// ------------------------------------------------------------------ CC WarpoMatic + +fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let rimg = layer_or_self(ctx, &b, "reactorLayer", true, false); + let mut m = prop_plane(&rimg, 4); + let sm = ctx.params.f("smoothness") * b.scale * 0.5; + if sm > 0.05 { + m = gauss_plane(&m, sm, sm); + } + m = match ctx.params.e("reactor") { + 1 => { + // Contrast differences: distance from the local mean. + let mean = gauss_plane(&m, 8.0 * b.scale, 8.0 * b.scale); + m.zip_map(&mean, |a, b| ((a - b).abs() * 4.0).min(1.0)) + } + 2 => { + // Local differences: gradient magnitude. + let mut o = Plane::new(m.w, m.h); + o.data.par_chunks_mut(m.w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let (gx, gy) = grad(&m, x, y); + *v = ((gx * gx + gy * gy).sqrt() * 8.0).min(1.0); + } + }); + o + } + _ => m, + }; + let amt = (ctx.params.f("warpAmount") * b.scale) as f32 * 20.0; + let dir = ctx.params.e("warpDirection"); + let span = (ctx.params.f("blendSpan") / 100.0) as f32 * 0.5; + let rev = reveal(ctx, &b, "layerToReveal"); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let q = wipe_t(m.get(x, y), c, span); + let (gx, gy) = grad(&m, x, y); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let (ox, oy) = ((gx * amt * q) as f64, (gy * amt * q) as f64); + let (ix, iy) = ((gx * amt * (1.0 - q)) as f64, (gy * amt * (1.0 - q)) as f64); + let (ix, iy) = match dir { + 1 => (-ix, -iy), + 2 => (-iy, ix), + _ => (ix, iy), + }; + let out = if q < 1.0 { src.sample_bilinear(fx + ox, fy + oy) } else { [0.0; 4] }; + let inc = match &rev { + Some(r) if q > 0.0 => r.sample_bilinear(fx + ix, fy + iy), + _ => [0.0; 4], + }; + *px = lerp4(out, inc, q); + } + }); + b +} + +// ------------------------------------------------------------------ specs pub fn specs() -> Vec { - Vec::new() + let done = || p("completion", "Completion", num(0.0), pct()); + vec![ + spec( + "ec.transition.ccglasswipe", + "CC Glass Wipe", + vec![ + done(), + p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), + p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), + p("softness", "Softness", num(10.0), pct()), + p("displacementAmount", "Displacement Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + ], + glass_wipe, + ), + spec( + "ec.transition.ccimagewipe", + "CC Image Wipe", + vec![ + done(), + p("borderSoftness", "Border Softness", num(0.0), pct()), + p("autoSoftness", "Auto Softness", Value::Bool(true), ParamUi::Checkbox), + p("layer", "Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(4), popup(&GRAD_PROPS)), + p("blur", "Blur", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("inverseGradient", "Inverse Gradient", Value::Bool(false), ParamUi::Checkbox), + ], + image_wipe, + ), + spec( + "ec.transition.ccjaws", + "CC Jaws", + vec![ + done(), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("height", "Height", num(50.0), pct()), + p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("shape", "Shape", Value::Enum(0), popup(&["Spikes", "RoboJaw", "Block", "Waves"])), + ], + jaws, + ), + spec( + "ec.transition.cclinesweep", + "CC Line Sweep", + vec![ + done(), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("thickness", "Thickness", num(20.0), slider(1.0, 500.0, 1.0, 100.0, 1)), + p("slant", "Slant", num(20.0), slider(-500.0, 500.0, -100.0, 100.0, 1)), + p("flipDirection", "Flip Direction", Value::Bool(false), ParamUi::Checkbox), + ], + line_sweep, + ), + spec( + "ec.transition.cctwister", + "CC Twister", + vec![ + done(), + p("backside", "Backside", Value::Layer(None), ParamUi::Layer), + p("shading", "Shading", Value::Bool(true), ParamUi::Checkbox), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("axis", "Axis", num(45.0), ParamUi::Angle), + ], + twister, + ), + spec( + "ec.transition.ccwarpomatic", + "CC WarpoMatic", + vec![ + done(), + p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), + p("reactorLayer", "Reactor Layer", Value::Layer(None), ParamUi::Layer), + p("reactor", "Reactor", Value::Enum(0), popup(&["Brightness", "Contrast Differences", "Local Differences"])), + p("smoothness", "Smoothness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("warpAmount", "Warp Amount", num(50.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)), + p("warpDirection", "Warp Direction", Value::Enum(0), popup(&["Joint", "Opposing", "Twisting"])), + p("blendSpan", "Blend Span", num(10.0), pct()), + ], + warpomatic, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + fn ramp(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]); + } + } + img + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf { + run_fx(id, vals, img, 0.0, EffectEnv::default()) + } + + const ALL: [&str; 6] = [ + "ec.transition.ccglasswipe", + "ec.transition.ccimagewipe", + "ec.transition.ccjaws", + "ec.transition.cclinesweep", + "ec.transition.cctwister", + "ec.transition.ccwarpomatic", + ]; + + fn centre(id: &str) -> Vec<(&'static str, Value)> { + match id { + "ec.transition.ccjaws" => vec![("center", pt(20.0, 16.0)), ("width", num(10.0))], + "ec.transition.cctwister" => vec![("center", pt(20.0, 16.0))], + _ => vec![], + } + } + + #[test] + fn zero_completion_is_identity() { + let img = ramp(40, 32); + for id in ALL { + let o = run(id, ¢re(id), img.clone()); + assert_eq!(o.img.data, img.data, "{id}"); + } + } + + #[test] + fn full_completion_is_transparent() { + let img = ramp(40, 32); + for id in ALL { + if id == "ec.transition.cctwister" { + continue; + } + let mut v = centre(id); + v.push(("completion", num(100.0))); + let o = run(id, &v, img.clone()); + assert!(o.img.data.iter().all(|p| p[3].abs() < 1e-5), "{id}"); + } + } + + #[test] + fn midway_deterministic_and_partial() { + let img = ramp(40, 32); + for id in ALL { + let mut v = centre(id); + v.push(("completion", num(50.0))); + let a = run(id, &v, img.clone()); + let b = run(id, &v, img.clone()); + assert_eq!(a.img.data, b.img.data, "{id}"); + let opaque = a.img.data.iter().filter(|p| p[3] > 0.99).count(); + let clear = a.img.data.iter().filter(|p| p[3] < 0.01).count(); + if id != "ec.transition.cctwister" { + assert!(opaque > 0 && clear > 0, "{id}: {opaque} {clear}"); + } + assert_ne!(a.img.data, img.data, "{id}"); + } + } + + #[test] + fn twister_full_shows_mirrored_back() { + let img = ramp(40, 32); + let o = run( + "ec.transition.cctwister", + &[("completion", num(100.0)), ("shading", Value::Bool(false)), ("axis", num(0.0)), ("center", pt(20.0, 16.0))], + img.clone(), + ); + // Axis horizontal through the centre: the back is the source flipped vertically. + for (x, y) in [(5usize, 4usize), (30, 20), (12, 27)] { + let got = o.img.data[y * 40 + x]; + let want = img.data[(31 - y) * 40 + x]; + assert!((got[1] - want[1]).abs() < 1e-3 && (got[0] - want[0]).abs() < 1e-3, "{x},{y} {got:?} {want:?}"); + } + } + + #[test] + fn line_sweep_flip_reverses_stagger() { + let img = ramp(40, 32); + let a = run("ec.transition.cclinesweep", &[("completion", num(40.0))], img.clone()); + let b = run("ec.transition.cclinesweep", &[("completion", num(40.0)), ("flipDirection", Value::Bool(true))], img.clone()); + let cleared = |o: &Buf, y: usize| (0..40).filter(|&x| o.img.data[y * 40 + x][3] < 0.01).count(); + // Unflipped: lower stripes trail; flipped: upper stripes trail. + assert!(cleared(&a, 2) > cleared(&a, 30)); + assert!(cleared(&b, 2) < cleared(&b, 30)); + } + + #[test] + fn image_wipe_follows_gradient() { + let img = ramp(40, 32); + let o = run("ec.transition.ccimagewipe", &[("completion", num(50.0)), ("property", Value::Enum(0))], img.clone()); + // Red ramps left→right: dark (left) side goes first. + assert!(o.img.data[16 * 40 + 2][3] < 0.01); + assert!(o.img.data[16 * 40 + 37][3] > 0.99); + } }