M9.13: Keylight colour suppression / balancing and source crop methods, Scribble caps / joins / Start-End Apply To
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d5e86601f
commit
e44af1f7e8
|
|
@ -11,8 +11,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
|
||||||
("ec.distort.reshape", "no correspondence points"),
|
("ec.distort.reshape", "no correspondence points"),
|
||||||
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
|
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
|
||||||
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
|
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
|
||||||
("ec.generate.scribble", "no caps, joins or Start/End Apply To"),
|
|
||||||
("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"),
|
|
||||||
("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"),
|
("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.addgrain", "no preview region, presets, channel balance or temporal controls"),
|
||||||
("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"),
|
("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"),
|
||||||
|
|
|
||||||
|
|
@ -466,6 +466,141 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64;
|
||||||
*out = merged;
|
*out = merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End Cap and Join options (Scribble's Edge Options).
|
||||||
|
const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"];
|
||||||
|
const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"];
|
||||||
|
|
||||||
|
/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`:
|
||||||
|
/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the
|
||||||
|
/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends).
|
||||||
|
pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec<Vec<[f64; 2]>> {
|
||||||
|
let disc = |c: [f64; 2]| -> Vec<[f64; 2]> {
|
||||||
|
(0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect()
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for l in lines {
|
||||||
|
let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| {
|
||||||
|
if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) {
|
||||||
|
v.push(p);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
});
|
||||||
|
if pts.len() < 2 {
|
||||||
|
if let (Some(p), 0) = (pts.first(), cap) {
|
||||||
|
out.push(disc(*p));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dir = |a: [f64; 2], b: [f64; 2]| {
|
||||||
|
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||||
|
let l = dx.hypot(dy).max(1e-12);
|
||||||
|
[dx / l, dy / l]
|
||||||
|
};
|
||||||
|
let n = pts.len();
|
||||||
|
for k in 0..n - 1 {
|
||||||
|
let (mut a, mut b) = (pts[k], pts[k + 1]);
|
||||||
|
let d = dir(a, b);
|
||||||
|
// Projecting caps extend the end segments by the half width.
|
||||||
|
if cap == 2 && k == 0 {
|
||||||
|
a = [a[0] - d[0] * r, a[1] - d[1] * r];
|
||||||
|
}
|
||||||
|
if cap == 2 && k == n - 2 {
|
||||||
|
b = [b[0] + d[0] * r, b[1] + d[1] * r];
|
||||||
|
}
|
||||||
|
let nn = [-d[1] * r, d[0] * r];
|
||||||
|
out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]);
|
||||||
|
}
|
||||||
|
// Joins.
|
||||||
|
for k in 1..n - 1 {
|
||||||
|
let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1]));
|
||||||
|
let cross = d0[0] * d1[1] - d0[1] * d1[0];
|
||||||
|
if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if join == 0 {
|
||||||
|
out.push(disc(p));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The outer side of the turn.
|
||||||
|
let s = if cross > 0.0 { -1.0 } else { 1.0 };
|
||||||
|
let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s];
|
||||||
|
let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s];
|
||||||
|
let bevel = vec![p, o0, o1];
|
||||||
|
if join == 1 {
|
||||||
|
// Miter point: where the two offset edges meet.
|
||||||
|
let bis = [d0[0] - d1[0], d0[1] - d1[1]];
|
||||||
|
let bl = bis[0].hypot(bis[1]);
|
||||||
|
let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt();
|
||||||
|
let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY };
|
||||||
|
if bl > 1e-9 && len <= miter_limit * r {
|
||||||
|
let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5];
|
||||||
|
let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12);
|
||||||
|
let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len];
|
||||||
|
out.push(vec![p, o0, tip, o1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bevel);
|
||||||
|
}
|
||||||
|
if cap == 0 {
|
||||||
|
out.push(disc(pts[0]));
|
||||||
|
out.push(disc(pts[n - 1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid.
|
||||||
|
pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane {
|
||||||
|
let mut out = Plane::new(w, h);
|
||||||
|
let inside = |poly: &[[f64; 2]], x: f64, y: f64| {
|
||||||
|
let n = poly.len();
|
||||||
|
let mut sign = 0.0;
|
||||||
|
for i in 0..n {
|
||||||
|
let (a, b) = (poly[i], poly[(i + 1) % n]);
|
||||||
|
let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]);
|
||||||
|
if c.abs() < 1e-12 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if sign == 0.0 {
|
||||||
|
sign = c.signum();
|
||||||
|
} else if c.signum() != sign {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
};
|
||||||
|
for poly in polys {
|
||||||
|
if poly.len() < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||||
|
for p in poly {
|
||||||
|
x0 = x0.min(p[0]);
|
||||||
|
y0 = y0.min(p[1]);
|
||||||
|
x1 = x1.max(p[0]);
|
||||||
|
y1 = y1.max(p[1]);
|
||||||
|
}
|
||||||
|
let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w));
|
||||||
|
let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h));
|
||||||
|
for y in ya..yb {
|
||||||
|
for x in xa..xb {
|
||||||
|
let mut hit = 0;
|
||||||
|
for sy in 0..4 {
|
||||||
|
for sx in 0..4 {
|
||||||
|
if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) {
|
||||||
|
hit += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let v = &mut out.data[y * w + x];
|
||||||
|
*v = v.max(hit as f32 / 16.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||||
let pr = ctx.params;
|
let pr = ctx.params;
|
||||||
let which = pr.e("scribble");
|
let which = pr.e("scribble");
|
||||||
|
|
@ -585,19 +720,33 @@ fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||||
lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect());
|
lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect());
|
||||||
}
|
}
|
||||||
let r = width * 0.5;
|
let r = width * 0.5;
|
||||||
let mut segs = Vec::new();
|
// Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own.
|
||||||
|
let each = pr.e("startEndApplyTo") == 1;
|
||||||
|
let mut parts: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||||
for group in &lines {
|
for group in &lines {
|
||||||
let lens: Vec<f64> = group.iter().map(|l| poly_length(l, false)).collect();
|
let lens: Vec<f64> = group.iter().map(|l| poly_length(l, false)).collect();
|
||||||
let total: f64 = lens.iter().sum();
|
let total: f64 = lens.iter().sum();
|
||||||
let (g0, g1) = (total * start, total * end);
|
let (g0, g1) = (total * start, total * end);
|
||||||
let mut acc = 0.0;
|
let mut acc = 0.0;
|
||||||
for (l, len) in group.iter().zip(lens) {
|
for (l, len) in group.iter().zip(lens) {
|
||||||
let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len));
|
let part =
|
||||||
|
if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) };
|
||||||
acc += len;
|
acc += len;
|
||||||
poly_segs(&part, false, r, 1.0, &mut segs);
|
parts.push(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let cov = raster_segs(w, h, &segs, 0.9);
|
let cap = pr.e("edgeOptions/endCap");
|
||||||
|
let join = pr.e("edgeOptions/join");
|
||||||
|
let cov = if cap == 0 && join == 0 {
|
||||||
|
let mut segs = Vec::new();
|
||||||
|
for part in &parts {
|
||||||
|
poly_segs(part, false, r, 1.0, &mut segs);
|
||||||
|
}
|
||||||
|
raster_segs(w, h, &segs, 0.9)
|
||||||
|
} else {
|
||||||
|
let polys = stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0));
|
||||||
|
raster_convex(w, h, &polys)
|
||||||
|
};
|
||||||
let color = pr.color("color");
|
let color = pr.color("color");
|
||||||
paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style);
|
paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style);
|
||||||
b
|
b
|
||||||
|
|
@ -1467,6 +1616,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||||
mask_idx("mask", "Mask", 1.0),
|
mask_idx("mask", "Mask", 1.0),
|
||||||
p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])),
|
p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])),
|
||||||
p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||||
|
p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)),
|
||||||
|
p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)),
|
||||||
|
p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.0, 1)),
|
||||||
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||||||
p("opacity", "Opacity", num(100.0), pct()),
|
p("opacity", "Opacity", num(100.0), pct()),
|
||||||
p("angle", "Angle", num(45.0), ang()),
|
p("angle", "Angle", num(45.0), ang()),
|
||||||
|
|
@ -1479,6 +1631,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||||
p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()),
|
p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()),
|
||||||
p("start", "Start", num(0.0), pct()),
|
p("start", "Start", num(0.0), pct()),
|
||||||
p("end", "End", num(100.0), pct()),
|
p("end", "End", num(100.0), pct()),
|
||||||
|
p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])),
|
||||||
check("fillPathsSequentially", "Fill Paths Sequentially", true),
|
check("fillPathsSequentially", "Fill Paths Sequentially", true),
|
||||||
p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])),
|
p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])),
|
||||||
p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)),
|
p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)),
|
||||||
|
|
@ -1936,4 +2089,49 @@ mod tests {
|
||||||
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
||||||
assert_eq!(peak, 8);
|
assert_eq!(peak, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stroke_outline_caps_and_joins() {
|
||||||
|
let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]];
|
||||||
|
let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0));
|
||||||
|
// Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square.
|
||||||
|
let (round, butt, proj) = (cov(0), cov(1), cov(2));
|
||||||
|
assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9);
|
||||||
|
assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner");
|
||||||
|
// A right-angle turn: Miter fills the outer corner, Bevel cuts it.
|
||||||
|
let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]];
|
||||||
|
let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0));
|
||||||
|
let (miter, bevel) = (j(1), j(2));
|
||||||
|
assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32));
|
||||||
|
// A tight miter limit falls back to a bevel.
|
||||||
|
let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0));
|
||||||
|
assert!(limited.get(32, 32) < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scribble_caps_and_start_end_apply_to() {
|
||||||
|
let masks = square_mask();
|
||||||
|
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||||
|
let img = Image::new(64, 64);
|
||||||
|
let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))];
|
||||||
|
let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env);
|
||||||
|
let butt = run_env(
|
||||||
|
"ec.generate.scribble",
|
||||||
|
&[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(),
|
||||||
|
img.clone(),
|
||||||
|
0.0,
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
assert_ne!(round.img.data, butt.img.data);
|
||||||
|
// Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over.
|
||||||
|
let two = vec![
|
||||||
|
crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false },
|
||||||
|
crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false },
|
||||||
|
];
|
||||||
|
let env2 = EffectEnv { masks: &two, ..Default::default() };
|
||||||
|
let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))];
|
||||||
|
let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2);
|
||||||
|
let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2);
|
||||||
|
assert_ne!(all.img.data, each.img.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,104 @@ fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] {
|
||||||
c.map(|v| ((v - 0.5) * contrast + 0.5) * bright)
|
c.map(|v| ((v - 0.5) * contrast + 0.5) * bright)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Colour Suppression choices.
|
||||||
|
const SUPPRESS: [&str; 7] = ["None", "Red", "Green", "Blue", "Cyan", "Magenta", "Yellow"];
|
||||||
|
/// Source Crops' X / Y Method choices.
|
||||||
|
const CROP_METHODS: [&str; 4] = ["Colour", "Repeat", "Reflect", "Wrap"];
|
||||||
|
|
||||||
|
/// (colour, balance 0..1, amount 0..1) of a Colour Suppression setting.
|
||||||
|
fn suppression(ctx: &EffectCtx, kind: &str, bal: &str, amt: &str) -> (u32, f32, f32) {
|
||||||
|
(ctx.params.e(kind), (ctx.params.get(bal).map(Value::as_f64).unwrap_or(50.0) / 100.0) as f32, (ctx.params.f(amt) / 100.0) as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour Suppression: remove the excess of a primary (over a Balance-weighted mix of the other
|
||||||
|
/// two) or of a secondary (the smaller of its two primaries over the third).
|
||||||
|
fn suppress(mut c: [f32; 3], (kind, bal, amt): (u32, f32, f32)) -> [f32; 3] {
|
||||||
|
if kind == 0 || amt <= 0.0 {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
1..=3 => {
|
||||||
|
let k = kind as usize - 1;
|
||||||
|
let (a, b) = ((k + 1) % 3, (k + 2) % 3);
|
||||||
|
let limit = c[a] * bal + c[b] * (1.0 - bal);
|
||||||
|
c[k] -= (c[k] - limit).max(0.0) * amt;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Cyan = G + B over R, Magenta = R + B over G, Yellow = R + G over B.
|
||||||
|
let third = match kind {
|
||||||
|
4 => 0,
|
||||||
|
5 => 1,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
let (a, b) = ((third + 1) % 3, (third + 2) % 3);
|
||||||
|
let excess = (c[a].min(c[b]) - c[third]).max(0.0) * amt;
|
||||||
|
c[a] -= excess;
|
||||||
|
c[b] -= excess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (tint offset) of a Colour Balancing wheel: hue (degrees) and saturation (0..1).
|
||||||
|
fn balance(ctx: &EffectCtx, hue: &str, sat: &str) -> [f32; 3] {
|
||||||
|
let s = (ctx.params.f(sat) / 100.0) as f32;
|
||||||
|
if s <= 0.0 {
|
||||||
|
return [0.0; 3];
|
||||||
|
}
|
||||||
|
let (r, g, b) = effectcraft_color::hsl_to_rgb((ctx.params.f(hue) / 360.0).rem_euclid(1.0) as f32, 1.0, 0.5);
|
||||||
|
let l = luminance(r, g, b);
|
||||||
|
[(r - l) * s * 0.5, (g - l) * s * 0.5, (b - l) * s * 0.5]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour Balancing: shift the colour towards the wheel's hue, keeping its luminance.
|
||||||
|
fn colour_balance(c: [f32; 3], tint: [f32; 3]) -> [f32; 3] {
|
||||||
|
if tint == [0.0; 3] {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
[c[0] + tint[0], c[1] + tint[1], c[2] + tint[2]].map(|v| v.max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The layer with Source Crops applied: inside the crop the picture, outside it the Edge Colour
|
||||||
|
/// (X / Y Method Colour) or the kept picture repeated, reflected or wrapped.
|
||||||
|
fn crop_source(ctx: &EffectCtx, b: &Buf) -> Image {
|
||||||
|
let pr = ctx.params;
|
||||||
|
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
|
||||||
|
if crops.iter().all(|c| *c <= 0.0) {
|
||||||
|
return b.img.clone();
|
||||||
|
}
|
||||||
|
let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
|
||||||
|
let (x0, x1) = (b.offset[0] + crops[0] * lw, b.offset[0] + (1.0 - crops[1]) * lw);
|
||||||
|
let (y0, y1) = (b.offset[1] + crops[2] * lh, b.offset[1] + (1.0 - crops[3]) * lh);
|
||||||
|
let methods = [pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")];
|
||||||
|
let ec = pr.color("sourceCrops/edgeColour");
|
||||||
|
let ea = (pr.f("sourceCrops/edgeColourAlpha") / 100.0) as f32;
|
||||||
|
let edge = premul([ec[0], ec[1], ec[2]], ea);
|
||||||
|
// Fold a coordinate into [lo, hi) by a method; None = outside with Colour.
|
||||||
|
let fold = |v: f64, lo: f64, hi: f64, m: u32| -> Option<f64> {
|
||||||
|
let span = (hi - lo).max(1.0);
|
||||||
|
if v >= lo && v < hi {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
match m {
|
||||||
|
1 => Some(v.clamp(lo, hi - 0.5)),
|
||||||
|
2 => {
|
||||||
|
let t = (v - lo).rem_euclid(2.0 * span);
|
||||||
|
Some(lo + if t >= span { 2.0 * span - t - 0.5 } else { t })
|
||||||
|
}
|
||||||
|
3 => Some(lo + (v - lo).rem_euclid(span)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
|
||||||
|
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||||
|
match (fold(fx, x0, x1, methods[0]), fold(fy, y0, y1, methods[1])) {
|
||||||
|
(Some(sx), Some(sy)) => b.img.get_clamped(sx.floor() as i64, sy.floor() as i64),
|
||||||
|
_ => edge,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||||
let pr = ctx.params;
|
let pr = ctx.params;
|
||||||
let view = pr.e("view");
|
let view = pr.e("view");
|
||||||
|
|
@ -151,7 +249,9 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||||
let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale;
|
let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale;
|
||||||
let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale;
|
let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale;
|
||||||
|
|
||||||
let src = b.img.clone();
|
// Source Crops: the cropped borders take the Edge Colour, or repeat / reflect / wrap the
|
||||||
|
// kept picture (X Method / Y Method), before keying.
|
||||||
|
let src = crop_source(ctx, &b);
|
||||||
let blurred;
|
let blurred;
|
||||||
let key_src: &Image = if preblur > 0.0 {
|
let key_src: &Image = if preblur > 0.0 {
|
||||||
blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true);
|
blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true);
|
||||||
|
|
@ -247,6 +347,22 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||||
pr.f("edgeColourCorrection/edgeBrightness") as f32 / 100.0 + 1.0,
|
pr.f("edgeColourCorrection/edgeBrightness") as f32 / 100.0 + 1.0,
|
||||||
);
|
);
|
||||||
let unpremultiply = pr.b("unpremultiplyResult");
|
let unpremultiply = pr.b("unpremultiplyResult");
|
||||||
|
let (x_method, y_method) = (pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod"));
|
||||||
|
let edge_alpha = pr.f("sourceCrops/edgeColourAlpha") as f32 / 100.0;
|
||||||
|
let fg_sup = suppression(
|
||||||
|
ctx,
|
||||||
|
"foregroundColourCorrection/colourSuppression",
|
||||||
|
"foregroundColourCorrection/suppressionBalance",
|
||||||
|
"foregroundColourCorrection/suppressionAmount",
|
||||||
|
);
|
||||||
|
let fg_bal = balance(ctx, "foregroundColourCorrection/colourBalanceHue", "foregroundColourCorrection/colourBalanceSaturation");
|
||||||
|
let edge_sup = suppression(
|
||||||
|
ctx,
|
||||||
|
"edgeColourCorrection/edgeColourSuppression",
|
||||||
|
"edgeColourCorrection/edgeSuppressionBalance",
|
||||||
|
"edgeColourCorrection/edgeSuppressionAmount",
|
||||||
|
);
|
||||||
|
let edge_bal = balance(ctx, "edgeColourCorrection/edgeColourBalanceHue", "edgeColourCorrection/edgeColourBalanceSaturation");
|
||||||
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
|
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
|
||||||
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
|
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
|
||||||
let w = b.img.width as usize;
|
let w = b.img.width as usize;
|
||||||
|
|
@ -258,7 +374,7 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||||
// Source crops (layer fractions from each edge).
|
// Source crops (layer fractions from each edge).
|
||||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||||
let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9));
|
let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9));
|
||||||
let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3];
|
let cropped = (lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]) && edge_alpha <= 0.0 && x_method == 0 && y_method == 0;
|
||||||
// Screen removal on the raw (unclipped) transparency, then despill.
|
// Screen removal on the raw (unclipped) transparency, then despill.
|
||||||
let ar = raw.data[i].max(1e-4);
|
let ar = raw.data[i].max(1e-4);
|
||||||
let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0));
|
let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0));
|
||||||
|
|
@ -287,11 +403,11 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||||
fg = apply_replace(fg, replace, rc, raised);
|
fg = apply_replace(fg, replace, rc, raised);
|
||||||
fg = apply_replace(fg, in_replace, in_rc, raised_in);
|
fg = apply_replace(fg, in_replace, in_rc, raised_in);
|
||||||
if fg_cc {
|
if fg_cc {
|
||||||
fg = correct(fg, fs, fc, fbr);
|
fg = colour_balance(suppress(correct(fg, fs, fc, fbr), fg_sup), fg_bal);
|
||||||
}
|
}
|
||||||
let edge = edges.as_ref().map_or(0.0, |e| e[i]);
|
let edge = edges.as_ref().map_or(0.0, |e| e[i]);
|
||||||
if edge > 0.0 {
|
if edge > 0.0 {
|
||||||
let ce = correct(fg, es, ec, ebr);
|
let ce = colour_balance(suppress(correct(fg, es, ec, ebr), edge_sup), edge_bal);
|
||||||
fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge);
|
fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge);
|
||||||
}
|
}
|
||||||
let grey = |v: f32| [v, v, v, 1.0];
|
let grey = |v: f32| [v, v, v, 1.0];
|
||||||
|
|
@ -380,6 +496,11 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||||
p("foregroundColourCorrection/saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
p("foregroundColourCorrection/saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||||
cc("foregroundColourCorrection/contrast", "Contrast"),
|
cc("foregroundColourCorrection/contrast", "Contrast"),
|
||||||
cc("foregroundColourCorrection/brightness", "Brightness"),
|
cc("foregroundColourCorrection/brightness", "Brightness"),
|
||||||
|
p("foregroundColourCorrection/colourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)),
|
||||||
|
pp("foregroundColourCorrection/suppressionBalance", "Suppression Balance", pct(50.0)),
|
||||||
|
pp("foregroundColourCorrection/suppressionAmount", "Suppression Amount", pct(0.0)),
|
||||||
|
p("foregroundColourCorrection/colourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle),
|
||||||
|
pp("foregroundColourCorrection/colourBalanceSaturation", "Colour Balance Saturation", pct(0.0)),
|
||||||
// Edge Colour Correction
|
// Edge Colour Correction
|
||||||
p("edgeColourCorrection/enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
p("edgeColourCorrection/enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
||||||
pp("edgeColourCorrection/edgeHardness", "Edge Hardness", pct(0.0)),
|
pp("edgeColourCorrection/edgeHardness", "Edge Hardness", pct(0.0)),
|
||||||
|
|
@ -388,7 +509,16 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||||
p("edgeColourCorrection/edgeSaturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
p("edgeColourCorrection/edgeSaturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||||
cc("edgeColourCorrection/edgeContrast", "Contrast"),
|
cc("edgeColourCorrection/edgeContrast", "Contrast"),
|
||||||
cc("edgeColourCorrection/edgeBrightness", "Brightness"),
|
cc("edgeColourCorrection/edgeBrightness", "Brightness"),
|
||||||
|
p("edgeColourCorrection/edgeColourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)),
|
||||||
|
pp("edgeColourCorrection/edgeSuppressionBalance", "Suppression Balance", pct(50.0)),
|
||||||
|
pp("edgeColourCorrection/edgeSuppressionAmount", "Suppression Amount", pct(0.0)),
|
||||||
|
p("edgeColourCorrection/edgeColourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle),
|
||||||
|
pp("edgeColourCorrection/edgeColourBalanceSaturation", "Colour Balance Saturation", pct(0.0)),
|
||||||
// Source Crops
|
// Source Crops
|
||||||
|
p("sourceCrops/xMethod", "X Method", Value::Enum(0), popup(&CROP_METHODS)),
|
||||||
|
p("sourceCrops/yMethod", "Y Method", Value::Enum(0), popup(&CROP_METHODS)),
|
||||||
|
p("sourceCrops/edgeColour", "Edge Colour", col(0.0, 0.0, 0.0), ParamUi::Color),
|
||||||
|
pp("sourceCrops/edgeColourAlpha", "Edge Colour Alpha", pct(0.0)),
|
||||||
pp("sourceCrops/cropLeft", "Left", pct(0.0)),
|
pp("sourceCrops/cropLeft", "Left", pct(0.0)),
|
||||||
pp("sourceCrops/cropRight", "Right", pct(0.0)),
|
pp("sourceCrops/cropRight", "Right", pct(0.0)),
|
||||||
pp("sourceCrops/cropTop", "Top", pct(0.0)),
|
pp("sourceCrops/cropTop", "Top", pct(0.0)),
|
||||||
|
|
@ -500,4 +630,44 @@ mod tests {
|
||||||
assert_eq!(g.sub("insideMask").and_then(|s| s.get("insideMask")).map(|p| p.name.as_str()), Some("Inside Mask"));
|
assert_eq!(g.sub("insideMask").and_then(|s| s.get("insideMask")).map(|p| p.name.as_str()), Some("Inside Mask"));
|
||||||
assert!(g.get("clipWhite").is_none());
|
assert!(g.get("clipWhite").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colour_suppression_balancing_and_crop_methods() {
|
||||||
|
// Suppress Red pulls red down to the balance of green and blue; Cyan the shared G+B.
|
||||||
|
assert!((suppress([0.9, 0.3, 0.1], (1, 0.5, 1.0))[0] - 0.2).abs() < 1e-6);
|
||||||
|
let c = suppress([0.1, 0.8, 0.6], (4, 0.5, 1.0));
|
||||||
|
assert!((c[1] - 0.3).abs() < 1e-6 && (c[2] - 0.1).abs() < 1e-6);
|
||||||
|
assert_eq!(suppress([0.9, 0.3, 0.1], (1, 0.5, 0.0)), [0.9, 0.3, 0.1]);
|
||||||
|
// Foreground colour correction with suppression / balancing changes the keyed colour.
|
||||||
|
let base = [("screenColour", green()), ("foregroundColourCorrection/enableColourCorrection", Value::Bool(true))];
|
||||||
|
let run = |extra: &[(&str, Value)]| {
|
||||||
|
let mut v: Vec<(&str, Value)> = base.to_vec();
|
||||||
|
v.extend_from_slice(extra);
|
||||||
|
run_fx("ec.keying.keylight", &v, plate(), 0.0, EffectEnv::default()).img.get(15, 15)
|
||||||
|
};
|
||||||
|
let plain = run(&[]);
|
||||||
|
let sup = run(&[("foregroundColourCorrection/colourSuppression", Value::Enum(1)), ("foregroundColourCorrection/suppressionAmount", num(100.0))]);
|
||||||
|
assert!(sup[0] < plain[0] - 0.1, "{sup:?} vs {plain:?}");
|
||||||
|
let bal = run(&[("foregroundColourCorrection/colourBalanceHue", num(240.0)), ("foregroundColourCorrection/colourBalanceSaturation", num(100.0))]);
|
||||||
|
assert!(bal[2] > plain[2] + 0.05);
|
||||||
|
// Source Crops: Colour fills the cropped border; Repeat copies the kept edge.
|
||||||
|
let crop = [("screenColour", green()), ("sourceCrops/cropLeft", num(10.0)), ("view", Value::Enum(1))];
|
||||||
|
let filled =
|
||||||
|
run_fx("ec.keying.keylight", &[crop.as_slice(), &[("sourceCrops/edgeColourAlpha", num(100.0))]].concat(), plate(), 0.0, EffectEnv::default()).img;
|
||||||
|
assert_eq!(filled.get(1, 15)[0], 1.0, "edge colour alpha shows in the source alpha view");
|
||||||
|
let src = crop_source(
|
||||||
|
&crate::EffectCtx {
|
||||||
|
params: &crate::Params {
|
||||||
|
values: [("sourceCrops/cropLeft".to_string(), num(25.0)), ("sourceCrops/xMethod".to_string(), Value::Enum(1))].into_iter().collect(),
|
||||||
|
},
|
||||||
|
time: 0.0,
|
||||||
|
layer_size: [40.0, 30.0],
|
||||||
|
seed: 0,
|
||||||
|
adjustment: false,
|
||||||
|
env: Default::default(),
|
||||||
|
},
|
||||||
|
&Buf { img: plate(), offset: [0.0; 2], scale: 1.0 },
|
||||||
|
);
|
||||||
|
assert_eq!(src.get(2, 15), plate().get(10, 15), "repeat");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
<!-- 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 -->
|
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||||
|
|
||||||
EffectCraft ships 303 effects with 2854 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 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.
|
||||||
|
|
||||||
- **GPU**: 24 effects also run on the GPU compositor with identical results.
|
- **GPU**: 24 effects also run on the GPU compositor with identical results.
|
||||||
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||||
- **Status**: 290 implemented in full, 13 partial (what is missing is listed).
|
- **Status**: 292 implemented in full, 11 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`).
|
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`).
|
||||||
|
|
||||||
|
|
@ -209,7 +209,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||||
| Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented |
|
| Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented |
|
||||||
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented |
|
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented |
|
||||||
| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented |
|
| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented |
|
||||||
| Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To |
|
| Scribble | `ec.generate.scribble` | 25 | | 32 | Implemented |
|
||||||
| Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented |
|
| Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented |
|
||||||
| Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented |
|
| Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented |
|
||||||
| Write-on | `ec.generate.writeon` | 10 | | 32 | Implemented |
|
| Write-on | `ec.generate.writeon` | 10 | | 32 | Implemented |
|
||||||
|
|
@ -243,7 +243,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||||
| Extract | `ec.key.extract` | 6 | | 32 | Implemented |
|
| Extract | `ec.key.extract` | 6 | | 32 | Implemented |
|
||||||
| Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented |
|
| Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented |
|
||||||
| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented |
|
| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented |
|
||||||
| Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing |
|
| Key Light | `ec.keying.keylight` | 56 | | 32 | Implemented |
|
||||||
| Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented |
|
| Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented |
|
||||||
| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented |
|
| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented |
|
||||||
| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented |
|
| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue