diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index ad66f9573..4a4a8d35c 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -1,7 +1,981 @@ -//! Text effects (Numbers, Timecode) and the obsolete text generators, drawn with a built-in stroke font. +//! Text effects (Numbers, Timecode) and the obsolete generators Basic Text, Path Text and +//! Lightning. +//! +//! Effects sit below the text engine in the crate layering, so text here is drawn with a small +//! built-in single-stroke font (original work: capitals, digits and common punctuation laid out +//! on a 4 × 6 unit grid; lowercase letters use the capitals) and rasterised as anti-aliased round +//! strokes. -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate3::{Seg, mask_px, poly_segs, raster_segs}; +use crate::util::{Plane, gauss_plane, hash1, poly_length, poly_point_at}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category, params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- stroke font + +/// Glyph strokes on a grid x ∈ [0, 4], y ∈ [0, 6] (y down, baseline at 6): polylines separated +/// by `;`, points by spaces. A single point is a dot. +fn glyph_src(c: char) -> &'static str { + match c.to_ascii_uppercase() { + '0' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 3.6,0.6 0.4,5.4", + '1' => "1,1 2,0 2,6; 1,6 3,6", + '2' => "0,1 1,0 3,0 4,1 4,2 0,6 4,6", + '3' => "0,0 4,0 2,2.5 3,2.5 4,3.5 4,5 3,6 1,6 0,5", + '4' => "3,6 3,0 0,4 4,4", + '5' => "4,0 0,0 0,2.5 3,2.5 4,3.5 4,5 3,6 0,6", + '6' => "3.5,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 3,2.5 0,2.5", + '7' => "0,0 4,0 1.5,6", + '8' => "1,0 3,0 4,1 4,2 3,3 1,3 0,2 0,1 1,0; 1,3 0,4 0,5 1,6 3,6 4,5 4,4 3,3", + '9' => "4,3.5 1,3.5 0,2.5 0,1 1,0 3,0 4,1 4,5 3,6 0.5,6", + 'A' => "0,6 2,0 4,6; 0.7,4 3.3,4", + 'B' => "0,0 0,6 3,6 4,5 4,4 3,3 0,3; 0,0 3,0 4,1 4,2 3,3", + 'C' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5", + 'D' => "0,0 0,6 2.5,6 4,4.5 4,1.5 2.5,0 0,0", + 'E' => "4,0 0,0 0,6 4,6; 0,3 3,3", + 'F' => "4,0 0,0 0,6; 0,3 3,3", + 'G' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 2.5,3.5", + 'H' => "0,0 0,6; 4,0 4,6; 0,3 4,3", + 'I' => "1,0 3,0; 2,0 2,6; 1,6 3,6", + 'J' => "4,0 4,5 3,6 1,6 0,5", + 'K' => "0,0 0,6; 4,0 0,4; 1.3,3 4,6", + 'L' => "0,0 0,6 4,6", + 'M' => "0,6 0,0 2,3 4,0 4,6", + 'N' => "0,6 0,0 4,6 4,0", + 'O' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0", + 'P' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3", + 'Q' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 2.5,4.5 4,6.3", + 'R' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3; 2,3 4,6", + 'S' => "4,1 3,0 1,0 0,1 0,2 1,3 3,3 4,4 4,5 3,6 1,6 0,5", + 'T' => "0,0 4,0; 2,0 2,6", + 'U' => "0,0 0,5 1,6 3,6 4,5 4,0", + 'V' => "0,0 2,6 4,0", + 'W' => "0,0 1,6 2,2 3,6 4,0", + 'X' => "0,0 4,6; 4,0 0,6", + 'Y' => "0,0 2,3 4,0; 2,3 2,6", + 'Z' => "0,0 4,0 0,6 4,6", + ':' => "2,2; 2,5", + ';' => "2,2; 2,5 1.5,6.8", + '.' => "2,6", + ',' => "2,5.6 1.5,6.8", + '-' => "1,3.5 3,3.5", + '+' => "0.5,3.5 3.5,3.5; 2,2 2,5", + '/' => "4,0 0,6", + '(' => "2.5,-0.3 1.5,1.2 1.5,4.8 2.5,6.3", + ')' => "1.5,-0.3 2.5,1.2 2.5,4.8 1.5,6.3", + '\'' => "2,0 2,1.5", + '"' => "1.4,0 1.4,1.5; 2.6,0 2.6,1.5", + '!' => "2,0 2,4.2; 2,6", + '?' => "0,1 1,0 3,0 4,1 4,2 2,3.5 2,4.4; 2,6", + '%' => "0,6 4,0; 0.6,0.6; 3.4,5.4", + '=' => "0.5,2.5 3.5,2.5; 0.5,4.5 3.5,4.5", + '#' => "1.3,0.5 1,5.5; 3,0.5 2.7,5.5; 0,2 4,2; 0,4 4,4", + '&' => "4,6 1,2 1,0.8 1.8,0 2.8,0.8 2.8,1.8 0,4 0,5.2 1,6 2.5,6 4,4", + '*' => "2,1 2,4; 0.7,1.8 3.3,3.2; 3.3,1.8 0.7,3.2", + '_' => "0,6.5 4,6.5", + '<' => "4,1 0,3.5 4,6", + '>' => "0,1 4,3.5 0,6", + '@' => "3,4 3,2 1.8,2 1.2,3 1.8,4 3,4 4,3.5 4,1 3,0 1,0 0,1 0,5 1,6 3.5,6", + '$' => "4,1 3,0.5 1,0.5 0,1.5 1,3 3,3 4,4.5 3,5.5 1,5.5 0,5; 2,-0.3 2,6.3", + _ => "", + } +} + +fn parse_glyph(c: char) -> Vec> { + glyph_src(c) + .split(';') + .map(|s| { + s.split_whitespace() + .filter_map(|pt| { + let (x, y) = pt.split_once(',')?; + Some([x.parse().ok()?, y.parse().ok()?]) + }) + .collect::>() + }) + .filter(|v| !v.is_empty()) + .collect() +} + +/// Advance width in grid units (glyph ink + 1.6 units of side bearing). +fn advance(c: char, proportional: bool) -> f64 { + if c == ' ' { + return 3.0; + } + if !proportional { + return 5.6; + } + let g = parse_glyph(c); + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for s in &g { + for q in s { + lo = lo.min(q[0]); + hi = hi.max(q[0]); + } + } + if lo > hi { 3.0 } else { (hi - lo) + 1.6 } +} + +/// Left ink edge of a glyph (so proportional glyphs start at their ink). +fn ink_left(c: char, proportional: bool) -> f64 { + if !proportional { + return 0.8; + } + parse_glyph(c).iter().flatten().map(|q| q[0]).fold(f64::INFINITY, f64::min).min(4.0) - 0.8 +} + +/// Text metrics: grid unit in px for a font size (cap height ≈ 0.7 em) and stroke radius. +fn unit(size: f64) -> f64 { + size * 0.7 / 6.0 +} +fn weight(size: f64) -> f64 { + (size * 0.045).max(0.35) +} + +/// Width of a line of text in px (tracking in px between characters). +fn line_width(text: &str, size: f64, tracking: f64, proportional: bool) -> f64 { + let u = unit(size); + let n = text.chars().count(); + if n == 0 { + return 0.0; + } + text.chars().map(|c| advance(c, proportional) * u).sum::() + tracking * (n as f64 - 1.0) - 1.6 * u +} + +/// Polylines (px) of a line of text whose baseline starts at `origin`. +fn layout_line(text: &str, origin: [f64; 2], size: f64, tracking: f64, proportional: bool, out: &mut Vec>) { + let u = unit(size); + let mut x = origin[0]; + for c in text.chars() { + let left = ink_left(c, proportional); + for s in parse_glyph(c) { + out.push(s.iter().map(|q| [x + (q[0] - left - 0.8) * u, origin[1] + (q[1] - 6.0) * u]).collect()); + } + x += advance(c, proportional) * u + tracking; + } +} + +/// How text paints: fill/stroke display option, colours, and compositing. +struct TextLook { + display: u32, + fill: [f32; 4], + stroke: [f32; 4], + stroke_w: f64, + on_original: bool, + opacity: f32, +} + +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] +} + +fn tint(c: [f32; 4], a: f32) -> Px { + let a = (a * c[3]).clamp(0.0, 1.0); + [c[0] * a, c[1] * a, c[2] * a, a] +} + +/// Draw text polylines with the given look (`r` = glyph stroke radius in px). +fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let segs_at = |rad: f64| { + let mut s = Vec::new(); + for pl in polys { + poly_segs(pl, false, rad.max(0.05), 1.0, &mut s); + } + s + }; + let fill = raster_segs(w, h, &segs_at(r), 1.0); + let sw = look.stroke_w.max(0.0); + let ring = if look.display == 0 || sw <= 0.0 { + None + } else { + let outer = raster_segs(w, h, &segs_at(r + sw * 0.5), 1.0); + let inner = if r - sw * 0.5 > 0.05 { raster_segs(w, h, &segs_at(r - sw * 0.5), 1.0) } else { Plane::new(w, h) }; + Some(outer.zip_map(&inner, |a, b| (a - b).max(0.0))) + }; + let op = look.opacity; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut out = if look.on_original { *px } else { [0.0; 4] }; + let f = fill.data[i] * op; + let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op; + match look.display { + 1 => out = over(out, tint(look.stroke, s)), + 2 => { + out = over(out, tint(look.stroke, s)); + out = over(out, tint(look.fill, f)); + } + 3 => { + out = over(out, tint(look.fill, f)); + out = over(out, tint(look.stroke, s)); + } + _ => out = over(out, tint(look.fill, f)), + } + *px = out; + }); +} + +fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook { + let pr = ctx.params; + TextLook { + display: pr.e("displayOptions"), + fill: pr.color("fillColor"), + stroke: pr.color("strokeColor"), + stroke_w: pr.f("strokeWidth") * b.scale, + on_original: pr.b(on_original_id), + opacity: 1.0, + } +} + +// ---------------------------------------------------------------- Numbers + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; + +/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday). +fn date_from_days(days: i64) -> (i64, u32, u32, usize) { + // Days since 0000-03-01 based civil calendar conversion. + let z = days + 730_425; // 2000-01-01 relative to 0000-03-01 + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d, days.rem_euclid(7) as usize) +} + +fn timecode(frames: i64, fps: f64) -> String { + let fr = fps.round().max(1.0) as i64; + let neg = frames < 0; + let f = frames.abs(); + let s = format!("{:02}:{:02}:{:02}:{:02}", f / (fr * 3600), (f / (fr * 60)) % 60, (f / fr) % 60, f % fr); + if neg { format!("-{s}") } else { s } +} + +fn numbers_text(ctx: &EffectCtx) -> String { + let pr = ctx.params; + let kind = pr.e("type"); + let dp = pr.f("decimalPlaces").round().clamp(0.0, 10.0) as usize; + let mut v = pr.f("value"); + if pr.b("randomValues") { + let frame = (ctx.time * ctx.fps()).floor() as i64 as u32; + v = hash1(frame, 17, ctx.seed) as f64 * pr.f("value"); + } + let cur = pr.b("currentTimeDate"); + let t = if cur { ctx.time + v } else { v }; + match kind { + 1 => { + let s = format!("{:.*}", dp, v.abs()); + let (ip, fp) = s.split_once('.').map(|(a, b)| (a.to_string(), format!(".{b}"))).unwrap_or((s.clone(), String::new())); + format!("{}{:0>5}{}", if v < 0.0 { "-" } else { "" }, ip, fp) + } + 2..=4 => { + let fps = [30.0, 25.0, 24.0][(kind - 2) as usize]; + timecode((t * fps + 1e-6).floor() as i64, fps) + } + 5 => { + let s = t.floor() as i64; + let a = s.abs(); + format!("{}{:02}:{:02}:{:02}", if s < 0 { "-" } else { "" }, a / 3600, (a / 60) % 60, a % 60) + } + 6..=8 => { + let (y, m, d, wd) = date_from_days(v.floor() as i64); + match kind { + 6 => format!("{:02}/{:02}/{:02}", m, d, y.rem_euclid(100)), + 7 => format!("{} {}, {}", &MONTHS[m as usize - 1][..3], d, y), + _ => format!("{}, {} {}, {}", DAYS[wd], MONTHS[m as usize - 1], d, y), + } + } + 9 => { + let i = v.round() as i64; + if i < 0 { format!("-{:X}", -i) } else { format!("{i:X}") } + } + _ => format!("{:.*}", dp, if cur { t } else { v }), + } +} + +fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let text = numbers_text(ctx); + let size = (pr.f("size") * b.scale).max(0.5); + let tracking = pr.f("tracking") * b.scale; + let prop = pr.b("proportionalSpacing"); + let pos = b.to_px(pr.v2("position")); + let wdt = line_width(&text, size, tracking, prop); + let mut polys = Vec::new(); + layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, tracking, prop, &mut polys); + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Timecode + +/// SMPTE drop-frame label for nominal 30/60 fps rates. +fn drop_frame(frames: i64, nominal: i64) -> String { + let drop = nominal / 15; // 2 at 30, 4 at 60 + let per_min = nominal * 60 - drop; + let per_10 = per_min * 10 + drop; + let f = frames.max(0); + let d = f / per_10; + let m = f % per_10; + let adj = if m > drop { f + drop * 9 * d + drop * ((m - drop) / per_min) } else { f + drop * 9 * d }; + format!("{:02};{:02};{:02};{:02}", adj / (nominal * 3600), (adj / (nominal * 60)) % 60, (adj / nominal) % 60, adj % nominal) +} + +fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let src = pr.e("timeSource"); + let (t, fps) = match src { + 1 => (ctx.env.comp_time, ctx.fps()), + 2 => (ctx.time, pr.f("timeUnits").max(1.0)), + _ => (ctx.time, ctx.fps()), + }; + let mut frame = (t * fps + 1e-6).floor() as i64; + if src == 2 { + frame += pr.f("startingFrame").round() as i64; + } + let nominal = fps.round() as i64; + let text = match pr.e("displayFormat") { + 1 => format!("{frame}"), + 2 => format!("{}+{:02}", frame.div_euclid(16), frame.rem_euclid(16)), + 3 => format!("{}+{:02}", frame.div_euclid(40), frame.rem_euclid(40)), + _ => { + if pr.b("dropFrame") && (nominal == 30 || nominal == 60) { + drop_frame(frame, nominal) + } else { + timecode(frame, fps) + } + } + }; + let size = (pr.f("textSize") * b.scale).max(0.5); + let pos = b.to_px(pr.v2("textPosition")); + let wdt = line_width(&text, size, 0.0, false); + let op = (pr.f("opacity") / 100.0) as f32; + if !pr.b("renderOnOriginal") { + b.img = Image::new(b.img.width, b.img.height); + } + if pr.b("showBox") { + let bc = pr.color("boxColor"); + let pad = size * 0.25; + let (x0, x1) = (pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad); + let (y0, y1) = (pos.1 - size * 0.5, pos.1 + size * 0.5); + let a = op * bc[3]; + b.img.rows_mut().for_each(|(y, row)| { + let fy = y as f64 + 0.5; + let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32; + if cy <= 0.0 { + return; + } + for (x, px) in row.iter_mut().enumerate() { + let fx = x as f64 + 0.5; + let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32; + let k = cx * cy * a; + if k > 0.0 { + *px = over(*px, [bc[0] * k, bc[1] * k, bc[2] * k, k]); + } + } + }); + } + let mut polys = Vec::new(); + layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, 0.0, false, &mut polys); + let look = TextLook { display: 0, fill: pr.color("textColor"), stroke: [0.0; 4], stroke_w: 0.0, on_original: true, opacity: op }; + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Basic Text + +fn basic_text(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let size = (pr.f("size") * b.scale).max(0.5); + let tracking = pr.f("tracking") * b.scale; + let lead = size * pr.f("lineSpacing") / 100.0 * 1.2; + let pos = b.to_px(pr.v2("position")); + let align = pr.e("alignment"); + let lines: Vec<&str> = pr.s("text").split(['\n', '\r']).collect(); + let n = lines.len() as f64; + let mut polys = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let wdt = line_width(l, size, tracking, true); + let x = match align { + 0 => pos.0, + 2 => pos.0 - wdt, + _ => pos.0 - wdt * 0.5, + }; + let y = pos.1 + unit(size) * 3.0 + (i as f64 - (n - 1.0) * 0.5) * lead; + layout_line(l, [x, y], size, tracking, true, &mut polys); + } + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Path Text + +fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let px = |id: &str| { + let q = b.to_px(pr.v2(id)); + [q.0, q.1] + }; + let (mut path, mut closed) = match mask_px(ctx, &b, pr.f("customPath").round() as usize) { + Some((p, c, _)) => (p, c), + None => match pr.e("shapeType") { + 1 | 2 => { + let c = px("vertex1"); + let s = px("tangent1"); + let r = ((s[0] - c[0]).powi(2) + (s[1] - c[1]).powi(2)).sqrt().max(1.0); + let a0 = (s[1] - c[1]).atan2(s[0] - c[0]); + let n = 128; + ( + (0..n) + .map(|i| { + let a = a0 + 2.0 * PI * i as f64 / n as f64; + [c[0] + a.cos() * r, c[1] + a.sin() * r] + }) + .collect(), + true, + ) + } + 3 => (vec![px("vertex1"), px("vertex2")], false), + _ => { + let (v1, t1, t2, v2) = (px("vertex1"), px("tangent1"), px("tangent2"), px("vertex2")); + ( + (0..=64) + .map(|i| { + let t = i as f64 / 64.0; + let u = 1.0 - t; + let f = |k: usize| u * u * u * v1[k] + 3.0 * u * u * t * t1[k] + 3.0 * u * t * t * t2[k] + t * t * t * v2[k]; + [f(0), f(1)] + }) + .collect(), + false, + ) + } + }, + }; + if pr.b("reversePath") { + path.reverse(); + } + if path.len() < 2 { + closed = false; + } + let len = poly_length(&path, closed); + let text: String = pr.s("text").chars().take(pr.f("visibleCharacters").max(0.0) as usize).collect(); + let size = (pr.f("size") * b.scale).max(0.5); + let u = unit(size); + let tracking = pr.f("tracking") * b.scale; + let lm = pr.f("leftMargin") * b.scale; + let rm = pr.f("rightMargin") * b.scale; + let shift = pr.f("baselineShift") * b.scale; + let rot = pr.f("characterRotation").to_radians(); + let n = text.chars().count(); + let advs: Vec = text.chars().map(|c| advance(c, true) * u).collect(); + let natural: f64 = advs.iter().sum::() + tracking * (n.max(1) as f64 - 1.0); + let avail = (len - lm - rm).max(0.0); + let (mut cursor, extra) = match pr.e("alignment") { + 1 => (len - rm - natural, 0.0), + 2 => (lm + (avail - natural) * 0.5, 0.0), + 3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }), + _ => (lm, 0.0), + }; + let mut polys = Vec::new(); + for (c, adv) in text.chars().zip(advs) { + let mid = cursor + adv * 0.5; + cursor += adv + tracking + extra; + if !closed && (mid < 0.0 || mid > len) { + continue; + } + let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid }); + let ang = d[1].atan2(d[0]) + rot; + let (s, co) = ang.sin_cos(); + let left = ink_left(c, true); + for st in parse_glyph(c) { + polys.push( + st.iter() + .map(|g| { + let lx = (g[0] - left - 0.8) * u - adv * 0.5 + 0.8 * u; + let ly = (g[1] - 6.0) * u - shift; + [q[0] + lx * co - ly * s, q[1] + lx * s + ly * co] + }) + .collect(), + ); + } + } + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Lightning (obsolete) + +/// Smooth 1-D value noise in [-1, 1]. +fn vnoise(i: u32, t: f64, seed: u32) -> f64 { + let k = t.floor(); + let f = t - k; + let a = hash1(i, k as i64 as u32, seed) as f64 * 2.0 - 1.0; + let c = hash1(i, (k as i64 + 1) as u32, seed) as f64 * 2.0 - 1.0; + let s = f * f * (3.0 - 2.0 * f); + a + (c - a) * s +} + +struct Bolt<'a> { + seed: u32, + phase: f64, + stability: f64, + detail: u32, + detail_amp: f64, + pull: [f64; 2], + out: &'a mut Vec, + counter: u32, +} + +impl Bolt<'_> { + /// One bolt from `a` to `z` with `n` segments of amplitude `amp`; returns its points. + fn path(&mut self, a: [f64; 2], z: [f64; 2], n: usize, amp: f64, fixed_end: bool) -> Vec<[f64; 2]> { + let (dx, dy) = (z[0] - a[0], z[1] - a[1]); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let nrm = [-dy / len, dx / len]; + let id = self.counter; + self.counter += 1; + let mut pts = Vec::with_capacity(n + 1); + for i in 0..=n { + let t = i as f64 / n as f64; + let env = if fixed_end { (t * PI).sin() } else { t.sqrt() }; + let off = vnoise(id * 1000 + i as u32, self.phase * (1.0 - self.stability * 0.9) + i as f64 * 0.37, self.seed) * amp * env; + let pull = t * t * len * 0.25; + pts.push([a[0] + dx * t + nrm[0] * off + self.pull[0] * pull, a[1] + dy * t + nrm[1] * off + self.pull[1] * pull]); + } + // Detail: midpoint displacement. + for lvl in 0..self.detail { + let mut nx = Vec::with_capacity(pts.len() * 2); + for (j, w) in pts.windows(2).enumerate() { + let (p0, p1) = (w[0], w[1]); + let (ex, ey) = (p1[0] - p0[0], p1[1] - p0[1]); + let l = (ex * ex + ey * ey).sqrt(); + let o = vnoise(id * 7919 + lvl * 131 + j as u32, self.phase * 1.7 + j as f64 * 0.61, self.seed ^ 0x55) * l * self.detail_amp; + nx.push(p0); + nx.push([(p0[0] + p1[0]) * 0.5 - ey / l.max(1e-9) * o, (p0[1] + p1[1]) * 0.5 + ex / l.max(1e-9) * o]); + } + nx.push(*pts.last().unwrap_or(&a)); + pts = nx; + } + pts + } +} + +fn lightning(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let s = b.to_px(pr.v2("startPoint")); + let e = b.to_px(pr.v2("endPoint")); + let sc = b.scale; + let rerun = pr.b("rerunAtEachFrame"); + let speed = pr.f("speed"); + let frame = (ctx.time * ctx.fps()).floor(); + let seed = (pr.f("randomSeed") as i64 as u32) ^ ctx.seed ^ if rerun { (frame as i64 as u32).wrapping_mul(2_654_435_761) } else { 0 }; + let phase = if rerun { 0.0 } else { ctx.time * speed * 0.25 }; + let pd = (pr.f("pullDirection") - 90.0).to_radians(); + let pf = pr.f("pullForce") / 100.0; + let width = (pr.f("width") * sc).max(0.2); + let wvar = pr.f("widthVariation").clamp(0.0, 1.0); + let core = pr.f("coreWidth").clamp(0.0, 1.0); + let mut segs: Vec = Vec::new(); + let mut bolt = Bolt { + seed, + phase, + stability: pr.f("stability").clamp(0.0, 1.0), + detail: pr.f("detailLevel").round().clamp(0.0, 6.0) as u32, + detail_amp: pr.f("detailAmplitude").clamp(0.0, 1.0) * 0.5, + pull: [pd.cos() * pf, pd.sin() * pf], + out: &mut segs, + counter: 0, + }; + let nseg = pr.f("segments").round().clamp(1.0, 64.0) as usize; + let amp = pr.f("amplitude") * sc; + let main = bolt.path([s.0, s.1], [e.0, e.1], nseg, amp, pr.b("fixedEndpoint")); + let branching = pr.f("branching").clamp(0.0, 1.0); + let rebranch = pr.f("rebranching").clamp(0.0, 1.0); + let bang = pr.f("branchAngle").to_radians(); + let bseg_len = pr.f("branchSegLength").max(0.0); + let bsegs = pr.f("branchSegments").round().clamp(1.0, 32.0) as usize; + let bwidth = pr.f("branchWidth").clamp(0.0, 1.0); + let main_len = poly_length(&main, false); + let seg_len = main_len / nseg as f64; + let mut lines: Vec<(Vec<[f64; 2]>, f64)> = vec![(main.clone(), 1.0)]; + let mut queue: Vec<(Vec<[f64; 2]>, f64, u32)> = vec![(main, 1.0, 0)]; + while let Some((pts, w, depth)) = queue.pop() { + if depth >= 3 || lines.len() > 256 { + continue; + } + let prob = if depth == 0 { branching } else { rebranch }; + for (i, wpair) in pts.windows(2).enumerate() { + if hash1(i as u32 + depth * 977, lines.len() as u32, seed ^ 0xB7) as f64 >= prob * 0.35 { + continue; + } + let (p0, p1) = (wpair[0], wpair[1]); + let base = (p1[1] - p0[1]).atan2(p1[0] - p0[0]); + let side = if hash1(i as u32, depth + 3, seed) < 0.5 { -1.0 } else { 1.0 }; + let a = base + side * bang * (0.5 + hash1(i as u32, depth + 9, seed) as f64); + let l = seg_len * bseg_len * bsegs as f64; + let z = [p0[0] + a.cos() * l, p0[1] + a.sin() * l]; + let br = bolt.path(p0, z, bsegs, amp * 0.5, false); + let bw = w * bwidth; + lines.push((br.clone(), bw)); + queue.push((br, bw, depth + 1)); + } + } + for (li, (pts, w)) in lines.iter().enumerate() { + for (i, wpair) in pts.windows(2).enumerate() { + let v = 1.0 - wvar * hash1(i as u32, li as u32, seed ^ 0x77) as f64; + bolt.out.push(Seg { a: wpair[0], b: wpair[1], r: width * 0.5 * w * v, v: 1.0 }); + } + } + let (wd, ht) = (b.img.width as usize, b.img.height as usize); + let outer = raster_segs(wd, ht, &segs, 0.0); + let core_segs: Vec = segs.iter().map(|g| Seg { r: (g.r * core).max(0.3), ..*g }).collect(); + let inner = raster_segs(wd, ht, &core_segs, 0.6); + let glow = gauss_plane(&outer, width * 0.5, width * 0.5); + let oc = pr.color("outsideColor"); + let ic = pr.color("insideColor"); + let mode = pr.e("blendingMode"); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let o = outer.data[i].max(glow.data[i] * 0.8); + let c = inner.data[i]; + let a = o.max(c).min(1.0); + if a <= 0.0 { + return; + } + let t = if a > 0.0 { c / a } else { 0.0 }; + let colr = [oc[0] + (ic[0] - oc[0]) * t, oc[1] + (ic[1] - oc[1]) * t, oc[2] + (ic[2] - oc[2]) * t]; + *px = match mode { + 1 => [px[0] + colr[0] * a, px[1] + colr[1] * a, px[2] + colr[2] * a, (px[3] + a * (1.0 - px[3])).min(1.0)], + 2 => { + let sc = |d: f32, s: f32| d + s - d * s; + [sc(px[0], colr[0] * a), sc(px[1], colr[1] * a), sc(px[2], colr[2] * a), sc(px[3], a)] + } + _ => over(*px, [colr[0] * a, colr[1] * a, colr[2] * a, a]), + }; + }); + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let ang = || ParamUi::Angle; + let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); + let pt = |x: f64, y: f64| Value::Vec2([x, y]); + let display = || popup(&["Fill Only", "Stroke Only", "Fill Over Stroke", "Stroke Over Fill"]); + let size = || slider(0.0, 512.0, 0.0, 200.0, 1); + vec![ + spec( + "ec.text.numbers", + "Numbers", + "Text", + vec![ + p( + "type", + "Format: Type", + Value::Enum(0), + popup(&[ + "Number", + "Number [Leading Zeros]", + "Timecode [30]", + "Timecode [25]", + "Timecode [24]", + "Time", + "Numerical Date", + "Short Date", + "Long Date", + "Hexadecimal", + ]), + ), + check("randomValues", "Format: Random Values", false), + p("value", "Format: Value/Offset/Random Max", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 3)), + p("decimalPlaces", "Format: Decimal Places", num(2.0), slider(0.0, 10.0, 0.0, 10.0, 0)), + check("currentTimeDate", "Format: Current Time/Date", false), + p("position", "Fill and Stroke: Position", pt(0.5, 0.5), ParamUi::Point), + p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Size", num(36.0), size()), + p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + check("proportionalSpacing", "Proportional Spacing", true), + check("compositeOnOriginal", "Composite On Original", true), + ], + numbers, + ), + spec( + "ec.text.timecode", + "Timecode", + "Text", + vec![ + p("displayFormat", "Display Format", Value::Enum(0), popup(&["SMPTE HH:MM:SS:FF", "Frames", "Feet + Frames (35mm)", "Feet + Frames (16mm)"])), + p("timeSource", "Time Source", Value::Enum(0), popup(&["Layer Source", "Composition", "Custom"])), + p("timeUnits", "Time Units", num(30.0), slider(1.0, 120.0, 1.0, 60.0, 2)), + check("dropFrame", "Drop Frame", false), + p("startingFrame", "Starting Frame", num(0.0), slider(-1_000_000.0, 1_000_000.0, 0.0, 10_000.0, 0)), + p("textPosition", "Text Position", pt(0.5, 0.9), ParamUi::Point), + p("textSize", "Text Size", num(36.0), size()), + p("textColor", "Text Color", col(1.0, 1.0, 1.0), ParamUi::Color), + check("showBox", "Show Box", true), + p("boxColor", "Box Color", col(0.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + check("renderOnOriginal", "Render On Original", true), + ], + timecode_fx, + ), + spec( + "ec.obsolete.basictext", + "Basic Text", + "Obsolete", + vec![ + p("text", "Text", Value::Str("Text".into()), ParamUi::Text), + p("alignment", "Alignment", Value::Enum(1), popup(&["Left", "Center", "Right"])), + p("position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Size", num(36.0), size()), + p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + p("lineSpacing", "Line Spacing", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + check("compositeOnOriginal", "Composite On Original", true), + ], + basic_text, + ), + spec( + "ec.obsolete.pathtext", + "Path Text", + "Obsolete", + vec![ + p("text", "Text", Value::Str("Text".into()), ParamUi::Text), + p("shapeType", "Path Options: Shape Type", Value::Enum(0), popup(&["Bezier", "Circle", "Loop", "Line"])), + p("tangent1", "Control Points: Tangent 1/Circle Point", pt(0.3, 0.3), ParamUi::Point), + p("vertex1", "Control Points: Vertex 1/Circle Center", pt(0.1, 0.6), ParamUi::Point), + p("tangent2", "Control Points: Tangent 2", pt(0.7, 0.3), ParamUi::Point), + p("vertex2", "Control Points: Vertex 2/Line Right", pt(0.9, 0.6), ParamUi::Point), + p("customPath", "Path Options: Custom Path", num(0.0), slider(0.0, 32.0, 0.0, 8.0, 0)), + check("reversePath", "Path Options: Reverse Path", false), + p("displayOptions", "Fill and Stroke: Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Character: Size", num(36.0), size()), + p("tracking", "Character: Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + p("characterRotation", "Character: Orientation: Character Rotation", num(0.0), ang()), + p("alignment", "Paragraph: Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])), + p("leftMargin", "Paragraph: Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("rightMargin", "Paragraph: Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("baselineShift", "Paragraph: Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("visibleCharacters", "Advanced: Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)), + check("compositeOnOriginal", "Composite On Original", true), + ], + path_text, + ), + spec( + "ec.obsolete.lightning", + "Lightning", + "Obsolete", + vec![ + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + p("segments", "Segments", num(7.0), slider(1.0, 64.0, 1.0, 32.0, 0)), + p("amplitude", "Amplitude", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("detailLevel", "Detail Level", num(2.0), slider(0.0, 6.0, 0.0, 6.0, 0)), + p("detailAmplitude", "Detail Amplitude", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("branching", "Branching", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("rebranching", "Rebranching", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("branchAngle", "Branch Angle", num(20.0), ang()), + p("branchSegLength", "Branch Seg. Length", num(0.5), slider(0.0, 2.0, 0.0, 2.0, 3)), + p("branchSegments", "Branch Segments", num(4.0), slider(1.0, 32.0, 1.0, 16.0, 0)), + p("branchWidth", "Branch Width", num(0.7), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("speed", "Speed", num(10.0), slider(-100.0, 100.0, -50.0, 50.0, 1)), + p("stability", "Stability", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + check("fixedEndpoint", "Fixed Endpoint", true), + p("width", "Width", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("widthVariation", "Width Variation", num(0.25), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("coreWidth", "Core Width", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("outsideColor", "Outside Color", col(0.36, 0.25, 0.9), ParamUi::Color), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("pullForce", "Pull Force", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("pullDirection", "Pull Direction", num(0.0), ang()), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Add", "Screen"])), + check("rerunAtEachFrame", "Rerun At Each Frame", false), + ], + lightning, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, Params}; + + 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, time: f64) -> Buf { + run_env(id, set, img, time, EffectEnv::default()) + } + + fn ink(img: &Image) -> f32 { + img.data.iter().map(|p| p[3]).sum() + } + + #[test] + fn every_glyph_parses_within_grid() { + for c in (b'!'..=b'~').map(|c| c as char) { + for s in parse_glyph(c) { + for q in s { + assert!((-0.5..=4.5).contains(&q[0]) && (-0.5..=7.0).contains(&q[1]), "{c}: {q:?}"); + } + } + } + assert!(!parse_glyph('A').is_empty() && !parse_glyph('a').is_empty() && parse_glyph(' ').is_empty()); + } + + #[test] + fn numbers_formats() { + let params = |vals: &[(&str, Value)]| { + let s = crate::find("ec.text.numbers").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + p.values.insert(k.to_string(), v.clone()); + } + p + }; + let txt = |vals: &[(&str, Value)], t: f64| { + let pr = params(vals); + let ctx = EffectCtx { params: &pr, time: t, layer_size: [10.0, 10.0], seed: 1, adjustment: false, env: Default::default() }; + numbers_text(&ctx) + }; + assert_eq!(txt(&[("value", num(8.0))], 0.0), "8.00"); + assert_eq!(txt(&[("value", num(42.5)), ("type", Value::Enum(1))], 0.0), "00042.50"); + assert_eq!(txt(&[("value", num(3661.5)), ("type", Value::Enum(2))], 0.0), "01:01:01:15"); + assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF"); + assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00"); + assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000"); + assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0"); + assert_ne!( + txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0), + txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 1.0) + ); + } + + #[test] + fn numbers_draws_glyph_at_position() { + let img = Image::new(80, 60); + let a = run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0); + assert_eq!(a.img.data, run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0).img.data); + // Ink only around the centre. + let mut inside = 0.0; + let mut outside = 0.0; + for y in 0..60 { + for x in 0..80 { + let v = a.img.get(x, y)[3]; + if (25..55).contains(&x) && (10..50).contains(&y) { + inside += v; + } else { + outside += v; + } + } + } + assert!(inside > 20.0 && outside < 0.5, "{inside} {outside}"); + let stroke = run("ec.text.numbers", &[("value", num(8.0)), ("displayOptions", Value::Enum(2))], img, 0.0); + assert!(stroke.img.data.iter().any(|p| p[3] > 0.5 && p[1] > 0.5 * p[3]), "white stroke visible"); + } + + #[test] + fn timecode_changes_over_time() { + let img = Image::filled(120, 60, [0.2, 0.2, 0.2, 1.0]); + let a = run("ec.text.timecode", &[], img.clone(), 0.0); + let b = run("ec.text.timecode", &[], img.clone(), 1.0); + assert_eq!(a.img.data, run("ec.text.timecode", &[], img.clone(), 0.0).img.data); + assert_ne!(a.img.data, b.img.data); + assert_eq!(timecode(30 * 61 + 5, 30.0), "00:01:01:05"); + assert_eq!(drop_frame(1800, 30), "00;01;00;02"); + assert_eq!(drop_frame(17982, 30), "00;10;00;00"); + } + + #[test] + fn basic_text_draws_and_respects_composite() { + let img = Image::filled(100, 50, [0.0, 0.0, 1.0, 1.0]); + let a = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into()))], img.clone(), 0.0); + assert_eq!(a.img.get(1, 1), [0.0, 0.0, 1.0, 1.0]); + assert!(a.img.data.iter().any(|p| p[0] > 0.9)); + let t = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into())), ("compositeOnOriginal", Value::Bool(false))], img, 0.0); + assert_eq!(t.img.get(1, 1)[3], 0.0); + assert!(ink(&t.img) > 10.0); + } + + #[test] + fn path_text_follows_line_and_mask() { + let img = Image::new(120, 60); + let line = run( + "ec.obsolete.pathtext", + &[("shapeType", Value::Enum(3)), ("vertex1", Value::Vec2([5.0, 50.0])), ("vertex2", Value::Vec2([115.0, 50.0])), ("size", num(20.0))], + img.clone(), + 0.0, + ); + assert!(ink(&line.img) > 10.0); + // Text sits above the baseline at y = 50. + let below: f32 = (52..60).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| line.img.get(x, y)[3]).sum(); + assert!(below < 1.0); + let masks = vec![MaskShape { name: "M".into(), points: vec![[10.0, 20.0], [110.0, 20.0]], closed: false, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let m = run_env("ec.obsolete.pathtext", &[("customPath", num(1.0)), ("size", num(20.0))], img.clone(), 0.0, env); + let near: f32 = (0..24).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| m.img.get(x, y)[3]).sum(); + assert!(near > 10.0 && (ink(&m.img) - near).abs() < 1.0); + let circle = run("ec.obsolete.pathtext", &[("shapeType", Value::Enum(1)), ("text", Value::Str("CIRCLE TEXT".into()))], img, 0.0); + assert!(ink(&circle.img) > 10.0); + } + + #[test] + fn lightning_deterministic_and_animates() { + let img = Image::new(96, 64); + let a = run("ec.obsolete.lightning", &[], img.clone(), 0.5); + let b = run("ec.obsolete.lightning", &[], img.clone(), 0.5); + assert_eq!(a.img.data, b.img.data); + assert!(a.img.get(48, 32)[3] > 0.0 || ink(&a.img) > 50.0); + let c = run("ec.obsolete.lightning", &[], img.clone(), 1.5); + assert_ne!(a.img.data, c.img.data); + let r1 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img.clone(), 0.5); + let r2 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img, 0.5); + assert_eq!(r1.img.data, r2.img.data); + } }