M9.3: Color Correction effects (Change Color, Video Limiter, CC Color Neutralizer, PS Arbitrary Map, Lumetri Color)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b58167d0c7
commit
2c4346a15e
|
|
@ -1,7 +1,575 @@
|
|||
//! Color Correction effects, batch 3.
|
||||
//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer,
|
||||
//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels,
|
||||
//! Vignette).
|
||||
|
||||
use crate::EffectSpec;
|
||||
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{Plane, gauss_plane, layer_rect, lerp, premul, smoothstep, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Color Correction", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
fn pct() -> ParamUi {
|
||||
slider(0.0, 100.0, 0.0, 100.0, 1)
|
||||
}
|
||||
|
||||
fn bipolar() -> ParamUi {
|
||||
slider(-100.0, 100.0, -100.0, 100.0, 1)
|
||||
}
|
||||
|
||||
/// Apply `f(straight colour) -> straight colour` to every pixel, keeping alpha.
|
||||
fn map_colour(b: &mut Buf, f: impl Fn(usize, [f32; 3]) -> [f32; 3] + Sync) {
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (c, a) = unpremul(*px);
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
*px = premul(f(i, c), a);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Change Color
|
||||
|
||||
/// Similarity mask (1 = matches) of colour `c` to `key`.
|
||||
#[inline]
|
||||
fn colour_match(c: [f32; 3], key: [f32; 3], using: u32, tol: f32, soft: f32) -> f32 {
|
||||
let d = match using {
|
||||
0 => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(),
|
||||
1 => {
|
||||
let (h1, s1, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (h2, _, _) = rgb_to_hsl(key[0], key[1], key[2]);
|
||||
let dh = (h1 - h2).abs();
|
||||
let dh = dh.min(1.0 - dh) * 2.0;
|
||||
// Greys have no reliable hue.
|
||||
if s1 < 0.02 { 1.0 } else { dh }
|
||||
}
|
||||
_ => {
|
||||
let chroma = |c: [f32; 3]| {
|
||||
let s = (c[0] + c[1] + c[2]).max(1e-6);
|
||||
[c[0] / s, c[1] / s]
|
||||
};
|
||||
let (a, k) = (chroma(c), chroma(key));
|
||||
((a[0] - k[0]).powi(2) + (a[1] - k[1]).powi(2)).sqrt() * 1.5
|
||||
}
|
||||
};
|
||||
if d <= tol {
|
||||
1.0
|
||||
} else if soft <= 1e-6 {
|
||||
0.0
|
||||
} else {
|
||||
(1.0 - (d - tol) / soft).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn change_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let view_mask = ctx.params.e("view") == 1;
|
||||
let dh = (ctx.params.f("hueTransform") / 360.0) as f32;
|
||||
let dl = (ctx.params.f("lightnessTransform") / 100.0) as f32;
|
||||
let ds = (ctx.params.f("saturationTransform") / 100.0) as f32;
|
||||
let k = ctx.params.color("colorToChange");
|
||||
let key = [k[0], k[1], k[2]];
|
||||
let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32;
|
||||
let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32;
|
||||
let using = ctx.params.e("matchColors");
|
||||
let invert = ctx.params.b("invertColorCorrectionMask");
|
||||
if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 {
|
||||
return b;
|
||||
}
|
||||
map_colour(&mut b, |_, c| {
|
||||
let mut m = colour_match(c, key, using, tol, soft);
|
||||
if invert {
|
||||
m = 1.0 - m;
|
||||
}
|
||||
if view_mask {
|
||||
return [m; 3];
|
||||
}
|
||||
if m <= 0.0 {
|
||||
return c;
|
||||
}
|
||||
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let h2 = (h + dh).rem_euclid(1.0);
|
||||
let s2 = (s + ds * if ds > 0.0 { 1.0 - s } else { s }).clamp(0.0, 1.0);
|
||||
let l2 = (l + dl * if dl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0);
|
||||
let (r, g, bb) = hsl_to_rgb(h2, s2, l2);
|
||||
[lerp(c[0], r, m), lerp(c[1], g, m), lerp(c[2], bb, m)]
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Video Limiter
|
||||
|
||||
const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09];
|
||||
|
||||
/// Soft knee: values above `start` approach `limit` smoothly.
|
||||
#[inline]
|
||||
fn knee(v: f32, start: f32, limit: f32) -> f32 {
|
||||
if v <= start || limit <= start {
|
||||
return v.min(limit);
|
||||
}
|
||||
let r = limit - start;
|
||||
start + r * (1.0 - (-(v - start) / r).exp())
|
||||
}
|
||||
|
||||
fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let level = CLIP_LEVELS[(ctx.params.e("clipLevel") as usize).min(CLIP_LEVELS.len() - 1)];
|
||||
let method = ctx.params.e("clipMethod");
|
||||
let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(ctx.params.e("compressionBeforeClipping") as usize).min(4)];
|
||||
let warn = ctx.params.b("gamutWarning");
|
||||
let wc = ctx.params.color("gamutWarningColor");
|
||||
let start = level * (1.0 - comp);
|
||||
map_colour(&mut b, |_, c| {
|
||||
let y = luminance(c[0], c[1], c[2]);
|
||||
let mut o = c;
|
||||
// Luma: compress luminance, keep chroma offsets.
|
||||
if method != 1 {
|
||||
let y2 = knee(y.max(0.0), start, level);
|
||||
o = [o[0] - y + y2, o[1] - y + y2, o[2] - y + y2];
|
||||
}
|
||||
// Chroma: scale chroma towards luma until every channel is within 0..level.
|
||||
if method != 0 {
|
||||
let yl = luminance(o[0], o[1], o[2]);
|
||||
let mut t = 1.0f32;
|
||||
for &v in &o {
|
||||
if v > level && v - yl > 1e-6 {
|
||||
t = t.min((level - yl) / (v - yl));
|
||||
}
|
||||
if v < 0.0 && yl - v > 1e-6 {
|
||||
t = t.min(yl / (yl - v));
|
||||
}
|
||||
}
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
o = [yl + (o[0] - yl) * t, yl + (o[1] - yl) * t, yl + (o[2] - yl) * t];
|
||||
}
|
||||
let o = o.map(|v| v.clamp(0.0, level));
|
||||
if warn && (c.iter().zip(o.iter()).any(|(a, b)| (a - b).abs() > 1e-4)) {
|
||||
return [wc[0], wc[1], wc[2]];
|
||||
}
|
||||
o
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Color Neutralizer
|
||||
|
||||
fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")];
|
||||
let off: Vec<[f32; 3]> = pairs
|
||||
.iter()
|
||||
.map(|(u, bal)| {
|
||||
let (u, v) = (ctx.params.color(u), ctx.params.color(bal));
|
||||
[v[0] - u[0], v[1] - u[1], v[2] - u[2]]
|
||||
})
|
||||
.collect();
|
||||
let pin = (ctx.params.f("pinning") / 100.0) as f32;
|
||||
let orig = (ctx.params.f("blendWOriginal") / 100.0) as f32;
|
||||
let contrast = (ctx.params.f("contrast") / 100.0) as f32;
|
||||
let darks = (ctx.params.f("darks") / 100.0) as f32;
|
||||
let brights = (ctx.params.f("brights") / 100.0) as f32;
|
||||
map_colour(&mut b, |_, c| {
|
||||
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let ws = 1.0 - smoothstep(0.0, 0.5, l);
|
||||
let wh = smoothstep(0.5, 1.0, l);
|
||||
let wm = 1.0 - ws - wh;
|
||||
// Pinning keeps pure black and pure white fixed.
|
||||
let keep = 1.0 - pin * (1.0 - 4.0 * l * (1.0 - l)).clamp(0.0, 1.0);
|
||||
let mut o = [0.0; 3];
|
||||
for k in 0..3 {
|
||||
let d = off[0][k] * ws + off[1][k] * wm + off[2][k] * wh;
|
||||
let mut v = c[k] + d * keep;
|
||||
v = 0.5 + (v - 0.5) * (1.0 + contrast);
|
||||
v += darks * 0.25 * (1.0 - l).powi(2) + brights * 0.25 * l * l;
|
||||
o[k] = v.max(0.0);
|
||||
}
|
||||
[lerp(o[0], c[0], orig), lerp(o[1], c[1], orig), lerp(o[2], c[2], orig)]
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PS Arbitrary Map
|
||||
|
||||
/// Parse a map string of 256 comma/space separated values (0..255, or 0..1 floats). An empty or
|
||||
/// malformed map is the identity.
|
||||
fn parse_map(s: &str) -> Vec<f32> {
|
||||
let vals: Vec<f32> = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::<f32>().ok()).collect();
|
||||
if vals.len() < 2 {
|
||||
return (0..256).map(|i| i as f32 / 255.0).collect();
|
||||
}
|
||||
let max = vals.iter().cloned().fold(0.0f32, f32::max);
|
||||
let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 };
|
||||
vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let map = parse_map(ctx.params.s("map"));
|
||||
let phase = ctx.params.f("phase");
|
||||
let alpha = ctx.params.b("applyPhaseMapToAlpha");
|
||||
let n = map.len();
|
||||
let lookup = |v: f32| -> f32 {
|
||||
let x = (v.clamp(0.0, 1.0) as f64 * (n - 1) as f64 + phase * (n - 1) as f64 / 255.0).rem_euclid(n as f64);
|
||||
let i0 = x.floor() as usize % n;
|
||||
let i1 = if i0 + 1 < n { i0 + 1 } else { i0 };
|
||||
let t = (x - x.floor()) as f32;
|
||||
lerp(map[i0], map[i1], t)
|
||||
};
|
||||
b.img.data.par_iter_mut().for_each(|px| {
|
||||
let (c, a) = unpremul(*px);
|
||||
let o = c.map(lookup);
|
||||
let na = if alpha { lookup(a) } else { a };
|
||||
*px = premul(o, na);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lumetri Color
|
||||
|
||||
/// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the
|
||||
/// end points fixed.
|
||||
#[inline]
|
||||
fn tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 {
|
||||
if s == 0.0 && m == 0.0 && h == 0.0 {
|
||||
return x;
|
||||
}
|
||||
let t = x.clamp(0.0, 1.0);
|
||||
let bump = |c: f32| {
|
||||
let d = (t - c) / 0.25;
|
||||
(1.0 - d * d).max(0.0).powi(2)
|
||||
};
|
||||
x + 0.25 * (s * bump(0.25) + m * bump(0.5) + h * bump(0.75))
|
||||
}
|
||||
|
||||
/// The colour part of a wheel/tint colour (offset from its own grey), 0 for neutral colours.
|
||||
#[inline]
|
||||
fn tint_offset(c: [f32; 4]) -> [f32; 3] {
|
||||
let l = (c[0] + c[1] + c[2]) / 3.0;
|
||||
[c[0] - l, c[1] - l, c[2] - l]
|
||||
}
|
||||
|
||||
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let temp = (pr.f("temperature") / 100.0) as f32;
|
||||
let tint = (pr.f("tint") / 100.0) as f32;
|
||||
let expo = 2f32.powf(pr.f("exposure") as f32);
|
||||
let contrast = (pr.f("contrast") / 100.0) as f32;
|
||||
let (hi, sh, wh, bl) =
|
||||
((pr.f("highlights") / 100.0) as f32, (pr.f("shadows") / 100.0) as f32, (pr.f("whites") / 100.0) as f32, (pr.f("blacks") / 100.0) as f32);
|
||||
let sat = (pr.f("saturation") / 100.0) as f32;
|
||||
let intensity = (pr.f("lookIntensity") / 100.0) as f32;
|
||||
let faded = (pr.f("fadedFilm") / 100.0) as f32;
|
||||
let vib = (pr.f("vibrance") / 100.0) as f32;
|
||||
let csat = (pr.f("creativeSaturation") / 100.0) as f32;
|
||||
let st = tint_offset(pr.color("shadowTint"));
|
||||
let ht = tint_offset(pr.color("highlightTint"));
|
||||
let tbal = (pr.f("tintBalance") / 100.0) as f32;
|
||||
let curve = |k: &str| {
|
||||
[(pr.f(&format!("{k}Shadows")) / 100.0) as f32, (pr.f(&format!("{k}Midtones")) / 100.0) as f32, (pr.f(&format!("{k}Highlights")) / 100.0) as f32]
|
||||
};
|
||||
let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue"));
|
||||
let ws = tint_offset(pr.color("shadowsWheel"));
|
||||
let wm = tint_offset(pr.color("midtonesWheel"));
|
||||
let wh_ = tint_offset(pr.color("highlightsWheel"));
|
||||
let vig = pr.f("vignetteAmount") as f32;
|
||||
let vmid = (pr.f("vignetteMidpoint") / 100.0) as f32;
|
||||
let vround = (pr.f("vignetteRoundness") / 100.0) as f32;
|
||||
let vfeather = (pr.f("vignetteFeather") / 100.0) as f32;
|
||||
let sharpen = (pr.f("sharpen") / 100.0) as f32;
|
||||
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
|
||||
let w = b.img.width as usize;
|
||||
let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5);
|
||||
map_colour(&mut b, |i, c| {
|
||||
let mut c = c;
|
||||
// Basic Correction: white balance, exposure, contrast, tone, saturation.
|
||||
if temp != 0.0 || tint != 0.0 {
|
||||
c = [c[0] * (1.0 + 0.25 * temp), c[1] * (1.0 - 0.25 * tint), c[2] * (1.0 - 0.25 * temp)];
|
||||
}
|
||||
if expo != 1.0 {
|
||||
c = c.map(|v| v * expo);
|
||||
}
|
||||
if contrast != 0.0 {
|
||||
c = c.map(|v| 0.5 + (v - 0.5) * (1.0 + contrast));
|
||||
}
|
||||
if hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0 {
|
||||
let l = luminance(c[0], c[1], c[2]).max(0.0);
|
||||
let lt = l.min(1.0);
|
||||
let d = 0.25 * hi * smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (1.0 - lt).powi(2);
|
||||
c = c.map(|v| v + d);
|
||||
}
|
||||
let l = luminance(c[0], c[1], c[2]);
|
||||
if sat != 1.0 {
|
||||
c = c.map(|v| l + (v - l) * sat);
|
||||
}
|
||||
// Creative.
|
||||
if intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) {
|
||||
let mut d = c;
|
||||
if faded != 0.0 {
|
||||
d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded);
|
||||
}
|
||||
let l = luminance(d[0], d[1], d[2]);
|
||||
if vib != 0.0 {
|
||||
let mx = d[0].max(d[1]).max(d[2]);
|
||||
let mn = d[0].min(d[1]).min(d[2]);
|
||||
let s = if mx > 1e-6 { (mx - mn) / mx } else { 0.0 };
|
||||
let k = 1.0 + vib * (1.0 - s.clamp(0.0, 1.0));
|
||||
d = d.map(|v| l + (v - l) * k);
|
||||
}
|
||||
if csat != 1.0 {
|
||||
d = d.map(|v| l + (v - l) * csat);
|
||||
}
|
||||
let pivot = (0.5 + 0.25 * tbal).clamp(0.05, 0.95);
|
||||
let lt = l.clamp(0.0, 1.0);
|
||||
let wsh = 1.0 - smoothstep(0.0, pivot, lt);
|
||||
let whi = smoothstep(pivot, 1.0, lt);
|
||||
for k in 0..3 {
|
||||
d[k] += 0.5 * (st[k] * wsh + ht[k] * whi);
|
||||
}
|
||||
c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)];
|
||||
}
|
||||
// Curves.
|
||||
c = c.map(|v| tone_curve(v, cm[0], cm[1], cm[2]));
|
||||
c = [tone_curve(c[0], cr[0], cr[1], cr[2]), tone_curve(c[1], cg[0], cg[1], cg[2]), tone_curve(c[2], cb[0], cb[1], cb[2])];
|
||||
// Color wheels.
|
||||
if ws != [0.0; 3] || wm != [0.0; 3] || wh_ != [0.0; 3] {
|
||||
let lt = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let a = 1.0 - smoothstep(0.0, 0.5, lt);
|
||||
let z = smoothstep(0.5, 1.0, lt);
|
||||
let m = 1.0 - a - z;
|
||||
for k in 0..3 {
|
||||
c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z);
|
||||
}
|
||||
}
|
||||
// Vignette.
|
||||
if vig != 0.0 {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let nx = ((x - cx) / (lw * 0.5).max(1e-6)) as f32;
|
||||
let ny = ((y - cy) / (lh * 0.5).max(1e-6)) as f32;
|
||||
// Roundness: 0 follows the frame's aspect, +1 a circle, -1 squarer.
|
||||
let aspect = (lw / lh.max(1e-6)) as f32;
|
||||
let (nx, ny) = if vround > 0.0 { (nx * lerp(1.0, aspect.max(1.0), vround), ny * lerp(1.0, (1.0 / aspect).max(1.0), vround)) } else { (nx, ny) };
|
||||
let pw = 2.0 + (-vround).max(0.0) * 6.0;
|
||||
let r = (nx.abs().powf(pw) + ny.abs().powf(pw)).powf(1.0 / pw) / std::f32::consts::SQRT_2;
|
||||
let start = vmid * 0.9;
|
||||
let f = smoothstep(start, start + 0.05 + vfeather * (1.05 - start), r);
|
||||
let k = if vig < 0.0 { 1.0 + vig * 0.2 * f } else { 1.0 };
|
||||
c = c.map(|v| if vig > 0.0 { v + (1.0 - v) * (vig * 0.2 * f).min(1.0) } else { v * k.max(0.0) });
|
||||
}
|
||||
c.map(|v| v.max(0.0))
|
||||
});
|
||||
if sharpen != 0.0 {
|
||||
let luma = Plane::luma(&b.img);
|
||||
let blur = gauss_plane(&luma, 1.0 * b.scale, 1.0 * b.scale);
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (c, a) = unpremul(*px);
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0;
|
||||
*px = premul(c.map(|v| (v + d).max(0.0)), a);
|
||||
});
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
Vec::new()
|
||||
let grey = || col(0.5, 0.5, 0.5);
|
||||
let mut lum = vec![
|
||||
p("inputLut", "Input LUT", Value::Enum(0), popup(&["None"])),
|
||||
p("temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("tint", "Tint", num(0.0), bipolar()),
|
||||
p("exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("highlights", "Highlights", num(0.0), bipolar()),
|
||||
p("shadows", "Shadows", num(0.0), bipolar()),
|
||||
p("whites", "Whites", num(0.0), bipolar()),
|
||||
p("blacks", "Blacks", num(0.0), bipolar()),
|
||||
p("saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("look", "Look", Value::Enum(0), popup(&["None"])),
|
||||
p("lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("fadedFilm", "Faded Film", num(0.0), pct()),
|
||||
p("sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("vibrance", "Vibrance", num(0.0), bipolar()),
|
||||
p("creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("shadowTint", "Shadow Tint", grey(), ParamUi::Color),
|
||||
p("highlightTint", "Highlight Tint", grey(), ParamUi::Color),
|
||||
p("tintBalance", "Tint Balance", num(0.0), bipolar()),
|
||||
];
|
||||
const CURVES: [(&str, &str); 12] = [
|
||||
("curveMasterShadows", "RGB Curve Shadows"),
|
||||
("curveMasterMidtones", "RGB Curve Midtones"),
|
||||
("curveMasterHighlights", "RGB Curve Highlights"),
|
||||
("curveRedShadows", "Red Curve Shadows"),
|
||||
("curveRedMidtones", "Red Curve Midtones"),
|
||||
("curveRedHighlights", "Red Curve Highlights"),
|
||||
("curveGreenShadows", "Green Curve Shadows"),
|
||||
("curveGreenMidtones", "Green Curve Midtones"),
|
||||
("curveGreenHighlights", "Green Curve Highlights"),
|
||||
("curveBlueShadows", "Blue Curve Shadows"),
|
||||
("curveBlueMidtones", "Blue Curve Midtones"),
|
||||
("curveBlueHighlights", "Blue Curve Highlights"),
|
||||
];
|
||||
for (id, name) in CURVES {
|
||||
lum.push(p(id, name, num(0.0), bipolar()));
|
||||
}
|
||||
lum.extend([
|
||||
p("shadowsWheel", "Shadows", grey(), ParamUi::Color),
|
||||
p("midtonesWheel", "Midtones", grey(), ParamUi::Color),
|
||||
p("highlightsWheel", "Highlights", grey(), ParamUi::Color),
|
||||
p("vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("vignetteMidpoint", "Midpoint", num(50.0), pct()),
|
||||
p("vignetteRoundness", "Roundness", num(0.0), bipolar()),
|
||||
p("vignetteFeather", "Feather", num(50.0), pct()),
|
||||
]);
|
||||
vec![
|
||||
spec(
|
||||
"ec.color.changecolor",
|
||||
"Change Color",
|
||||
vec![
|
||||
p("view", "View", Value::Enum(0), popup(&["Corrected Layer", "Color Correction Mask"])),
|
||||
p("hueTransform", "Hue Transform", num(0.0), slider(-360.0, 360.0, -180.0, 180.0, 1)),
|
||||
p("lightnessTransform", "Lightness Transform", num(0.0), bipolar()),
|
||||
p("saturationTransform", "Saturation Transform", num(0.0), bipolar()),
|
||||
p("colorToChange", "Color To Change", col(0.8, 0.2, 0.2), ParamUi::Color),
|
||||
p("matchingTolerance", "Matching Tolerance", num(15.0), pct()),
|
||||
p("matchingSoftness", "Matching Softness", num(0.0), pct()),
|
||||
p("matchColors", "Match colors", Value::Enum(1), popup(&["Using RGB", "Using Hue", "Using Chroma"])),
|
||||
p("invertColorCorrectionMask", "Invert Color Correction Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
change_color,
|
||||
),
|
||||
spec(
|
||||
"ec.color.videolimiter",
|
||||
"Video Limiter",
|
||||
vec![
|
||||
p("clipLevel", "Clip Level", Value::Enum(2), popup(&["90%", "95%", "100%", "105%", "109%"])),
|
||||
p("clipMethod", "Clip Method", Value::Enum(2), popup(&["Luma", "Chroma", "Smart Limit"])),
|
||||
p("compressionBeforeClipping", "Compression before Clipping", Value::Enum(2), popup(&["None", "3%", "5%", "10%", "20%"])),
|
||||
p("gamutWarning", "Gamut Warning", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("gamutWarningColor", "Gamut Warning Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||||
],
|
||||
video_limiter,
|
||||
),
|
||||
spec(
|
||||
"ec.color.cccolorneutralizer",
|
||||
"CC Color Neutralizer",
|
||||
vec![
|
||||
p("shadowsUnbalance", "Shadows Unbalance", col(0.0, 0.0, 0.0), ParamUi::Color),
|
||||
p("shadowsBalance", "Shadows Balance", col(0.0, 0.0, 0.0), ParamUi::Color),
|
||||
p("midtonesUnbalance", "Midtones Unbalance", grey(), ParamUi::Color),
|
||||
p("midtonesBalance", "Midtones Balance", grey(), ParamUi::Color),
|
||||
p("highlightsUnbalance", "Highlights Unbalance", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("highlightsBalance", "Highlights Balance", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("pinning", "Pinning", num(0.0), pct()),
|
||||
p("blendWOriginal", "Blend w. Original", num(0.0), pct()),
|
||||
p("darks", "Darks", num(0.0), bipolar()),
|
||||
p("brights", "Brights", num(0.0), bipolar()),
|
||||
p("contrast", "Contrast", num(0.0), bipolar()),
|
||||
],
|
||||
color_neutralizer,
|
||||
),
|
||||
spec(
|
||||
"ec.color.psarbitrarymap",
|
||||
"PS Arbitrary Map",
|
||||
vec![
|
||||
p("phase", "Phase", num(0.0), slider(-1000.0, 1000.0, 0.0, 255.0, 0)),
|
||||
p("applyPhaseMapToAlpha", "Apply Phase Map To Alpha", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("map", "Map", Value::Str(String::new()), ParamUi::Hidden),
|
||||
],
|
||||
arbitrary_map,
|
||||
),
|
||||
spec("ec.color.lumetri", "Lumetri Color", lum, lumetri),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, run_fx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
fn ramp() -> Image {
|
||||
let mut img = Image::new(16, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..16 {
|
||||
let a = if y < 7 { 1.0 } else { 0.5 };
|
||||
img.set(x, y, [x as f32 / 16.0 * a, (y as f32 / 8.0) * a, 0.3 * a, a]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn close(a: &Image, b: &Image, tol: f32) -> bool {
|
||||
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
|
||||
}
|
||||
|
||||
fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Image {
|
||||
run_fx(id, vals, img, 0.0, EffectEnv::default()).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_color_zero_is_identity_and_hue_shift_moves_red() {
|
||||
let img = ramp();
|
||||
assert!(close(&run("ec.color.changecolor", &[], img.clone()), &img, 0.0));
|
||||
let red = Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0]);
|
||||
let out = run("ec.color.changecolor", &[("hueTransform", num(120.0))], red.clone());
|
||||
let p = out.get(1, 1);
|
||||
assert!(p[1] > p[0] && p[1] > p[2], "{p:?}");
|
||||
let blue = Image::filled(4, 4, [0.1, 0.2, 0.9, 1.0]);
|
||||
assert!(close(&run("ec.color.changecolor", &[("hueTransform", num(120.0))], blue.clone()), &blue, 1e-6));
|
||||
let mask = run("ec.color.changecolor", &[("view", Value::Enum(1))], red);
|
||||
assert!((mask.get(0, 0)[0] - 1.0).abs() < 1e-6);
|
||||
assert_eq!(out.data, run("ec.color.changecolor", &[("hueTransform", num(120.0))], Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0])).data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_limiter_keeps_legal_and_limits_hot() {
|
||||
let mid = Image::filled(4, 4, [0.4, 0.5, 0.3, 1.0]);
|
||||
assert!(close(&run("ec.color.videolimiter", &[], mid.clone()), &mid, 1e-6));
|
||||
let hot = Image::filled(4, 4, [1.6, 1.2, 0.9, 1.0]);
|
||||
let out = run("ec.color.videolimiter", &[], hot);
|
||||
assert!(out.data.iter().all(|p| p[0] <= 1.0 + 1e-6 && p[1] <= 1.0 + 1e-6));
|
||||
let warn = run("ec.color.videolimiter", &[("gamutWarning", Value::Bool(true))], Image::filled(2, 2, [1.6, 1.2, 0.9, 1.0]));
|
||||
assert_eq!(warn.get(0, 0), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_neutralizer_defaults_identity_and_removes_cast() {
|
||||
let img = ramp();
|
||||
assert!(close(&run("ec.color.cccolorneutralizer", &[], img.clone()), &img, 1e-6));
|
||||
let cast = Image::filled(4, 4, [0.6, 0.5, 0.4, 1.0]);
|
||||
let out = run("ec.color.cccolorneutralizer", &[("midtonesUnbalance", col(0.6, 0.5, 0.4)), ("midtonesBalance", col(0.5, 0.5, 0.5))], cast);
|
||||
let p = out.get(0, 0);
|
||||
assert!((p[0] - p[2]).abs() < 0.1, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_map_identity_and_inverting_map() {
|
||||
let img = ramp();
|
||||
assert!(close(&run("ec.color.psarbitrarymap", &[], img.clone()), &img, 1e-5));
|
||||
let inv: Vec<String> = (0..256).map(|i| (255 - i).to_string()).collect();
|
||||
let out = run("ec.color.psarbitrarymap", &[("map", Value::Str(inv.join(",")))], Image::filled(2, 2, [0.25, 0.5, 1.0, 1.0]));
|
||||
let p = out.get(0, 0);
|
||||
assert!((p[0] - 0.75).abs() < 0.01 && p[2].abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_defaults_identity_and_controls_act() {
|
||||
let img = ramp();
|
||||
assert!(close(&run("ec.color.lumetri", &[], img.clone()), &img, 1e-5));
|
||||
let g = Image::filled(8, 8, [0.4, 0.4, 0.4, 1.0]);
|
||||
let e = run("ec.color.lumetri", &[("exposure", num(1.0))], g.clone());
|
||||
assert!((e.get(0, 0)[0] - 0.8).abs() < 1e-5);
|
||||
let warm = run("ec.color.lumetri", &[("temperature", num(50.0))], g.clone());
|
||||
assert!(warm.get(0, 0)[0] > warm.get(0, 0)[2]);
|
||||
let bw = run("ec.color.lumetri", &[("saturation", num(0.0))], img.clone());
|
||||
let p = bw.get(5, 3);
|
||||
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
|
||||
let v = run("ec.color.lumetri", &[("vignetteAmount", num(-3.0))], g.clone());
|
||||
assert!(v.get(0, 0)[0] < v.get(4, 4)[0]);
|
||||
let c = run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g.clone());
|
||||
assert!(c.get(0, 0)[0] > 0.4);
|
||||
assert_eq!(c.data, run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g).data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue