diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0e1b5593..28d44884 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1773,6 +1773,7 @@ fn json_to_param(template: &ParamValue, v: &Value) -> Option { ParamValue::Bool(_) => ParamValue::Bool(v.as_bool()?), ParamValue::Choice(_) => ParamValue::Choice(v.as_u64()? as u32), ParamValue::Text(_) => ParamValue::Text(v.as_str()?.to_string()), + ParamValue::Curve(_) => ParamValue::Curve(v.as_array()?.iter().filter_map(|p| Some([p.get(0)?.as_f64()? as f32, p.get(1)?.as_f64()? as f32])).collect()), }) } diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index 57e27e18..aaa8abc2 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -37,6 +37,10 @@ pub enum ParamKind { Choice(&'static [&'static str]), Angle, Text, + /// Tone curve (x in, y out); `hue` curves use a rainbow baseline with y = 0.5 as neutral. + Curve { hue: bool }, + /// Colour wheel offset (x, y in −1..1). + Wheel, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -195,6 +199,13 @@ fn ch(id: &'static str, label: &'static str, opts: &'static [&'static str], def: fn ang(id: &'static str, label: &'static str, def: f64) -> ParamDef { ParamDef { id, label, kind: ParamKind::Angle, default: ParamValue::Float(def), animatable: true, group: None } } +fn curve(id: &'static str, label: &'static str, hue: bool) -> ParamDef { + let default = if hue { ParamValue::Curve(vec![]) } else { ParamValue::Curve(vec![[0.0, 0.0], [1.0, 1.0]]) }; + ParamDef { id, label, kind: ParamKind::Curve { hue }, default, animatable: false, group: None } +} +fn wheel(id: &'static str, label: &'static str) -> ParamDef { + ParamDef { id, label, kind: ParamKind::Wheel, default: ParamValue::Vec2(Vec2::new(0.0, 0.0)), animatable: true, group: None } +} fn grp(mut p: ParamDef, g: &'static str) -> ParamDef { p.group = Some(g); p @@ -391,13 +402,42 @@ fn build_effects() -> Vec { grp(f("whites", "Whites", 0.0, -100.0, 100.0, ""), "Basic Correction"), grp(f("blacks", "Blacks", 0.0, -100.0, 100.0, ""), "Basic Correction"), grp(fs("saturation", "Saturation", 100.0, (0.0, 200.0), (0.0, 200.0), "", 1), "Basic Correction"), - grp(f("faded_film", "Faded Film", 0.0, 0.0, 100.0, ""), "Creative"), + grp(ch("look", "Look", &["None", "Teal & Orange", "Warm Film", "Cool Blue", "Bleach Bypass", "Faded Matte", "Monochrome", "Golden Hour", "Night"], 0), "Creative"), + grp(fs("look_intensity", "Intensity", 100.0, (0.0, 200.0), (0.0, 200.0), "", 1), "Creative"), + grp(f("faded_film", "Faded Film", 0.0, 0.0, 100.0, ""), "Creative"), grp(f("sharpen", "Sharpen", 0.0, -100.0, 100.0, ""), "Creative"), grp(f("vibrance", "Vibrance", 0.0, -100.0, 100.0, ""), "Creative"), grp(fs("creative_sat", "Saturation", 100.0, (0.0, 200.0), (0.0, 200.0), "", 1), "Creative"), grp(col("shadow_tint", "Shadow Tint", [0.5, 0.5, 0.5, 1.0]), "Creative"), grp(col("highlight_tint", "Highlight Tint", [0.5, 0.5, 0.5, 1.0]), "Creative"), - grp(f("vignette_amount", "Amount", 0.0, -5.0, 5.0, ""), "Vignette"), + grp(curve("curve_luma", "Luma Curve", false), "Curves"), + grp(curve("curve_red", "Red Curve", false), "Curves"), + grp(curve("curve_green", "Green Curve", false), "Curves"), + grp(curve("curve_blue", "Blue Curve", false), "Curves"), + grp(curve("hue_vs_sat", "Hue vs Sat", true), "Curves"), + grp(curve("hue_vs_hue", "Hue vs Hue", true), "Curves"), + grp(curve("hue_vs_luma", "Hue vs Luma", true), "Curves"), + grp(curve("luma_vs_sat", "Luma vs Sat", true), "Curves"), + grp(curve("sat_vs_sat", "Sat vs Sat", true), "Curves"), + grp(wheel("wheel_shadows", "Shadows"), "Color Wheels & Match"), + grp(f("wheel_shadows_l", "Shadows Lightness", 0.0, -100.0, 100.0, ""), "Color Wheels & Match"), + grp(wheel("wheel_midtones", "Midtones"), "Color Wheels & Match"), + grp(f("wheel_midtones_l", "Midtones Lightness", 0.0, -100.0, 100.0, ""), "Color Wheels & Match"), + grp(wheel("wheel_highlights", "Highlights"), "Color Wheels & Match"), + grp(f("wheel_highlights_l", "Highlights Lightness", 0.0, -100.0, 100.0, ""), "Color Wheels & Match"), + grp(b("hsl_on", "Enable HSL Secondary", false), "HSL Secondary"), + grp(fs("hsl_hue", "Hue Center", 0.0, (0.0, 360.0), (0.0, 360.0), "°", 0), "HSL Secondary"), + grp(fs("hsl_hue_range", "Hue Range", 30.0, (1.0, 180.0), (1.0, 180.0), "°", 0), "HSL Secondary"), + grp(fs("hsl_sat_min", "Saturation Min", 10.0, (0.0, 100.0), (0.0, 100.0), "", 0), "HSL Secondary"), + grp(fs("hsl_luma_min", "Luma Min", 5.0, (0.0, 100.0), (0.0, 100.0), "", 0), "HSL Secondary"), + grp(fs("hsl_luma_max", "Luma Max", 95.0, (0.0, 100.0), (0.0, 100.0), "", 0), "HSL Secondary"), + grp(f("hsl_soft", "Soften", 20.0, 0.0, 100.0, ""), "HSL Secondary"), + grp(ch("hsl_show_mask", "Show Mask", &["Off", "Color/Gray", "Color/Black", "White/Black"], 0), "HSL Secondary"), + grp(f("hsl_temp", "Temperature", 0.0, -100.0, 100.0, ""), "HSL Secondary"), + grp(f("hsl_tint", "Tint", 0.0, -100.0, 100.0, ""), "HSL Secondary"), + grp(fs("hsl_sat", "Saturation", 100.0, (0.0, 200.0), (0.0, 200.0), "", 1), "HSL Secondary"), + grp(f("hsl_hue_shift", "Hue Shift", 0.0, -180.0, 180.0, "°"), "HSL Secondary"), + grp(f("vignette_amount", "Amount", 0.0, -5.0, 5.0, ""), "Vignette"), grp(f("vignette_midpoint", "Midpoint", 50.0, 0.0, 100.0, ""), "Vignette"), grp(f("vignette_roundness", "Roundness", 0.0, -100.0, 100.0, ""), "Vignette"), grp(f("vignette_feather", "Feather", 50.0, 0.0, 100.0, ""), "Vignette"), diff --git a/crates/project/src/keyframe.rs b/crates/project/src/keyframe.rs index 1d9b0662..3e1029f0 100644 --- a/crates/project/src/keyframe.rs +++ b/crates/project/src/keyframe.rs @@ -16,6 +16,8 @@ pub enum ParamValue { Bool(bool), Choice(u32), Text(String), + /// Curve control points (x, y) in 0..1, sorted by x (Lumetri curves). + Curve(Vec<[f32; 2]>), } impl ParamValue { @@ -39,6 +41,12 @@ impl ParamValue { _ => None, } } + pub fn as_curve(&self) -> Option<&[[f32; 2]]> { + match self { + ParamValue::Curve(c) => Some(c), + _ => None, + } + } pub fn as_bool(&self) -> Option { match self { ParamValue::Bool(b) => Some(*b), diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index a32d4b3a..35f3cd45 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -925,6 +925,7 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { } dec(v) }); + lumetri_advanced(img, e, cx); if sharpen.abs() > 1e-3 { unsharp(img, 1.2 * cx.px_scale.max(0.35), sharpen.max(-1.0), 0.0); } @@ -1164,3 +1165,246 @@ mod tests { } } } + +/// Monotone cubic (Fritsch–Carlson) interpolation through sorted control points, as a LUT of `n` +/// entries over x ∈ [0, 1]. Monotone curves never overshoot, which keeps tone curves well-behaved. +pub fn curve_lut(points: &[[f32; 2]], n: usize) -> Vec { + let mut pts: Vec<[f32; 2]> = points.to_vec(); + pts.sort_by(|a, b| a[0].total_cmp(&b[0])); + pts.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6); + if pts.len() < 2 { + let y = pts.first().map_or(0.0, |p| p[1]); + return if pts.is_empty() { (0..n).map(|i| i as f32 / (n - 1) as f32).collect() } else { vec![y; n] }; + } + let m = pts.len(); + let d: Vec = (0..m - 1).map(|i| (pts[i + 1][1] - pts[i][1]) / (pts[i + 1][0] - pts[i][0]).max(1e-6)).collect(); + let mut t = vec![0f32; m]; + t[0] = d[0]; + t[m - 1] = d[m - 2]; + for i in 1..m - 1 { + t[i] = if d[i - 1] * d[i] <= 0.0 { 0.0 } else { (d[i - 1] + d[i]) / 2.0 }; + } + for i in 0..m - 1 { + if d[i].abs() < 1e-9 { + t[i] = 0.0; + t[i + 1] = 0.0; + continue; + } + let a = t[i] / d[i]; + let b = t[i + 1] / d[i]; + let h = a * a + b * b; + if h > 9.0 { + let k = 3.0 / h.sqrt(); + t[i] = k * a * d[i]; + t[i + 1] = k * b * d[i]; + } + } + (0..n) + .map(|j| { + let x = j as f32 / (n - 1) as f32; + if x <= pts[0][0] { + return pts[0][1]; + } + if x >= pts[m - 1][0] { + return pts[m - 1][1]; + } + let i = pts.partition_point(|p| p[0] <= x) - 1; + let hh = pts[i + 1][0] - pts[i][0]; + let u = (x - pts[i][0]) / hh; + let (h00, h10, h01, h11) = (2.0 * u * u * u - 3.0 * u * u + 1.0, u * u * u - 2.0 * u * u + u, -2.0 * u * u * u + 3.0 * u * u, u * u * u - u * u); + h00 * pts[i][1] + h10 * hh * t[i] + h01 * pts[i + 1][1] + h11 * hh * t[i + 1] + }) + .collect() +} + +/// Periodic (hue) curve: points around the colour wheel, neutral 0.5 where there are none. +fn hue_lut(points: &[[f32; 2]], n: usize) -> Option> { + if points.is_empty() { + return None; + } + // wrap: repeat points one period left and right, then sample 0..1 + let mut ext = Vec::new(); + for off in [-1.0f32, 0.0, 1.0] { + for p in points { + ext.push([p[0] + off, p[1]]); + } + } + let lut = curve_lut(&ext.iter().map(|p| [(p[0] + 1.0) / 3.0, p[1]]).collect::>(), n * 3); + Some(lut[n..2 * n].to_vec()) +} + +fn curve_param(e: &EffectInstance, id: &str) -> Option> { + e.param(id).and_then(|p| p.value.as_curve().map(|c| c.to_vec())) +} + +fn is_identity_curve(c: &[[f32; 2]]) -> bool { + c.iter().all(|p| (p[0] - p[1]).abs() < 1e-4) +} + +/// Wheel offset (zero-mean RGB direction for a wheel position). +fn wheel_rgb(v: Vec2) -> [f32; 3] { + let len = (v.x * v.x + v.y * v.y).sqrt().min(1.0) as f32; + if len < 1e-5 { + return [0.0; 3]; + } + let a = (v.y).atan2(v.x) as f32; + let tau = std::f32::consts::TAU; + [a.cos() * len, (a - tau / 3.0).cos() * len, (a + tau / 3.0).cos() * len] +} + +/// Looks: our own procedural grades (no third-party LUTs), applied on display-encoded colour. +fn apply_look(look: u32, c: [f32; 3]) -> [f32; 3] { + let l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + let mix = |a: [f32; 3], b: [f32; 3], k: f32| [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]; + let s_curve = |v: f32, k: f32| { + let x = v.clamp(0.0, 1.0); + x + (x * x * (3.0 - 2.0 * x) - x) * k + }; + match look { + 1 => { + // teal shadows, orange highlights + let shadow = [0.0, 0.08, 0.1]; + let high = [0.1, 0.04, -0.06]; + let c = [c[0] + shadow[0] * (1.0 - l) + high[0] * l, c[1] + shadow[1] * (1.0 - l) + high[1] * l, c[2] + shadow[2] * (1.0 - l) + high[2] * l]; + c.map(|v| s_curve(v, 0.35)) + } + 2 => { + let c = [c[0] * 1.06 + 0.02, c[1] * 1.0 + 0.01, c[2] * 0.9]; + mix(c, [l, l, l], 0.15).map(|v| v * 0.94 + 0.04) + } + 3 => [c[0] * 0.9, c[1] * 0.98, c[2] * 1.1 + 0.02].map(|v| s_curve(v, 0.2)), + 4 => mix(c, [l, l, l], 0.55).map(|v| s_curve(v, 0.6)), + 5 => mix(c, [l, l, l], 0.25).map(|v| 0.08 + v * 0.84), + 6 => [l, l, l].map(|v| s_curve(v, 0.3)), + 7 => [c[0] * 1.1 + 0.03, c[1] * 1.02 + 0.01, c[2] * 0.82].map(|v| s_curve(v, 0.15)), + 8 => [c[0] * 0.75, c[1] * 0.85, c[2] * 1.15].map(|v| v * 0.8), + _ => c, + } +} + +fn lumetri_advanced(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { + const N: usize = 1024; + let lut = |id: &str| curve_param(e, id).filter(|c| !is_identity_curve(c)).map(|c| curve_lut(&c, N)); + let (luma_c, red_c, green_c, blue_c) = (lut("curve_luma"), lut("curve_red"), lut("curve_green"), lut("curve_blue")); + let hue = |id: &str| curve_param(e, id).and_then(|c| hue_lut(&c, N)); + let (hvs, hvh, hvl, lvs, svs) = (hue("hue_vs_sat"), hue("hue_vs_hue"), hue("hue_vs_luma"), hue("luma_vs_sat"), hue("sat_vs_sat")); + let look = choice(e, "look"); + let look_k = f(e, "look_intensity", cx) / 100.0; + let v2 = |id: &str| e.param(id).map(|p| p.vec2_at(cx.t)).unwrap_or_default(); + let (ws, wm, wh) = (wheel_rgb(v2("wheel_shadows")), wheel_rgb(v2("wheel_midtones")), wheel_rgb(v2("wheel_highlights"))); + let (ls, lm, lh) = (f(e, "wheel_shadows_l", cx) / 100.0, f(e, "wheel_midtones_l", cx) / 100.0, f(e, "wheel_highlights_l", cx) / 100.0); + let wheels = ws.iter().chain(&wm).chain(&wh).any(|v| v.abs() > 1e-5) || ls.abs() + lm.abs() + lh.abs() > 1e-5; + let hsl_on = b(e, "hsl_on"); + let any = luma_c.is_some() || red_c.is_some() || green_c.is_some() || blue_c.is_some() || hvs.is_some() || hvh.is_some() || hvl.is_some() || lvs.is_some() || svs.is_some() || look > 0 || wheels || hsl_on; + if !any { + return; + } + let (hc, hr) = (f(e, "hsl_hue", cx) / 360.0, (f(e, "hsl_hue_range", cx) / 360.0).max(1e-3)); + let (smin, lmin, lmax) = (f(e, "hsl_sat_min", cx) / 100.0, f(e, "hsl_luma_min", cx) / 100.0, f(e, "hsl_luma_max", cx) / 100.0); + let soft = (f(e, "hsl_soft", cx) / 100.0 * 0.3).max(0.01); + let show_mask = choice(e, "hsl_show_mask"); + let (htemp, htint, hsat, hshift) = (f(e, "hsl_temp", cx) / 100.0, f(e, "hsl_tint", cx) / 100.0, f(e, "hsl_sat", cx) / 100.0, f(e, "hsl_hue_shift", cx) / 360.0); + let sample = |l: &Vec, x: f32| { + let p = x.clamp(0.0, 1.0) * (N - 1) as f32; + let i = p as usize; + let j = (i + 1).min(N - 1); + l[i] + (l[j] - l[i]) * (p - i as f32) + }; + img.map_rgb(|c, _, _| { + let mut v = enc(c); + if look > 0 { + let lk = apply_look(look, v); + v = [v[0] + (lk[0] - v[0]) * look_k, v[1] + (lk[1] - v[1]) * look_k, v[2] + (lk[2] - v[2]) * look_k]; + } + if wheels { + let l = (0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2]).clamp(0.0, 1.0); + let wsh = (1.0 - l).powi(2); + let whi = l * l; + let wmid = (1.0 - wsh - whi).max(0.0); + for k in 0..3 { + // lift (shadows), gamma-ish (midtones), gain (highlights) + v[k] += (ws[k] * 0.3 + ls * 0.3) * wsh; + v[k] += (wm[k] * 0.3 + lm * 0.3) * wmid; + v[k] *= 1.0 + (wh[k] * 0.5 + lh * 0.5) * whi; + } + } + if let Some(l) = &luma_c { + v = v.map(|q| sample(l, q)); + } + if let Some(l) = &red_c { + v[0] = sample(l, v[0]); + } + if let Some(l) = &green_c { + v[1] = sample(l, v[1]); + } + if let Some(l) = &blue_c { + v[2] = sample(l, v[2]); + } + if hvs.is_some() || hvh.is_some() || hvl.is_some() || lvs.is_some() || svs.is_some() { + let mut h = rgb_to_hsl(v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0)); + let (h0, s0, l0) = (h[0], h[1], h[2]); + if let Some(t) = &hvh { + h[0] = (h[0] + (sample(t, h0) - 0.5)).rem_euclid(1.0); + } + let mut sm = 1.0; + if let Some(t) = &hvs { + sm *= sample(t, h0) * 2.0; + } + if let Some(t) = &lvs { + sm *= sample(t, l0) * 2.0; + } + if let Some(t) = &svs { + sm *= sample(t, s0) * 2.0; + } + h[1] = (h[1] * sm).clamp(0.0, 1.0); + if let Some(t) = &hvl { + h[2] = (h[2] + (sample(t, h0) - 0.5) * 0.5).clamp(0.0, 1.0); + } + v = hsl_to_rgb(h[0], h[1], h[2]); + } + if hsl_on { + let h = rgb_to_hsl(v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0)); + let dh = (h[0] - hc).abs().min(1.0 - (h[0] - hc).abs()); + let mh = 1.0 - ((dh - hr / 2.0) / soft).clamp(0.0, 1.0); + let ms = ((h[1] - smin) / soft).clamp(0.0, 1.0); + let ml = ((h[2] - lmin) / soft).clamp(0.0, 1.0).min(((lmax - h[2]) / soft).clamp(0.0, 1.0)); + let m = mh * ms * ml; + match show_mask { + 1 => { + let g = h[2]; + v = [g + (v[0] - g) * m, g + (v[1] - g) * m, g + (v[2] - g) * m]; + } + 2 => v = v.map(|q| q * m), + 3 => v = [m, m, m], + _ => { + let mut hh = h; + hh[0] = (hh[0] + hshift).rem_euclid(1.0); + hh[1] = (hh[1] * hsat).clamp(0.0, 1.0); + let mut c2 = hsl_to_rgb(hh[0], hh[1], hh[2]); + c2[0] *= 1.0 + 0.25 * htemp; + c2[2] *= 1.0 - 0.25 * htemp; + c2[1] *= 1.0 - 0.2 * htint; + v = [v[0] + (c2[0] - v[0]) * m, v[1] + (c2[1] - v[1]) * m, v[2] + (c2[2] - v[2]) * m]; + } + } + } + dec(v) + }); +} + +#[cfg(test)] +mod curve_tests { + use super::*; + + #[test] + fn identity_and_monotone() { + let l = curve_lut(&[[0.0, 0.0], [1.0, 1.0]], 64); + for (i, v) in l.iter().enumerate() { + assert!((v - i as f32 / 63.0).abs() < 1e-4); + } + let s = curve_lut(&[[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]], 256); + assert!(s.windows(2).all(|w| w[1] >= w[0] - 1e-6), "monotone"); + let h = hue_lut(&[[0.0, 0.8], [0.5, 0.2]], 64).unwrap(); + assert!((h[0] - h[63]).abs() < 0.05, "periodic"); + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 8a260b0d..5dc96855 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -164,14 +164,14 @@ pub fn workspace(name: &str) -> DockNode { ), bottom_editing(), ), - "Color" => vsplit( - Ratio(0.58), - hsplit( - FixedB(330.0), + "Color" => hsplit( + FixedB(340.0), + vsplit( + Ratio(0.58), hsplit(Ratio(0.42), tabs(&[LumetriScopes, Source, EffectControls], 0), tabs(&[Program], 0)), - tabs(&[LumetriColor, Effects], 0), + hsplit(Ratio(0.3), tabs(&[Project, Info, Markers, History], 0), bottom_editing()), ), - hsplit(FixedB(330.0), hsplit(Ratio(0.3), tabs(&[Project, Info, Markers, History], 0), bottom_editing()), tabs(&[LumetriColor], 0)), + tabs(&[LumetriColor, Effects], 0), ), "Effects" => hsplit( FixedB(320.0), diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs new file mode 100644 index 00000000..3896b17e --- /dev/null +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -0,0 +1,416 @@ +//! Lumetri Color panel: Basic Correction, Creative, Curves (RGB + hue curves), Color Wheels, +//! HSL Secondary, Vignette — editing the selected clip's Lumetri effect through engine commands. + +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_project::{ClipId, EffectInstance, ParamValue}; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; + +type Actions = Vec; + +fn selected(app: &FilmcraftApp) -> Option<(ClipId, filmcraft_project::TrackItem)> { + let seq = app.session.active_sequence()?; + app.session.state.selection.iter().find_map(|c| { + let (tid, it) = seq.find_item(*c)?; + (seq.track(tid)?.kind == filmcraft_project::TrackKind::Video).then(|| (*c, it.clone())) + }) +} + +pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let Some((clip, it)) = selected(app) else { + crate::dock::placeholder(ui, rect, &t, "(no clip selected)"); + return; + }; + let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(12.0, 6.0))).id_salt("lumetri2")); + let Some(idx) = it.effects.iter().position(|e| e.effect == "lumetri") else { + bui.add_space(8.0); + bui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + bui.add_space(8.0); + if bui.button("Add Lumetri Color to clip").clicked() { + let _ = app.session.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"})); + } + return; + }; + let e = it.effects[idx].clone(); + let ph = app.session.playhead(); + let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1))); + let mut actions: Actions = Vec::new(); + // header: clip context + fx toggle + bui.horizontal(|ui| { + ui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let fx = ui.add(egui::Button::new(egui::RichText::new("fx").italics().color(if e.enabled { t.text } else { t.text_faint })).frame(false)); + if fx.clicked() { + let _ = app.session.execute("effects.toggleEnabled", json!({"clip": clip.0, "index": idx})); + } + }); + }); + let set = |actions: &mut Actions, param: &str, v: Value| actions.push(json!({"clip": clip.0, "effect": idx, "param": param, "value": v})); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut bui, |ui| { + if section(ui, app, "Basic Correction") { + sub(ui, &t, "Color"); + slider(ui, &e, mt, "temperature", "Temperature", Some(Gradient::Temp), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "tint", "Tint", Some(Gradient::Tint), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "saturation", "Saturation", None, &t, &mut actions, clip, idx); + sub(ui, &t, "Light"); + for (id, label) in [("exposure", "Exposure"), ("contrast", "Contrast"), ("highlights", "Highlights"), ("shadows", "Shadows"), ("whites", "Whites"), ("blacks", "Blacks")] { + slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); + } + } + if section(ui, app, "Creative") { + let look = match e.param("look").map(|p| &p.value) { + Some(ParamValue::Choice(c)) => *c as usize, + _ => 0, + }; + let opts = filmcraft_project::find_effect("lumetri").and_then(|d| d.param("look")).and_then(|p| if let filmcraft_project::ParamKind::Choice(o) = p.kind { Some(o) } else { None }).unwrap_or(&[]); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Look").color(t.text_dim))); + let mut sel = look; + egui::ComboBox::from_id_salt("lumetri-look").selected_text(opts.get(look).copied().unwrap_or("None")).width(ui.available_width() - 8.0).show_ui(ui, |ui| { + for (i, o) in opts.iter().enumerate() { + if ui.selectable_value(&mut sel, i, *o).changed() { + set(&mut actions, "look", json!(i)); + } + } + }); + }); + slider(ui, &e, mt, "look_intensity", "Intensity", None, &t, &mut actions, clip, idx); + sub(ui, &t, "Adjustments"); + for (id, label) in [("faded_film", "Faded Film"), ("sharpen", "Sharpen"), ("vibrance", "Vibrance"), ("creative_sat", "Saturation")] { + slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); + } + } + if section(ui, app, "Curves") { + sub(ui, &t, "RGB Curves"); + let key = egui::Id::new("lumetri-curve-channel"); + let mut ch: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); + ui.horizontal(|ui| { + for (i, c) in [Color32::WHITE, Color32::from_rgb(230, 70, 70), Color32::from_rgb(80, 200, 90), Color32::from_rgb(80, 130, 250)].iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + ui.painter().circle_filled(r.center(), 6.0, *c); + if ch == i { + ui.painter().circle_stroke(r.center(), 8.5, Stroke::new(1.5, t.hot_text)); + } + if resp.clicked() { + ch = i; + } + } + }); + ui.data_mut(|d| d.insert_temp(key, ch)); + let (id, col) = [("curve_luma", Color32::WHITE), ("curve_red", Color32::from_rgb(230, 70, 70)), ("curve_green", Color32::from_rgb(80, 200, 90)), ("curve_blue", Color32::from_rgb(80, 130, 250))][ch]; + curve_editor(ui, &e, id, false, col, &t, &mut actions, clip, idx); + sub(ui, &t, "Hue Saturation Curves"); + for (id, label) in [("hue_vs_sat", "Hue vs Sat"), ("hue_vs_hue", "Hue vs Hue"), ("hue_vs_luma", "Hue vs Luma"), ("luma_vs_sat", "Luma vs Sat"), ("sat_vs_sat", "Sat vs Sat")] { + ui.label(egui::RichText::new(label).color(t.text_dim)); + curve_editor(ui, &e, id, true, Color32::WHITE, &t, &mut actions, clip, idx); + } + } + if section(ui, app, "Color Wheels & Match") { + let w = ((ui.available_width() - 24.0) / 3.0).min(120.0); + ui.horizontal(|ui| { + for (id, lid, label) in [("wheel_shadows", "wheel_shadows_l", "Shadows"), ("wheel_midtones", "wheel_midtones_l", "Midtones"), ("wheel_highlights", "wheel_highlights_l", "Highlights")] { + ui.vertical(|ui| { + ui.label(egui::RichText::new(label).color(t.text_dim)); + color_wheel(ui, &e, mt, id, lid, w, &t, &mut actions, clip, idx); + }); + } + }); + } + if section(ui, app, "HSL Secondary") { + let on = e.param("hsl_on").and_then(|p| p.value.as_bool()).unwrap_or(false); + let mut v = on; + if ui.checkbox(&mut v, "Enable key").changed() { + set(&mut actions, "hsl_on", json!(v)); + } + sub(ui, &t, "Key"); + slider(ui, &e, mt, "hsl_hue", "Hue", Some(Gradient::Hue), &t, &mut actions, clip, idx); + for (id, label) in [("hsl_hue_range", "Hue Range"), ("hsl_sat_min", "Saturation Min"), ("hsl_luma_min", "Luma Min"), ("hsl_luma_max", "Luma Max"), ("hsl_soft", "Soften")] { + slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); + } + let mask = match e.param("hsl_show_mask").map(|p| &p.value) { + Some(ParamValue::Choice(c)) => *c as usize, + _ => 0, + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Show Mask").color(t.text_dim))); + let mut sel = mask; + egui::ComboBox::from_id_salt("hsl-mask").selected_text(["Off", "Color/Gray", "Color/Black", "White/Black"][mask.min(3)]).show_ui(ui, |ui| { + for (i, o) in ["Off", "Color/Gray", "Color/Black", "White/Black"].iter().enumerate() { + if ui.selectable_value(&mut sel, i, *o).changed() { + set(&mut actions, "hsl_show_mask", json!(i)); + } + } + }); + }); + sub(ui, &t, "Correction"); + slider(ui, &e, mt, "hsl_temp", "Temperature", Some(Gradient::Temp), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_tint", "Tint", Some(Gradient::Tint), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_sat", "Saturation", None, &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_hue_shift", "Hue Shift", Some(Gradient::Hue), &t, &mut actions, clip, idx); + } + if section(ui, app, "Vignette") { + for (id, label) in [("vignette_amount", "Amount"), ("vignette_midpoint", "Midpoint"), ("vignette_roundness", "Roundness"), ("vignette_feather", "Feather")] { + slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); + } + } + ui.add_space(20.0); + }); + for a in actions { + if let Err(err) = app.session.execute("effects.setParam", a) { + app.ui.status = err.to_string(); + } + } +} + +fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str) -> bool { + let t = app.tokens; + let key = format!("lumetri2:{name}"); + let open = !app.ui.collapsed_fx.contains(&key) && (name == "Basic Correction" || app.ui.expanded_fx.contains(&key)); + ui.add_space(2.0); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click()); + ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 6.0, r.center().y), vec2(10.0, 10.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); + ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + // toggle switch (visual; section bypass lands with per-section enables) + let sw = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(26.0, 14.0)); + ui.painter().rect_filled(sw, 7.0, t.accent); + ui.painter().circle_filled(pos2(sw.max.x - 7.0, sw.center().y), 5.0, Color32::WHITE); + app.auto.add(&format!("lumetri.section.{name}"), r, name); + if resp.clicked() { + if open { + app.ui.collapsed_fx.push(key.clone()); + app.ui.expanded_fx.retain(|k| *k != key); + } else { + app.ui.collapsed_fx.retain(|k| *k != key); + app.ui.expanded_fx.push(key); + } + } + open +} + +fn sub(ui: &mut egui::Ui, t: &Tokens, name: &str) { + ui.add_space(4.0); + ui.label(egui::RichText::new(name).color(t.text).strong().size(12.0)); +} + +#[derive(Clone, Copy)] +enum Gradient { + Temp, + Tint, + Hue, +} + +/// Spectrum slider: 1 pt track (or gradient), hollow ring knob, scrubby value at the right. +#[allow(clippy::too_many_arguments)] +fn slider(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, label: &str, grad: Option, t: &Tokens, actions: &mut Actions, clip: ClipId, idx: usize) { + let Some(pd) = e.def().and_then(|d| d.param(id)) else { return }; + let filmcraft_project::ParamKind::Float { min, max, soft_min, soft_max, decimals, .. } = pd.kind else { return }; + let v = e.param(id).map(|p| p.f64_at(mt)).unwrap_or(pd.default.as_f64().unwrap_or(0.0)); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover()); + ui.painter().text(pos2(row.min.x + 4.0, row.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); + let track = Rect::from_min_max(pos2(row.min.x + 112.0, row.center().y - 1.0), pos2(row.max.x - 58.0, row.center().y + 1.0)); + match grad { + Some(g) => { + let n = 24; + for i in 0..n { + let f0 = i as f32 / n as f32; + let c = match g { + Gradient::Temp => lerp_col(Color32::from_rgb(70, 120, 230), Color32::from_rgb(240, 150, 50), f0), + Gradient::Tint => lerp_col(Color32::from_rgb(60, 190, 80), Color32::from_rgb(220, 70, 200), f0), + Gradient::Hue => { + let rgb = filmcraft_color::hsl_to_rgb(f0, 0.8, 0.5); + Color32::from_rgb((rgb[0] * 255.0) as u8, (rgb[1] * 255.0) as u8, (rgb[2] * 255.0) as u8) + } + }; + let x0 = track.min.x + track.width() * f0; + ui.painter().rect_filled(Rect::from_min_max(pos2(x0, track.min.y - 1.0), pos2(x0 + track.width() / n as f32 + 0.5, track.max.y + 1.0)), 0.0, c); + } + } + None => { + ui.painter().rect_filled(track, 0.0, Color32::from_rgb(0x6e, 0x6e, 0x6e)); + } + } + let f = ((v - soft_min) / (soft_max - soft_min)).clamp(0.0, 1.0) as f32; + let kx = track.min.x + f * track.width(); + let hit = Rect::from_min_max(pos2(track.min.x - 6.0, row.min.y), pos2(track.max.x + 6.0, row.max.y)); + let resp = ui.interact(hit, egui::Id::new(("lslider", clip.0, id)), Sense::click_and_drag()); + ui.painter().circle_filled(pos2(kx, track.center().y), 5.0, t.panel_bg); + ui.painter().circle_stroke(pos2(kx, track.center().y), 5.0, Stroke::new(1.5, if resp.dragged() { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) })); + let mut out = None; + if (resp.dragged() || resp.clicked()) + && let Some(p) = resp.interact_pointer_pos() + { + let nf = ((p.x - track.min.x) / track.width()).clamp(0.0, 1.0) as f64; + out = Some(soft_min + nf * (soft_max - soft_min)); + } + if resp.double_clicked() { + out = pd.default.as_f64(); + } + let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(row.max.x - 52.0, row.min.y + 3.0), pos2(row.max.x, row.max.y - 3.0))).layout(egui::Layout::right_to_left(egui::Align::Center))); + let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("lval", clip.0, id)), v, (soft_max - soft_min) / 300.0, (min, max), decimals as usize, "", t); + if let Some(nv) = out.or(nv) { + actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": (nv * 10f64.powi(decimals as i32)).round() / 10f64.powi(decimals as i32)})); + } +} + +fn lerp_col(a: Color32, b: Color32, f: f32) -> Color32 { + let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * f) as u8; + Color32::from_rgb(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b())) +} + +/// Curve editor: drag points, click the curve to add, double-click a point to remove. +#[allow(clippy::too_many_arguments)] +fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col: Color32, t: &Tokens, actions: &mut Actions, clip: ClipId, idx: usize) { + let pts: Vec<[f32; 2]> = e.param(id).and_then(|p| p.value.as_curve().map(|c| c.to_vec())).unwrap_or_default(); + let w = ui.available_width().min(260.0); + let h = if hue { w * 0.45 } else { w }; + let (area, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click_and_drag()); + let p = ui.painter(); + p.rect_filled(area, 2.0, Color32::from_rgb(0x16, 0x16, 0x16)); + for i in 1..4 { + let f = i as f32 / 4.0; + p.line_segment([pos2(area.min.x + area.width() * f, area.min.y), pos2(area.min.x + area.width() * f, area.max.y)], Stroke::new(1.0, Color32::from_rgb(0x2a, 0x2a, 0x2a))); + p.line_segment([pos2(area.min.x, area.min.y + area.height() * f), pos2(area.max.x, area.min.y + area.height() * f)], Stroke::new(1.0, Color32::from_rgb(0x2a, 0x2a, 0x2a))); + } + if hue { + let n = 36; + for i in 0..n { + let f0 = i as f32 / n as f32; + let rgb = filmcraft_color::hsl_to_rgb(f0, 0.8, 0.5); + let c = Color32::from_rgb((rgb[0] * 255.0) as u8, (rgb[1] * 255.0) as u8, (rgb[2] * 255.0) as u8); + p.rect_filled(Rect::from_min_max(pos2(area.min.x + area.width() * f0, area.max.y - 6.0), pos2(area.min.x + area.width() * (f0 + 1.0 / n as f32) + 0.5, area.max.y)), 0.0, c); + } + } + let to_screen = |q: [f32; 2]| pos2(area.min.x + q[0] * area.width(), area.max.y - q[1] * area.height()); + let from_screen = |s: Pos2| [((s.x - area.min.x) / area.width()).clamp(0.0, 1.0), ((area.max.y - s.y) / area.height()).clamp(0.0, 1.0)]; + // curve polyline + let draw_pts: Vec<[f32; 2]> = if hue && pts.is_empty() { vec![[0.0, 0.5], [1.0, 0.5]] } else { pts.clone() }; + let lut = if hue && !pts.is_empty() { + let mut ext = Vec::new(); + for off in [-1.0f32, 0.0, 1.0] { + for q in &pts { + ext.push([(q[0] + off + 1.0) / 3.0, q[1]]); + } + } + let l = filmcraft_render::effects::curve_lut(&ext, 3 * 128); + l[128..256].to_vec() + } else { + filmcraft_render::effects::curve_lut(&draw_pts, 128) + }; + let line: Vec = lut.iter().enumerate().map(|(i, y)| to_screen([i as f32 / 127.0, y.clamp(0.0, 1.0)])).collect(); + p.add(egui::Shape::line(line, Stroke::new(1.5, col))); + let drag_key = egui::Id::new(("curve-drag", clip.0, id)); + let mut dragging: Option = ui.data(|d| d.get_temp(drag_key)); + let mut new_pts = pts.clone(); + let mut changed = false; + for (i, q) in pts.iter().enumerate() { + let sp = to_screen(*q); + p.circle_filled(sp, 4.0, if dragging == Some(i) { t.hot_text } else { col }); + } + if resp.drag_started() + && let Some(pos) = resp.interact_pointer_pos() + { + let near = pts.iter().enumerate().map(|(i, q)| (i, to_screen(*q).distance(pos))).filter(|(_, d)| *d < 9.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|x| x.0); + dragging = near.or_else(|| { + new_pts.push(from_screen(pos)); + changed = true; + Some(new_pts.len() - 1) + }); + } + if resp.dragged() + && let (Some(i), Some(pos)) = (dragging, resp.interact_pointer_pos()) + && i < new_pts.len() + { + new_pts[i] = from_screen(pos); + changed = true; + } + if resp.drag_stopped() { + dragging = None; + } + if resp.double_clicked() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(i) = pts.iter().position(|q| to_screen(*q).distance(pos) < 9.0) + { + let endpoint = !hue && (i == 0 || i == pts.len() - 1); + if !endpoint { + new_pts.remove(i); + changed = true; + } + } else if resp.double_clicked() && !hue { + new_pts = vec![[0.0, 0.0], [1.0, 1.0]]; + changed = true; + } + ui.data_mut(|d| { + if let Some(i) = dragging { + d.insert_temp(drag_key, i); + } else { + d.remove::(drag_key); + } + }); + if changed { + let v: Vec = new_pts.iter().map(|q| json!([q[0], q[1]])).collect(); + actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": v})); + } + ui.add_space(6.0); +} + +/// Colour wheel: hue ring, draggable puck (offset), lightness slider on the left. +#[allow(clippy::too_many_arguments)] +fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: &str, size: f32, t: &Tokens, actions: &mut Actions, clip: ClipId, idx: usize) { + let v = e.param(id).map(|p| p.vec2_at(mt)).unwrap_or_default(); + let (area, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); + let c = area.center() + vec2(6.0, 0.0); + let r_out = size / 2.0 - 4.0; + let r_in = r_out - 10.0; + let p = ui.painter(); + let n = 72; + let mut mesh = egui::Mesh::default(); + for i in 0..n { + let a0 = i as f32 / n as f32 * std::f32::consts::TAU; + let a1 = (i + 1) as f32 / n as f32 * std::f32::consts::TAU; + let rgb = filmcraft_color::hsl_to_rgb(1.0 - (i as f32 / n as f32), 0.75, 0.5); + let col = Color32::from_rgb((rgb[0] * 255.0) as u8, (rgb[1] * 255.0) as u8, (rgb[2] * 255.0) as u8); + let base = mesh.vertices.len() as u32; + for (a, r) in [(a0, r_in), (a0, r_out), (a1, r_out), (a1, r_in)] { + mesh.colored_vertex(c + vec2(a.cos(), -a.sin()) * r, col); + } + mesh.add_triangle(base, base + 1, base + 2); + mesh.add_triangle(base, base + 2, base + 3); + } + p.add(mesh); + p.circle_filled(c, r_in - 1.0, Color32::from_rgb(0x16, 0x16, 0x16)); + p.line_segment([c - vec2(6.0, 0.0), c + vec2(6.0, 0.0)], Stroke::new(1.0, t.text_faint)); + p.line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], Stroke::new(1.0, t.text_faint)); + let puck = c + vec2(v.x as f32, -v.y as f32) * (r_in - 4.0); + p.circle_stroke(puck, 5.0, Stroke::new(1.5, Color32::WHITE)); + if (resp.dragged() || resp.clicked()) + && let Some(pos) = resp.interact_pointer_pos() + { + let d = (pos - c) / (r_in - 4.0); + let len = d.length().min(1.0); + let dir = if d.length() > 1e-6 { d / d.length() } else { d }; + actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": [dir.x * len, -dir.y * len]})); + } + if resp.double_clicked() { + actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": [0.0, 0.0]})); + } + // lightness slider (vertical) at the wheel's left edge + let l = e.param(lid).map(|p| p.f64_at(mt)).unwrap_or(0.0) as f32; + let track = Rect::from_min_max(pos2(area.min.x, area.min.y + 8.0), pos2(area.min.x + 4.0, area.max.y - 8.0)); + p.rect_filled(track, 2.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); + let ky = track.center().y - l / 100.0 * track.height() / 2.0; + p.circle_stroke(pos2(track.center().x, ky), 4.0, Stroke::new(1.5, Color32::from_rgb(0xb0, 0xb0, 0xb0))); + let lr = ui.interact(track.expand2(vec2(6.0, 0.0)), egui::Id::new(("wl", clip.0, lid)), Sense::click_and_drag()); + if (lr.dragged() || lr.clicked()) + && let Some(pos) = lr.interact_pointer_pos() + { + let nv = ((track.center().y - pos.y) / (track.height() / 2.0) * 100.0).clamp(-100.0, 100.0); + actions.push(json!({"clip": clip.0, "effect": idx, "param": lid, "value": nv})); + } + let _ = StrokeKind::Inside; +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 79ab15fa..399fd0eb 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -4,6 +4,7 @@ pub mod dialogs; pub mod effect_controls; pub mod effects; +pub mod lumetri; pub mod export_mode; pub mod import_mode; pub mod meters; @@ -30,7 +31,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::Effects => effects::show(app, ui, rect), PanelKind::EffectControls => effect_controls::show(app, ui, rect), PanelKind::AudioMeters => meters::show(app, ui, rect), - PanelKind::LumetriColor => effect_controls::lumetri_panel(app, ui, rect), + PanelKind::LumetriColor => lumetri::show(app, ui, rect), PanelKind::Properties => effect_controls::properties_panel(app, ui, rect), PanelKind::History => misc::history(app, ui, rect), PanelKind::Markers => misc::markers(app, ui, rect),