M8: Lumetri curves, hue curves, colour wheels, looks, HSL secondary (render + panel UI); Color workspace layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c13f9170fb
commit
3dff6e78e2
|
|
@ -1773,6 +1773,7 @@ fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
|
|||
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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,12 +402,41 @@ fn build_effects() -> Vec<EffectDef> {
|
|||
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(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(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"),
|
||||
|
|
|
|||
|
|
@ -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<bool> {
|
||||
match self {
|
||||
ParamValue::Bool(b) => Some(*b),
|
||||
|
|
|
|||
|
|
@ -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<f32> {
|
||||
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<f32> = (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<Vec<f32>> {
|
||||
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::<Vec<_>>(), n * 3);
|
||||
Some(lut[n..2 * n].to_vec())
|
||||
}
|
||||
|
||||
fn curve_param(e: &EffectInstance, id: &str) -> Option<Vec<[f32; 2]>> {
|
||||
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<f32>, 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,14 +164,14 @@ pub fn workspace(name: &str) -> DockNode {
|
|||
),
|
||||
bottom_editing(),
|
||||
),
|
||||
"Color" => vsplit(
|
||||
"Color" => hsplit(
|
||||
FixedB(340.0),
|
||||
vsplit(
|
||||
Ratio(0.58),
|
||||
hsplit(
|
||||
FixedB(330.0),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<Value>;
|
||||
|
||||
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<Gradient>, 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<Pos2> = 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<usize> = 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::<usize>(drag_key);
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
let v: Vec<Value> = 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue