diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 4ec20906..449b1a4d 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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) -> 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(¶m) || 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) { diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index f1c918bc..6e7475ea 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -911,7 +911,15 @@ pub fn specs() -> Vec { 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), diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index 5b9716ef..7d626d19 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -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::().ok()?, y.trim().parse::().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, +} + +impl OffsetCurve { + const N: usize = 512; + + /// `None` for an empty or flat-neutral curve. + pub(crate) fn parse(s: &str, periodic: bool) -> Option { + 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 = (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, 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 { 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 { 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 { 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:?}"); + } } diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 71707daa..240dee1f 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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 { + 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 { + 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 { 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 { 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 { 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: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() }; + assert!(!huesat_ranges_identity(&ctx)); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f346..72ef00e1 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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"), diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index a6058132..e0c1aad7 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -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 { 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::() / 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 { + 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> { + 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 = vec![]; + for &(s, _) in &instants { + let (f0, _) = split(s); + times.push(f0 / fps); + times.push((f0 + 1.0) / fps); } - let times: Vec = samples.iter().map(|s| s.0).collect(); - let weights: Vec = samples.iter().map(|s| s.1).collect(); let Some(fr) = fetch(ctx, ×) 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", ×, &grid); + let warps = fetch_layer(ctx, "warpLayer", ×, &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(×_mask(a, m0, show == 2), ×_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::>(), &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 = out_frames.iter().map(|(i, _)| i.clone()).collect(); + let weights: Vec = 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 { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + 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 { + 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::(); + 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 }; diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index fdc317b3..f84d4bb1 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -307,11 +307,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { 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]; diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs index c398eadd..feb033d6 100644 --- a/crates/raster/src/flow.rs +++ b/crates/raster/src/flow.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index ff6d07a8..02dcb1a9 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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 { + 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; } diff --git a/docs/effects.md b/docs/effects.md index 75d32478..15db5435 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -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..`) 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..`) 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..`) 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