mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Align every effect's display name, category, parameter names, order, twirl-down groups, popup options, units, defaults and ranges with the public After Effects effect reference, and implement controls that were ignored or missing where cheap. One commit because the categories share the registry, migration tables and GPU kernels (only green states). Infrastructure - effects::migrate: saved instances are upgraded on project open (Session::replace_project → upgrade_effects): renamed ids (PARAM_ID_ALIASES), params moved into twirl-down groups, popup values remapped by label (POPUP_LABEL_ALIASES), refreshed names/UI hints, added params created with legacy_default values that keep old renders. - flatten_params also keys nested props by match path (group/param), so effects declare grouped params with `group/param` ids; layer-source companions sit next to grouped layer params. - lookup() resolves old effect names (EFFECT_NAME_ALIASES). - Registry-wide test: unique ids/names, non-empty names, defaults in range, popup defaults within options, value kinds match UI. - docs/effects.md generated from the registry (catalog.rs) with GPU / 32-bpc badges and per-effect status; a test keeps it current. Batches - Blur & Sharpen, Channel, 3D Channel, Perspective, Text, Utility: Camera Lens Blur iris/highlight groups, Compound Blur layer, Camera- Shake Deblur method/strength, Invert full channel list (CPU HLS/YIQ), Set Channels/Set Matte layers, Channel Combiner targets, 3D Channel Extract clamp/invert. - Color Correction: temporal smoothing + scene detect for Auto Color/ Contrast/Levels and Shadow/Highlight, Brightness & Contrast legacy switch, Change to Color tolerance group, Leave Color, Photo Filter presets, Exposure channels, Levels clip popups, Colorama phase source, Color Link sample list, Lumetri groups (CPU + GPU kernels). - Distort: Bulge, Displacement Map layer/behaviour, Magnify, Optics Compensation resize, Ripple conversion, Transform sampling, Turbulent Displace types/pinning/evolution, Wave Warp types/pinning, Twirl and Ripple radius fixes. - Generate: 4-Color Gradient, Checkerboard, Grid, Circle size/feather/ blending, Advanced Lightning types, Cell Pattern, Radio Waves, Fractal palettes, Scribble/Vegas names. - Keying, Matte, Obsolete: Color Difference Key partials/views, Unmult controls, Key Light groups, Refine Soft/Hard Matte, Roto Brush groups, Mocha shape moved to Matte, Spill Suppressor accuracy, Basic/Path Text groups. - Noise & Grain, Immersive Video, Audio, Time: Fractal/Turbulent Noise full control set (noise3.rs, GPU), grain look groups, Remove Grain, VR effects, Compressor/Gate/Distortion controls, Timewarp groups, VR Sphere to Plane name. - Simulation: AE twirl-down groups for all simulations, Foam strength, Particle Playground barrel radius and mapper targets. - Stylize, Transition: Glow looping/dimensions/alpha (CPU + GPU), Mosaic averaging, Cartoon groups, Roughen Edges evolution, Texturize layer, Gradient Wipe layer, Radial Wipe both, Card Wipe options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1012 lines
45 KiB
Rust
1012 lines
45 KiB
Rust
//! 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 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<crate::ParamSpec>, 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<Vec<[f64; 2]>> {
|
||
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::<Vec<[f64; 2]>>()
|
||
})
|
||
.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::<f64>() + 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<Vec<[f64; 2]>>) {
|
||
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;
|
||
});
|
||
}
|
||
|
||
/// Parameter `id`, read from its `group` twirl-down when the effect nests it there.
|
||
fn grouped<'a>(pr: &'a crate::Params, group: &str, id: &str) -> Option<&'a Value> {
|
||
pr.get(&format!("{group}/{id}")).or_else(|| pr.get(id))
|
||
}
|
||
|
||
fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook {
|
||
let pr = ctx.params;
|
||
let fs = |id: &str| grouped(pr, "fillAndStroke", id);
|
||
TextLook {
|
||
display: fs("displayOptions").map(Value::as_enum).unwrap_or(0),
|
||
fill: fs("fillColor").map(Value::as_color).unwrap_or([1.0; 4]),
|
||
stroke: fs("strokeColor").map(Value::as_color).unwrap_or([1.0; 4]),
|
||
stroke_w: fs("strokeWidth").map(Value::as_f64).unwrap_or(0.0) * 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 }
|
||
}
|
||
|
||
/// Numbers' twirl-down groups (Format, Fill and Stroke).
|
||
const NUMBERS_GROUPS: [&str; 2] = ["format/", "fillAndStroke/"];
|
||
|
||
/// `params` with Numbers' group prefixes stripped (`format/value` → `value`), so the drawing
|
||
/// helpers shared with Basic Text read plain ids. Plain keys already present win.
|
||
fn ungroup(params: &crate::Params) -> crate::Params {
|
||
let mut out = params.clone();
|
||
for (k, v) in ¶ms.values {
|
||
if let Some(leaf) = NUMBERS_GROUPS.iter().find_map(|g| k.strip_prefix(g)) {
|
||
out.values.entry(leaf.to_string()).or_insert_with(|| v.clone());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn numbers_text(ctx: &EffectCtx) -> String {
|
||
let flat = ungroup(ctx.params);
|
||
let pr = ♭
|
||
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 flat = ungroup(ctx.params);
|
||
let ctx = &EffectCtx { params: &flat, time: ctx.time, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||
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 g = |group: &str, id: &str| grouped(pr, group, id).cloned().unwrap_or(Value::Scalar(0.0));
|
||
let px = |id: &str| {
|
||
let q = b.to_px(g("pathOptions/controlPoints", id).as_vec2());
|
||
[q.0, q.1]
|
||
};
|
||
let (mut path, mut closed) = match mask_px(ctx, &b, g("pathOptions", "customPath").as_f64().round() as usize) {
|
||
Some((p, c, _)) => (p, c),
|
||
None => match g("pathOptions", "shapeType").as_enum() {
|
||
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 g("pathOptions", "reversePath").as_bool() {
|
||
path.reverse();
|
||
}
|
||
if path.len() < 2 {
|
||
closed = false;
|
||
}
|
||
let len = poly_length(&path, closed);
|
||
let text: String = pr.s("text").chars().take(g("advanced", "visibleCharacters").as_f64().max(0.0) as usize).collect();
|
||
let size = (g("character", "size").as_f64() * b.scale).max(0.5);
|
||
let u = unit(size);
|
||
let tracking = g("character", "tracking").as_f64() * b.scale;
|
||
let lm = g("paragraph", "leftMargin").as_f64() * b.scale;
|
||
let rm = g("paragraph", "rightMargin").as_f64() * b.scale;
|
||
let shift = g("paragraph", "baselineShift").as_f64() * b.scale;
|
||
let rot = g("character/orientation", "characterRotation").as_f64().to_radians();
|
||
let n = text.chars().count();
|
||
let advs: Vec<f64> = text.chars().map(|c| advance(c, true) * u).collect();
|
||
let natural: f64 = advs.iter().sum::<f64>() + tracking * (n.max(1) as f64 - 1.0);
|
||
let avail = (len - lm - rm).max(0.0);
|
||
let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() {
|
||
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<Seg>,
|
||
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<Seg> = 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<Seg> = 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<EffectSpec> {
|
||
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(
|
||
"format/type",
|
||
"Type",
|
||
Value::Enum(0),
|
||
popup(&[
|
||
"Number",
|
||
"Number [Leading Zeros]",
|
||
"Timecode [30]",
|
||
"Timecode [25]",
|
||
"Timecode [24]",
|
||
"Time",
|
||
"Numerical Date",
|
||
"Short Date",
|
||
"Long Date",
|
||
"Hexadecimal",
|
||
]),
|
||
),
|
||
check("format/randomValues", "Random Values", false),
|
||
p("format/value", "Value/Offset/Random Max", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 3)),
|
||
p("format/decimalPlaces", "Decimal Places", num(2.0), slider(0.0, 10.0, 0.0, 10.0, 0)),
|
||
check("format/currentTimeDate", "Current Time/Date", false),
|
||
p("fillAndStroke/position", "Position", pt(0.5, 0.5), ParamUi::Point),
|
||
p("fillAndStroke/displayOptions", "Display Options", Value::Enum(0), display()),
|
||
p("fillAndStroke/fillColor", "Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||
p("fillAndStroke/strokeColor", "Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||
p("fillAndStroke/strokeWidth", "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", "Composite 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("fillAndStroke/displayOptions", "Display Options", Value::Enum(0), display()),
|
||
p("fillAndStroke/fillColor", "Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||
p("fillAndStroke/strokeColor", "Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||
p("fillAndStroke/strokeWidth", "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("pathOptions/shapeType", "Shape Type", Value::Enum(0), popup(&["Bezier", "Circle", "Loop", "Line"])),
|
||
p("pathOptions/controlPoints/tangent1", "Tangent 1/Circle Point", pt(0.3, 0.3), ParamUi::Point),
|
||
p("pathOptions/controlPoints/vertex1", "Vertex 1/Circle Center", pt(0.1, 0.6), ParamUi::Point),
|
||
p("pathOptions/controlPoints/tangent2", "Tangent 2", pt(0.7, 0.3), ParamUi::Point),
|
||
p("pathOptions/controlPoints/vertex2", "Vertex 2/Line Right", pt(0.9, 0.6), ParamUi::Point),
|
||
p("pathOptions/customPath", "Custom Path", num(0.0), slider(0.0, 32.0, 0.0, 8.0, 0)),
|
||
check("pathOptions/reversePath", "Reverse Path", false),
|
||
p("fillAndStroke/displayOptions", "Options", Value::Enum(0), display()),
|
||
p("fillAndStroke/fillColor", "Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||
p("fillAndStroke/strokeColor", "Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||
p("fillAndStroke/strokeWidth", "Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
|
||
p("character/size", "Size", num(36.0), size()),
|
||
p("character/tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)),
|
||
p("character/orientation/characterRotation", "Character Rotation", num(0.0), ang()),
|
||
p("paragraph/alignment", "Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])),
|
||
p("paragraph/leftMargin", "Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
|
||
p("paragraph/rightMargin", "Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
|
||
p("paragraph/baselineShift", "Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||
p("advanced/visibleCharacters", "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", &[("format/value", num(8.0)), ("format/decimalPlaces", num(0.0))], img.clone(), 0.0);
|
||
assert_eq!(a.img.data, run("ec.text.numbers", &[("format/value", num(8.0)), ("format/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", &[("format/value", num(8.0)), ("fillAndStroke/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",
|
||
&[
|
||
("pathOptions/shapeType", Value::Enum(3)),
|
||
("pathOptions/controlPoints/vertex1", Value::Vec2([5.0, 50.0])),
|
||
("pathOptions/controlPoints/vertex2", Value::Vec2([115.0, 50.0])),
|
||
("character/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", &[("pathOptions/customPath", num(1.0)), ("character/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", &[("pathOptions/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);
|
||
}
|
||
}
|