mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
LR-EDIT-LIGHT-CURVE-TAT: targeted adjustment tool for the tone curve and the colour mixer
develop.targeted {target: curve|hue|sat|lum, x, y, delta, channel?}: samples the photo at the
point on a proxy render with the target stage and everything after it neutralized, then
nudges the parametric curve region (by the sampled tone and the splits), moves/adds a point
on the master/RGB point curve, or adjusts the colour-mixer (or B&W mix) bands weighted by
their share of the sampled OkLCh hue. UI: target buttons in the curve editor and the colour
mixer; a vertical drag on the loupe sends one step per unit inside one interaction (one
undo step). New original vector icon (Target) in icons.rs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
490fe943cd
commit
8b7fb306dc
@@ -1,13 +1,16 @@
|
||||
//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool.
|
||||
//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool
|
||||
//! (`develop.targeted`: a vertical drag on the photo raises/lowers the tone-curve region or the
|
||||
//! colour-mixer bands under the pointer; the UI sends one call per drag step inside an interaction).
|
||||
//!
|
||||
//! Sampling renders a small proxy with every stage *after* the sampled one neutralized, so the
|
||||
//! picked colour is the one the adjustment will see (e.g. Point Color samples after the colour
|
||||
//! mixer, before vibrance, grading, vignette and curves).
|
||||
|
||||
use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
|
||||
use lightcraft_color::spline::MonotoneCurve;
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, PointColor, Treatment};
|
||||
use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, MIXER_BANDS, PointColor, ToneCurve, Treatment, controls};
|
||||
use lightcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -64,6 +67,94 @@ pub(crate) fn after_mixer_neutral(d: &mut DevelopSettings) {
|
||||
d.curve = Default::default();
|
||||
}
|
||||
|
||||
/// The targeted adjustment tool: the controls (and their new values) a vertical drag of `delta`
|
||||
/// at `(x, y)` changes, computed on `d` (applied by the caller).
|
||||
fn targeted(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "develop.targeted";
|
||||
let (x, y, delta) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?, f64_req(p, "delta", c)?);
|
||||
let target = p.get("target").and_then(Value::as_str).unwrap_or("curve").to_string();
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let mut changed = serde_json::Map::new();
|
||||
fn nudge(changed: &mut serde_json::Map<String, Value>, d: &mut DevelopSettings, ctl: &str, by: f64) {
|
||||
let v = controls::get(d, ctl).unwrap_or(0.0) + by;
|
||||
controls::set(d, ctl, v);
|
||||
changed.insert(ctl.to_string(), json!(controls::get(d, ctl)));
|
||||
}
|
||||
let label;
|
||||
match target.as_str() {
|
||||
"curve" => {
|
||||
// the encoded value the tone curve sees there (curves and grain neutralized)
|
||||
let e = probe(s, c, x, y, |d| {
|
||||
d.curve = ToneCurve { refine_saturation: d.curve.refine_saturation, ..Default::default() };
|
||||
d.grain.amount = 0.0;
|
||||
})?;
|
||||
let channel = p.get("channel").and_then(Value::as_str).unwrap_or("parametric");
|
||||
label = "Tone Curve";
|
||||
if channel == "parametric" {
|
||||
let v = (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]) as f64 * 100.0;
|
||||
let cv = &d.curve;
|
||||
let region = if v < cv.split_shadows {
|
||||
"curve.shadows"
|
||||
} else if v < cv.split_mid {
|
||||
"curve.darks"
|
||||
} else if v < cv.split_highlights {
|
||||
"curve.lights"
|
||||
} else {
|
||||
"curve.highlights"
|
||||
};
|
||||
nudge(&mut changed, &mut d, region, delta);
|
||||
} else {
|
||||
// point curve: move the point at the sampled input up/down (or add one there)
|
||||
let (pts, v) = match channel {
|
||||
"red" => (&mut d.curve.red, e[0]),
|
||||
"green" => (&mut d.curve.green, e[1]),
|
||||
"blue" => (&mut d.curve.blue, e[2]),
|
||||
_ => (&mut d.curve.master, 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]),
|
||||
};
|
||||
let v = v as f64;
|
||||
if pts.is_empty() {
|
||||
*pts = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)];
|
||||
}
|
||||
let curve = MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>());
|
||||
let dy = delta / 255.0;
|
||||
match pts.iter().position(|q| (q.x - v).abs() < 0.03) {
|
||||
Some(i) => pts[i].y = (pts[i].y + dy).clamp(0.0, 1.0),
|
||||
None => {
|
||||
let i = pts.iter().position(|q| q.x > v).unwrap_or(pts.len());
|
||||
pts.insert(i, Point::new(v, (curve.eval(v) + dy).clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
changed.insert(format!("curve.{channel}"), json!(pts.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
"hue" | "sat" | "lum" => {
|
||||
// the hue the colour mixer sees there (the mixer and everything after it neutralized)
|
||||
let e = probe(s, c, x, y, |d| {
|
||||
d.mixer = Default::default();
|
||||
d.bw_mix = Default::default();
|
||||
after_mixer_neutral(d);
|
||||
})?;
|
||||
let [_, _, h] = encoded_to_oklch(e);
|
||||
let w = lightcraft_pipeline::colorops::band_weights(h);
|
||||
let top = w.iter().cloned().fold(0.0f32, f32::max).max(1e-6);
|
||||
let bw = d.treatment == Treatment::Bw || d.profile.id == "lc.mono";
|
||||
label = if bw { "B&W Mix" } else { "Color Mixer" };
|
||||
for (i, band) in MIXER_BANDS.iter().enumerate() {
|
||||
let k = (w[i] / top) as f64;
|
||||
if k < 0.05 {
|
||||
continue;
|
||||
}
|
||||
let ctl = if bw { format!("bw.{band}") } else { format!("mixer.{band}.{target}") };
|
||||
nudge(&mut changed, &mut d, &ctl, delta * k);
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(c, "target must be curve, hue, sat or lum")),
|
||||
}
|
||||
s.set_develop(id, d, label)?;
|
||||
Ok(Value::Object(changed))
|
||||
}
|
||||
|
||||
fn index(p: &Value, c: &str) -> Result<usize> {
|
||||
Ok(f64_req(p, "index", c)? as usize)
|
||||
}
|
||||
@@ -93,6 +184,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"index": i, "lum": sample.lum, "chroma": sample.chroma, "hue": sample.hue}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.targeted",
|
||||
"Targeted Adjustment",
|
||||
[],
|
||||
None,
|
||||
"{target: curve|hue|sat|lum, x, y (normalized image coords), delta (slider units; point curves: output levels 0..255), channel?: parametric|master|red|green|blue (curve)} — adjusts the curve region / colour-mixer bands under the point; returns the changed controls",
|
||||
has_active,
|
||||
targeted
|
||||
),
|
||||
cmd!("pointColor.delete", "Delete Point Color Sample", [], None, "{index}", has_active, |s, p| {
|
||||
let c = "pointColor.delete";
|
||||
let i = index(p, c)?;
|
||||
|
||||
@@ -52,3 +52,32 @@ fn point_color_pick_and_adjust() {
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_adjustment_on_curve_and_mixer() {
|
||||
let mut s = demo();
|
||||
// the bright sky lies in the upper regions of the parametric curve
|
||||
let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.5, "y": 0.35, "delta": 20})).unwrap();
|
||||
let (k, v) = r.as_object().unwrap().iter().next().map(|(k, v)| (k.clone(), v.clone())).unwrap();
|
||||
assert!(k == "curve.lights" || k == "curve.highlights", "{r}");
|
||||
assert_eq!(v, 20.0);
|
||||
assert_eq!(lightcraft_develop::controls::get(&active_dev(&s), &k), Some(20.0));
|
||||
// the dark trees in the lower ones
|
||||
let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.4, "y": 0.62, "delta": -10})).unwrap();
|
||||
assert!(r.get("curve.shadows").is_some() || r.get("curve.darks").is_some(), "{r}");
|
||||
// point curve: a point appears at the sampled input, raised by delta levels
|
||||
s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap();
|
||||
let m = active_dev(&s).curve.master;
|
||||
assert_eq!(m.len(), 3);
|
||||
assert!((m[1].y - m[1].x - 0.05).abs() < 1e-6, "{m:?}");
|
||||
// a second step moves the same point
|
||||
s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap();
|
||||
assert_eq!(active_dev(&s).curve.master.len(), 3);
|
||||
// colour mixer: the purple sky's bands lose saturation, the dominant one by the full delta
|
||||
let r = s.execute("develop.targeted", &json!({"target": "sat", "x": 0.5, "y": 0.1, "delta": -30})).unwrap();
|
||||
let o = r.as_object().unwrap();
|
||||
assert!(!o.is_empty() && o.keys().all(|k| k.starts_with("mixer.") && k.ends_with(".sat")), "{r}");
|
||||
assert!(o.values().any(|v| v.as_f64() == Some(-30.0)), "{r}");
|
||||
assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0));
|
||||
assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err());
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub enum Icon {
|
||||
Video,
|
||||
Heart,
|
||||
Edited,
|
||||
/// Targeted adjustment: a ring with a centre dot and up/down arrows (drag vertically).
|
||||
Target,
|
||||
}
|
||||
|
||||
struct Pen<'a> {
|
||||
@@ -402,5 +404,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
||||
pen.circle(10.0, 10.0, 7.0);
|
||||
pen.line(&[(7.0, 13.0), (13.0, 7.0)]);
|
||||
}
|
||||
Target => {
|
||||
pen.circle(8.0, 10.0, 5.0);
|
||||
pen.dot(8.0, 10.0, 1.3);
|
||||
pen.line(&[(15.5, 3.5), (15.5, 16.5)]);
|
||||
pen.line(&[(13.5, 5.5), (15.5, 3.5), (17.5, 5.5)]);
|
||||
pen.line(&[(13.5, 14.5), (15.5, 16.5), (17.5, 14.5)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ pub enum Gesture {
|
||||
Guide {
|
||||
a: Point,
|
||||
},
|
||||
/// Targeted adjustment drag from `at` (normalized); `acc` = vertical travel not yet applied.
|
||||
Targeted {
|
||||
at: Point,
|
||||
acc: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Screen ↔ normalized-image mapping for the displayed frame.
|
||||
@@ -200,6 +205,38 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
/// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or
|
||||
/// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all
|
||||
/// in one interaction = one undo step).
|
||||
fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, target: &str) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Targeted Adjustment"}));
|
||||
app.gesture = Some(Gesture::Targeted { at: map.norm(q), acc: 0.0 });
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(Gesture::Targeted { at, acc }) = &mut app.gesture
|
||||
{
|
||||
*acc -= resp.drag_delta().y * 0.5;
|
||||
let step = acc.trunc();
|
||||
if step != 0.0 {
|
||||
*acc -= step;
|
||||
let at = *at;
|
||||
let mut p = json!({"target": target, "x": at.x, "y": at.y, "delta": step});
|
||||
if target == "curve" && app.ui.curve_channel != "parametric" {
|
||||
p["channel"] = json!(app.ui.curve_channel);
|
||||
}
|
||||
let _ = app.run("develop.targeted", p);
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::Targeted { .. })) {
|
||||
app.gesture = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The diagnostic overlay the loupe shows (Point Color's visualized range).
|
||||
fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
|
||||
use lightcraft_pipeline::Overlay;
|
||||
@@ -244,6 +281,10 @@ fn general_interaction(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(target) = app.ui.tool.strip_prefix("tat:").map(str::to_string) {
|
||||
targeted_drag(app, ui, resp, map, &target);
|
||||
return;
|
||||
}
|
||||
if app.ui.tool == "pointColor" {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked()
|
||||
|
||||
@@ -422,6 +422,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
||||
app.ui.curve_channel = ch.into();
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there");
|
||||
});
|
||||
});
|
||||
});
|
||||
let ch = app.ui.curve_channel.clone();
|
||||
@@ -568,6 +571,15 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
/// Toggle for a targeted-adjustment tool (`tool` = `tat:<target>`).
|
||||
fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) {
|
||||
let active = app.ui.tool == tool;
|
||||
let id = tool.replace(':', "-");
|
||||
if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() {
|
||||
app.ui.tool = if active { String::new() } else { tool.to_string() };
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ colour mixer
|
||||
|
||||
fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
@@ -591,6 +603,26 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// targeted adjustment: pick the attribute, then drag on the photo
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover());
|
||||
paint(ui.painter(), r, Icon::Target, t.icon);
|
||||
let items: &[(&str, &str)] =
|
||||
if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] };
|
||||
for (k, label) in items {
|
||||
let tool = format!("tat:{k}");
|
||||
let active = app.ui.tool == tool;
|
||||
if text_button(ui, &format!("tatMixer-{k}"), label, active)
|
||||
.on_hover_text("Targeted adjustment: drag up/down on the photo to adjust the colours there")
|
||||
.clicked()
|
||||
{
|
||||
app.ui.tool = if active { String::new() } else { tool };
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let b = bands[sel];
|
||||
if crate::is_bw(d) {
|
||||
control(app, ui, d, &format!("bw.{b}"), true);
|
||||
|
||||
Reference in New Issue
Block a user