M9.13: Lumetri (HSL Secondary, hue/sat curves, RGB curves, HDR, looks) + Hue/Sat ranges + Levels/Curves channels + Timewarp optical flow

- Lumetri: HSL Secondary key/refine/correction with Show Mask, Hue vs Sat/Hue/Luma,
  Luma vs Sat, Sat vs Sat and per-channel RGB curves, High Dynamic Range (HDR White /
  Specular), Input LUT and Look (custom .cube + built-in looks).
- Hue/Saturation: Channel Control with Reds..Magentas ranges (range + fall-offs).
- Levels: Channel popup and Red/Green/Blue/Alpha controls; Curves: Channel parameter.
- catalog::param_shown: Effect Controls shows only what a popup selects.
- Timewarp Pixel Motion: block-matching flow (raster::flow::block_flow_window) with
  Vector Detail, Smoothing, Error Threshold, Block Size, Weighting, Correct Luminance,
  Filtering, Build From One Image; Matte Layer/Channel, Warp Layer, Show, Source Crops.
- GPU falls back to CPU when channel/range controls are used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 02:20:57 -04:00
1 parent e3ae230387
commit ce6db35d14
10 files changed
+1366 -88

No files matched your search

+39 -5
View File
@@ -12,11 +12,7 @@ pub const PARTIAL: &[(&str, &str)] = &[
("ec.channel.minimax", "no Don't Shrink Edges option"),
("ec.channel.combiner", "no Saturation Multiplied target"),
("ec.3d.channelextract", "no anti-alias option"),
("ec.color.huesaturation", "no per-range Channel Control"),
("ec.color.levels", "master channel only (no Channel popup)"),
("ec.color.curves", "no Channel popup"),
("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"),
("ec.color.lumetri", "no HSL Secondary, hue/saturation curves, HDR mode or look files"),
("ec.color.selectivecolor", "simplified Colors / Details layout"),
("ec.distort.turbulentdisplace", "no locked pinning variants"),
("ec.distort.opticscompensation", "no Optimal Pixels"),
@@ -39,7 +35,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
("ec.noise.removegrain", "no preview region, sampling or temporal filtering"),
("ec.vr.digitalglitch", "reduced control set"),
("ec.vr.converter", "common layouts only"),
("ec.time.timewarp", "Pixel Motion blends frames (no optical flow); no matte, warp or crop controls"),
("ec.sim.carddance", "no camera system, corner pins or lighting/material controls"),
("ec.sim.shatter", "no custom shatter map, gradient, textures or extrusion rendering"),
("ec.sim.caustics", "no Sky group or light type"),
@@ -52,6 +47,45 @@ pub const PARTIAL: &[(&str, &str)] = &[
("ec.transition.blockdissolve", "no Soft Edges option"),
];
/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path,
/// `group/param`) of an instance of effect `effect`, given the instance's current values
/// (`value(spec id)`). After Effects shows only the controls a popup selects: Levels' channel,
/// Hue/Saturation's Channel Control, the camera system of the card effects, and so on. Every
/// parameter stays animatable and addressable; this is presentation only.
pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option<effectcraft_keyframe::Value>) -> bool {
let e = |id: &str| value(id).map(|v| v.as_enum()).unwrap_or(0);
let b = |id: &str| value(id).is_some_and(|v| v.as_bool());
match effect {
"ec.color.levels" => {
let ch = e("channel") as usize;
for (i, pre) in crate::color_fx::LEVELS_CHANNEL_PREFIX.iter().enumerate() {
if param.starts_with(pre) {
return ch == i + 1;
}
}
let master = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite", "clipToOutputBlack", "clipToOutputWhite"];
!master.contains(&param) || ch == 0
}
"ec.color.huesaturation" => {
let ch = e("channelControl") as usize;
for (i, (pre, _, _)) in crate::color_fx::HUESAT_RANGES.iter().enumerate() {
if param.starts_with(pre) {
return ch == i + 1;
}
}
match param {
"hue" | "saturation" | "lightness" => ch == 0,
"colorizeSaturation" | "colorizeLightness" => b("colorize"),
_ => true,
}
}
_ => {
let _ = b("");
true
}
}
}
/// Implementation status of effect `id` (`Implemented` or `Partial: …`).
pub fn status(id: &str) -> String {
match PARTIAL.iter().find(|(e, _)| *e == id) {
+9 -1
View File
@@ -911,7 +911,15 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.color.curves",
"Curves",
vec![curve("rgb", "RGB"), curve("red", "Red"), curve("green", "Green"), curve("blue", "Blue"), curve("alpha", "Alpha")],
vec![
// The channel the graph edits (the Effect Controls graph's Channel popup).
p("channel", "Channel", Value::Enum(0), ParamUi::Hidden),
curve("rgb", "RGB"),
curve("red", "Red"),
curve("green", "Green"),
curve("blue", "Blue"),
curve("alpha", "Alpha"),
],
curves,
),
spec("ec.color.levelsic", "Levels (Individual Controls)", lv_params, levels_ic),
+501 -10
View File
@@ -249,8 +249,193 @@ fn tint_offset(c: [f32; 4]) -> [f32; 3] {
[c[0] - l, c[1] - l, c[2] - l]
}
/// Control points `x,y x,y …` (0..1, whitespace or `;` separated), sorted by x.
fn parse_points(s: &str) -> Vec<(f32, f32)> {
let mut pts: Vec<(f32, f32)> = s
.split(|c: char| c.is_whitespace() || c == ';')
.filter_map(|t| {
let (x, y) = t.split_once(',')?;
let (x, y) = (x.trim().parse::<f32>().ok()?, y.trim().parse::<f32>().ok()?);
(x.is_finite() && y.is_finite()).then_some((x, y))
})
.collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
pts
}
/// A Lumetri hue / saturation curve: control points `x,y` in 0..1 where y = 0.5 is neutral,
/// interpolated with a cardinal (Catmull-Rom) spline through the points. Hue curves wrap
/// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points.
/// Tabulated as offsets `y − 0.5`.
#[derive(Clone, Debug)]
pub(crate) struct OffsetCurve {
lut: Vec<f32>,
}
impl OffsetCurve {
const N: usize = 512;
/// `None` for an empty or flat-neutral curve.
pub(crate) fn parse(s: &str, periodic: bool) -> Option<OffsetCurve> {
let pts = parse_points(s);
if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) {
return None;
}
let mut ext: Vec<(f32, f32)> = vec![];
if periodic {
let wrapped: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x.rem_euclid(1.0), y)).collect();
let mut w = wrapped;
w.sort_by(|a, b| a.0.total_cmp(&b.0));
for k in -1..=1 {
ext.extend(w.iter().map(|&(x, y)| (x + k as f32, y)));
}
} else {
let (x0, y0) = pts[0];
let (x1, y1) = pts[pts.len() - 1];
ext.push((x0.min(0.0) - 2.0, y0));
ext.push((x0.min(0.0) - 1.0, y0));
ext.extend(pts.iter().copied());
ext.push((x1.max(1.0) + 1.0, y1));
ext.push((x1.max(1.0) + 2.0, y1));
}
let n = ext.len();
// Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat
// segments so the curve never overshoots its points.
let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6);
let m: Vec<f32> = (0..n)
.map(|k| {
if k == 0 || k == n - 1 {
return 0.0;
}
let (a, b) = (slope(k - 1), slope(k));
if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 }
})
.collect();
let eval = |x: f32| -> f32 {
let k = ext.partition_point(|p| p.0 <= x).saturating_sub(1).min(n - 2);
let (xa, ya) = ext[k];
let (xb, yb) = ext[k + 1];
let h = (xb - xa).max(1e-6);
let t = ((x - xa) / h).clamp(0.0, 1.0);
let (t2, t3) = (t * t, t * t * t);
(2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * m[k] + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * m[k + 1]
};
let single = pts.len() == 1;
let lut = (0..=Self::N).map(|i| if single { pts[0].1 - 0.5 } else { eval(i as f32 / Self::N as f32) - 0.5 }).collect();
Some(OffsetCurve { lut })
}
/// Offset from neutral at `x` (clamped to 0..1).
pub(crate) fn at(&self, x: f32) -> f32 {
let f = x.clamp(0.0, 1.0) * Self::N as f32;
let i = (f as usize).min(Self::N - 1);
let t = f - i as f32;
self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t
}
}
/// Lumetri's built-in creative looks (our own formulas; Custom loads a `.cube` file).
pub const LUMETRI_LOOKS: [&str; 8] =
["None", "Custom", "EC Warm Film", "EC Cool Night", "EC Bleach Bypass", "EC Teal & Orange", "EC Faded Matte", "EC Monochrome"];
fn builtin_look(look: u32, c: [f32; 3]) -> [f32; 3] {
let l = luminance(c[0], c[1], c[2]);
match look {
2 => [c[0] * 1.06 + 0.015, c[1] * 1.01 + 0.01, c[2] * 0.88],
3 => [c[0] * 0.82, c[1] * 0.92, c[2] * 1.08 + 0.02].map(|v| v * 0.92),
4 => {
// Silver retained: half the saturation, a luminance overlay for contrast.
let d = c.map(|v| l + (v - l) * 0.45);
let o = |v: f32| if l < 0.5 { 2.0 * v * l } else { 1.0 - 2.0 * (1.0 - v) * (1.0 - l) };
d.map(|v| v * 0.5 + o(v) * 0.5)
}
5 => {
let t = (l - 0.5).clamp(-0.5, 0.5);
[c[0] + 0.18 * t, c[1] + 0.03 * t, c[2] - 0.16 * t]
}
6 => c.map(|v| 0.08 + v * 0.84).map(|v| l + (v - l) * 0.8),
7 => [l; 3],
_ => c,
}
}
/// HSL Secondary's Show Mask styles.
const HSL_MASK_STYLES: [&str; 3] = ["Color/Gray", "Color/Black", "White/Black"];
/// HSL Secondary key: membership (0..1) of an HSL colour (h, s, l in 0..1).
struct HslKey {
hue: Option<(f32, f32, f32)>,
sat: Option<(f32, f32, f32)>,
luma: Option<(f32, f32, f32)>,
}
impl HslKey {
fn from(ctx: &EffectCtx) -> HslKey {
let pr = ctx.params;
let k = |id: &str| pr.f(&format!("hslSecondary/key/{id}")) as f32;
let on = |id: &str| pr.get(&format!("hslSecondary/key/{id}")).is_none_or(|v| v.as_bool());
let band = |lo: &str, hi: &str, soft: &str| (k(lo) / 100.0, k(hi) / 100.0, k(soft) / 100.0);
HslKey {
hue: on("hueEnabled").then(|| (k("hueCenter"), k("hueRange"), k("hueSoftness"))),
sat: on("saturationEnabled").then(|| band("saturationLow", "saturationHigh", "saturationSoftness")),
luma: on("lightnessEnabled").then(|| band("lightnessLow", "lightnessHigh", "lightnessSoftness")),
}
}
fn band(v: f32, (lo, hi, soft): (f32, f32, f32)) -> f32 {
let (lo, hi) = (lo.min(hi), lo.max(hi));
let d = if v < lo {
lo - v
} else if v > hi {
v - hi
} else {
0.0
};
if d <= 0.0 {
1.0
} else if soft > 0.0 {
(1.0 - d / soft).max(0.0)
} else {
0.0
}
}
fn weight(&self, h: f32, s: f32, l: f32) -> f32 {
let mut w = 1.0;
if let Some((centre, range, soft)) = self.hue
&& range < 180.0
{
let d = ((h * 360.0 - centre).rem_euclid(360.0)).min((centre - h * 360.0).rem_euclid(360.0));
let hw = if d <= range {
1.0
} else if soft > 0.0 {
(1.0 - (d - range) / soft).max(0.0)
} else {
0.0
};
// Greys have no hue: they belong to a hue key only as far as they are saturated.
w *= hw * (s * 20.0).min(1.0);
}
if let Some(b) = self.sat {
w *= Self::band(s, b);
}
if let Some(b) = self.luma {
w *= Self::band(l, b);
}
w
}
}
/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves),
/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order.
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let hdr = pr.b("highDynamicRange");
let hdr_white = if hdr { (pr.get("basicCorrection/tone/hdrWhite").map(Value::as_f64).unwrap_or(100.0) / 100.0).max(0.01) as f32 } else { 1.0 };
let hdr_spec = if hdr { (pr.f("basicCorrection/tone/hdrSpecular") / 100.0) as f32 } else { 0.0 };
let input_lut = if pr.e("basicCorrection/inputLut") == 1 { crate::utility::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None };
let look = pr.e("creative/look");
let look_lut = if look == 1 { crate::utility::load_lut(pr.s("creative/lookFile")) } else { None };
let temp = (pr.f("basicCorrection/whiteBalance/temperature") / 100.0) as f32;
let tint = (pr.f("basicCorrection/whiteBalance/tint") / 100.0) as f32;
let expo = 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32);
@@ -277,6 +462,12 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
]
};
let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue"));
let rgb_curves =
["curves/rgbCurves/master", "curves/rgbCurves/red", "curves/rgbCurves/green", "curves/rgbCurves/blue"].map(|id| crate::color2::Curve::parse(pr.s(id)));
let hs = |id: &str, periodic: bool| OffsetCurve::parse(pr.s(&format!("curves/hueSaturationCurves/{id}")), periodic);
let (hue_sat, hue_hue, hue_luma, luma_sat, sat_sat) =
(hs("hueVsSat", true), hs("hueVsHue", true), hs("hueVsLuma", true), hs("lumaVsSat", false), hs("satVsSat", false));
let any_hs = hue_sat.is_some() || hue_hue.is_some() || hue_luma.is_some() || luma_sat.is_some() || sat_sat.is_some();
let ws = tint_offset(pr.color("colorWheels/shadowsWheel"));
let wm = tint_offset(pr.color("colorWheels/midtonesWheel"));
let wh_ = tint_offset(pr.color("colorWheels/highlightsWheel"));
@@ -285,12 +476,29 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
let vround = (pr.f("vignette/vignetteRoundness") / 100.0) as f32;
let vfeather = (pr.f("vignette/vignetteFeather") / 100.0) as f32;
let sharpen = (pr.f("creative/adjustments/sharpen") / 100.0) as f32;
// HSL Secondary.
let sec = |id: &str, d: f64| pr.get(&format!("hslSecondary/{id}")).map(Value::as_f64).unwrap_or(d) as f32;
let s_temp = sec("correction/temperature", 0.0) / 100.0;
let s_tint = sec("correction/tint", 0.0) / 100.0;
let s_contrast = sec("correction/contrast", 0.0) / 100.0;
let s_sharpen = sec("correction/sharpen", 0.0) / 100.0;
let s_sat = sec("correction/saturation", 100.0) / 100.0;
let s_wheel = pr.get("hslSecondary/correction/correctionWheel").map(|v| tint_offset(v.as_color())).unwrap_or([0.0; 3]);
let show_mask = pr.b("hslSecondary/key/showMask");
let sec_active = show_mask || s_temp != 0.0 || s_tint != 0.0 || s_contrast != 0.0 || s_sharpen != 0.0 || s_sat != 1.0 || s_wheel != [0.0; 3];
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| {
map_colour(&mut b, |_, c| {
let mut c = c;
// Basic Correction: white balance, exposure, contrast, tone, saturation.
if let Some(l) = &input_lut {
c = l.apply(c);
}
// Basic Correction: white balance, exposure, contrast, tone, saturation. With High
// Dynamic Range the tone controls work on a range whose white is HDR White.
if hdr_white != 1.0 {
c = c.map(|v| v / hdr_white);
}
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)];
}
@@ -306,13 +514,24 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
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);
}
if hdr_spec != 0.0 {
// HDR Specular scales what lies above white.
c = c.map(|v| if v > 1.0 { 1.0 + (v - 1.0) * (1.0 + hdr_spec).max(0.0) } else { v });
}
if hdr_white != 1.0 {
c = c.map(|v| v * hdr_white);
}
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;
// Creative: the look, then the adjustments, all at Intensity.
let has_look = look >= 2 || look_lut.is_some();
if intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) {
let mut d = match &look_lut {
Some(lut) => lut.apply(c),
None => builtin_look(look, c),
};
if faded != 0.0 {
d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded);
}
@@ -336,9 +555,40 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)];
}
// Curves.
// Curves: tone sliders, RGB curves (master, then each channel), hue/saturation 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])];
if let Some(m) = &rgb_curves[0] {
c = c.map(|v| m.eval(v));
}
for k in 0..3 {
if let Some(cv) = &rgb_curves[k + 1] {
c[k] = cv.eval(c[k]);
}
}
if any_hs {
let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0));
let chroma = (s * 20.0).min(1.0);
let off = |cv: &Option<OffsetCurve>, x: f32| cv.as_ref().map(|cv| cv.at(x)).unwrap_or(0.0);
// y = 1 doubles saturation, y = 0 removes it.
let mut ms = (1.0 + 2.0 * off(&hue_sat, h) * chroma) * (1.0 + 2.0 * off(&luma_sat, l)) * (1.0 + 2.0 * off(&sat_sat, s));
ms = ms.max(0.0);
let dh = off(&hue_hue, h) * chroma;
let dl = off(&hue_luma, h) * chroma;
let luma = luminance(c[0], c[1], c[2]);
if dh != 0.0 {
let (r, g, b2) = hsl_to_rgb((h + dh).rem_euclid(1.0), s, l);
// Keep values outside 0..1 (HDR) by moving only the in-range part.
let base = [c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)];
c = [c[0] + r - base[0], c[1] + g - base[1], c[2] + b2 - base[2]];
}
if ms != 1.0 {
c = c.map(|v| luma + (v - luma) * ms);
}
if dl != 0.0 {
c = c.map(|v| v + dl);
}
}
// 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);
@@ -349,6 +599,70 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z);
}
}
c
});
// HSL Secondary: key the graded colour, refine the matte, apply the correction through it.
let mask = sec_active.then(|| {
let key = HslKey::from(ctx);
let mut m = Plane::from_image(&b.img, |px| {
let (c, a) = unpremul(px);
if a <= 0.0 {
return 0.0;
}
let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0));
key.weight(h, s, l)
});
let denoise = sec("refine/denoise", 0.0) / 100.0;
if denoise > 0.0 {
// Smooth speckle, then restore the matte's edge contrast.
let s = (denoise * 3.0) as f64 * b.scale;
m = gauss_plane(&m, s, s);
let k = 1.0 + denoise * 2.0;
m.data.iter_mut().for_each(|v| *v = ((*v - 0.5) * k + 0.5).clamp(0.0, 1.0));
}
let blur = sec("refine/blur", 0.0) / 100.0;
if blur > 0.0 {
let s = (blur * 10.0) as f64 * b.scale;
m = gauss_plane(&m, s, s);
}
if pr.b("hslSecondary/key/invertMask") {
m.data.iter_mut().for_each(|v| *v = 1.0 - *v);
}
m
});
let style = pr.e("hslSecondary/key/maskStyle");
map_colour(&mut b, |i, c| {
let mut c = c;
if let Some(m) = &mask {
let k = m.data[i];
if show_mask {
return match style {
0 => {
let g = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * 0.2 + 0.4;
[lerp(g, c[0], k), lerp(g, c[1], k), lerp(g, c[2], k)]
}
1 => c.map(|v| v * k),
_ => [k; 3],
};
}
if k > 0.0 {
let mut d = c;
if s_temp != 0.0 || s_tint != 0.0 {
d = [d[0] * (1.0 + 0.25 * s_temp), d[1] * (1.0 - 0.25 * s_tint), d[2] * (1.0 - 0.25 * s_temp)];
}
if s_contrast != 0.0 {
d = d.map(|v| 0.5 + (v - 0.5) * (1.0 + s_contrast));
}
let l = luminance(d[0], d[1], d[2]);
if s_sat != 1.0 {
d = d.map(|v| l + (v - l) * s_sat);
}
for j in 0..3 {
d[j] += 0.5 * s_wheel[j];
}
c = [lerp(c[0], d[0], k), lerp(c[1], d[1], k), lerp(c[2], d[2], k)];
}
}
// Vignette.
if vig != 0.0 {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
@@ -366,7 +680,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
c.map(|v| v.max(0.0))
});
if sharpen != 0.0 {
let sec_sharpen = if show_mask { 0.0 } else { s_sharpen };
if sharpen != 0.0 || (sec_sharpen != 0.0 && mask.is_some()) {
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)| {
@@ -374,7 +689,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
if a <= 0.0 {
return;
}
let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0;
let amount = sharpen + sec_sharpen * mask.as_ref().map(|m| m.data[i]).unwrap_or(0.0);
let d = (luma.data[i] - blur.data[i]) * amount * 2.0;
*px = premul(c.map(|v| (v + d).max(0.0)), a);
});
}
@@ -384,7 +700,10 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
pub fn specs() -> Vec<EffectSpec> {
let grey = || col(0.5, 0.5, 0.5);
let mut lum = vec![
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None"])),
p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox),
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])),
// A .cube file path (or the file's text) for Input LUT = Custom.
p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text),
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()),
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()),
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
@@ -393,8 +712,11 @@ pub fn specs() -> Vec<EffectSpec> {
p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()),
p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()),
p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()),
p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)),
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()),
p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("creative/look", "Look", Value::Enum(0), popup(&["None"])),
p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)),
p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text),
p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()),
p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()),
@@ -421,10 +743,50 @@ pub fn specs() -> Vec<EffectSpec> {
for (id, name) in CURVES {
lum.push(p(id, name, num(0.0), bipolar()));
}
// Curve graphs: control points "x,y x,y …" in 0..1 (like Curves). RGB curves default to
// the identity diagonal; hue/saturation curves are flat at y = 0.5 (no change) and empty
// means flat.
let pts = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Hidden);
lum.extend([
pts("curves/rgbCurves/master", "RGB Curve", "0,0 1,1"),
pts("curves/rgbCurves/red", "Red Curve", "0,0 1,1"),
pts("curves/rgbCurves/green", "Green Curve", "0,0 1,1"),
pts("curves/rgbCurves/blue", "Blue Curve", "0,0 1,1"),
pts("curves/hueSaturationCurves/hueVsSat", "Hue vs Sat", ""),
pts("curves/hueSaturationCurves/hueVsHue", "Hue vs Hue", ""),
pts("curves/hueSaturationCurves/hueVsLuma", "Hue vs Luma", ""),
pts("curves/hueSaturationCurves/lumaVsSat", "Luma vs Sat", ""),
pts("curves/hueSaturationCurves/satVsSat", "Sat vs Sat", ""),
]);
lum.extend([
p("colorWheels/shadowsWheel", "Shadows", grey(), ParamUi::Color),
p("colorWheels/midtonesWheel", "Midtones", grey(), ParamUi::Color),
p("colorWheels/highlightsWheel", "Highlights", grey(), ParamUi::Color),
// HSL Secondary: key (hue centre / range / softness in degrees, saturation and
// lightness bands in %), refine, correction.
p("hslSecondary/key/hueEnabled", "H", Value::Bool(true), ParamUi::Checkbox),
p("hslSecondary/key/hueCenter", "Hue Center", num(0.0), ParamUi::Angle),
p("hslSecondary/key/hueRange", "Hue Range", num(180.0), slider(0.0, 180.0, 0.0, 180.0, 1)),
p("hslSecondary/key/hueSoftness", "Hue Softness", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)),
p("hslSecondary/key/saturationEnabled", "S", Value::Bool(true), ParamUi::Checkbox),
p("hslSecondary/key/saturationLow", "Saturation Low", num(0.0), pct()),
p("hslSecondary/key/saturationHigh", "Saturation High", num(100.0), pct()),
p("hslSecondary/key/saturationSoftness", "Saturation Softness", num(0.0), pct()),
p("hslSecondary/key/lightnessEnabled", "L", Value::Bool(true), ParamUi::Checkbox),
p("hslSecondary/key/lightnessLow", "Lightness Low", num(0.0), pct()),
p("hslSecondary/key/lightnessHigh", "Lightness High", num(100.0), pct()),
p("hslSecondary/key/lightnessSoftness", "Lightness Softness", num(0.0), pct()),
p("hslSecondary/key/showMask", "Show Mask", Value::Bool(false), ParamUi::Checkbox),
p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)),
p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox),
p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()),
p("hslSecondary/refine/blur", "Blur", num(0.0), pct()),
p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color),
p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()),
p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()),
p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()),
p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()),
p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()),
p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()),
@@ -580,4 +942,133 @@ mod tests {
assert!(c.get(0, 0)[0] > 0.4);
assert_eq!(c.data, run("ec.color.lumetri", &[("curves/curveMasterMidtones", num(50.0))], g).data);
}
fn s(v: &str) -> Value {
Value::Str(v.into())
}
/// Red, green and grey swatches side by side.
fn swatches() -> Image {
let mut img = Image::new(3, 1);
img.set(0, 0, [0.8, 0.1, 0.1, 1.0]);
img.set(1, 0, [0.1, 0.8, 0.1, 1.0]);
img.set(2, 0, [0.5, 0.5, 0.5, 1.0]);
img
}
#[test]
fn lumetri_rgb_curves_per_channel() {
let g = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]);
let m = run("ec.color.lumetri", &[("curves/rgbCurves/master", s("0,0 0.5,0.75 1,1"))], g.clone()).get(0, 0);
assert!((m[0] - 0.75).abs() < 1e-3 && (m[2] - 0.75).abs() < 1e-3);
let r = run("ec.color.lumetri", &[("curves/rgbCurves/red", s("0,0 0.5,0.25 1,1"))], g).get(0, 0);
assert!((r[0] - 0.25).abs() < 1e-3 && (r[1] - 0.5).abs() < 1e-4);
}
#[test]
fn lumetri_hue_saturation_curves_target_hues() {
let img = swatches();
// Hue vs Sat: desaturate reds only (a dip to 0 at hue 0, neutral from 0.2 to 0.8).
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsSat", s("0,0 0.2,0.5 0.8,0.5"))], img.clone());
let red = out.get(0, 0);
assert!((red[0] - red[1]).abs() < 0.02, "red desaturated: {red:?}");
assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-3), "green untouched");
assert!(close_px(out.get(2, 0), img.get(2, 0), 1e-4), "grey untouched");
// Hue vs Hue: shift reds by +1/6 turn (y = 0.5 + 60/360) towards yellow.
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsHue", s("0,0.6667 0.2,0.5 0.8,0.5"))], img.clone());
let p = out.get(0, 0);
assert!(p[1] > 0.6 && p[0] > 0.6 && p[2] < 0.2, "red became yellow: {p:?}");
// Hue vs Luma brightens greens only.
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsLuma", s("0.333,0.7 0.1,0.5 0.6,0.5"))], img.clone());
assert!(out.get(1, 0)[1] > 0.85 && close_px(out.get(0, 0), img.get(0, 0), 1e-3));
// Luma vs Sat / Sat vs Sat: y = 0 everywhere removes all saturation.
for id in ["lumaVsSat", "satVsSat"] {
let out = run("ec.color.lumetri", &[(&format!("curves/hueSaturationCurves/{id}"), s("0,0 1,0"))], img.clone());
let p = out.get(0, 0);
assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: {p:?}");
}
}
fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
}
#[test]
fn lumetri_hsl_secondary_keys_and_corrects() {
let img = swatches();
let key_red = [("hslSecondary/key/hueCenter", num(0.0)), ("hslSecondary/key/hueRange", num(20.0)), ("hslSecondary/key/hueSoftness", num(10.0))];
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
vals.push(("hslSecondary/correction/saturation", num(0.0)));
let out = run("ec.color.lumetri", &vals, img.clone());
let r = out.get(0, 0);
assert!((r[0] - r[1]).abs() < 1e-3, "red desaturated through the key: {r:?}");
assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-5) && close_px(out.get(2, 0), img.get(2, 0), 1e-5));
// Show Mask (White/Black) shows the key; Invert Mask flips it.
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
vals.extend([("hslSecondary/key/showMask", Value::Bool(true)), ("hslSecondary/key/maskStyle", Value::Enum(2))]);
let m = run("ec.color.lumetri", &vals, img.clone());
assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [1.0, 0.0, 0.0]);
vals.push(("hslSecondary/key/invertMask", Value::Bool(true)));
let m = run("ec.color.lumetri", &vals, img.clone());
assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [0.0, 1.0, 1.0]);
// Lightness band keys only the bright grey; Blur softens the matte across pixels.
let vals = [
("hslSecondary/key/lightnessLow", num(40.0)),
("hslSecondary/key/lightnessHigh", num(60.0)),
("hslSecondary/key/showMask", Value::Bool(true)),
("hslSecondary/key/maskStyle", Value::Enum(2)),
];
let m = run("ec.color.lumetri", &vals, img.clone());
assert_eq!(m.get(2, 0)[0], 1.0);
let mut wide = Image::new(32, 1);
for x in 0..32 {
wide.set(x, 0, if x < 16 { [0.8, 0.1, 0.1, 1.0] } else { [0.1, 0.8, 0.1, 1.0] });
}
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
vals.extend([
("hslSecondary/key/showMask", Value::Bool(true)),
("hslSecondary/key/maskStyle", Value::Enum(2)),
("hslSecondary/refine/blur", num(50.0)),
]);
let m = run("ec.color.lumetri", &vals, wide);
let v = m.get(16, 0)[0];
assert!(v > 0.05 && v < 0.95, "blurred matte edge {v}");
}
#[test]
fn lumetri_hdr_range_and_looks() {
// HDR White 200 nits: the tone controls see 1.6 as 0.8, so Highlights act on it.
let bright = Image::filled(2, 2, [1.6, 1.6, 1.6, 1.0]);
let sdr = run("ec.color.lumetri", &[("basicCorrection/tone/highlights", num(-100.0))], bright.clone()).get(0, 0)[0];
let hdr = run(
"ec.color.lumetri",
&[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrWhite", num(200.0)), ("basicCorrection/tone/highlights", num(-100.0))],
bright.clone(),
)
.get(0, 0)[0];
assert!(hdr < 1.6 - 0.05 && sdr < 1.6, "{sdr} {hdr}");
let spec = run("ec.color.lumetri", &[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrSpecular", num(100.0))], bright).get(0, 0)[0];
assert!((spec - 2.2).abs() < 1e-4, "{spec}");
// Built-in looks change the image at Intensity; 0 % leaves it.
let img = ramp();
let mono = run("ec.color.lumetri", &[("creative/look", Value::Enum(7))], img.clone());
let p = mono.get(9, 3);
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
let none = run("ec.color.lumetri", &[("creative/look", Value::Enum(7)), ("creative/lookIntensity", num(0.0))], img.clone());
assert!(close(&none, &img, 1e-5));
// A Custom look from inline .cube text (swap red and blue).
let mut cube = String::from("LUT_3D_SIZE 2\n");
for bb in 0..2 {
for g in 0..2 {
for r in 0..2 {
cube += &format!("{bb} {g} {r}\n");
}
}
}
let g = Image::filled(1, 1, [0.9, 0.2, 0.1, 1.0]);
let o = run("ec.color.lumetri", &[("creative/look", Value::Enum(1)), ("creative/lookFile", Value::Str(cube.clone()))], g.clone()).get(0, 0);
assert!((o[0] - 0.1).abs() < 1e-3 && (o[2] - 0.9).abs() < 1e-3, "{o:?}");
let o = run("ec.color.lumetri", &[("basicCorrection/inputLut", Value::Enum(1)), ("basicCorrection/inputLutFile", Value::Str(cube))], g).get(0, 0);
assert!((o[0] - 0.1).abs() < 1e-3, "{o:?}");
}
}
+257 -25
View File
@@ -70,6 +70,85 @@ fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Hue/Saturation's colour ranges (Channel Control entries after Master): id prefix, display
/// name and centre hue in degrees.
pub const HUESAT_RANGES: [(&str, &str, f64); 6] = [
("reds", "Reds", 0.0),
("yellows", "Yellows", 60.0),
("greens", "Greens", 120.0),
("cyans", "Cyans", 180.0),
("blues", "Blues", 240.0),
("magentas", "Magentas", 300.0),
];
/// Hue/Saturation's Channel Control popup.
pub const HUESAT_CHANNELS: [&str; 7] = ["Master", "Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"];
/// One colour range of Hue/Saturation: the hues it covers fully (`start..end`, degrees, may
/// wrap), the fall-off widths either side, and its hue / saturation / lightness adjustments.
#[derive(Clone, Copy, Debug)]
struct HueRange {
start: f32,
end: f32,
fall_start: f32,
fall_end: f32,
hue: f32,
sat: f32,
light: f32,
}
impl HueRange {
/// Membership of hue `h` (degrees): 1 inside the range, ramping to 0 across the fall-offs.
fn weight(&self, h: f32) -> f32 {
let width = (self.end - self.start).rem_euclid(360.0);
let d = (h - self.start).rem_euclid(360.0);
if d <= width {
return 1.0;
}
let after = d - width;
let before = 360.0 - d;
let wa = if self.fall_end > 0.0 { 1.0 - after / self.fall_end } else { 0.0 };
let wb = if self.fall_start > 0.0 { 1.0 - before / self.fall_start } else { 0.0 };
wa.max(wb).clamp(0.0, 1.0)
}
}
/// The colour ranges with non-zero adjustments.
fn huesat_ranges(ctx: &EffectCtx) -> Vec<HueRange> {
let f = |id: String, d: f64| ctx.params.get(&id).map(Value::as_f64).unwrap_or(d) as f32;
HUESAT_RANGES
.iter()
.map(|(r, _, c)| HueRange {
start: f(format!("{r}RangeStart"), c - 15.0),
end: f(format!("{r}RangeEnd"), c + 15.0),
fall_start: f(format!("{r}StartFalloff"), 30.0).max(0.0),
fall_end: f(format!("{r}EndFalloff"), 30.0).max(0.0),
hue: f(format!("{r}Hue"), 0.0) / 360.0,
sat: f(format!("{r}Saturation"), 0.0) / 100.0,
light: f(format!("{r}Lightness"), 0.0) / 100.0,
})
.filter(|r| r.hue != 0.0 || r.sat != 0.0 || r.light != 0.0)
.collect()
}
/// Whether no colour range of Hue/Saturation adjusts anything (the GPU kernel does Master and
/// Colorize only).
pub fn huesat_ranges_identity(ctx: &EffectCtx) -> bool {
huesat_ranges(ctx).is_empty()
}
fn adjust_sat(s: f32, sat: f32) -> f32 {
if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) }
}
fn adjust_light(l: f32, light: f32) -> f32 {
if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) }
}
/// Hue/Saturation. Master adjusts every pixel; each colour range (Reds … Magentas, chosen in
/// Channel Control) adjusts pixels whose original hue lies in its range, fading across the
/// fall-offs (greys, having no hue, belong to no range). Colorize replaces hue and
/// saturation.
fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hue = ctx.params.f("hue") as f32 / 360.0;
let sat = ctx.params.f("saturation") as f32 / 100.0;
@@ -78,14 +157,24 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ch = ctx.params.f("colorizeHue") as f32 / 360.0;
let cs = ctx.params.f("colorizeSaturation") as f32 / 100.0;
let cl = ctx.params.f("colorizeLightness") as f32 / 100.0;
let ranges = huesat_ranges(ctx);
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (h, s, l) = if colorize {
(ch, cs, (l + cl * if cl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0))
} else {
let s2 = if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) };
let l2 = if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) };
((h + hue).rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
let mut s2 = adjust_sat(s, sat);
let mut l2 = adjust_light(l, light);
let mut h2 = h + hue;
for r in &ranges {
let w = r.weight(h * 360.0) * (s * 20.0).min(1.0);
if w > 0.0 {
h2 += r.hue * w;
s2 = adjust_sat(s2.clamp(0.0, 1.0), r.sat * w);
l2 = adjust_light(l2, r.light * w);
}
}
(h2.rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
};
let (r, g, bl) = hsl_to_rgb(h, s, l);
[r, g, bl]
@@ -107,29 +196,87 @@ pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) {
(on("clipToOutputBlack"), on("clipToOutputWhite"))
}
/// Levels' Channel popup.
pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"];
/// Spec-id prefixes of Levels' per-channel controls (`redInBlack`, …; the RGB controls have
/// plain ids).
pub const LEVELS_CHANNEL_PREFIX: [&str; 4] = ["red", "green", "blue", "alpha"];
/// Levels' five controls (input black, input white, gamma, output black, output white) for
/// Channel popup index `ch` (0 = RGB).
pub fn levels_channel_ids(ch: usize) -> [String; 5] {
if ch == 0 || ch > 4 {
return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from);
}
let pre = LEVELS_CHANNEL_PREFIX[ch - 1];
["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}{s}"))
}
fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) -> f32 {
let mut t = (v - ib) / (iw - ib).max(1e-6);
if clip_b {
t = t.max(0.0);
}
if clip_w {
t = t.min(1.0);
}
let t = t.max(0.0).powf(1.0 / g);
ob + (ow - ob) * t
}
const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
/// Levels: the RGB controls map every colour channel, then the Red / Green / Blue controls
/// their own channel and the Alpha controls alpha. All five sets are kept; the Channel popup
/// only picks the set Effect Controls shows.
fn levels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ib = ctx.params.f("inBlack") as f32;
let iw = ctx.params.f("inWhite") as f32;
let g = ctx.params.f("gamma").max(0.01) as f32;
let ob = ctx.params.f("outBlack") as f32;
let ow = ctx.params.f("outWhite") as f32;
let (clip_b, clip_w) = levels_clip(ctx);
b.img.map_straight(|c| {
c.map(|v| {
let mut t = (v - ib) / (iw - ib).max(1e-6);
if clip_b {
t = t.max(0.0);
let clip = levels_clip(ctx);
b.img.map_straight(|c| c.map(|v| level(v, [ib, iw, g, ob, ow], clip)));
let chans = levels_channel_settings(ctx);
let ident = |l: &([f32; 5], (bool, bool))| l.0 == LEVELS_IDENTITY;
if chans.iter().all(ident) {
return b;
}
use rayon::prelude::*;
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = crate::util::unpremul(*px);
let mut o = c;
for i in 0..3 {
if !ident(&chans[i]) {
o[i] = level(c[i], chans[i].0, chans[i].1);
}
if clip_w {
t = t.min(1.0);
}
let t = t.max(0.0).powf(1.0 / g);
ob + (ow - ob) * t
})
}
let na = if ident(&chans[3]) { a } else { level(a, chans[3].0, chans[3].1).clamp(0.0, 1.0) };
*px = crate::util::premul(o, na);
});
b
}
/// Levels' Red, Green, Blue and Alpha settings and clip switches (identity for projects saved
/// before the Channel popup).
pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] {
std::array::from_fn(|i| {
let ids = levels_channel_ids(i + 1);
let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k]));
let pre = LEVELS_CHANNEL_PREFIX[i];
let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0);
let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) };
([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip)
})
}
/// Whether Levels' per-channel controls are all at their defaults (the GPU kernel handles
/// only the RGB controls).
pub fn levels_channels_identity(ctx: &EffectCtx) -> bool {
levels_channel_settings(ctx).iter().all(|l| l.0 == LEVELS_IDENTITY)
}
/// Exposure's per-channel (gain, offset, gamma) for R, G, B: the Master group's values, or the
/// Red / Green / Blue groups' with Channels = Individual Channels.
pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
@@ -457,6 +604,24 @@ fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Levels' Red / Green / Blue / Alpha controls ("Red Input Black", …), in that order.
fn levels_channel_params() -> Vec<crate::ParamSpec> {
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
let names = ["Input Black", "Input White", "Gamma", "Output Black", "Output White"];
let mut v = vec![];
for (i, pre) in LEVELS_CHANNEL_PREFIX.iter().enumerate() {
let label = LEVELS_CHANNELS[i + 1];
let ids = levels_channel_ids(i + 1);
for k in 0..5 {
let ui = if k == 2 { slider(0.1, 10.0, 0.1, 3.0, 2) } else { slider(-1.0, 2.0, 0.0, 1.0, 3) };
v.push(p(leak(ids[k].clone()), leak(format!("{label} {}", names[k])), num(LEVELS_IDENTITY[k] as f64), ui));
}
v.push(p(leak(format!("{pre}ClipToOutputBlack")), leak(format!("{label} Clip To Output Black")), Value::Enum(2), popup(&LEVELS_CLIP)));
v.push(p(leak(format!("{pre}ClipToOutputWhite")), leak(format!("{label} Clip To Output White")), Value::Enum(2), popup(&LEVELS_CLIP)));
}
v
}
pub fn specs() -> Vec<EffectSpec> {
let pct = || slider(-100.0, 100.0, -100.0, 100.0, 1);
let mut gpg = vec![p("blackStretch", "Black Stretch", num(0.0), slider(0.0, 4.0, 0.0, 4.0, 2))];
@@ -501,21 +666,39 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.color.huesaturation",
"Hue/Saturation",
vec![
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
p("saturation", "Master Saturation", num(0.0), pct()),
p("lightness", "Master Lightness", num(0.0), pct()),
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
],
{
let mut v = vec![
p("channelControl", "Channel Control", Value::Enum(0), popup(&HUESAT_CHANNELS)),
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
p("saturation", "Master Saturation", num(0.0), pct()),
p("lightness", "Master Lightness", num(0.0), pct()),
];
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
for (r, name, c) in HUESAT_RANGES {
let single = name.trim_end_matches('s');
v.push(p(leak(format!("{r}Hue")), leak(format!("{single} Hue")), num(0.0), ParamUi::Angle));
v.push(p(leak(format!("{r}Saturation")), leak(format!("{single} Saturation")), num(0.0), pct()));
v.push(p(leak(format!("{r}Lightness")), leak(format!("{single} Lightness")), num(0.0), pct()));
v.push(p(leak(format!("{r}RangeStart")), leak(format!("{single} Range Start")), num(c - 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0)));
v.push(p(leak(format!("{r}RangeEnd")), leak(format!("{single} Range End")), num(c + 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0)));
v.push(p(leak(format!("{r}StartFalloff")), leak(format!("{single} Start Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0)));
v.push(p(leak(format!("{r}EndFalloff")), leak(format!("{single} End Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0)));
}
v.extend([
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
]);
v
},
hue_saturation,
),
spec(
"ec.color.levels",
"Levels",
vec![
p("channel", "Channel", Value::Enum(0), popup(&LEVELS_CHANNELS)),
p("inBlack", "Input Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("inWhite", "Input White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)),
@@ -525,7 +708,10 @@ pub fn specs() -> Vec<EffectSpec> {
p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&LEVELS_CLIP)),
// Projects saved before the two popups: "Don't Clip".
p("noClip", "Don't Clip", Value::Bool(false), ParamUi::Hidden),
],
]
.into_iter()
.chain(levels_channel_params())
.collect(),
levels,
),
spec(
@@ -837,4 +1023,50 @@ mod tests {
assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15);
assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6));
}
#[test]
fn levels_channel_controls_act_on_their_channel() {
let g = [0.5, 0.5, 0.5, 1.0];
assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing");
let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g);
assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}");
let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]);
assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}");
let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]);
assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}");
// Per-channel clipping follows its own popups.
let hot = [0.9, 0.9, 0.9, 1.0];
let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1];
let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1];
assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}");
}
#[test]
fn hue_saturation_ranges_adjust_their_hues_only() {
let red = [0.8, 0.1, 0.1, 1.0];
let blue = [0.1, 0.1, 0.8, 1.0];
let grey = [0.5, 0.5, 0.5, 1.0];
let v = [("redsSaturation", num(-100.0))];
let r = px("ec.color.huesaturation", &v, red);
assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}");
assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5));
assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5));
// Blues hue +120°: blue becomes red.
let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue);
assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}");
// Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half.
let orange_ish = {
let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5);
[r, g, b, 1.0]
};
let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish);
let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]);
assert!((l - 0.75).abs() < 0.01, "half weight: {l}");
// Moving the range changes which hues it takes.
let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish);
assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99);
let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() };
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() };
assert!(!huesat_ranges_identity(&ctx));
}
}
+10 -1
View File
@@ -60,7 +60,7 @@ pub mod warp_stab;
use std::collections::HashMap;
use std::sync::OnceLock;
pub use color_fx::{exposure_settings, levels_clip};
pub use color_fx::{HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip};
pub use color2::Curve;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
@@ -504,6 +504,12 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("curves", "Curves"),
("colorWheels", "Color Wheels"),
("vignette", "Vignette"),
("rgbCurves", "RGB Curves"),
("hueSaturationCurves", "Hue Saturation Curves"),
("hslSecondary", "HSL Secondary"),
("key", "Key"),
("refine", "Refine"),
("correction", "Correction"),
// Camera Lens Blur.
("irisProperties", "Iris Properties"),
("highlight", "Highlight"),
@@ -530,6 +536,9 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
// Time: Timewarp.
("tuning", "Tuning"),
("motionBlur", "Motion Blur"),
("smoothing", "Smoothing"),
("weighting", "Weighting"),
("sourceCrops", "Source Crops"),
// Simulation.
("extras", "Extras"),
("wiggle", "Wiggle"),
+454 -21
View File
@@ -6,8 +6,8 @@
//! masks and ignore effects applied before them (precompose to include those). Without a host
//! (tests, thumbnails) they pass their input through.
//!
//! Pixel-motion based methods (Timewarp's Pixel Motion, Pixel Motion Blur) are approximated by
//! frame mixing / sub-frame sampling; there is no optical-flow estimation yet.
//! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames
//! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples.
//!
//! [`EffectHost::self_at`]: crate::EffectHost::self_at
@@ -16,6 +16,8 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use effectcraft_raster::flow::Flow;
use crate::util::{fit_layer, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
@@ -90,11 +92,29 @@ pub fn specs() -> Vec<EffectSpec> {
p("speed", "Speed", num(50.0), slider(-10000.0, 10000.0, 0.0, 200.0, 1)),
p("sourceFrame", "Source Frame", num(0.0), slider(-100000.0, 100000.0, 0.0, 300.0, 1)),
p("tuning/vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("tuning/smoothing/globalSmoothness", "Global Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/smoothing/localSmoothness", "Local Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/smoothing/smoothingIterations", "Smoothing Iterations", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 0)),
p("tuning/buildFromOneImage", "Build From One Image", Value::Bool(false), ParamUi::Checkbox),
p("tuning/correctLuminanceChanges", "Correct Luminance Changes", Value::Bool(false), ParamUi::Checkbox),
p("tuning/filtering", "Filtering", Value::Enum(0), popup(&["Normal", "Extreme"])),
p("tuning/errorThreshold", "Error Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("tuning/blockSize", "Block Size", num(8.0), slider(4.0, 64.0, 4.0, 32.0, 0)),
p("tuning/weighting/redWeighting", "Red Weighting", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/weighting/greenWeighting", "Green Weighting", num(0.59), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/weighting/blueWeighting", "Blue Weighting", num(0.11), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("motionBlur/enableMotionBlur", "Enable Motion Blur", Value::Bool(false), ParamUi::Checkbox),
p("motionBlur/shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])),
p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
p("motionBlur/shutterSamples", "Shutter Samples", num(5.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
p("matteLayer", "Matte Layer", Value::Layer(None), ParamUi::Layer),
p("matteChannel", "Matte Channel", Value::Enum(0), popup(&TW_MATTE_CHANNELS)),
p("warpLayer", "Warp Layer", Value::Layer(None), ParamUi::Layer),
p("show", "Show", Value::Enum(0), popup(&TW_SHOW)),
p("sourceCrops/leftCrop", "Left Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
p("sourceCrops/rightCrop", "Right Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
p("sourceCrops/bottomCrop", "Bottom Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
p("sourceCrops/topCrop", "Top Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
],
timewarp,
),
@@ -428,6 +448,238 @@ pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f6
if by_frame { source_frame / fps } else { t * speed / 100.0 }
}
/// Timewarp's Pixel Motion settings (the Tuning group and Source Crops).
#[derive(Clone, Copy, Debug)]
pub struct PixelMotion {
/// Vector spacing in pixels (from Vector Detail).
pub tile: u32,
/// Matching block size in pixels.
pub window: u32,
pub global_smoothness: f32,
pub local_smoothness: f32,
pub iterations: u32,
pub build_from_one: bool,
pub correct_luminance: bool,
pub extreme: bool,
/// Error Threshold (0..1): pixels whose two warped frames disagree by more fall back to a
/// frame mix.
pub error_threshold: f32,
pub weights: [f32; 3],
/// Left, right, top, bottom crops in pixels.
pub crops: [u32; 4],
}
impl PixelMotion {
fn from(ctx: &EffectCtx, scale: f64) -> PixelMotion {
let pr = ctx.params;
let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
let detail = g("tuning/vectorDetail", 20.0).clamp(0.0, 100.0) / 100.0;
// Vector Detail 0 → one vector per 32 px, 100 → one per 2 px (exponential).
let tile = (32.0 * (1.0f64 / 16.0).powf(detail) * scale).round().max(2.0) as u32;
let px = |id: &str| (g(id, 0.0).max(0.0) * scale).round() as u32;
PixelMotion {
tile,
window: ((g("tuning/blockSize", 8.0).max(4.0)) * scale).round().max(4.0) as u32,
global_smoothness: g("tuning/smoothing/globalSmoothness", 0.5).clamp(0.0, 1.0) as f32,
local_smoothness: g("tuning/smoothing/localSmoothness", 0.5).clamp(0.0, 1.0) as f32,
iterations: g("tuning/smoothing/smoothingIterations", 20.0).clamp(0.0, 200.0) as u32,
build_from_one: pr.b("tuning/buildFromOneImage"),
correct_luminance: pr.b("tuning/correctLuminanceChanges"),
extreme: pr.e("tuning/filtering") == 1,
error_threshold: (g("tuning/errorThreshold", 10.0) / 100.0).clamp(0.0, 1.0) as f32,
weights: [g("tuning/weighting/redWeighting", 0.3), g("tuning/weighting/greenWeighting", 0.59), g("tuning/weighting/blueWeighting", 0.11)]
.map(|v| v.max(0.0) as f32),
crops: [px("sourceCrops/leftCrop"), px("sourceCrops/rightCrop"), px("sourceCrops/topCrop"), px("sourceCrops/bottomCrop")],
}
}
}
/// Replace the cropped edges of `img` with the nearest kept pixel (Source Crops: edge garbage
/// must not drive or smear the motion).
fn crop_edges(img: &Image, [l, r, t, b]: [u32; 4]) -> Image {
if l == 0 && r == 0 && t == 0 && b == 0 {
return img.clone();
}
let (w, h) = (img.width as i64, img.height as i64);
let (x0, x1) = ((l as i64).min(w - 1), (w - 1 - r as i64).max(0));
let (y0, y1) = ((t as i64).min(h - 1), (h - 1 - b as i64).max(0));
let mut out = img.clone();
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
*px = img.get((x as i64).clamp(x0, x1.max(x0)), (y as i64).clamp(y0, y1.max(y0)));
}
});
out
}
/// Weighted grey (R·wr + G·wg + B·wb in every colour channel) that the motion is estimated on.
fn motion_grey(img: &Image, wt: [f32; 3], gain: f32) -> Image {
let sum = (wt[0] + wt[1] + wt[2]).max(1e-6);
let mut out = img.clone();
out.data.par_iter_mut().for_each(|p| {
let g = (p[0] * wt[0] + p[1] * wt[1] + p[2] * wt[2]) / sum * gain;
*p = [g, g, g, p[3]];
});
out
}
fn mean_grey(img: &Image) -> f32 {
let n = img.data.len().max(1) as f32;
img.data.par_iter().map(|p| p[0]).sum::<f32>() / n
}
/// Smooth a motion field: `iterations` passes pulling each vector towards its 3×3 mean by
/// Local Smoothness, then a blend towards a wide average by Global Smoothness.
fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
let (c, r) = (flow.cols, flow.rows);
if c * r <= 1 {
return;
}
let mean = |v: &[[f32; 2]], rad: i64| -> Vec<[f32; 2]> {
(0..c * r)
.into_par_iter()
.map(|i| {
let (x, y) = ((i % c) as i64, (i / c) as i64);
let mut s = [0.0f32; 2];
let mut n = 0.0;
for dy in -rad..=rad {
for dx in -rad..=rad {
let (nx, ny) = (x + dx, y + dy);
if nx >= 0 && ny >= 0 && (nx as usize) < c && (ny as usize) < r {
let q = v[ny as usize * c + nx as usize];
s[0] += q[0];
s[1] += q[1];
n += 1.0;
}
}
}
[s[0] / n, s[1] / n]
})
.collect()
};
if pm.local_smoothness > 0.0 {
for _ in 0..pm.iterations {
let m = mean(&flow.v, 1);
for (v, q) in flow.v.iter_mut().zip(m) {
v[0] += (q[0] - v[0]) * pm.local_smoothness * 0.5;
v[1] += (q[1] - v[1]) * pm.local_smoothness * 0.5;
}
}
}
if pm.global_smoothness > 0.0 {
let m = mean(&flow.v, 3);
for (v, q) in flow.v.iter_mut().zip(m) {
v[0] += (q[0] - v[0]) * pm.global_smoothness * 0.5;
v[1] += (q[1] - v[1]) * pm.global_smoothness * 0.5;
}
}
}
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
let gain = if pm.correct_luminance {
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
if mb > 1e-4 { ma / mb } else { 1.0 }
} else {
1.0
};
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
smooth_flow(&mut flow, pm);
flow
}
/// Pixel Motion: the frame at fraction `w` between `a` and `b` built by warping along `flow`
/// (Build From One Image: from the nearer frame only), falling back to a mix where the two
/// warped frames disagree by more than the Error Threshold.
pub fn timewarp_interpolate(a: &Image, b: &Image, w: f32, flow: &Flow, pm: &PixelMotion) -> Image {
let (a, b) = (crop_edges(a, pm.crops), crop_edges(b, pm.crops));
let mut out = Image::new(a.width, a.height);
let wd = w as f64;
let sample = |img: &Image, x: f64, y: f64| if pm.extreme { img.sample_bicubic(x, y) } else { img.sample_bilinear_clamped(x, y) };
out.rows_mut().for_each(|(y, row)| {
let cy = y as f64 + 0.5;
for (x, px) in row.iter_mut().enumerate() {
let cx = x as f64 + 0.5;
let f = flow.at(cx, cy);
let (fx, fy) = (f[0] as f64, f[1] as f64);
let pa = sample(&a, cx - wd * fx, cy - wd * fy);
let pb = sample(&b, cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy);
if pm.build_from_one {
*px = if w < 0.5 { pa } else { pb };
continue;
}
let err = (0..4).map(|c| (pa[c] - pb[c]).abs()).fold(0.0f32, f32::max);
let (pa, pb) = if pm.error_threshold > 0.0 && err > pm.error_threshold && pm.error_threshold < 1.0 {
(a.get(x as i64, y as i64), b.get(x as i64, y as i64))
} else {
(pa, pb)
};
for c in 0..4 {
px[c] = pa[c] + (pb[c] - pa[c]) * w;
}
}
});
out
}
/// Timewarp's Matte Channel options.
const TW_MATTE_CHANNELS: [&str; 4] = ["Alpha", "Inverted Alpha", "Luminance", "Inverted Luminance"];
/// Timewarp's Show options.
const TW_SHOW: [&str; 4] = ["Normal", "Foreground Only", "Background Only", "Matte"];
/// A matte plane (foreground = 1) from a matte layer frame.
fn matte_of(img: &Image, channel: u32) -> Vec<f32> {
img.data
.par_iter()
.map(|p| {
let (c, a) = unpremul(*p);
let l = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * a;
match channel {
1 => 1.0 - a,
2 => l,
3 => 1.0 - l,
_ => a,
}
.clamp(0.0, 1.0)
})
.collect()
}
fn times_mask(img: &Image, m: &[f32], invert: bool) -> Image {
let mut out = img.clone();
out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| {
let k = if invert { 1.0 - k } else { k };
p.iter_mut().for_each(|v| *v *= k);
});
out
}
/// Another layer (layer parameter `id`) at the comp times matching source layer times `times`,
/// fitted onto `grid`. `None` when no layer is chosen.
fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option<Vec<Image>> {
let lid = ctx.params.get(id)?.as_layer()?;
let host = ctx.env.host?;
let me = ctx.params.get(&crate::layer_source_id(id)).is_none_or(|v| v.as_enum() != 0);
let shift = ctx.env.comp_time - ctx.time;
Some(
times
.iter()
.map(|t| match host.layer_at(lid, t + shift, me) {
Some(lp) => fit_layer(ctx, grid, &lp, false),
None => Image::new(grid.img.width, grid.img.height),
})
.collect(),
)
}
/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two
/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion
/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning
/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
/// Samples instants across the shutter.
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
if ctx.env.host.is_none() {
return b;
@@ -437,36 +689,126 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
let speed = ctx.params.f("speed");
let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
let method = ctx.params.e("method");
// Build From One Image: interpolated frames come from the nearest source frame alone.
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
// Sample times (with weights) for one instant of source time.
let instant = |s: f64| -> Vec<(f64, f32)> {
let f = s * fps;
let f0 = (f + 1e-6).floor();
let frac = (f - f0) as f32;
if one {
return vec![((f + 1e-6).round() / fps, 1.0)];
}
if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] }
};
let mut samples: Vec<(f64, f32)> = Vec::new();
// Instants of source time (with weights) across the shutter.
let mut instants: Vec<(f64, f32)> = vec![];
if ctx.params.b("motionBlur/enableMotionBlur") {
let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 };
let n = ctx.params.f("motionBlur/shutterSamples").round().clamp(1.0, 64.0) as usize;
let rate = if by_frame { 1.0 } else { speed / 100.0 };
let span = angle / 360.0 * rate / fps;
for i in 0..n {
let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 };
samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32)));
instants.push((src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }, 1.0 / n as f32));
}
} else {
samples = instant(src);
instants.push((src, 1.0));
}
// Each instant needs the frame at or before it and the next one.
let split = |s: f64| {
let f = s * fps;
let f0 = (f + 1e-6).floor();
(f0, ((f - f0) as f32).max(0.0))
};
let mut times: Vec<f64> = vec![];
for &(s, _) in &instants {
let (f0, _) = split(s);
times.push(f0 / fps);
times.push((f0 + 1.0) / fps);
}
let times: Vec<f64> = samples.iter().map(|s| s.0).collect();
let weights: Vec<f32> = samples.iter().map(|s| s.1).collect();
let Some(fr) = fetch(ctx, &times) else { return b };
let img = average(&fr.imgs, &weights);
with_img(fr.grid, img)
let grid = fr.grid.clone();
let frame = |t: f64| -> &Image {
let k = times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
&fr.imgs[k]
};
let pm = PixelMotion::from(ctx, grid.scale);
let mattes = fetch_layer(ctx, "matteLayer", &times, &grid);
let warps = fetch_layer(ctx, "warpLayer", &times, &grid);
let matte_ch = ctx.params.e("matteChannel");
let show = ctx.params.e("show");
let mut flows: Vec<((i64, bool), Flow)> = vec![];
let mut out_frames = vec![];
for &(s, wgt) in &instants {
let (f0, frac) = split(s);
let (t0, t1) = (f0 / fps, (f0 + 1.0) / fps);
let (a, bb) = (frame(t0), frame(t1));
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let ma = mattes.as_ref().map(|m| matte_of(&m[idx(t0)], matte_ch));
let mb = mattes.as_ref().map(|m| matte_of(&m[idx(t1)], matte_ch));
let img = if method == 0 || frac < 1e-4 || (one && method == 1) {
// Whole frames (Build From One Image with Frame Mix: the nearest frame).
let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() };
let base = if pick < 0.5 { a } else { bb };
match (show, &ma, &mb) {
(1, Some(m), _) if pick < 0.5 => times_mask(base, m, false),
(2, Some(m), _) if pick < 0.5 => times_mask(base, m, true),
(1, _, Some(m)) => times_mask(base, m, false),
(2, _, Some(m)) => times_mask(base, m, true),
(3, Some(m), _) => matte_image(if pick < 0.5 { m } else { mb.as_ref().unwrap_or(m) }, base),
_ => base.clone(),
}
} else if method == 1 {
match (&ma, &mb, show) {
(Some(m0), Some(m1), 3) => effectcraft_raster::flow::mix(&matte_image(m0, a), &matte_image(m1, bb), frac),
(Some(m0), Some(m1), 1 | 2) => effectcraft_raster::flow::mix(&times_mask(a, m0, show == 2), &times_mask(bb, m1, show == 2), frac),
_ => effectcraft_raster::flow::mix(a, bb, frac),
}
} else {
// Pixel Motion: vectors from the warp layer if any, else from the layer.
let key = ((f0 as i64), false);
let flow_ab = |fa: &Image, fb: &Image| -> Flow { timewarp_flow(fa, fb, &pm) };
let flow = match flows.iter().find(|(k, _)| *k == key) {
Some((_, f)) => f.clone(),
None => {
let f = match &warps {
Some(w) => flow_ab(&w[idx(t0)], &w[idx(t1)]),
None => flow_ab(a, bb),
};
flows.push((key, f.clone()));
f
}
};
match (&ma, &mb) {
(Some(m0), Some(m1)) => {
// Foreground and background each move on their own vectors.
let (fa, fb) = (times_mask(a, m0, false), times_mask(bb, m1, false));
let (ga, gb) = (times_mask(a, m0, true), times_mask(bb, m1, true));
let fg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&fa, &fb) };
let bg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&ga, &gb) };
let fg = timewarp_interpolate(&fa, &fb, frac, &fg_flow, &pm);
let bg = timewarp_interpolate(&ga, &gb, frac, &bg_flow, &pm);
match show {
1 => fg,
2 => bg,
3 => matte_image(&fg.data.iter().map(|p| p[3]).collect::<Vec<_>>(), &fg),
_ => {
let mut o = bg;
o.data.par_iter_mut().zip(fg.data.par_iter()).for_each(|(p, q)| {
let k = 1.0 - q[3];
for c in 0..4 {
p[c] = q[c] + p[c] * k;
}
});
o
}
}
}
_ => timewarp_interpolate(a, bb, frac, &flow, &pm),
}
};
out_frames.push((img, wgt));
}
let imgs: Vec<Image> = out_frames.iter().map(|(i, _)| i.clone()).collect();
let weights: Vec<f32> = out_frames.iter().map(|(_, w)| *w).collect();
let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) };
with_img(grid, img)
}
/// A matte shown as opaque grey.
fn matte_image(m: &[f32], like: &Image) -> Image {
let mut out = Image::new(like.width, like.height);
out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| *p = [k, k, k, 1.0]);
out
}
// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur
@@ -563,6 +905,97 @@ mod tests {
assert!((out.img.data[0][0] - 0.5).abs() < 1e-5, "{}", out.img.data[0][0]);
}
/// Texture `x − dx` (smooth, non-repeating).
fn texture(w: u32, h: u32, dx: f64) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let (u, v) = (x as f64 - dx, y as f64);
let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.031 + v * 0.017).sin()) as f32;
img.set(x, y, [s, s * 0.8, 1.0 - s, 1.0]);
}
}
img
}
/// A textured layer moving right 3 px per frame (10 fps); layer 7 (a "warp" / matte layer)
/// is the same texture standing still, layer 8 a matte that is white on the left half.
struct Moving;
impl EffectHost for Moving {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
Some(Buf { img: texture(96, 64, (t * 10.0).round() * 3.0), offset: [0.0; 2], scale: 1.0 })
}
fn layer_at(&self, id: u64, _: f64, _: bool) -> Option<LayerPixels> {
let img = match id {
7 => texture(96, 64, 0.0),
_ => {
let mut m = Image::new(96, 64);
for y in 0..64 {
for x in 0..48 {
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
m
}
};
Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [96.0, 64.0] })
}
}
fn mae(a: &Image, b: &Image, margin: i64) -> f32 {
let (w, h) = (a.width as i64, a.height as i64);
let mut s = 0.0;
let mut n = 0.0;
for y in margin..h - margin {
for x in margin..w - margin {
let (p, q) = (a.get(x, y), b.get(x, y));
s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::<f32>();
n += 3.0;
}
}
s / n
}
fn run_moving(vals: &[(&str, Value)], t: f64) -> Image {
let env = EffectEnv { host: Some(&Moving), frame_rate: 10.0, comp_time: t, ..Default::default() };
run_fx("ec.time.timewarp", vals, Image::new(96, 64), t, env).img
}
#[test]
fn timewarp_pixel_motion_follows_the_motion() {
// Speed 50 % at layer time 1.1 s: source 0.55 s, halfway between frames 5 and 6 (15 and
// 18 px), so the true in-between frame is the texture at 16.5 px.
let want = texture(96, 64, 16.5);
let pm = run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1);
let mix = run_moving(&[("method", Value::Enum(1))], 1.1);
let (e_pm, e_mix) = (mae(&pm, &want, 8), mae(&mix, &want, 8));
assert!(e_pm < e_mix * 0.5, "pixel motion {e_pm} vs frame mix {e_mix}");
// Deterministic and seek-consistent (same time → same pixels).
assert_eq!(pm.data, run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1).data);
// Build From One Image warps the nearer frame alone: still close to the truth.
let one = run_moving(&[("method", Value::Enum(2)), ("tuning/buildFromOneImage", Value::Bool(true)), ("tuning/vectorDetail", num(60.0))], 1.1);
assert!(mae(&one, &want, 8) < e_mix * 0.5);
// A Warp Layer standing still supplies zero motion: the result is a plain mix.
let warped = run_moving(&[("method", Value::Enum(2)), ("warpLayer", Value::Layer(Some(7))), ("tuning/errorThreshold", num(100.0))], 1.1);
assert!(mae(&warped, &mix, 0) < 1e-3, "{}", mae(&warped, &mix, 0));
// Show Matte displays the matte layer; Foreground Only keeps the matte's left half.
let m = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(3))], 1.1);
assert!(m.get(10, 10)[0] > 0.99 && m.get(80, 10)[0] < 0.01);
let fg = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(1))], 1.1);
assert!(fg.get(10, 30)[3] > 0.99 && fg.get(85, 30)[3] < 0.01);
let normal = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("tuning/vectorDetail", num(60.0))], 1.1);
assert!(mae(&normal, &want, 12) < e_mix, "{} {e_mix}", mae(&normal, &want, 12));
// Source Crops: a crop replicates the kept edge into the cropped band.
let c = run_moving(&[("method", Value::Enum(2)), ("sourceCrops/leftCrop", num(10.0))], 1.1);
assert_eq!(c.get(0, 20), c.get(5, 20));
}
#[test]
fn timewarp_and_wide_time() {
let h = Clock { static_layer: false };
+8
View File
@@ -307,11 +307,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[0] = [br, k, ct, 0.0];
}
"ec.color.huesaturation" => {
// Colour ranges (Channel Control) render on the CPU.
if !effectcraft_effects::huesat_ranges_identity(ctx) {
return None;
}
p.u[0] = [3, ctx.params.b("colorize") as u32, 0, 0];
p.f[0] = [f("hue") as f32 / 360.0, f("saturation") as f32 / 100.0, f("lightness") as f32 / 100.0, 0.0];
p.f[1] = [f("colorizeHue") as f32 / 360.0, f("colorizeSaturation") as f32 / 100.0, f("colorizeLightness") as f32 / 100.0, 0.0];
}
"ec.color.levels" => {
// Red / Green / Blue / Alpha controls render on the CPU.
if !effectcraft_effects::levels_channels_identity(ctx) {
return None;
}
let (clip_b, clip_w) = effectcraft_effects::levels_clip(ctx);
p.u[0] = [4, clip_b as u32, clip_w as u32, 0];
p.f[0] = [f("inBlack") as f32, f("inWhite") as f32, f("gamma").max(0.01) as f32, f("outBlack") as f32];
+21 -10
View File
@@ -95,18 +95,28 @@ fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32
/// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution;
/// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`).
pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
let bs = block.max(4) as usize;
block_flow_window(a, b, block, block, radius)
}
/// [`block_flow`] with separate vector spacing and matching window: one vector per
/// `tile × tile` tile, each matched with the `window × window` block centred on its tile
/// (Timewarp's Vector Detail and Block Size).
pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i32) -> Flow {
let bs = tile.max(2) as usize;
let win = window.max(4) as i64;
// Window origin relative to the tile origin.
let wo = bs as i64 / 2 - win / 2;
let (w, h) = (a.width as usize, a.height as usize);
let cols = w.div_ceil(bs).max(1);
let rows = h.div_ceil(bs).max(1);
let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] };
if a.width != b.width || a.height != b.height || w < bs || h < bs {
if a.width != b.width || a.height != b.height || w < bs || h < bs || (w as i64) < win || (h as i64) < win {
return flow;
}
// Pyramids (level 0 = full resolution); stop while blocks still fit.
let mut pa = vec![Plane::luma(a)];
let mut pb = vec![Plane::luma(b)];
while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) {
while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= (bs as i64).max(win) as usize * 2 && p.h / 2 >= (bs as i64).max(win) as usize * 2) {
let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half));
pa.extend(na);
pb.extend(nb);
@@ -118,15 +128,15 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
let (la, lb) = (&pa[level], &pb[level]);
let k = 1i64 << level;
// The block covers the tile's footprint at this level (at least 4 pixels).
let lbs = ((bs as i64) / k).max(4);
let lbs = (win / k).max(4);
let r = if level == top { radius as i64 } else { 1 };
let prev = cur.clone();
cur = (0..cols * rows)
.into_par_iter()
.map(|i| {
let (c, rr) = ((i % cols) as i64, (i / cols) as i64);
let bx = c * bs as i64 / k;
let by = rr * bs as i64 / k;
let bx = (c * bs as i64 + wo) / k;
let by = (rr * bs as i64 + wo) / k;
// Candidates: this tile's and its neighbours' vectors from the coarser level.
let mut seeds: Vec<[i64; 2]> = vec![[0, 0]];
if level != top {
@@ -160,18 +170,19 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
// Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution.
let (la, lb) = (&pa[0], &pb[0]);
let bsi = bs as i64;
let wsi = win;
let fine: Vec<[f32; 2]> = (0..cols * rows)
.into_par_iter()
.map(|i| {
let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi);
let (bx, by) = ((i % cols) as i64 * bsi + wo, (i / cols) as i64 * bsi + wo);
let d = cur[i];
let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]);
let e0 = sad(la, lb, bx, by, wsi, d[0], d[1]);
let fit = |em: f32, ep: f32| {
let den = em - 2.0 * e0 + ep;
if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 }
};
let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1]));
let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1));
let sx = fit(sad(la, lb, bx, by, wsi, d[0] - 1, d[1]), sad(la, lb, bx, by, wsi, d[0] + 1, d[1]));
let sy = fit(sad(la, lb, bx, by, wsi, d[0], d[1] - 1), sad(la, lb, bx, by, wsi, d[0], d[1] + 1));
[d[0] as f32 + sx, d[1] as f32 + sy]
})
.collect();
+60 -8
View File
@@ -441,6 +441,37 @@ fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &P
}
}
/// The effect instance a row belongs to, for conditional visibility
/// ([`effectcraft_engine::effects::catalog::param_shown`]): effect id, the instance's root
/// group and the spec-id path of the group being drawn (`""` or `cameraPosition/`).
struct FxScope<'a> {
effect: &'a str,
root: &'a PropGroup,
path: String,
}
impl<'a> FxScope<'a> {
fn sub(&self, m: &str) -> FxScope<'a> {
FxScope { effect: self.effect, root: self.root, path: format!("{}{m}/", self.path) }
}
/// Whether child `m` of the current group is shown.
fn shown(&self, layer: &Layer, ectx: &EvalCtx, m: &str) -> bool {
let root = self.root;
let value = |id: &str| -> Option<Value> {
let mut g = root;
let mut parts = id.split('/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
return g.get(part).map(|p| ectx.value(layer, p));
}
g = g.sub(part)?;
}
None
};
effectcraft_engine::effects::catalog::param_shown(self.effect, &format!("{}{m}", self.path), &value)
}
}
#[allow(clippy::too_many_arguments)]
fn group_rows(
app: &mut EffectcraftApp,
@@ -452,6 +483,7 @@ fn group_rows(
rect: Rect,
y: &mut f32,
depth: usize,
fx: &FxScope,
actions: &mut Actions,
) {
let t = app.tokens;
@@ -459,7 +491,7 @@ fn group_rows(
let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW));
match c {
Node::Prop(pr) => {
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() {
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() || !fx.shown(layer, ectx, &pr.match_id) {
continue;
}
*y += ROW;
@@ -486,7 +518,7 @@ fn group_rows(
}
Node::Group(sg) => {
// Paint strokes live in the Timeline only (AE's Paint shows Paint on Transparent).
if effectcraft_engine::effects::paint::is_paint(g) {
if effectcraft_engine::effects::paint::is_paint(g) || !fx.shown(layer, ectx, &sg.match_id) {
continue;
}
*y += ROW;
@@ -503,7 +535,8 @@ fn group_rows(
p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text);
}
if open {
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, actions);
let sub = fx.sub(&sg.match_id);
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions);
}
}
}
@@ -642,12 +675,24 @@ fn channel_popup(
fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
let t = app.tokens;
let euid = g.uid;
let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(4);
// The channel lives in the effect's hidden Channel parameter (UI state for instances
// saved before it existed).
let chan_prop = g.get("channel");
let ch = match chan_prop {
Some(pr) => ectx.value(layer, pr).as_enum() as usize,
None => app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0),
}
.min(4);
let x0 = r.min.x + 30.0;
let cy = r.min.y + 17.0;
let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect();
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) {
app.ui.fx_curve_channel.insert(euid, n);
match chan_prop {
Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))),
None => {
app.ui.fx_curve_channel.insert(euid, n);
}
}
}
let pencil = app.ui.fx_curve_pencil.contains(&euid);
let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0));
@@ -696,7 +741,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
/// Levels parameter ids for a channel: input black, input white, gamma, output black, output white.
fn levels_ids(effect: &str, ch: usize) -> [String; 5] {
if effect == "ec.color.levels" {
return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from);
return effectcraft_engine::effects::levels_channel_ids(ch);
}
// Levels (Individual Controls) nests each channel's controls in its twirl-down group.
let pre = fw::LEVELS_CHANNELS[ch.min(4)].0;
@@ -721,7 +766,13 @@ fn levels_editor(
let euid = g.uid;
let x0 = r.min.x + 30.0;
let cy = r.min.y + 17.0;
let ch = if effect == "ec.color.levelsic" { app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) } else { 0 };
// Levels keeps its channel in the Channel parameter; Levels (Individual Controls) shows
// every channel and the popup only picks the histogram.
let ch = if effect == "ec.color.levelsic" {
app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4)
} else {
g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4)
};
if effect == "ec.color.levelsic" {
let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect();
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
@@ -959,7 +1010,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions);
let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() };
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &scope, &mut actions);
}
y += 4.0;
}
+7 -7
View File
@@ -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 2434 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 2562 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**: 261 implemented in full, 42 partial (what is missing is listed).
- **Status**: 266 implemented in full, 37 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`).
@@ -103,15 +103,15 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented |
| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented |
| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking |
| Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup |
| Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented |
| Equalize | `ec.color.equalize` | 2 | | 32 | Implemented |
| Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented |
| Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | | 32 | Implemented |
| Hue/Saturation | `ec.color.huesaturation` | 7 | GPU | 32 | Partial: no per-range Channel Control |
| Hue/Saturation | `ec.color.huesaturation` | 50 | GPU | 32 | Implemented |
| Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented |
| Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) |
| Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented |
| Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented |
| Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files |
| Lumetri Color | `ec.color.lumetri` | 66 | | 32 | Implemented |
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented |
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented |
| OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented |
@@ -383,7 +383,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Posterize Time | `ec.time.posterizetime` | 1 | | 32 | Implemented |
| Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented |
| Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented |
| Timewarp | `ec.time.timewarp` | 10 | | 32 | Partial: Pixel Motion blends frames (no optical flow); no matte, warp or crop controls |
| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented |
## Transition