diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index a84be413..84017432 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -47,7 +47,7 @@ pub struct WireJob { pub stages: Option, /// The request's diagnostic overlay (`Overlay::to_parts`). #[serde(default)] - pub overlay: (u8, f32), + pub overlay: (u8, f64), } impl WireJob { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 4cbfc22d..32239656 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -45,6 +45,7 @@ fn step(s: &mut Session, d: isize) -> Result { s.end_interaction()?; s.selection = Selection::single(vis[next]); s.active_mask = None; + s.active_spot = None; Ok(json!({"active": vis[next].0})) } @@ -169,6 +170,7 @@ pub fn specs() -> Vec { other => return Err(bad("library.select", format!("unknown mode `{other}`"))), } s.active_mask = None; + s.active_spot = None; Ok(json!({"selected": s.selection.ids.len()})) }), cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 0db1c58c..a09cecbb 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Add Remove Spot", [], None, - "{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy]}", + "{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}", has_active, |s, p| { let mode: SpotMode = @@ -265,31 +265,113 @@ pub fn specs() -> Vec { return Err(bad("spot.add", "missing points")); } let d = Spot::default(); - let spot = Spot { + let mut spot = Spot { mode, points: pts, - size: f64_or(p, "size", d.size), - feather: f64_or(p, "feather", d.feather), - opacity: f64_or(p, "opacity", d.opacity), + size: f64_or(p, "size", d.size).clamp(SPOT_SIZE.0, SPOT_SIZE.1), + feather: f64_or(p, "feather", d.feather).clamp(0.0, 100.0), + opacity: f64_or(p, "opacity", d.opacity).clamp(0.0, 100.0), source_offset: point(p, "source"), }; let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + if spot.source_offset.is_none() { + // resolve the automatic source now: it gets a pin and stays put at every size + spot.source_offset = pick_source(s, id, &dd, &spot, None); + } dd.spots.push(spot); let n = dd.spots.len(); s.set_develop(id, dd, "Remove")?; + s.active_spot = Some(n - 1); Ok(json!({"index": n - 1})) } ), - cmd!("spot.delete", "Delete Spot", [], None, "{index}", has_active, |s, p| { - let i = super::f64_req(p, "index", "spot.delete")? as usize; - let id = s.active().ok_or_else(|| bad("spot.delete", "no active photo"))?; - let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); - if i >= dd.spots.len() { - return Err(bad("spot.delete", "no such spot")); + cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| { + let n = spots_len(s); + s.active_spot = match p.get("index").and_then(Value::as_u64) { + Some(i) if (i as usize) < n => Some(i as usize), + Some(_) => return Err(bad("spot.select", "no such spot")), + None => None, + }; + Ok(json!({"activeSpot": s.active_spot})) + }), + cmd!( + "spot.update", + "Edit Spot", + [], + None, + "{index? (the selected spot), move?: [dx,dy] (the target, normalized), source?: [dx,dy] (offset from the target), moveSource?: [dx,dy], size?, feather?, opacity?, mode?}", + has_active, + |s, p| { + let c = "spot.update"; + let i = spot_index(s, p, c)?; + let mode: Option = match p.get("mode") { + Some(m) => Some(serde_json::from_value(m.clone()).map_err(|e| bad(c, e.to_string()))?), + None => None, + }; + spots_edit(s, c, "Edit Spot", |spots| { + let sp = &mut spots[i]; + if let Some(d) = point(p, "move") { + sp.points.iter_mut().for_each(|q| *q = Point::new(q.x + d.x, q.y + d.y)); + // the source moves along (its offset is relative to the target) + } + if let Some(o) = point(p, "source") { + sp.source_offset = Some(o); + } + if let Some(d) = point(p, "moveSource") { + let o = sp.source_offset.unwrap_or(Point::new(0.0, 0.0)); + sp.source_offset = Some(Point::new(o.x + d.x, o.y + d.y)); + } + if let Some(v) = p.get("size").and_then(Value::as_f64) { + sp.size = v.clamp(SPOT_SIZE.0, SPOT_SIZE.1); + } + if let Some(v) = p.get("feather").and_then(Value::as_f64) { + sp.feather = v.clamp(0.0, 100.0); + } + if let Some(v) = p.get("opacity").and_then(Value::as_f64) { + sp.opacity = v.clamp(0.0, 100.0); + } + if let Some(m) = mode { + sp.mode = m; + } + Ok(()) + })?; + Ok(json!({"index": i})) } - dd.spots.remove(i); - s.set_develop(id, dd, "Delete Spot")?; + ), + cmd!( + "spot.refreshSource", + "Refresh Source", + [], + None, + "{index? (the selected spot)} — picks the next-best source away from the current one", + has_active, + |s, p| { + let c = "spot.refreshSource"; + let i = spot_index(s, p, c)?; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let dd = (*s.develop_of(id).unwrap_or_default()).clone(); + let spot = dd.spots[i].clone(); + let next = pick_source(s, id, &dd, &spot, spot.source_offset).ok_or_else(|| bad(c, "no other source fits in the photo"))?; + spots_edit(s, c, "Refresh Source", |spots| { + spots[i].source_offset = Some(next); + Ok(()) + })?; + Ok(json!({"index": i, "source": [next.x, next.y]})) + } + ), + cmd!("spot.delete", "Delete Spot", [], None, "{index? (the selected spot)}", has_active, |s, p| { + let c = "spot.delete"; + let i = spot_index(s, p, c)?; + spots_edit(s, c, "Delete Spot", |spots| { + spots.remove(i); + Ok(()) + })?; + s.active_spot = match s.active_spot { + Some(a) if a == i => None, + Some(a) if a > i => Some(a - 1), + a => a, + }; ok() }), // ---- Red eye / pet eye @@ -358,6 +440,36 @@ pub fn specs() -> Vec { ] } +/// Spot radius limits (fraction of the long edge). +pub const SPOT_SIZE: (f64, f64) = (0.001, 0.25); + +fn spots_len(s: &Session) -> usize { + s.active().and_then(|id| s.develop_of(id)).map(|d| d.spots.len()).unwrap_or(0) +} + +/// `index` from the params, else the selected spot. +fn spot_index(s: &Session, p: &Value, c: &str) -> Result { + let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?; + if i >= spots_len(s) { + return Err(bad(c, "no such spot")); + } + Ok(i) +} + +fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec) -> Result<()>) -> Result<()> { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + f(&mut d.spots)?; + s.set_develop(id, d, label) +} + +/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`). +fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option) -> Option { + let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?; + let info = crate::media::source_info(s.catalog.photo(id)?); + lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid) +} + fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 }; c(&mut a.exposure, -4.0, 4.0); diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 1e42c7ca..74eec8d5 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -89,6 +89,7 @@ pub fn specs() -> Vec { "selection": s.selection, "visibleCount": n, "activeMask": s.active_mask, + "activeSpot": s.active_spot, "undo": s.undo.last().map(|u| u.label.clone()), "redo": s.redo.last().map(|u| u.label.clone()), "clipboard": s.clipboard.is_some(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6edde4b9..63a93e40 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -96,6 +96,8 @@ pub struct Session { depth: u32, /// Selected mask (Masking panel), by mask id. pub active_mask: Option, + /// Selected spot (Remove panel), by index into the active photo's spots. + pub active_spot: Option, /// The persistent library this session writes to (`None` = in-memory only). pub library: Option, /// XMP sidecar preferences (persisted with the library). @@ -138,6 +140,7 @@ impl Session { clock: Box::new(|| "2026-09-30T12:00:00".to_string()), depth: 0, active_mask: None, + active_spot: None, library: None, xmp: sidecar::XmpPrefs::default(), last_export: None, @@ -361,4 +364,6 @@ mod tests_organize; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_spots; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs new file mode 100644 index 00000000..16bfe52b --- /dev/null +++ b/crates/engine/src/tests_spots.rs @@ -0,0 +1,57 @@ +//! Remove tool commands: add (automatic source), select, move target/source, edit, refresh, delete. + +use serde_json::json; + +use crate::Session; + +fn spots(s: &Session) -> Vec { + s.develop_of(s.active().unwrap()).unwrap().spots.clone() +} + +#[test] +fn spots_select_move_edit_refresh_and_delete() { + let mut s = Session::with_demo(); + // a remove spot gets a resolved source (a pin to show) and is selected + let r = s.execute("spot.add", &json!({"points": [[0.4, 0.5]], "size": 0.02})).unwrap(); + assert_eq!(r["index"], 0); + assert_eq!(s.active_spot, Some(0)); + let src0 = spots(&s)[0].source_offset.expect("automatic source resolved"); + assert!(src0.x.hypot(src0.y) > 0.02, "the source is outside the spot: {src0:?}"); + s.execute("spot.add", &json!({"mode": "clone", "points": [[0.7, 0.3]], "source": [0.1, 0.0]})).unwrap(); + assert_eq!(s.active_spot, Some(1)); + assert_eq!(s.execute("library.state", &json!({})).unwrap()["activeSpot"], 1); + // select + s.execute("spot.select", &json!({"index": 0})).unwrap(); + assert_eq!(s.active_spot, Some(0)); + assert!(s.execute("spot.select", &json!({"index": 9})).is_err()); + // move the target (the source follows: its offset is relative), then the source alone + s.execute("spot.update", &json!({"move": [0.05, -0.1]})).unwrap(); + let sp = &spots(&s)[0]; + assert!((sp.points[0].x - 0.45).abs() < 1e-9 && (sp.points[0].y - 0.4).abs() < 1e-9); + assert_eq!(sp.source_offset, Some(src0)); + s.execute("spot.update", &json!({"moveSource": [0.01, 0.02]})).unwrap(); + let o = spots(&s)[0].source_offset.unwrap(); + assert!((o.x - src0.x - 0.01).abs() < 1e-9 && (o.y - src0.y - 0.02).abs() < 1e-9); + // size / feather / opacity of the selected spot (clamped), one undo step each + s.execute("spot.update", &json!({"size": 0.03, "feather": 20, "opacity": 150})).unwrap(); + let sp = &spots(&s)[0]; + assert_eq!((sp.size, sp.feather, sp.opacity), (0.03, 20.0, 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spots(&s)[0].size, 0.02); + // refresh: a different source, at least a spot radius away + let before = spots(&s)[0].source_offset.unwrap(); + let r = s.execute("spot.refreshSource", &json!({})).unwrap(); + let after = spots(&s)[0].source_offset.unwrap(); + assert!((after.x - before.x).hypot(after.y - before.y) > 0.01, "{r}"); + // delete the selected spot: the selection clears; deleting before a selected one shifts it + s.execute("spot.delete", &json!({})).unwrap(); + assert_eq!((spots(&s).len(), s.active_spot), (1, None)); + assert_eq!(spots(&s)[0].mode, lightcraft_develop::SpotMode::Clone); + assert!(s.execute("spot.delete", &json!({})).is_err(), "nothing selected"); + s.execute("spot.add", &json!({"points": [[0.2, 0.2]], "source": [0.05, 0.0]})).unwrap(); + s.execute("spot.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.active_spot, Some(0)); + // another photo: no selection + s.execute("library.next", &json!({})).unwrap(); + assert_eq!(s.active_spot, None); +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 38cf8c07..ad40d680 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -38,7 +38,22 @@ const MODULES: &[Module] = &[ Module { src: include_str!("wgsl/map.wgsl"), bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")], - entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample", "redeye_k"], + entries: &[ + "log_lum_k", + "dark_k", + "guided_pre", + "guided_ab", + "guided_apply", + "wb_k", + "nr_lum", + "chroma_k", + "nr_col", + "subsample", + "redeye_k", + "xguided_pre", + "xguided_ab", + "xguided_apply", + ], }, Module { src: include_str!("wgsl/mask.wgsl"), @@ -214,7 +229,7 @@ pub struct Gpu { /// WGSL constants shared by every module (generated from the CPU pipeline's values). fn constants() -> String { - use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_TERMS, SRGB_LUT_N}; + use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N}; use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N}; let mut s = String::new(); let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices(); @@ -226,9 +241,14 @@ fn constants() -> String { s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV)); let b = lightcraft_pipeline::geometry::BLANK; s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]); - s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N); + s += &format!( + "const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\nconst MASK_SUMS: u32 = {MASK_SUMS}u;\n", + crate::params::CURVE_N + ); s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS); s += &format!("const EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_WORDS); + use lightcraft_pipeline::masks::{AUTO_TOL_CHROMA, AUTO_TOL_EV}; + s += &format!("const AUTO_TOL_EV: f32 = {AUTO_TOL_EV:?};\nconst AUTO_TOL_CHROMA: f32 = {AUTO_TOL_CHROMA:?};\n"); s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT); for (i, h) in GRAIN_HASH.iter().enumerate() { s += &format!("const GRAIN_H{i}: u32 = {h}u;\n"); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 279692c9..e35c6d8d 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -31,6 +31,7 @@ const FIELDS: &[(&str, usize)] = &[ ("HAS_CLAR", 1), ("HAS_TEX", 1), ("HAS_DARK", 1), + ("HAS_CHROMA", 1), ("OPS_IDENTITY", 1), ("VIBRANCE", 1), ("SATURATION", 1), @@ -127,6 +128,8 @@ pub struct Present { pub clarity: bool, pub texture: bool, pub dark: bool, + /// The blurred chromaticity follows the mask planes in the `masks` buffer. + pub chroma: bool, } /// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs | @@ -170,6 +173,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.b("HAS_CLAR", present.clarity); p.b("HAS_TEX", present.texture); p.b("HAS_DARK", present.dark); + p.b("HAS_CHROMA", present.chroma); let ops = &fp.ops; p.b("OPS_IDENTITY", ops.is_identity()); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 776acc32..ff983a0b 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -44,6 +44,7 @@ struct Planes { clarity: Option<(u32, Arc)>, texture: Option<(u32, Arc)>, dark: Option<(u32, Arc, f32)>, + chroma: Option<(u32, Arc)>, } impl GpuStages { @@ -170,6 +171,20 @@ impl<'a> Cx<'a> { out } + /// `len` values of `b` from `offset` (both in 32-bit words), as a new buffer (recorded). + pub fn slice(&mut self, b: &Buf, offset: usize, len: usize) -> Buf { + let out = self.gpu.buffer(len); + let enc = self.enc.get_or_insert_with(|| self.gpu.encoder()); + enc.copy_buffer_to_buffer(b.raw(), (offset * 4) as u64, out.raw(), 0, (len * 4) as u64); + out + } + + /// Copy all of `src` into `dst` at `offset` (32-bit words; recorded). + pub fn copy_into(&mut self, src: &Buf, dst: &Buf, offset: usize) { + let enc = self.enc.get_or_insert_with(|| self.gpu.encoder()); + enc.copy_buffer_to_buffer(src.raw(), 0, dst.raw(), (offset * 4) as u64, (src.len * 4) as u64); + } + pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f { Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) } } @@ -496,7 +511,12 @@ pub fn render( // 5. per-pixel stage let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); - let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() }; + let present = Present { + clarity: prep.clarity.is_some(), + texture: prep.texture.is_some(), + dark: prep.dark.is_some(), + chroma: masks.is_some() && prep.chroma.is_some(), + }; let (p, aux) = finish_block(&fp, &terms, &present); let aux = gpu.upload(&aux); let out = gpu.buffer(n); @@ -516,13 +536,30 @@ pub fn render( ], groups2(w, h, [16, 16]), ); + // the alpha a mask overlay shows: copied out before the readback below submits + let overlay_mask = req.overlay.mask(s).map(|m| { + let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()); + match (list.map(|m| m.id).position(|id| id == m.id), masks.as_ref()) { + (Some(mi), Some(all)) => Ok(cx.slice(all, mi * n, n)), + _ => Err(m), + } + }); let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t, &mut cx); let mut image = image; - lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan); + let overlay_mask = overlay_mask.map(|r| match r { + Ok(b) => cx.read_plane(&b, w, h), + // a hidden mask: evaluate it on the CPU + Err(m) => { + let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(&lin, w, h))).clone(); + let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone(); + lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32) + } + }); + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); Some(Rendered { image, histogram, deep: None }) } @@ -590,6 +627,8 @@ struct Prep { clarity: Option>, texture: Option>, dark: Option>, + /// Blurred chromaticity (local Moiré / Noise). + chroma: Option>, air: f32, } @@ -644,14 +683,58 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla } }, }; - Prep { log_l, base, clarity, texture, dark, air } + let chroma = sig.chroma.map(|sg| { + plane_at(&mut planes.chroma, sg, || { + let ch = cx.gpu.buffer(n * 3); + map(cx, "chroma_k", n, &[], [Some(lin), None, None], &ch); + gaussian(cx, &ch, w, h, 3, sg) + }) + }); + Prep { log_l, base, clarity, texture, dark, chroma, air } } /// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush). const MAX_DABS: usize = 4096; -/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated) -/// and their adjustment terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. +/// The `shape` kernel's brush data for `dabs`: stroke records (12 words: dab offset, dab count, +/// r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto) then the dab centres. +fn brush_aux(dabs: &[&lightcraft_pipeline::masks::BrushDabs], erase: bool) -> Vec { + let mut aux = Vec::new(); + let mut off = 12 * dabs.len(); + for d in dabs { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for q in &d.dabs { + (x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r)); + } + aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, (erase && d.erase) as u8 as f32]); + aux.extend([x0, y0, x1, y1].map(|v| v as f32)); + aux.push(d.auto as u8 as f32); + off += 2 * d.dabs.len(); + } + for d in dabs { + aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32])); + } + aux +} + +/// Cross guided filter (`masks::guided_cross`) of `p` steered by `guide`, max-combined with `p`. +fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf { + let n = w * h; + let (m0, m1) = (cx.gpu.buffer(2 * n), cx.gpu.buffer(2 * n)); + map(cx, "xguided_pre", n, &[0], [Some(guide), Some(p), None], &m0); + map(cx, "xguided_pre", n, &[1], [Some(guide), Some(p), None], &m1); + let (b0, b1) = (gaussian(cx, &m0, w, h, 2, sigma), gaussian(cx, &m1, w, h, 2, sigma)); + let ab = cx.gpu.buffer(2 * n); + map(cx, "xguided_ab", n, &[eps.to_bits()], [Some(&b0), Some(&b1), None], &ab); + let ab = gaussian(cx, &ab, w, h, 2, sigma); + let q = cx.gpu.buffer(n); + map(cx, "xguided_apply", n, &[], [Some(guide), Some(&ab), Some(p)], &q); + q +} + +/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated, +/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment +/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option, Vec<[f32; MASK_TERMS]>) { use lightcraft_develop::{MaskOp, MaskShape}; let s = &*plan.settings; @@ -667,7 +750,10 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho let [a, b, c, d, e, f] = frame.out_to_norm(w, h).0; let (kx, ky) = (frame.ow / long, frame.oh / long); let aff = [a * kx, b * ky, c * kx, d * ky, e * kx, f * ky].map(|v| (v as f32).to_bits()); - let alpha = cx.zeroed(n * list.len()); + let alpha = cx.zeroed(n * list.len() + if prep.chroma.is_some() { 3 * n } else { 0 }); + if let Some(ch) = &prep.chroma { + cx.copy_into(ch, &alpha, n * list.len()); + } let comp_plane = cx.gpu.buffer(n); for (mi, m) in list.iter().enumerate() { let mut first = true; @@ -683,6 +769,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho p.extend_from_slice(&aff); let f32s = |v: &[f64]| v.iter().map(|x| (*x as f32).to_bits()).collect::>(); let mut aux: Vec = Vec::new(); + let mut own: Option = None; let kind = match &comp.shape { MaskShape::Linear { start, end } => { let (a, b) = (frame.norm_to_long(*start), frame.norm_to_long(*end)); @@ -710,29 +797,46 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } MaskShape::Brush { strokes } => { let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect(); - if dabs.iter().map(|d| d.dabs.len()).sum::() <= MAX_DABS { - p.push(dabs.len() as u32); - let mut off = 11 * dabs.len(); - for d in &dabs { - let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for q in &d.dabs { - (x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r)); - } - aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, d.erase as u8 as f32]); - aux.extend([x0, y0, x1, y1].map(|v| v as f32)); - off += 2 * d.dabs.len(); - } - for d in &dabs { - aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32])); - } - Some(4) - } else { + if dabs.iter().map(|d| d.dabs.len()).sum::() > MAX_DABS { None + } else if dabs.iter().any(|d| d.auto) { + // Auto Mask: stroke by stroke (each one refined before it's combined) + let brush = cx.zeroed(n); + let stroke = cx.gpu.buffer(n); + for d in &dabs { + let mut q = p.clone(); + q[2] = 4; + q[3] = 0; + q.push(1); + let aux = cx.gpu.upload(&brush_aux(&[d], false)); + cx.run("shape", &q, &[Some(lin), Some(&prep.log_l), Some(&aux), Some(&stroke), Some(&brush)], groups2(w, h, [16, 16])); + let refined = d.auto.then(|| { + let (sigma, eps) = lightcraft_pipeline::masks::auto_refine(d.r); + guided_cross_max(cx, &prep.log_l, &stroke, w, h, sigma, eps) + }); + let op = if d.erase { 2 } else { 1 }; + cx.run( + "combine", + &[n as u32, 0, 0, op], + &[None, None, None, Some(refined.as_ref().unwrap_or(&stroke)), Some(&brush)], + groups1(n), + ); + } + if comp.invert { + cx.run("finalize", &[n as u32, 0, 0, 1, 1f32.to_bits()], &[None, None, None, Some(&stroke), Some(&brush)], groups1(n)); + } + own = Some(brush); + None + } else { + p.push(dabs.len() as u32); + aux = brush_aux(&dabs.iter().collect::>(), true); + Some(4) } } _ => None, }; let plane = match kind { + _ if own.is_some() => own, Some(k) => { p[2] = k; let aux = (!aux.is_empty()).then(|| cx.gpu.upload(&aux)); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 71b20d5b..30c2d25c 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -1,5 +1,6 @@ // The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it). -// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes), aux +// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the +// blurred chromaticity when HAS_CHROMA), aux // (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8). fn tone_apply(y: f32) -> f32 { @@ -259,7 +260,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let l0 = l_pre + ev; // --- local (mask) contributions - var lt: array; + var lt: array; var tint_on = false; var tint_dir = vec2(0.0, 0.0); var tint_amt = 0.0; @@ -270,18 +271,42 @@ fn main(@builtin(global_invocation_id) gid: vec3) { continue; } let t = pu(F_MASK_OFF) + m * MASK_TERMS; - for (var k = 0u; k < 14u; k++) { + for (var k = 0u; k < MASK_SUMS; k++) { lt[k] += a * aux[t + k]; } - if (aux[t + 14u] > 0.0) { + if (aux[t + MASK_SUMS] > 0.0) { tint_on = true; - tint_dir = vec2(aux[t + 15u], aux[t + 16u]); - tint_amt = a * aux[t + 17u]; + tint_dir = vec2(aux[t + MASK_SUMS + 1u], aux[t + MASK_SUMS + 2u]); + tint_amt = a * aux[t + MASK_SUMS + 3u]; } } let l_exp = lt[0]; let l_temp = lt[1]; let l_tint = lt[2]; + let l_noise = lt[14]; + + // --- local Moiré (and the colour part of Noise): chromaticity towards its blur + let mt = clamp(lt[15] + 0.5 * max(l_noise, 0.0), -1.0, 1.0); + if (mt != 0.0 && pu(F_HAS_CHROMA) != 0u) { + let raw = vec3(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]); + let y = lum2020(c); + let ch0 = raw / max(lum2020(raw), 1e-6); + let o = nm * n + 3u * i; + let cb = vec3(masks[o], masks[o + 1u], masks[o + 2u]); + c = max((ch0 + (cb - ch0) * mt) * y, vec3(0.0)); + } + // --- local Defringe: desaturate purple / green fringes along edges + let df = clamp(lt[16], 0.0, 1.0); + if (df > 0.0 && pu(F_HAS_TEX) != 0u) { + let yd = max(lum2020(c), 1e-6); + let purple = (min(c.x, c.z) - c.y) / yd; + let green = (c.y - max(c.x, c.z)) / yd; + let k = df * sstep(0.04, 0.3, abs(l_pre - tex[i])) * max(sstep(0.02, 0.2, purple), sstep(0.02, 0.2, green)); + if (k > 0.0) { + let y = lum2020(c); + c = c + (y - c) * k; + } + } // --- dehaze (scene linear) let dz = pf(F_DEHAZE) + lt[10]; @@ -354,6 +379,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk; } } + // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges + if (l_noise != 0.0 && pu(F_HAS_TEX) != 0u) { + let det = l_pre - tex[i]; + delta -= clamp(l_noise, -1.0, 1.0) * 0.9 * det * (1.0 - sstep(0.1, 0.5, abs(det))); + } if (delta != 0.0) { c = c * exp2(delta); } diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index 9a54e7f6..fc8eebec 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -70,6 +70,51 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3, @builtin(num_workgr dst[i] = b[2u * i] * a[i] + b[2u * i + 1u]; } +// Cross guided filter (`masks::guided_cross`), a: guide, b: input. P[1] = 0: (guide, input); +// 1: (guide·input, guide²). +@compute @workgroup_size(256) +fn xguided_pre(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let gi = a[i]; + let p = b[i]; + if (pu(1u) == 0u) { + dst[2u * i] = gi; + dst[2u * i + 1u] = p; + } else { + dst[2u * i] = gi * p; + dst[2u * i + 1u] = gi * gi; + } +} + +// Coefficients (a, b) from blurred means (a: guide, input) and correlations (b: guide·input, +// guide²). P[1] = eps. +@compute @workgroup_size(256) +fn xguided_ab(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let mi = a[2u * i]; + let mp = a[2u * i + 1u]; + let v = max(b[2u * i + 1u] - mi * mi, 0.0); + let k = (b[2u * i] - mi * mp) / (v + pf(1u)); + dst[2u * i] = k; + dst[2u * i + 1u] = mp - k * mi; +} + +// `max(p, clamp(a·guide + b, 0, 1))` (a: guide, b: blurred coefficients, c: the input p). +@compute @workgroup_size(256) +fn xguided_apply(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + dst[i] = max(c[i], clamp(b[2u * i] * a[i] + b[2u * i + 1u], 0.0, 1.0)); +} + // White balance: 3×3 matrix (P[1] = has matrix, P[2..11] row-major), clamped at 0. @compute @workgroup_size(256) fn wb_k(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { diff --git a/crates/gpu/src/wgsl/mask.wgsl b/crates/gpu/src/wgsl/mask.wgsl index 2075e186..c2d15b64 100644 --- a/crates/gpu/src/wgsl/mask.wgsl +++ b/crates/gpu/src/wgsl/mask.wgsl @@ -13,6 +13,19 @@ fn pos(x: u32, y: u32) -> vec2 { // 2 luminance range: lo, hi, lo feather, hi feather, ev // 3 colour range: tol, gain, sample count (OkLab triples in aux) // 4 brush: stroke count (stroke records + dabs in aux) +// `masks::chromaticity` of pixel i. +fn chroma_at(i: u32) -> vec3 { + let c = vec3(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]); + return c / max(lum2020(c), 1e-6); +} + +// `masks::auto_similarity`. +fn auto_similarity(l: f32, ch: vec3, rl: f32, rch: vec3) -> f32 { + let dl = (l - rl) / AUTO_TOL_EV; + let dc = length(ch - rch) / AUTO_TOL_CHROMA; + return 1.0 - sstep(0.5, 1.0, sqrt(dl * dl + dc * dc)); +} + @compute @workgroup_size(16, 16) fn shape(@builtin(global_invocation_id) g: vec3) { let w = pu(0u); @@ -64,11 +77,14 @@ fn shape(@builtin(global_invocation_id) g: vec3) { } v = 1.0 - sstep(tol * 0.5, tol, best); } else if (kind == 4u) { - // stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1 + // stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto let px = f32(g.x) + 0.5; let py = f32(g.y) + 0.5; + let lp = log_l[i]; + let chp = chroma_at(i); for (var k = 0u; k < pu(10u); k++) { - let o = 11u * k; + let o = 12u * k; + let auto_mask = aux[o + 11u] != 0.0; if (px < aux[o + 7u] || py < aux[o + 8u] || px > aux[o + 9u] || py > aux[o + 10u]) { continue; } @@ -89,6 +105,12 @@ fn shape(@builtin(global_invocation_id) g: vec3) { if (dd > hard) { a = 1.0 - sstep(hard, r, dd); } + if (auto_mask) { + let rx = min(u32(max(floor(aux[off + 2u * j]), 0.0)), w - 1u); + let ry = min(u32(max(floor(aux[off + 2u * j + 1u]), 0.0)), h - 1u); + let ri = ry * w + rx; + a *= auto_similarity(lp, chp, log_l[ri], chroma_at(ri)); + } keep *= 1.0 - a * flow; } let sa = min(1.0 - keep, aux[o + 5u]); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 61911476..d56de054 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -275,6 +275,37 @@ fn cases() -> Vec<(&'static str, Edit)> { }, ]; }), + ("masks (local noise, moiré, defringe)", |s| { + let radial = MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.3, ry: 0.25, angle: 0.0, feather: 50.0, invert: false }; + let linear = MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.7) }; + let m = |shape, adjust| Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() }; + use lightcraft_develop::LocalAdjustments as L; + s.masks = vec![ + m(radial, L { noise: 80.0, moire: 60.0, defringe: 100.0, ..Default::default() }), + m(linear, L { noise: -60.0, moire: -40.0, ..Default::default() }), + ]; + }), + ("masks (auto mask brush)", |s| { + let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke { + points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(), + size: 0.06, + feather: 40.0, + flow: 80.0, + auto_mask, + erase, + ..Default::default() + }; + let strokes = vec![ + st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false), + st(&[(0.3, 0.2), (0.6, 0.25)], false, false), + st(&[(0.5, 0.58)], true, true), + ]; + s.masks = vec![Mask { + components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }], + adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() }, + ..Default::default() + }]; + }), ("calibration", |s| { s.calibration.shadows_tint = 40.0; s.calibration.red_hue = 50.0; @@ -414,6 +445,43 @@ fn overlays_match() { eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0); assert!(differ < 0.002, "spots t={t}: {differ}"); } + // Mask overlays: the alpha the GPU evaluated (or, for a hidden mask, the CPU's) drawn the same way. + let mut s = DevelopSettings::default(); + let stroke = BrushStroke { points: vec![Point::new(0.2, 0.3), Point::new(0.6, 0.5)], size: 0.05, ..Default::default() }; + s.masks = vec![ + Mask { + id: 1, + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false }, + }], + ..Default::default() + }, + Mask { + id: 2, + components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }], + ..Default::default() + }, + Mask { + id: 3, + visible: false, + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, + }], + ..Default::default() + }, + ]; + s.masks[0].adjust.exposure = 0.5; + use lightcraft_pipeline::{MaskView, Overlay}; + for (id, view) in + [(1, MaskView::Color), (2, MaskView::ColorOnBw), (2, MaskView::WhiteOnBlack), (3, MaskView::ImageOnWhite), (1, MaskView::ImageOnBlack)] + { + let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) }; + check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req); + } } #[test] diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 688d5b89..022448ae 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -106,12 +106,15 @@ fn grain_noise(x: f32, y: f32, seed: u32) -> f32 { } /// Number of per-mask terms in [`mask_terms`]. -pub const MASK_TERMS: usize = 18; +pub const MASK_TERMS: usize = 21; + +/// Number of alpha-weighted terms at the start of [`mask_terms`]. +pub const MASK_SUMS: usize = 17; /// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order: /// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze, -/// saturation, hue, sharpness, then the colour overlay: on (0/1), cos and sin of its OkLCh hue, and -/// its strength. +/// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and +/// sin of its OkLCh hue, and its strength. pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { let (on, cos, sin, amt) = if j.color_sat > 0.0 { let hue = crate::colorops::oklch_hue_of_srgb_hue(j.color_hue); @@ -134,6 +137,9 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { (j.saturation / 100.0) as f32, (j.hue / 100.0) as f32 * 0.6, (j.sharpness / 100.0) as f32, + (j.noise / 100.0) as f32, + (j.moire / 100.0) as f32, + (j.defringe / 100.0) as f32, on, cos, sin, @@ -331,21 +337,42 @@ pub(crate) fn finish_with( let l0 = l_pre + ev; // --- local (mask) contributions: alpha-weighted sums of the masks' terms - let mut l = [0.0f32; 14]; + let mut l = [0.0f32; MASK_SUMS]; let mut tint_col: Option<([f32; 3], f32)> = None; for (m, t) in p.masks.iter().zip(&terms) { let a = m.alpha.data[i]; if a <= 0.0 { continue; } - for k in 0..14 { + for k in 0..MASK_SUMS { l[k] += a * t[k]; } - if t[14] > 0.0 { - tint_col = Some(([t[15], t[16], 0.0], a * t[17])); + if t[MASK_SUMS] > 0.0 { + tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3])); + } + } + let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] = + l; + + // --- local Moiré (and the colour part of Noise): chromaticity towards its blur + let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); + if mt != 0.0 + && let Some(cb) = &p.chroma_blur + { + let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]); + c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0)); + } + // --- local Defringe: desaturate purple / green fringes along edges + let df = l_defringe.clamp(0.0, 1.0); + if df > 0.0 + && let Some(b) = &p.texture_blur + { + let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]); + if k > 0.0 { + let y = luminance_2020(c); + c = c.map(|v| v + (y - v) * k); } } - let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp] = l; // --- dehaze (scene linear) let dz = dehaze + l_dehaze; @@ -415,6 +442,13 @@ pub(crate) fn finish_with( delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; } } + // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges + if l_noise != 0.0 + && let Some(b) = &p.texture_blur + { + let det = l_pre - b.data[i]; + delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs())); + } if delta != 0.0 { let g = delta.exp2(); c = c.map(|v| v * g); @@ -514,6 +548,16 @@ pub(crate) fn finish_with( out } +/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from +/// its fine blur: high on strong edges with a purple or green cast. +#[inline] +pub fn defringe_weight(c: [f32; 3], det: f32) -> f32 { + let y = luminance_2020(c).max(1e-6); + let purple = (c[0].min(c[2]) - c[1]) / y; + let green = (c[1] - c[0].max(c[2])) / y; + smooth(0.04, 0.3, det.abs()) * smooth(0.02, 0.2, purple).max(smooth(0.02, 0.2, green)) +} + /// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its /// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is /// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation. diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index fe0705dd..7ffd65f1 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -39,7 +39,7 @@ pub mod upright; pub mod visualize; pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}; -pub use visualize::Overlay; +pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; use std::any::Any; @@ -119,6 +119,8 @@ pub(crate) struct Prepared { pub clarity_blur: Option>, pub texture_blur: Option>, pub dark: Option>, + /// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise. + pub chroma_blur: Option>, /// Airlight of `dark` (before exposure). pub air: f32, pub masks: Vec, @@ -398,10 +400,22 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); let mut image = image; - visualize::apply(&mut image, req.overlay, &plan); + let mask = overlay_alpha(req.overlay, &plan, &prep); + visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); Rendered { image, histogram, deep: None } } +/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a +/// fresh evaluation. +fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + let m = o.mask(&plan.settings)?; + if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { + return Some(e.alpha.clone()); + } + let ev = plan.settings.light.exposure as f32; + Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev)) +} + /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" /// (default look, keeping the crop so framing matches). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { @@ -439,3 +453,5 @@ pub(crate) fn for_rows(data: &mut [T], w: usize, f: impl Fn(usize, &mut mod tests; #[cfg(test)] mod tests_geometry; +#[cfg(test)] +mod tests_local; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 8efb4126..ae26888c 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -208,6 +208,8 @@ pub(crate) struct Planes { pub texture: Option<(u32, Arc)>, /// Dark channel and its airlight. pub dark: Option<(u32, Arc, f32)>, + /// Blurred chromaticity (local Moiré / Noise). + pub chroma: Option<(u32, Arc)>, } /// Reuse `slot` if it was computed at `sigma`, else compute and store it. @@ -232,6 +234,9 @@ pub fn dark_of(c: [f32; 3]) -> f32 { c[0].min(c[1]).min(c[2]) } +/// Radius of the local Moiré / colour-noise chromaticity blur, as a fraction of the long edge. +pub const CHROMA_SIGMA: f32 = 0.004; + /// Radii (px) of the spatial planes the settings need (`None` = not needed). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct PlaneSigmas { @@ -243,6 +248,8 @@ pub struct PlaneSigmas { pub texture: Option, /// Dehaze dark channel (Gaussian). pub dark: Option, + /// Chromaticity blur for local Moiré / colour noise (Gaussian). + pub chroma: Option, } pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas { @@ -256,10 +263,17 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS if q == Quality::Draft { sigma.min(24.0) } else { sigma } }); let clarity = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0)); - let texture = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness)) - .then(|| (0.0018 * ppl).max(0.6)); + // local Noise and Defringe read the fine detail band too + let texture = (s.effects.texture != 0.0 + || s.detail.sharpen_amount != 0.0 + || local_any(|a| a.texture) + || local_any(|a| a.sharpness) + || local_any(|a| a.noise) + || s.masks.iter().any(|m| m.adjust.defringe > 0.0)) + .then(|| (0.0018 * ppl).max(0.6)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); - PlaneSigmas { base, clarity, texture, dark } + let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); + PlaneSigmas { base, clarity, texture, dark, chroma } } /// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), @@ -267,9 +281,18 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS /// one alone scales poorly: the guided filters work on small subsampled grids). pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared { let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); - let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma } = plane_sigmas(s, px_per_long, q); + let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } = + plane_sigmas(s, px_per_long, q); - let Planes { base: sb, clarity: sc, texture: st, dark: sd, .. } = planes; + let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes; + let chroma_blur = chroma_sigma.map(|sg| match sch { + Some((k, c)) if *k == sg.to_bits() => c.clone(), + _ => { + let c = timed("chroma", || Arc::new(gaussian(&img.map(masks::chromaticity), sg))); + *sch = Some((sg.to_bits(), c.clone())); + c + } + }); let l = &log_l; let (base, (clarity_blur, (texture_blur, dark))) = par_join( || base_sigma.map(|sg| plane_at(sb, sg, || timed("base", || guided_fast(l, sg, BASE_EPS)))), @@ -302,7 +325,7 @@ pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_p }; let ev = s.light.exposure as f32; let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev)); - Prepared { img, log_l, base, clarity_blur, texture_blur, dark, air, masks, px_per_long } + Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long } } /// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel. diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 8f1796a7..f02185f8 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -11,6 +11,8 @@ use crate::for_rows; use crate::geometry::Frame; pub struct Evaluated { + /// The mask's id ([`Mask::id`]). + pub id: u32, pub alpha: Plane, pub adjust: LocalAdjustments, } @@ -21,44 +23,49 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 { t * t * (3.0 - 2.0 * t) } +/// The visible masks with components, evaluated in order. pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec { masks .iter() .filter(|m| m.visible && !m.components.is_empty()) - .map(|m| { - let mut alpha = Plane::new(w, h); - let mut first = true; - for comp in &m.components { - let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev); - if comp.invert { - c.data.iter_mut().for_each(|v| *v = 1.0 - *v); - } - if first && comp.op != MaskOp::Intersect { - alpha = c; - first = false; - continue; - } - for (a, c) in alpha.data.iter_mut().zip(&c.data) { - *a = match comp.op { - MaskOp::Add => a.max(*c), - MaskOp::Subtract => *a * (1.0 - c), - MaskOp::Intersect => *a * c, - }; - } - first = false; - } - if m.invert { - alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); - } - let amt = (m.adjust.amount / 100.0) as f32; - if (amt - 1.0).abs() > 1e-6 { - alpha.data.iter_mut().for_each(|v| *v *= amt); - } - Evaluated { alpha, adjust: m.adjust } - }) + .map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev), adjust: m.adjust }) .collect() } +/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and +/// scaled by its amount. +pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane { + let mut alpha = Plane::new(w, h); + let mut first = true; + for comp in &m.components { + let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev); + if comp.invert { + c.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + if first && comp.op != MaskOp::Intersect { + alpha = c; + first = false; + continue; + } + for (a, c) in alpha.data.iter_mut().zip(&c.data) { + *a = match comp.op { + MaskOp::Add => a.max(*c), + MaskOp::Subtract => *a * (1.0 - c), + MaskOp::Intersect => *a * c, + }; + } + first = false; + } + if m.invert { + alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + let amt = (m.adjust.amount / 100.0) as f32; + if (amt - 1.0).abs() > 1e-6 { + alpha.data.iter_mut().for_each(|v| *v *= amt); + } + alpha +} + /// Per-pixel positions in long-edge units for output pixel centres. fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(Point, usize) -> f32 + Sync + Send) { let m = frame.out_to_norm(w, h); @@ -100,7 +107,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R if inv { 1.0 - a } else { a } }); } - MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, &mut out), + MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out), MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => { let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3)); for (v, l) in out.data.iter_mut().zip(&log_l.data) { @@ -181,6 +188,8 @@ pub struct BrushDabs { pub flow: f32, pub density: f32, pub erase: bool, + /// Auto Mask: paint only pixels like the one under each dab, then snap to edges. + pub auto: bool, } /// The dabs of stroke `s` in a `w × h` output. @@ -203,15 +212,90 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa } dabs.push(q); } - BrushDabs { dabs, r, hard, flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32, density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase } + BrushDabs { + dabs, + r, + hard, + flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32, + density: (s.density / 100.0).clamp(0.0, 1.0) as f32, + erase: s.erase, + auto: s.auto_mask, + } } -/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). -fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, out: &mut Plane) { +/// Auto Mask tolerances: a pixel takes a dab's paint fully up to half of these from the colour +/// under the dab centre, none beyond them (log2 luminance, chromaticity `rgb / Y`). +pub const AUTO_TOL_EV: f32 = 0.5; +pub const AUTO_TOL_CHROMA: f32 = 0.25; + +/// Chromaticity `rgb / Y` (as colour noise reduction uses it). +#[inline] +pub fn chromaticity(c: [f32; 3]) -> [f32; 3] { + let y = lightcraft_color::luminance_2020(c).max(1e-6); + [c[0] / y, c[1] / y, c[2] / y] +} + +/// Auto Mask: how much a pixel (log luminance `l`, chromaticity `ch`) is like the reference. +#[inline] +pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 { + let dl = (l - rl) / AUTO_TOL_EV; + let dc = ((ch[0] - rch[0]).powi(2) + (ch[1] - rch[1]).powi(2) + (ch[2] - rch[2]).powi(2)).sqrt() / AUTO_TOL_CHROMA; + 1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt()) +} + +/// The output pixel a dab at `d` samples its reference colour from. +#[inline] +pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize { + let x = (d.x.floor().max(0.0) as usize).min(w - 1); + let y = (d.y.floor().max(0.0) as usize).min(h - 1); + y * w + x +} + +/// Guided-filter refinement of an Auto Mask stroke of radius `r` px: (sigma px, epsilon EV²). The +/// stroke keeps the larger of its own and the refined alpha. +pub fn auto_refine(r: f64) -> (f32, f32) { + ((0.25 * r).max(1.0) as f32, 0.02) +} + +/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the +/// guide's. +pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { + use lightcraft_raster::blur::gaussian; + let mi = gaussian(guide, sigma); + let mp = gaussian(p, sigma); + let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma); + let cii = gaussian(&guide.map(|a| a * a), sigma); + let mut a = Plane::new(p.width, p.height); + let mut b = Plane::new(p.width, p.height); + for i in 0..p.data.len() { + let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0); + let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps); + a.data[i] = k; + b.data[i] = mp.data[i] - k * mi.data[i]; + } + let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma)); + let mut q = Plane::new(p.width, p.height); + for i in 0..q.data.len() { + q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0); + } + q +} + +/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). Auto Mask strokes weight +/// each dab by [`auto_similarity`] to the pixel under its centre and are refined with +/// [`guided_cross`] on log luminance. +fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, out: &mut Plane) { + if w == 0 || h == 0 { + return; + } for s in strokes { - let BrushDabs { dabs, r, hard, flow, density: dens, erase } = brush_dabs(s, frame, w, h); + let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h); let mut stroke_alpha = Plane::new(w, h); for d in &dabs { + let refc = auto.then(|| { + let j = dab_pixel(*d, w, h); + (log_l.data[j], chromaticity(img.data[j])) + }); let (x0, x1) = (((d.x - r).floor().max(0.0)) as usize, ((d.x + r).ceil().max(0.0) as usize).min(w)); let (y0, y1) = (((d.y - r).floor().max(0.0)) as usize, ((d.y + r).ceil().max(0.0) as usize).min(h)); for y in y0..y1 { @@ -220,13 +304,23 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, o if dd > r { continue; } - let a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) }; - let v = &mut stroke_alpha.data[y * w + x]; + let mut a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) }; + let i = y * w + x; + if let Some((rl, rch)) = refc { + a *= auto_similarity(log_l.data[i], chromaticity(img.data[i]), rl, rch); + } + let v = &mut stroke_alpha.data[i]; // flow accumulates within a stroke up to density *v = (*v + a * flow * (1.0 - *v)).min(dens); } } } + if auto { + let (sigma, eps) = auto_refine(r); + let q = guided_cross(log_l, &stroke_alpha, sigma, eps); + // the refinement fills gaps along edges; it never takes paint away + stroke_alpha = stroke_alpha.zip_map(&q, f32::max); + } for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) { *o = if erase { *o * (1.0 - a) } else { o.max(*a) }; } @@ -282,4 +376,35 @@ mod tests { assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); assert!(a.get(40, 5) < 0.01); } + + #[test] + fn auto_mask_stops_at_edges() { + // dark left half, bright right half; a stroke along the boundary, centred on the dark side + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); + let l = img.map(crate::local::log_lum); + let stroke = |auto_mask| BrushStroke { + points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], + size: 0.08, + feather: 0.0, + auto_mask, + ..Default::default() + }; + let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; + let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0); + let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0); + // without Auto Mask the brush spills over the edge; with it, it stays on the dark side + assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); + assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); + assert!(auto.get(110, 50) < 0.02); + assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); + assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); + // on a flat area Auto Mask paints like the plain brush + let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); + let fl = flat.map(crate::local::log_lum); + let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0); + let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0); + assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); + } } diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 3e79f67c..f9edff72 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -54,20 +54,56 @@ fn score(img: &Rgb32f, target: (f32, f32), source: (f32, f32), r: f32) -> f32 { s } -/// Pick a source offset (in output pixels) for a remove/heal spot. -pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) { - let mut best = (f32::MAX, (r * 2.5, 0.0)); +/// Candidate source offsets (output pixels) for a spot of radius `r` at `target`, best first +/// (candidates reaching outside the image are left out). +pub fn ranked_sources(img: &Rgb32f, target: (f32, f32), r: f32) -> Vec<(f32, f32)> { + let mut c: Vec<(f32, (f32, f32))> = Vec::new(); for ring in [2.4f32, 3.2, 4.2] { for i in 0..16 { let a = i as f32 / 16.0 * std::f32::consts::TAU; let d = (a.cos() * r * ring, a.sin() * r * ring); let s = score(img, target, (target.0 + d.0, target.1 + d.1), r); - if s < best.0 { - best = (s, d); + if s < f32::MAX { + c.push((s, d)); } } } - best.1 + c.sort_by(|a, b| a.0.total_cmp(&b.0)); + c.into_iter().map(|(_, d)| d).collect() +} + +/// Pick a source offset (in output pixels) for a remove/heal spot. +pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) { + ranked_sources(img, target, r).first().copied().unwrap_or((r * 2.5, 0.0)) +} + +/// A source offset (normalized, as [`Spot::source_offset`]) for `spot` on `src` developed with +/// `s`: the best match, or with `avoid` (the current offset) the best one at least a spot radius +/// away from it ("refresh source"). `None` when no candidate fits in the image. +pub fn pick_source( + src: &Rgb32f, + info: &crate::SourceInfo, + s: &lightcraft_develop::DevelopSettings, + spot: &Spot, + avoid: Option, +) -> Option { + let frame = crate::frame_for(src, info, s, true); + let (w, h) = frame.fit(512, 512); + if w == 0 || h == 0 { + return None; + } + let img = frame.sample(src, w, h); + let (to_out, to_norm) = (frame.norm_to_out(w, h), frame.out_to_norm(w, h)); + let t = to_out.apply(*spot.points.first()?); + let r = (spot.size * frame.px_per_long(w)).max(1.0) as f32; + let avoid = avoid.map(|o| { + let (a, b) = (to_out.apply(Point::new(0.0, 0.0)), to_out.apply(o)); + ((b.x - a.x) as f32, (b.y - a.y) as f32) + }); + let far = |d: &(f32, f32)| avoid.is_none_or(|a| ((d.0 - a.0).powi(2) + (d.1 - a.1).powi(2)).sqrt() > r); + let d = ranked_sources(&img, (t.x as f32, t.y as f32), r).into_iter().find(far)?; + let (n0, n1) = (to_norm.apply(t), to_norm.apply(Point::new(t.x + d.0 as f64, t.y + d.1 as f64))); + Some(Point::new(n1.x - n0.x, n1.y - n0.y)) } /// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit. diff --git a/crates/pipeline/src/tests_local.rs b/crates/pipeline/src/tests_local.rs new file mode 100644 index 00000000..3ccf961b --- /dev/null +++ b/crates/pipeline/src/tests_local.rs @@ -0,0 +1,109 @@ +//! Pixel tests of the local (mask) Noise, Moiré and Defringe sliders. + +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; +use lightcraft_geom::Point; +use lightcraft_raster::{Rgb32f, Rgba8}; + +use crate::{RenderRequest, SourceInfo, render}; + +const W: usize = 240; +const H: usize = 120; + +/// A deterministic hash in −1..1. +fn noise(x: usize, y: usize, k: u32) -> f32 { + let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ k.wrapping_mul(0xcb1a_b31f); + v ^= v >> 13; + v = v.wrapping_mul(0x5bd1_e995); + v ^= v >> 15; + (v & 0xffff) as f32 / 32768.0 - 1.0 +} + +/// Settings with one mask covering the left half (x < 0.45), with `adjust`. +fn left_mask(adjust: LocalAdjustments) -> DevelopSettings { + let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.46, 0.5) }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { id: 1, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() }); + s +} + +fn shot(src: &Rgb32f, s: &DevelopSettings) -> Rgba8 { + render(src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image +} + +/// Standard deviation of `f(pixel)` over columns `x0..x1` (rows 4..H-4). +fn spread(img: &Rgba8, x0: usize, x1: usize, f: impl Fn([u8; 4]) -> f32) -> f32 { + let v: Vec = (4..H - 4).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f(img.data[y * W + x])).collect(); + let m = v.iter().sum::() / v.len() as f32; + (v.iter().map(|a| (a - m).powi(2)).sum::() / v.len() as f32).sqrt() +} + +fn luma(p: [u8; 4]) -> f32 { + 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 +} + +fn chroma(p: [u8; 4]) -> f32 { + p[0] as f32 - p[2] as f32 +} + +#[test] +fn local_noise_smooths_luminance_noise_inside_the_mask() { + let src = Rgb32f::from_fn(W, H, |x, y| [0.18 * (1.0 + 0.12 * noise(x, y, 1)); 3]); + let plain = shot(&src, &DevelopSettings::default()); + let base = spread(&plain, 10, 90, luma); + let on = shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() })); + let off = shot(&src, &left_mask(LocalAdjustments { noise: -100.0, ..Default::default() })); + let (smoothed, boosted) = (spread(&on, 10, 90, luma), spread(&off, 10, 90, luma)); + assert!(smoothed < 0.6 * base, "noise +100: {smoothed} vs {base}"); + assert!(boosted > 1.2 * base, "noise −100: {boosted} vs {base}"); + // outside the mask nothing changes + assert_eq!(spread(&on, 150, 230, luma), spread(&plain, 150, 230, luma)); + // the mean brightness is kept + let mean = |img: &Rgba8| (4..H - 4).flat_map(|y| (10..90).map(move |x| luma(img.data[y * W + x]))).sum::() / (80 * (H - 8)) as f32; + assert!((mean(&on) - mean(&plain)).abs() < 2.0); +} + +#[test] +fn local_moire_removes_colour_speckle_inside_the_mask() { + let src = Rgb32f::from_fn(W, H, |x, y| { + let n = 0.25 * noise(x, y, 2); + [0.18 * (1.0 + n), 0.18, 0.18 * (1.0 - n)] + }); + let plain = shot(&src, &DevelopSettings::default()); + let base = spread(&plain, 10, 90, chroma); + let on = shot(&src, &left_mask(LocalAdjustments { moire: 100.0, ..Default::default() })); + let reduced = spread(&on, 10, 90, chroma); + assert!(reduced < 0.4 * base, "moiré +100: {reduced} vs {base}"); + assert_eq!(spread(&on, 150, 230, chroma), spread(&plain, 150, 230, chroma)); + // luminance is kept + assert!((spread(&on, 10, 90, luma) - spread(&plain, 10, 90, luma)).abs() < 1.0); + // positive Noise also calms colour noise (less than Moiré) + let n = spread(&shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() })), 10, 90, chroma); + assert!(n < 0.8 * base && n > reduced, "{n}"); +} + +#[test] +fn local_defringe_desaturates_purple_edges_only() { + // dark | bright edge at x = 40 (inside the mask) and x = 200 (outside), each with a purple + // fringe column; a flat purple patch (no edge) at x 60..80 + let src = Rgb32f::from_fn(W, H, |x, _| { + let fringe = x == 39 || x == 40 || x == 199 || x == 200; + if fringe { + [0.25, 0.04, 0.3] + } else if (60..80).contains(&x) { + [0.2, 0.08, 0.22] + } else if (40..120).contains(&x) || x >= 200 { + [0.7; 3] + } else { + [0.02; 3] + } + }); + let s = left_mask(LocalAdjustments { defringe: 100.0, ..Default::default() }); + let (plain, on) = (shot(&src, &DevelopSettings::default()), shot(&src, &s)); + let sat = |p: [u8; 4]| p[0].max(p[2]) as f32 - p[1] as f32; + let at = |img: &Rgba8, x: usize| img.data[60 * W + x]; + assert!(sat(at(&plain, 40)) > 40.0, "a purple fringe to start with: {:?}", at(&plain, 40)); + assert!(sat(at(&on, 40)) < 0.5 * sat(at(&plain, 40)), "fringe in the mask: {:?} vs {:?}", at(&on, 40), at(&plain, 40)); + assert_eq!(at(&on, 200), at(&plain, 200), "outside the mask"); + let flat = |img: &Rgba8| sat(at(img, 70)); + assert!((flat(&on) - flat(&plain)).abs() <= 2.0, "flat purple kept: {} vs {}", flat(&on), flat(&plain)); +} diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index e8e47e06..6b85113e 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -6,6 +6,9 @@ //! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white, //! so dust spots and specks stand out as white dots. The scale is relative to the image (the //! same spots show at any preview size); the threshold slider (0..100) raises sensitivity. +//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour +//! tint on a black-and-white image, the image on black / white, or the alpha as white on black +//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`]. use std::borrow::Cow; @@ -13,7 +16,7 @@ use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; use lightcraft_color::transfer::srgb_to_linear; use lightcraft_color::{REC2020, SRGB}; use lightcraft_develop::DevelopSettings; -use lightcraft_raster::Rgba8; +use lightcraft_raster::{Plane, Rgba8}; use crate::colorops::PointK; use crate::{Plan, for_rows}; @@ -27,23 +30,99 @@ pub enum Overlay { PointColorRange(u8), /// Visualize Spots with a threshold 0..100 (higher = more sensitive). Spots(u8), + /// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in + /// `color` at `opacity` (0..100; the colour views only). + Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 }, +} + +/// How [`Overlay::Mask`] draws the mask. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MaskView { + /// The photo with the mask tinted in the overlay colour. + #[default] + Color, + /// A black-and-white photo with the mask tinted in the overlay colour. + ColorOnBw, + /// The photo where the mask is, black elsewhere. + ImageOnBlack, + /// The photo where the mask is, white elsewhere. + ImageOnWhite, + /// The mask itself: white on black. + WhiteOnBlack, +} + +impl MaskView { + pub const ALL: [MaskView; 5] = [MaskView::Color, MaskView::ColorOnBw, MaskView::ImageOnBlack, MaskView::ImageOnWhite, MaskView::WhiteOnBlack]; + + /// Stable name (control channel, settings). + pub fn name(self) -> &'static str { + match self { + MaskView::Color => "color", + MaskView::ColorOnBw => "colorOnBw", + MaskView::ImageOnBlack => "imageOnBlack", + MaskView::ImageOnWhite => "imageOnWhite", + MaskView::WhiteOnBlack => "whiteOnBlack", + } + } + + pub fn label(self) -> &'static str { + match self { + MaskView::Color => "Color Overlay", + MaskView::ColorOnBw => "Color Overlay on B&W", + MaskView::ImageOnBlack => "Image on Black", + MaskView::ImageOnWhite => "Image on White", + MaskView::WhiteOnBlack => "White on Black", + } + } + + pub fn parse(s: &str) -> Option { + MaskView::ALL.into_iter().find(|v| v.name() == s) + } + + /// The next view (Shift+O cycles them). + pub fn next(self) -> MaskView { + let i = MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0); + MaskView::ALL[(i + 1) % MaskView::ALL.len()] + } + + fn index(self) -> u64 { + MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0) as u64 + } +} + +/// [`Overlay::Mask`]'s fields in 51 bits (exact in an `f64`): id 16, view 4, colour 24, opacity 7. +fn pack_mask(id: u16, view: MaskView, color: [u8; 3], opacity: u8) -> u64 { + let rgb = (color[0] as u64) << 16 | (color[1] as u64) << 8 | color[2] as u64; + id as u64 | view.index() << 16 | rgb << 20 | (opacity.min(100) as u64) << 44 +} + +fn unpack_mask(v: u64) -> Overlay { + let rgb = (v >> 20) & 0xff_ffff; + Overlay::Mask { + id: (v & 0xffff) as u16, + view: MaskView::ALL.get(((v >> 16) & 0xf) as usize).copied().unwrap_or_default(), + color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8], + opacity: ((v >> 44) & 0x7f).min(100) as u8, + } } impl Overlay { /// A plain (kind, value) pair, e.g. for sending a request to a web worker. - pub fn to_parts(self) -> (u8, f32) { + pub fn to_parts(self) -> (u8, f64) { match self { Overlay::None => (0, 0.0), - Overlay::PointColorRange(i) => (1, i as f32), - Overlay::Spots(t) => (2, t as f32), + Overlay::PointColorRange(i) => (1, i as f64), + Overlay::Spots(t) => (2, t as f64), + Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), } } /// Inverse of [`Overlay::to_parts`] (unknown kinds: no overlay). - pub fn from_parts(kind: u8, v: f32) -> Overlay { + pub fn from_parts(kind: u8, v: f64) -> Overlay { match kind { 1 => Overlay::PointColorRange(v as u8), 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), + 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), _ => Overlay::None, } } @@ -54,6 +133,15 @@ impl Overlay { Overlay::None => 0, Overlay::PointColorRange(i) => 0x1000 + i as u64, Overlay::Spots(t) => 0x2000 + t as u64, + Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), + } + } + + /// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components). + pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> { + match self { + Overlay::Mask { id, .. } => s.masks.iter().find(|m| m.id == id as u32 && !m.components.is_empty()), + _ => None, } } } @@ -70,8 +158,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } } -/// Draw overlay `o` over `img` (rendered with `plan`). -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { +/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask +/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { match o { Overlay::None => {} Overlay::PointColorRange(i) => { @@ -80,9 +169,37 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { } } Overlay::Spots(t) => spots(img, t, plan.px_per_long), + Overlay::Mask { view, color, opacity, .. } => { + if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + mask_view(img, a, view, color, opacity); + } + } } } +/// Draw mask alpha `a` over `img` as `view`. +pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) { + let op = opacity.min(100) as f32 / 100.0; + let col = color.map(|c| c as f32); + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let a = a.data[y * w + x].clamp(0.0, 1.0); + let p = [px[0] as f32, px[1] as f32, px[2] as f32]; + let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let out: [f32; 3] = match view { + MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op), + MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op), + MaskView::ImageOnBlack => p.map(|v| v * a), + MaskView::ImageOnWhite => p.map(|v| v * a + 255.0 * (1.0 - a)), + MaskView::WhiteOnBlack => [255.0 * a; 3], + }; + let o = out.map(|v| v.round().clamp(0.0, 255.0) as u8); + *px = [o[0], o[1], o[2], 255]; + } + }); +} + /// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. const SPOT_SIGMA: f64 = 0.006; @@ -184,4 +301,45 @@ mod tests { let e = render(&src, &info, &s, &RenderRequest::fit(32, 16)).image.data[8 * 32 + 4]; assert!(e[1].abs_diff(px[1]) > 10, "{e:?} vs {px:?}"); } + + #[test] + fn mask_overlay_shows_the_evaluated_alpha() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = Rgb32f::from_fn(80, 40, |x, _| if x < 40 { [0.18, 0.18, 0.18] } else { [0.05, 0.1, 0.3] }); + let info = SourceInfo::default(); + let shape = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { id: 7, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], ..Default::default() }); + let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; + let shot = |s: &DevelopSettings, view: MaskView| { + let o = Overlay::Mask { id: 7, view, color: [255, 0, 0], opacity: 100 }; + render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image + }; + let (inside, outside) = (20 * 80 + 20, 20 * 80 + 70); + let c = shot(&s, MaskView::Color); + assert_eq!(c.data[inside], [255, 0, 0, 255], "full tint inside the mask"); + assert_eq!(c.data[outside], plain.data[outside], "untouched outside"); + let bw = shot(&s, MaskView::WhiteOnBlack); + assert_eq!((bw.data[inside], bw.data[outside]), ([255; 4], [0, 0, 0, 255])); + let ib = shot(&s, MaskView::ImageOnBlack); + assert_eq!((ib.data[inside], ib.data[outside]), (plain.data[inside], [0, 0, 0, 255])); + let iw = shot(&s, MaskView::ImageOnWhite); + assert_eq!(iw.data[outside], [255; 4]); + let g = shot(&s, MaskView::ColorOnBw).data[outside]; + assert!(g[0] == g[1] && g[1] == g[2], "grey outside: {g:?}"); + // a hidden mask still shows (it's the one being edited) + s.masks[0].visible = false; + assert_eq!(shot(&s, MaskView::WhiteOnBlack).data[inside], [255; 4]); + // an unknown mask draws nothing + let o = Overlay::Mask { id: 9, view: MaskView::WhiteOnBlack, color: [0; 3], opacity: 50 }; + assert_eq!(render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image, plain); + // parts round trip (web worker requests) and distinct cache keys + let o = Overlay::Mask { id: 513, view: MaskView::ImageOnWhite, color: [12, 200, 255], opacity: 65 }; + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::Mask { id: 513, view: MaskView::Color, color: [12, 200, 255], opacity: 65 }.key()); + assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color); + assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f08da703..4ea36eb7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -112,6 +112,8 @@ fn key_from(name: &str) -> Option { "tab" => Some(egui::Key::Tab), "\\" | "backslash" => Some(egui::Key::Backslash), "/" | "slash" => Some(egui::Key::Slash), + "[" => Some(egui::Key::OpenBracket), + "]" => Some(egui::Key::CloseBracket), _ => None, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3718fe82..c857545a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,9 @@ pub mod state; pub mod theme; pub mod widgets; +#[cfg(test)] +mod tests_masking; + use std::sync::mpsc::{Receiver, Sender}; use lightcraft_engine::Session; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 065cbb52..a0823502 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -116,6 +116,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "view.clipping", "view.maskOverlay", + "view.maskOverlayMode", + "view.maskPins", "view.cropOverlay", "---", "@Sort", @@ -216,6 +218,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), "view.maskOverlay" => Some(u.mask_overlay), + "view.maskPins" => Some(u.mask_pins), "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 820a838c..21488525 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -34,6 +34,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.clipping", "Show Clipping", Some("J"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) + ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), + ("view.maskOverlayColor", "Mask Overlay Color", None, ""), + ("view.maskPins", "Show Mask Pins", None, "View"), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.back", "Back to Grid", Some("Escape"), ""), @@ -64,6 +68,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), ("tool.none", "No Tool", None, ""), + // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) + ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), + ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), + ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), + ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), @@ -103,6 +112,45 @@ fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str let _ = app.session.end_interaction(); } +/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's +/// (and its selected spot's) or the Masking brush's. +fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { + if app.ui.right == RightPanel::Remove { + app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); + app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); + if app.session.active_spot.is_some() { + let mut p = json!({}); + if k != 1.0 { + p["size"] = json!(app.ui.remove_size); + } + if df != 0.0 { + p["feather"] = json!(app.ui.remove_feather); + } + let _ = app.run("spot.update", p); + } + json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) + } else { + app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); + app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) + } +} + +/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). +pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { + if let Some(s) = v.as_str() { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 { + return None; + } + let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); + return Some([c(0)?, c(2)?, c(4)?]); + } + let a = v.as_array()?; + let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); + Some([c(0)?, c(1)?, c(2)?]) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { let ctx = egui::Context::default(); @@ -252,8 +300,52 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.mask_overlay = !app.ui.mask_overlay; - Ok(Value::Null) + app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); + Ok(json!({"maskOverlay": app.ui.mask_overlay})) + } + "view.maskOverlayMode" => { + use lightcraft_engine::pipeline::MaskView; + let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + let next = match p.get("mode").and_then(Value::as_str) { + Some(m) => match MaskView::parse(m) { + Some(v) => v, + None => { + let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); + return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); + } + }, + None => cur.next(), + }; + app.ui.mask_overlay_mode = next.name().into(); + app.ui.mask_overlay = true; + app.toast(&ctx, next.label()); + Ok(json!({"mode": next.name()})) + } + "view.maskOverlayColor" => { + if let Some(c) = p.get("color") { + match parse_rgb(c) { + Some(rgb) => app.ui.mask_overlay_color = rgb, + None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), + } + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; + } + let [r, g, b] = app.ui.mask_overlay_color; + Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) + } + "brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => { + let (k, df) = match id { + "brush.smaller" => (1.0 / 1.2, 0.0), + "brush.larger" => (1.2, 0.0), + "brush.featherLess" => (1.0, -10.0), + _ => (1.0, 10.0), + }; + Ok(adjust_brush(app, k, df)) + } + "view.maskPins" => { + app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); + Ok(json!({"maskPins": app.ui.mask_pins})) } "view.visualizeSpots" => { // like Lightroom's A: opens the Remove tool with the view on, or toggles it there diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 03d3c38d..420482ad 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -23,6 +23,11 @@ pub enum Gesture { Spot { points: Vec, }, + /// Remove tool: dragging spot `spot`'s target (or its source). + SpotMove { + spot: usize, + source: bool, + }, CropHandle { handle: u8, start: lightcraft_geom::Rect, @@ -40,8 +45,11 @@ pub enum Gesture { start: (f32, f32), at: Pos2, }, + /// Dragging component `comp` of `mask`: its pin (`handle` 0) or a linear gradient's start (1) + /// or end (2). MaskHandle { mask: u32, + comp: usize, handle: u8, }, /// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates). @@ -440,12 +448,25 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon } } -/// 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; +/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range, +/// Visualize Spots). +pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { + use lightcraft_pipeline::{MaskView, Overlay}; if app.ui.fullscreen { return Overlay::None; } + if app.ui.right == RightPanel::Masking + && app.ui.mask_overlay + && let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)) + && !m.components.is_empty() + { + return Overlay::Mask { + id: m.id.min(u16::MAX as u32) as u16, + view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), + color: app.ui.mask_overlay_color, + opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8, + }; + } if app.ui.right == RightPanel::Remove && app.ui.visualize_spots { return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8); } @@ -724,26 +745,55 @@ fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { // ------------------------------------------------------------------------ masks +/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. +pub(crate) fn component_pin(shape: &MaskShape) -> Option { + match shape { + MaskShape::Radial { center, .. } => Some(*center), + MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), + MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), + _ => None, + } +} + +/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. +fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape { + let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); + match shape.clone() { + MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + MaskShape::Linear { start, end } => match handle { + 1 => MaskShape::Linear { start: at, end }, + 2 => MaskShape::Linear { start, end: at }, + _ => MaskShape::Linear { start: mv(start), end: mv(end) }, + }, + MaskShape::Brush { mut strokes } => { + for st in &mut strokes { + st.points.iter_mut().for_each(|p| *p = mv(*p)); + } + MaskShape::Brush { strokes } + } + s => s, + } +} + +/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask +/// component (click selects its mask, drag moves the component), and brush painting. The mask +/// itself shows as a rendered overlay ([`view_overlay`]). fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90); + let tint = { + let [r, g, b] = app.ui.mask_overlay_color; + Color32::from_rgba_unmultiplied(r, g, b, 110) + }; let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); let p = &ui.painter_at(clip); + // (mask, component, handle, screen position) of everything that can be grabbed + let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); for m in &d.masks { let sel = Some(m.id) == active; - for c in &m.components { + for (ci, c) in m.components.iter().enumerate() { match &c.shape { - MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => { - for s in strokes { - let r = (s.size * long) as f32; - for q in &s.points { - p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red }); - } - } - } - MaskShape::Radial { center, rx, ry, angle, .. } => { - let c0 = map.screen(*center); + MaskShape::Radial { center, rx, ry, angle, .. } if sel => { let pts: Vec = (0..64) .map(|i| { let a = i as f64 / 64.0 * std::f64::consts::TAU; @@ -753,35 +803,31 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) }) .collect(); - if sel && app.ui.mask_overlay { - p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE)); - } - p.add(egui::Shape::closed_line( - pts, - Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 })), - )); - pin(p, c0, sel); - if sel { - register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0))); - } + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230)))); } - MaskShape::Linear { start, end } => { + MaskShape::Linear { start, end } if sel => { let (a, b) = (map.screen(*start), map.screen(*end)); let dir = (b - a).normalized(); let perp = vec2(-dir.y, dir.x) * 3000.0; for (q, alpha) in [(a, 230), (b, 110)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 }))); + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); } - pin(p, a + (b - a) * 0.5, sel); - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - } + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, *q)); } } _ => {} } + if app.ui.mask_pins + && let Some(at) = component_pin(&c.shape) + { + let q = map.screen(at); + pin(p, q, sel); + register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 0, q)); + } } } // brush tool @@ -801,9 +847,10 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), } } + // the stroke being painted (the render shows it once it's committed) if let Some(Gesture::Brush { points }) = &app.gesture { for q in points { - p.circle_filled(map.screen(*q), r, red); + p.circle_filled(map.screen(*q), r, tint); } } if (resp.drag_stopped() || resp.clicked()) @@ -812,57 +859,53 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); let _ = app.run( "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}), + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": app.ui.brush_erase, "autoMask": app.ui.brush_auto_mask}), ); } return; } - // drag pins/handles of the active mask - let Some(mid) = active else { return }; - let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return }; - let Some(comp) = m.components.first() else { return }; - if resp.drag_started() + // the grip under the pointer: the selected mask's first, then the nearest + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.3.distance(q) < 10.0) + .min_by(|a, b| { + (Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + if resp.clicked() && let Some(q) = resp.interact_pointer_pos() + && let Some((mid, ..)) = hit(q) + && Some(mid) != active { - let handle = match &comp.shape { - MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0), - MaskShape::Linear { start, end } => { - if map.screen(*start).distance(q) < 14.0 { - Some(1) - } else if map.screen(*end).distance(q) < 14.0 { - Some(2) - } else { - Some(0) - } + let _ = app.run("mask.select", json!({"id": mid})); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { + // anywhere on the photo drags the selected linear gradient + let m = d.masks.iter().find(|m| Some(m.id) == active)?; + matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) + }); + if let Some((mask, comp, handle)) = grip { + if Some(mask) != active { + let _ = app.run("mask.select", json!({"id": mask})); } - _ => None, - }; - if let Some(h) = handle { let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h }); + app.gesture = Some(Gesture::MaskHandle { mask, comp, handle }); } } if resp.dragged() - && let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + && let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + && let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape) { let n = map.norm(q); - let dn = { - let q0 = q - resp.drag_delta(); - let n0 = map.norm(q0); - Point::new(n.x - n0.x, n.y - n0.y) - }; - let new_shape = match comp.shape.clone() { - MaskShape::Radial { center, rx, ry, angle, feather, invert } => { - MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert } - } - MaskShape::Linear { start, end } => match handle { - 1 => MaskShape::Linear { start: n, end }, - 2 => MaskShape::Linear { start, end: n }, - _ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) }, - }, - s => s, - }; - let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape})); + let n0 = map.norm(q - resp.drag_delta()); + let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n); + let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); } if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { app.gesture = None; @@ -887,19 +930,89 @@ fn pin(p: &egui::Painter, c: Pos2, sel: bool) { // ------------------------------------------------------------------------ remove +/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), +/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { let long = (map.rect.width().max(map.rect.height())) as f64; - let p = ui.painter(); - for s in &d.spots { + let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); + let active = app.session.active_spot.filter(|i| *i < d.spots.len()); + // (spot, is source, screen centre, radius) of everything that can be grabbed + let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); + for (i, s) in d.spots.iter().enumerate() { let r = (s.size * long) as f32; + let sel = Some(i) == active; + let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); for q in &s.points { - p.circle_stroke(map.screen(*q), r, Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); } + let Some(&t) = s.points.first() else { continue }; + let tq = map.screen(t); + if let Some(o) = s.source_offset.filter(|_| sel) { + let sq = map.screen(Point::new(t.x + o.x, t.y + o.y)); + for q in &s.points { + p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170))); + } + let dir = (tq - sq).normalized(); + p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200))); + register(ui.ctx(), format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); + grips.push((i, true, sq, r)); + } + pin(&p, tq, sel); + register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); + grips.push((i, false, tq, r)); } let r = (app.ui.remove_size as f64 * long) as f32; + // a target or source under `q`: the selected spot's first, then the nearest pin + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.2.distance(q) < g.3.max(8.0)) + .min_by(|a, b| { + (Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + let hover = resp.hover_pos().and_then(hit); if let Some(h) = resp.hover_pos() { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + if hover.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } else { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110))); + } + } + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some((spot, source, ..)) = press.and_then(hit) + { + if Some(spot) != active { + let _ = app.run("spot.select", json!({"index": spot})); + } + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + app.gesture = Some(Gesture::SpotMove { spot, source }); + } + if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() { + if resp.dragged() + && let Some(q) = resp.interact_pointer_pos() + { + let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta())); + let dn = [n.x - n0.x, n.y - n0.y]; + if dn != [0.0, 0.0] { + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn})); + } + } + if resp.drag_stopped() { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } + return; + } + if resp.clicked() + && let Some((spot, ..)) = press.and_then(hit) + { + let _ = app.run("spot.select", json!({"index": spot})); + return; } if let Some(q) = resp.interact_pointer_pos() && (resp.drag_started() || resp.dragged() || resp.clicked()) @@ -924,7 +1037,10 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo _ => "remove", }; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - let _ = app.run("spot.add", json!({"mode": mode, "points": pts, "size": app.ui.remove_size})); + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); } } diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index eea553e8..2aad39ff 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -33,6 +33,8 @@ pub const LOCAL: &[ControlSpec] = &[ spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }), spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered), spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered), + spec("moire", "Moiré", -100.0, 100.0, 1.0, 0, Track::Centered), + spec("defringe", "Defringe", -100.0, 100.0, 1.0, 0, Track::Centered), ]; pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 { @@ -142,6 +144,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("mask.select", json!({"id": m.id})); } } + if !d.masks.is_empty() { + ui.add_space(6.0); + overlay_options(app, ui); + } }); let Some(mid) = app.session.active_mask else { return }; let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return }; @@ -202,6 +208,62 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = Stroke::NONE; } +/// Overlay colours offered as swatches (the colour of the selected one is used for the tint). +const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]]; + +/// How the selected mask is shown: overlay mode, colour, opacity, pins. +fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use lightcraft_pipeline::MaskView; + let t = Tokens::get(ui.ctx()); + let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Overlay").color(t.text_dim)); + let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label); + egui::Popup::menu(&r).show(|ui| { + for v in MaskView::ALL { + if ui.selectable_label(v == view, v.label()).clicked() { + let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()})); + } + } + }); + }); + let colored = matches!(view, MaskView::Color | MaskView::ColorOnBw); + if colored { + ui.horizontal(|ui| { + for c in OVERLAY_COLORS { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); + ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2])); + if app.ui.mask_overlay_color == c { + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + } + if resp.clicked() { + let _ = app.run("view.maskOverlayColor", json!({"color": c})); + } + } + }); + } + let spec = ControlSpec { + id: "ui.maskOverlayOpacity", + label: "Opacity", + section: Section::Light, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let out = slider(ui, &spec, app.ui.mask_overlay_opacity as f64, colored && app.ui.mask_overlay, None); + if let Some(v) = out.value { + app.ui.mask_overlay_opacity = v as f32; + } + let mut pins = app.ui.mask_pins; + if ui.checkbox(&mut pins, "Show Pins").changed() { + let _ = app.run("view.maskPins", json!({"show": pins})); + } +} + fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { for (kind, label) in [ ("brush", "Brush"), @@ -234,6 +296,10 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.brush_erase = true; } }); + let mut auto = app.ui.brush_auto_mask; + if ui.checkbox(&mut auto, "Auto Mask").on_hover_text("Paint only areas like the one under the brush").changed() { + app.ui.brush_auto_mask = auto; + } }); for (id, label, min, max, get) in [ ("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64), diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 7bb3c3e2..6231c573 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -214,20 +214,59 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(4.0); ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim)); }); - let spec = lightcraft_develop::ControlSpec { - id: "ui.removeSize", - label: "Size", + // brush settings; with a spot selected they edit that spot too + let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone()))); + if let Some((i, sp)) = &sel { + (app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32); + divider(ui); + let mode = match sp.mode { + lightcraft_develop::SpotMode::Heal => "Heal", + lightcraft_develop::SpotMode::Clone => "Clone", + lightcraft_develop::SpotMode::Remove => "Remove", + }; + super::edit::sub_title(ui, &format!("{mode} spot {} of {}", i + 1, d.spots.len())); + } + let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { + id, + label, section: lightcraft_develop::Section::Detail, - min: 1.0, - max: 100.0, - default: 20.0, + min, + max, + default, step: 1.0, decimals: 0, track: lightcraft_develop::Track::Plain, }; - let out = slider(ui, &spec, (app.ui.remove_size * 1000.0) as f64, true, None); - if let Some(v) = out.value { - app.ui.remove_size = (v / 1000.0) as f32; + let sliders = [ + (plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64), + (plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64), + (plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64), + ]; + for (spec, key, v) in sliders { + let out = slider(ui, &spec, v, true, None); + let scale = if key == "size" { 1000.0 } else { 1.0 }; + if let Some(v) = out.value { + match key { + "size" => app.ui.remove_size = (v / scale) as f32, + "feather" => app.ui.remove_feather = v as f32, + _ => app.ui.remove_opacity = v as f32, + } + } + if sel.is_some() { + super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale}))); + } + } + if sel.is_some() { + padded(ui, |ui| { + ui.horizontal(|ui| { + if text_button(ui, "spotRefresh", "Refresh Source (/)", false).clicked() { + let _ = app.run("spot.refreshSource", json!({})); + } + if text_button(ui, "spotDelete", "Delete (⌫)", false).clicked() { + let _ = app.run("spot.delete", json!({})); + } + }); + }); } // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out padded(ui, |ui| { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 83a2bf78..be33a30c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -15,6 +15,9 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("Shift+M", "version.create", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), + // Shift+[ / Shift+] arrive as { / } on most layouts + ("Shift+{", "brush.featherLess", "{}"), + ("Shift+}", "brush.featherMore", "{}"), ]; pub fn parse(s: &str) -> Option<(Modifiers, Key)> { @@ -173,7 +176,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } continue; } - R::Remove | R::RedEye => continue, + R::Remove => { + if app.session.active_spot.is_some() { + let _ = app.run("spot.delete", json!({})); + } + continue; + } + R::RedEye => continue, _ => {} } if crate::menus::confirm_delete(app) { @@ -188,6 +197,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { continue; } + // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) + if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() { + let _ = app.run("spot.refreshSource", json!({})); + continue; + } + // Shift+O cycles the mask overlay mode while masking (the crop overlay elsewhere) + if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking { + let _ = app.run("view.maskOverlayMode", json!({})); + continue; + } let _ = app.run(&f, json!({})); match f.as_str() { "photo.pick" => app.toast(ctx, "Flagged as Pick"), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 006c1aa2..58439090 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -189,7 +189,13 @@ pub struct UiState { pub single_panel: bool, pub show_clipping: bool, pub histogram: bool, + /// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles), + /// in which colour and opacity (0..100, colour views), and whether pins are drawn. pub mask_overlay: bool, + pub mask_overlay_mode: String, + pub mask_overlay_color: [u8; 3], + pub mask_overlay_opacity: f32, + pub mask_pins: bool, pub crop_overlay: CropOverlay, pub show_filenames: bool, pub search: String, @@ -205,7 +211,12 @@ pub struct UiState { pub brush_feather: f32, pub brush_flow: f32, pub brush_erase: bool, + /// Brush Auto Mask: dabs stick to areas like the one under the brush centre. + pub brush_auto_mask: bool, + /// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100). pub remove_size: f32, + pub remove_feather: f32, + pub remove_opacity: f32, /// Selected Point Color sample. pub point_color: usize, /// Point Color "Visualize range": the selected sample's range in colour, the rest grey. @@ -327,6 +338,10 @@ impl Default for UiState { show_clipping: false, histogram: true, mask_overlay: true, + mask_overlay_mode: "color".into(), + mask_overlay_color: [230, 30, 40], + mask_overlay_opacity: 50.0, + mask_pins: true, crop_overlay: CropOverlay::Thirds, show_filenames: true, search: String::new(), @@ -338,7 +353,10 @@ impl Default for UiState { brush_feather: 50.0, brush_flow: 60.0, brush_erase: false, + brush_auto_mask: false, remove_size: 0.02, + remove_feather: 50.0, + remove_opacity: 100.0, point_color: 0, point_color_visualize: false, eye: 0, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs new file mode 100644 index 00000000..75e946ff --- /dev/null +++ b/crates/ui-egui/src/tests_masking.rs @@ -0,0 +1,170 @@ +//! Headless tests of the Masking and Remove tools on the photo: overlay keys, pins, spot editing. + +use std::time::Duration; + +use lightcraft_develop::MaskShape; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::RightPanel; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn detail(panel: &str) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn pointer(h: &mut Headless, events: serde_json::Value) { + let r = h.request("ui.pointer", json!({"events": events}), T); + assert_eq!(r["ok"], true, "{r}"); +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.active().expect("active photo"); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +#[test] +fn mask_overlay_keys_and_pins() { + use lightcraft_pipeline::{MaskView, Overlay}; + let mut h = detail("panel.masking"); + assert_eq!(h.app.ui.right, RightPanel::Masking); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]})); + assert_eq!(h.app.session.active_mask, Some(2)); + // the loupe asks the renderer for the selected mask's overlay + let d = develop(&h); + let o = crate::panels::detail::view_overlay(&h.app, &d); + assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 }); + // O toggles it, Shift+O cycles the mode (and leaves the crop overlay alone) + h.request("ui.key", json!({"key": "o"}), T); + assert!(!h.app.ui.mask_overlay); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + h.request("ui.key", json!({"key": "o"}), T); + let crop = h.app.ui.crop_overlay; + h.request("ui.key", json!({"key": "o", "shift": true}), T); + assert_eq!(h.app.ui.mask_overlay_mode, "colorOnBw"); + assert_eq!(h.app.ui.crop_overlay, crop); + exec(&mut h, "view.maskOverlayMode", json!({"mode": "whiteOnBlack"})); + exec(&mut h, "view.maskOverlayColor", json!({"color": "#2870f0", "opacity": 80})); + assert_eq!((h.app.ui.mask_overlay_color, h.app.ui.mask_overlay_opacity), ([0x28, 0x70, 0xf0], 80.0)); + let r = h.request("engine.execute", json!({"command": "view.maskOverlayMode", "params": {"mode": "nope"}}), T); + assert_eq!(r["ok"], false, "{r}"); + // clicking another mask's pin selects that mask + pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "up", "x": 0.3, "y": 0.4}])); + assert_eq!(h.app.session.active_mask, Some(1)); + // dragging a pin moves its component (one undo step) + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "drag", "x": 0.35, "y": 0.45}, {"kind": "drag", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]), + ); + let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.5).abs() < 0.02 && (center.y - 0.6).abs() < 0.02, "{center:?}"); + // dragging the linear gradient's pin (a non-selected mask) selects and moves it + pointer( + &mut h, + json!([{"kind": "down", "x": 0.7, "y": 0.4}, {"kind": "drag", "x": 0.72, "y": 0.4}, {"kind": "drag", "x": 0.8, "y": 0.4}, {"kind": "up", "x": 0.8, "y": 0.4}]), + ); + assert_eq!(h.app.session.active_mask, Some(2)); + let MaskShape::Linear { start, end } = develop(&h).masks[1].components[0].shape.clone() else { panic!("linear") }; + assert!((start.x - 0.8).abs() < 0.02 && (end.x - 0.8).abs() < 0.02 && (start.y - 0.2).abs() < 0.02, "{start:?} {end:?}"); + // hidden pins can't be grabbed + exec(&mut h, "view.maskPins", json!({"show": false})); + pointer(&mut h, json!([{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}])); + assert_eq!(h.app.session.active_mask, Some(2)); + h.settle(SETTLE); +} + +#[test] +fn brush_strokes_carry_auto_mask() { + let mut h = detail("panel.masking"); + let r = h.request("ui.set", json!({"brushAutoMask": true}), T); + assert_eq!(r["ok"], true, "{r}"); + exec(&mut h, "tool.brush", json!({})); + assert_eq!(h.app.ui.tool, "brush"); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.4, "y": 0.5}, {"kind": "drag", "x": 0.5, "y": 0.5}, {"kind": "up", "x": 0.5, "y": 0.5}]), + ); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert!(strokes[0].auto_mask && strokes[0].points.len() >= 2, "{strokes:?}"); + h.settle(SETTLE); +} + +#[test] +fn remove_spots_by_pointer_and_keyboard() { + let mut h = detail("panel.remove"); + assert_eq!(h.app.ui.right, RightPanel::Remove); + let spots = |h: &Headless| develop(h).spots; + // paint a spot: it's added with the brush's size/feather/opacity and selected + let r = h.request("ui.set", json!({"removeFeather": 30.0, "removeOpacity": 80.0}), T); + assert_eq!(r["ok"], true, "{r}"); + pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}])); + assert_eq!(spots(&h).len(), 1); + assert_eq!((spots(&h)[0].feather, spots(&h)[0].opacity), (30.0, 80.0)); + assert_eq!(h.app.session.active_spot, Some(0)); + // [ / ] resize the selected spot, Shift+[ / Shift+] feather it + let s0 = spots(&h)[0].size; + h.request("ui.key", json!({"key": "]"}), T); + assert!(spots(&h)[0].size > s0 * 1.1, "{} vs {s0}", spots(&h)[0].size); + h.request("ui.key", json!({"key": "["}), T); + h.request("ui.key", json!({"key": "["}), T); + assert!(spots(&h)[0].size < s0 * 0.9); + h.request("ui.key", json!({"key": "[", "shift": true}), T); + assert_eq!(spots(&h)[0].feather, 20.0); + h.request("ui.key", json!({"key": "]", "shift": true}), T); + assert_eq!(spots(&h)[0].feather, 30.0); + // / picks another source (and leaves the filmstrip alone) + let (src, film) = (spots(&h)[0].source_offset, h.app.ui.filmstrip); + h.request("ui.key", json!({"key": "/"}), T); + assert_ne!(spots(&h)[0].source_offset, src); + assert_eq!(h.app.ui.filmstrip, film); + // a second spot elsewhere; clicking the first one's pin selects it + pointer(&mut h, json!([{"kind": "down", "x": 0.7, "y": 0.3}, {"kind": "up", "x": 0.7, "y": 0.3}])); + assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(1))); + pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}])); + assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(0))); + // drag its target: it moves, its source offset stays + let src = spots(&h)[0].source_offset; + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "drag", "x": 0.32, "y": 0.6}, {"kind": "drag", "x": 0.4, "y": 0.6}, {"kind": "up", "x": 0.4, "y": 0.6}]), + ); + let sp = spots(&h)[0].clone(); + assert!((sp.points[0].x - 0.4).abs() < 0.01 && (sp.points[0].y - 0.6).abs() < 0.01, "{:?}", sp.points); + assert_eq!(sp.source_offset, src); + // drag its source to a fixed place + let o = sp.source_offset.unwrap(); + let (sx, sy) = (sp.points[0].x + o.x, sp.points[0].y + o.y); + pointer( + &mut h, + json!([{"kind": "down", "x": sx, "y": sy}, {"kind": "drag", "x": sx + 0.01, "y": sy}, {"kind": "drag", "x": 0.5, "y": 0.8}, {"kind": "up", "x": 0.5, "y": 0.8}]), + ); + let sp = spots(&h)[0].clone(); + let o = sp.source_offset.unwrap(); + assert!((sp.points[0].x + o.x - 0.5).abs() < 0.01 && (sp.points[0].y + o.y - 0.8).abs() < 0.01, "{o:?}"); + // ⌫ deletes the selected spot, not the photo + let photos = h.app.session.visible_cloned().len(); + h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!((spots(&h).len(), h.app.session.active_spot), (1, None)); + assert_eq!(h.app.session.visible_cloned().len(), photos); + h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(spots(&h).len(), 1, "nothing selected: nothing deleted"); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index bd7bfb0d..c4e840f3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -29,9 +29,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | | G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) | | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | -| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | +| I. Remove / healing (REM) | 5 | 1 | 4 | 2 | 4/4 (100%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | -| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) | +| K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) | | L. Presets (PRE) | 3 | 2 | 2 | 1 | 2/2 (100%) | 1/2 (50%) | | M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | | N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -46,31 +46,25 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 10 | 3 | 4 | 1 | 7/8 (88%) | 3/5 (60%) | | Y. Menus | 50 | 18 | 17 | 8 | 38/47 (81%) | 10/23 (43%) | -| Z. Keyboard shortcuts (desktop) | 49 | 16 | 15 | 1 | 40/52 (77%) | 9/23 (39%) | +| Z. Keyboard shortcuts (desktop) | 53 | 14 | 13 | 1 | 44/52 (85%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 230 | 99 | 171 | 37 | 159/198 (80%) | 62/144 (43%) | +| **Total** | 240 | 91 | 169 | 37 | 168/198 (85%) | 63/144 (44%) | ## Top gaps Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on. -1. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask - kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. -2. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local - Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. -3. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, - feather/opacity sliders, `[`/`]` size keys. Medium effort. -4. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. -5. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -6. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination +1. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +2. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +3. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination album; browse folders before adding. Medium effort. -7. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in +4. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. -8. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +5. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). -9. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. -10. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +6. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +7. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -99,10 +93,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Still missing / broken:** - No command yet: F full-screen preview, ⇧⌘F window full screen, ⇧⌘V paste selected, - ⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, `[` `]` / ⇧`[` ⇧`]` - brush size/feather, ⌃H / ⌃M merges, F1 help, ⇧6–9 label + advance. -- `⌫` in the Masking panel deletes the active mask; spots have no pin selection yet (⌫ does nothing in Remove). -- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — no pin toggle yet. + ⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, ⌃H / ⌃M merges, F1 help, + ⇧6–9 label + advance. +- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -302,8 +295,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | | | LR-REM-GEN | Generative remove | OOS | 🚫 | | | | LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | | -| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | 🟡 | `cmd:spot.add` (`size`, `feather`, `opacity`), `crates/ui-egui/src/panels/right.rs` | UI has size only; no `[` `]` keys | -| LR-REM-SPOT-EDIT | Edit existing spots | P0 | 🟡 | `cmd:spot.delete` | no pin selection, no moving target/source, ⌫ deletes the photo instead | +| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | `cmd:spot.add` (`size`, `feather`, `opacity`), `cmd:brush.smaller`, `cmd:brush.larger`, `cmd:brush.featherLess`, `cmd:brush.featherMore`, `crates/ui-egui/src/panels/right.rs` | Size / Feather / Opacity sliders (for new spots and the selected one); `[` `]` size, ⇧`[` ⇧`]` feather (also the Masking brush) | +| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added | | LR-REM-VISUALIZE | Visualize spots | P1 | ⬜ | | | | LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | | LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | @@ -328,7 +321,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-OBJECTS | Object selection | P2 | ⬜ | | shape exists (falls back to the subject heuristic); no UI | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | -| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` | size/feather/flow/density/erase; Auto Mask flag not applied; no A/B brushes, no pressure | +| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | | LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified | @@ -338,9 +331,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | | -| LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets | -| LR-MASK-OVERLAY | Mask overlay | P0 | 🟡 | `cmd:view.maskOverlay`, `crates/ui-egui/src/panels/detail.rs` | brush dabs + shape outlines only; no rendered alpha, colour or mode options | -| LR-MASK-PINS | Pins | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | handles drawn; no show/hide-pins option | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; no auto-show on hover | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | @@ -630,7 +623,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-PREFS | Settings — ⌘, | P0 | ✅ | `cmd:app.settings` | | | KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command | | KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | | -| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | 🟡 | `cmd:view.maskOverlay`, `cmd:view.cropOverlay` | O toggles the mask overlay; crop overlays cycle on ⇧O | +| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.cropOverlay` | O toggles the mask overlay; ⇧O cycles mask overlay modes while masking, crop overlays elsewhere | | KEY-PHOTOSPANEL | Photos panel — P | P0 | 🟡 | `cmd:view.leftPanel` | bound to ⌘⇧L; P = pick | | KEY-LINEAR | Linear gradient — L | P0 | ✅ | `cmd:tool.linear` | | | KEY-RADIAL | Radial gradient — R | P0 | ✅ | `cmd:tool.radial` | | @@ -655,8 +648,8 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | 🟡 | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | bound to ⌘⌥1–5 | | KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | | | KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit | -| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ⬜ | | brush size is UI state, no command | -| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ⬜ | | | +| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ✅ | `cmd:brush.smaller`, `cmd:brush.larger` | Masking brush and Remove tool (and its selected spot) | +| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ✅ | `cmd:brush.featherLess`, `cmd:brush.featherMore` | | | KEY-BRUSH | Brush — B | P0 | ✅ | `cmd:tool.brush` | | | KEY-HEAL | Remove / heal — H | P0 | ✅ | `cmd:panel.remove` | | | KEY-MERGE | HDR / panorama merges — ⌃H ⇧⌃H ⌃M ⇧⌃M | P2 | ⬜ | | | @@ -689,7 +682,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | | KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back` | | | KEY-COMMIT | Commit tool — Return | P1 | 🟡 | | edits apply live; no explicit commit step | -| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | 🟡 | `cmd:mask.delete` | ⌫ deletes the active mask in the Masking panel and never the photo while retouching; no spot pin selection yet | +| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching | | KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel | ## Lightroom Classic extras