mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M9.13: Path Text kerning, shear / scale, line spacing, fade time and jitter
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
af78c34b25
commit
4b97b44cac
4 files changed
+150
-40
No files matched your search
@@ -8,7 +8,6 @@ use crate::{CATEGORIES, registry};
|
||||
/// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far
|
||||
/// as the public behaviour documentation describes it.
|
||||
pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"),
|
||||
("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"),
|
||||
("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"),
|
||||
("ec.noise.removegrain", "no preview region, sampling or temporal filtering"),
|
||||
|
||||
@@ -657,6 +657,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
||||
("orientation", "Orientation"),
|
||||
("paragraph", "Paragraph"),
|
||||
("preview", "Preview"),
|
||||
("jitterSettings", "Jitter Settings"),
|
||||
// Generate.
|
||||
("positionsColors", "Positions & Colors"),
|
||||
("feather", "Feather"),
|
||||
@@ -737,6 +738,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
||||
"ec.obsolete.lightning",
|
||||
"ec.text.timecode",
|
||||
"ec.text.numbers",
|
||||
// Path Text's jitter changes every frame.
|
||||
"ec.obsolete.pathtext",
|
||||
// Time effects read neighbouring frames of the layer.
|
||||
"ec.time.echo",
|
||||
"ec.time.posterizetime",
|
||||
|
||||
+141
-33
@@ -508,49 +508,112 @@ fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
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 visible = g("advanced", "visibleCharacters").as_f64().max(0.0);
|
||||
let all: Vec<char> = pr.s("text").chars().collect();
|
||||
let n_all = all.len();
|
||||
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 line_spacing = pr.get("paragraph/lineSpacing").map(Value::as_f64).unwrap_or(size / b.scale * 1.2) * 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 shear = pr.f("character/orientation/characterShear").to_radians().tan();
|
||||
let hshear = pr.f("character/horizontalShear").to_radians().tan();
|
||||
let sx = pr.get("character/horizontalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0;
|
||||
let sy = pr.get("character/verticalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0;
|
||||
// Kerning: "index:value …" — the gap after character `index` (0-based) in 1/1000 em.
|
||||
let kerning: Vec<(usize, f64)> = pr
|
||||
.s("character/kerning")
|
||||
.split_whitespace()
|
||||
.filter_map(|t| {
|
||||
let (i, v) = t.split_once(':')?;
|
||||
Some((i.trim().parse().ok()?, v.trim().parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let kern = |i: usize| kerning.iter().filter(|(k, _)| *k == i).map(|(_, v)| v / 1000.0 * size).sum::<f64>();
|
||||
// Jitter: per character, re-randomised every frame.
|
||||
let jit = |id: &str| pr.f(&format!("advanced/jitterSettings/{id}"));
|
||||
let (j_base, j_kern, j_rot, j_scale) =
|
||||
(jit("baselineJitterMax") * b.scale, jit("kerningJitterMax") * b.scale, jit("rotationJitterMax").to_radians(), jit("scaleJitterMax") / 100.0);
|
||||
let frame = (ctx.time * ctx.fps()).floor() as i64 as u32;
|
||||
let rnd = |i: usize, k: u32| hash1(i as u32, k.wrapping_add(frame.wrapping_mul(7919)), 0x7e17) as f64 * 2.0 - 1.0;
|
||||
// Fade Time: characters fade in over this share of the visible characters.
|
||||
let fade = pr.f("advanced/fadeTime").clamp(0.0, 100.0) / 100.0 * n_all.max(1) as f64;
|
||||
let char_alpha = |i: usize| -> f32 {
|
||||
if fade <= 0.0 { if (i as f64) < visible.floor() { 1.0 } else { 0.0 } } else { ((visible - i as f64) / fade).clamp(0.0, 1.0) as f32 }
|
||||
};
|
||||
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(),
|
||||
);
|
||||
// Lines (separated by line breaks) stack along the path's normal by Line Spacing.
|
||||
let mut polys_by_alpha: Vec<(f32, Vec<Vec<[f64; 2]>>)> = Vec::new();
|
||||
let mut gi = 0usize;
|
||||
for (li, line) in all.split(|c| *c == '\n' || *c == '\r').enumerate() {
|
||||
let idx0 = gi;
|
||||
gi += line.len() + 1;
|
||||
let n = line.len();
|
||||
let advs: Vec<f64> = line.iter().map(|&c| advance(c, true) * u * sx).collect();
|
||||
let kerns: Vec<f64> = (0..n).map(|k| kern(idx0 + k) + if j_kern != 0.0 { rnd(idx0 + k, 2) * j_kern } else { 0.0 }).collect();
|
||||
let natural: f64 = advs.iter().sum::<f64>() + tracking * (n.max(1) as f64 - 1.0) + kerns.iter().take(n.saturating_sub(1)).sum::<f64>();
|
||||
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 line_off = li as f64 * line_spacing;
|
||||
for (k, (&c, adv)) in line.iter().zip(advs).enumerate() {
|
||||
let i = idx0 + k;
|
||||
let mid = cursor + adv * 0.5;
|
||||
cursor += adv + tracking + extra + kerns[k];
|
||||
let alpha = char_alpha(i);
|
||||
if alpha <= 0.0 || (!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 + if j_rot != 0.0 { rnd(i, 3) * j_rot } else { 0.0 };
|
||||
let (s, co) = ang.sin_cos();
|
||||
let js = if j_scale != 0.0 { (1.0 + rnd(i, 4) * j_scale).max(0.05) } else { 1.0 };
|
||||
let jb = if j_base != 0.0 { rnd(i, 1) * j_base } else { 0.0 };
|
||||
let left = ink_left(c, true);
|
||||
let set = match polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) {
|
||||
Some((_, v)) => v,
|
||||
None => {
|
||||
polys_by_alpha.push((alpha, Vec::new()));
|
||||
&mut polys_by_alpha.last_mut().expect("pushed").1
|
||||
}
|
||||
};
|
||||
for st in parse_glyph(c) {
|
||||
set.push(
|
||||
st.iter()
|
||||
.map(|gl| {
|
||||
// Glyph space (baseline at 0, y down), then scale, shear and lift.
|
||||
let gx = ((gl[0] - left - 0.8) * u + 0.8 * u) * sx * js - adv * 0.5;
|
||||
let gy = (gl[1] - 6.0) * u * sy * js;
|
||||
let gx = gx - gy * (shear + hshear);
|
||||
let gy = gy - shift - jb + line_off;
|
||||
[q[0] + gx * co - gy * s, q[1] + gx * s + gy * co]
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let look = look_from(ctx, &b, "compositeOnOriginal");
|
||||
draw_text(&mut b, &polys, weight(size), &look);
|
||||
let mut look = look_from(ctx, &b, "compositeOnOriginal");
|
||||
let base_op = look.opacity;
|
||||
let mut first = true;
|
||||
for (alpha, polys) in &polys_by_alpha {
|
||||
look.opacity = base_op * alpha;
|
||||
draw_text(&mut b, polys, weight(size), &look);
|
||||
if first {
|
||||
// Later passes go over the first one.
|
||||
look.on_original = true;
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
if polys_by_alpha.is_empty() {
|
||||
draw_text(&mut b, &[], weight(size), &look);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -811,12 +874,24 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
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)),
|
||||
// Kerning: "index:value" pairs, the gap after character `index` in 1/1000 em.
|
||||
p("character/kerning", "Kerning", Value::Str(String::new()), ParamUi::Text),
|
||||
p("character/orientation/characterRotation", "Character Rotation", num(0.0), ang()),
|
||||
p("character/orientation/characterShear", "Character Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
|
||||
p("character/horizontalShear", "Horizontal Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
|
||||
p("character/horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)),
|
||||
p("character/verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)),
|
||||
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/lineSpacing", "Line Spacing", num(43.0), slider(-1000.0, 1000.0, 0.0, 200.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)),
|
||||
p("advanced/fadeTime", "Fade Time", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("advanced/jitterSettings/baselineJitterMax", "Baseline Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("advanced/jitterSettings/kerningJitterMax", "Kerning Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("advanced/jitterSettings/rotationJitterMax", "Rotation Jitter Max", num(0.0), slider(0.0, 360.0, 0.0, 360.0, 1)),
|
||||
p("advanced/jitterSettings/scaleJitterMax", "Scale Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
check("compositeOnOriginal", "Composite On Original", true),
|
||||
],
|
||||
path_text,
|
||||
@@ -1000,6 +1075,39 @@ mod tests {
|
||||
assert!(ink(&circle.img) > 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_text_kerning_scale_lines_fade_and_jitter() {
|
||||
let img = Image::new(160, 100);
|
||||
let line = [
|
||||
("pathOptions/shapeType", Value::Enum(3)),
|
||||
("pathOptions/controlPoints/vertex1", Value::Vec2([5.0, 40.0])),
|
||||
("pathOptions/controlPoints/vertex2", Value::Vec2([155.0, 40.0])),
|
||||
("character/size", num(20.0)),
|
||||
("text", Value::Str("AB".into())),
|
||||
];
|
||||
let with = |extra: &[(&str, Value)], t: f64| run("ec.obsolete.pathtext", &[line.as_slice(), extra].concat(), img.clone(), t).img;
|
||||
let right_edge = |i: &Image| (0..160).rev().find(|&x| (0..100).any(|y| i.get(x, y)[3] > 0.3)).unwrap_or(0);
|
||||
let base = with(&[], 0.0);
|
||||
// Kerning after the first character pushes the second one along.
|
||||
assert!(right_edge(&with(&[("character/kerning", Value::Str("0:500".into()))], 0.0)) >= right_edge(&base) + 8);
|
||||
// Horizontal Scale widens, shear changes the shapes.
|
||||
assert!(right_edge(&with(&[("character/horizontalScale", num(200.0))], 0.0)) > right_edge(&base) + 5);
|
||||
assert_ne!(with(&[("character/horizontalShear", num(30.0))], 0.0), base);
|
||||
// A second line sits Line Spacing below the first.
|
||||
let two = with(&[("text", Value::Str("AB\rCD".into())), ("paragraph/lineSpacing", num(30.0))], 0.0);
|
||||
let ink_rows = |i: &Image, y0: i64, y1: i64| (y0..y1).flat_map(|y| (0..160).map(move |x| (x, y))).map(|(x, y)| i.get(x, y)[3]).sum::<f32>();
|
||||
assert!(ink_rows(&two, 52, 72) > 5.0 && ink_rows(&base, 52, 72) < 0.5);
|
||||
// Fade Time: the character being revealed is partly transparent.
|
||||
let fading = with(&[("advanced/visibleCharacters", num(1.5)), ("advanced/fadeTime", num(50.0))], 0.0);
|
||||
let total = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
let (one, both) = (with(&[("advanced/visibleCharacters", num(1.0))], 0.0), with(&[("advanced/visibleCharacters", num(2.0))], 0.0));
|
||||
assert!(total(&fading) > total(&one) + 1.0 && total(&fading) < total(&both) - 1.0, "{} {} {}", total(&one), total(&fading), total(&both));
|
||||
// Jitter differs between frames but is repeatable.
|
||||
let j = [("advanced/jitterSettings/baselineJitterMax", num(10.0))];
|
||||
assert_ne!(with(&j, 0.0), with(&j, 0.5));
|
||||
assert_eq!(with(&j, 0.5), with(&j, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_deterministic_and_animates() {
|
||||
let img = Image::new(96, 64);
|
||||
|
||||
+6
-6
@@ -3,11 +3,11 @@
|
||||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 303 effects with 2872 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 303 effects with 2892 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 24 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 292 implemented in full, 11 partial (what is missing is listed).
|
||||
- **Status**: 296 implemented in full, 7 partial (what is missing is listed).
|
||||
|
||||
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
|
||||
|
||||
@@ -157,9 +157,9 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented |
|
||||
| Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented |
|
||||
| Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented |
|
||||
| Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points |
|
||||
| Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented |
|
||||
| Ripple | `ec.distort.ripple` | 7 | | 32 | Implemented |
|
||||
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect |
|
||||
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented |
|
||||
| Smear | `ec.distort.smear` | 8 | | 32 | Implemented |
|
||||
| Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented |
|
||||
| Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented |
|
||||
@@ -188,7 +188,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| 4-Color Gradient | `ec.generate.fourcolor` | 12 | | 32 | Implemented |
|
||||
| Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing |
|
||||
| Advanced Lightning | `ec.generate.advancedlightning` | 24 | | 32 | Implemented |
|
||||
| Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented |
|
||||
| Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented |
|
||||
| Beam | `ec.generate.beam` | 11 | | 32 | Implemented |
|
||||
@@ -287,7 +287,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented |
|
||||
| Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented |
|
||||
| Luma Key | `ec.key.luma` | 5 | | 32 | Implemented |
|
||||
| Path Text | `ec.obsolete.pathtext` | 21 | | 32 | Partial: no kerning, shear/scale, line spacing, fade time or jitter |
|
||||
| Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented |
|
||||
| Reduce Interlace Flicker | `ec.blur.reduceflicker` | 1 | | 32 | Implemented |
|
||||
| Spill Suppressor | `ec.key.spill` | 3 | | 32 | Implemented |
|
||||
|
||||
|
||||
Reference in new issue
Block a user