mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M9.13: Keylight colour suppression / balancing and source crop methods, Scribble caps / joins / Start-End Apply To
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6d5e86601f
commit
e44af1f7e8
4 files changed
+380
-14
No files matched your search
@@ -11,8 +11,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("ec.distort.reshape", "no correspondence points"),
|
||||
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
|
||||
("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.noise.addgrain", "no preview region, presets, channel balance 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;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let pr = ctx.params;
|
||||
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());
|
||||
}
|
||||
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 {
|
||||
let lens: Vec<f64> = group.iter().map(|l| poly_length(l, false)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
let (g0, g1) = (total * start, total * end);
|
||||
let mut acc = 0.0;
|
||||
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;
|
||||
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");
|
||||
paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style);
|
||||
b
|
||||
@@ -1467,6 +1616,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
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("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("opacity", "Opacity", num(100.0), pct()),
|
||||
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("start", "Start", num(0.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),
|
||||
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)),
|
||||
@@ -1936,4 +2089,49 @@ mod tests {
|
||||
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let pr = ctx.params;
|
||||
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_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 key_src: &Image = if preblur > 0.0 {
|
||||
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,
|
||||
);
|
||||
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 (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
|
||||
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).
|
||||
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 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.
|
||||
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));
|
||||
@@ -287,11 +403,11 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
fg = apply_replace(fg, replace, rc, raised);
|
||||
fg = apply_replace(fg, in_replace, in_rc, raised_in);
|
||||
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]);
|
||||
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);
|
||||
}
|
||||
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)),
|
||||
cc("foregroundColourCorrection/contrast", "Contrast"),
|
||||
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
|
||||
p("edgeColourCorrection/enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
||||
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)),
|
||||
cc("edgeColourCorrection/edgeContrast", "Contrast"),
|
||||
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
|
||||
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/cropRight", "Right", 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!(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");
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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 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.
|
||||
- **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`).
|
||||
|
||||
@@ -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 |
|
||||
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 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 |
|
||||
| Vegas | `ec.generate.vegas` | 26 | | 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 |
|
||||
| Inner/Outer Key | `ec.key.innerouter` | 88 | | 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 |
|
||||
| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented |
|
||||
| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented |
|
||||
|
||||
Reference in new issue
Block a user