Merge branch 'worktree-agent-a27a1456c70c1074d'

This commit is contained in:
Brandon Thomas 2026-10-01 13:24:42 -04:00
commit db89203fb2
31 changed files with 1739 additions and 237 deletions

View File

@ -47,7 +47,7 @@ pub struct WireJob {
pub stages: Option<String>,
/// The request's diagnostic overlay (`Overlay::to_parts`).
#[serde(default)]
pub overlay: (u8, f32),
pub overlay: (u8, f64),
}
impl WireJob {

View File

@ -45,6 +45,7 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
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<CommandSpec> {
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, _| {

View File

@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
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<SpotMode> = 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<CommandSpec> {
]
}
/// 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<usize> {
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<Spot>) -> 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<Point>) -> Option<Point> {
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);

View File

@ -89,6 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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(),

View File

@ -96,6 +96,8 @@ pub struct Session {
depth: u32,
/// Selected mask (Masking panel), by mask id.
pub active_mask: Option<u32>,
/// Selected spot (Remove panel), by index into the active photo's spots.
pub active_spot: Option<usize>,
/// The persistent library this session writes to (`None` = in-memory only).
pub library: Option<library::Library>,
/// 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;

View File

@ -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<lightcraft_develop::Spot> {
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);
}

View File

@ -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");

View File

@ -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());

View File

@ -44,6 +44,7 @@ struct Planes {
clarity: Option<(u32, Arc<Buf>)>,
texture: Option<(u32, Arc<Buf>)>,
dark: Option<(u32, Arc<Buf>, f32)>,
chroma: Option<(u32, Arc<Buf>)>,
}
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<Arc<Buf>>,
texture: Option<Arc<Buf>>,
dark: Option<Arc<Buf>>,
/// Blurred chromaticity (local Moiré / Noise).
chroma: Option<Arc<Buf>>,
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<f32> {
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<Buf>, 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::<Vec<u32>>();
let mut aux: Vec<f32> = Vec::new();
let mut own: Option<Buf> = 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::<usize>() <= 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::<usize>() > 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::<Vec<_>>(), 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));

View File

@ -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<u32>) {
let l0 = l_pre + ev;
// --- local (mask) contributions
var lt: array<f32, 14>;
var lt: array<f32, MASK_SUMS>;
var tint_on = false;
var tint_dir = vec2<f32>(0.0, 0.0);
var tint_amt = 0.0;
@ -270,18 +271,42 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
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<f32>(aux[t + 15u], aux[t + 16u]);
tint_amt = a * aux[t + 17u];
tint_dir = vec2<f32>(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<f32>(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<f32>(masks[o], masks[o + 1u], masks[o + 2u]);
c = max((ch0 + (cb - ch0) * mt) * y, vec3<f32>(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<u32>) {
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);
}

View File

@ -70,6 +70,51 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3<u32>, @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<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
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<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
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<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
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<u32>, @builtin(num_workgroups) nw: vec3<u32>) {

View File

@ -13,6 +13,19 @@ fn pos(x: u32, y: u32) -> vec2<f32> {
// 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<f32> {
let c = vec3<f32>(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<f32>, rl: f32, rch: vec3<f32>) -> 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<u32>) {
let w = pu(0u);
@ -64,11 +77,14 @@ fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
}
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<u32>) {
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]);

View File

@ -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]

View File

@ -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<T: Copy + Default + Send>(
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<T: Copy + Default + Send>(
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<T: Copy + Default + Send>(
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.

View File

@ -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<Arc<Plane>>,
pub texture_blur: Option<Arc<Plane>>,
pub dark: Option<Arc<Plane>>,
/// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise.
pub chroma_blur: Option<Arc<Rgb32f>>,
/// Airlight of `dark` (before exposure).
pub air: f32,
pub masks: Vec<masks::Evaluated>,
@ -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<Plane> {
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<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut
mod tests;
#[cfg(test)]
mod tests_geometry;
#[cfg(test)]
mod tests_local;

View File

@ -208,6 +208,8 @@ pub(crate) struct Planes {
pub texture: Option<(u32, Arc<Plane>)>,
/// Dark channel and its airlight.
pub dark: Option<(u32, Arc<Plane>, f32)>,
/// Blurred chromaticity (local Moiré / Noise).
pub chroma: Option<(u32, Arc<Rgb32f>)>,
}
/// 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<f32>,
/// Dehaze dark channel (Gaussian).
pub dark: Option<f32>,
/// Chromaticity blur for local Moiré / colour noise (Gaussian).
pub chroma: Option<f32>,
}
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<Rgb32f>, 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<Rgb32f>, 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.

View File

@ -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<Evaluated> {
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));
}
}

View File

@ -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<Point>,
) -> Option<Point> {
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.

View File

@ -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<f32> = (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::<f32>() / v.len() as f32;
(v.iter().map(|a| (a - m).powi(2)).sum::<f32>() / 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::<f32>() / (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));
}

View File

@ -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> {
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));
}
}

View File

@ -112,6 +112,8 @@ fn key_from(name: &str) -> Option<egui::Key> {
"tab" => Some(egui::Key::Tab),
"\\" | "backslash" => Some(egui::Key::Backslash),
"/" | "slash" => Some(egui::Key::Slash),
"[" => Some(egui::Key::OpenBracket),
"]" => Some(egui::Key::CloseBracket),
_ => None,
})
}

View File

@ -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;

View File

@ -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<bool> {
"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),

View File

@ -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<Result<Value, String>> {
let ctx = egui::Context::default();
@ -252,8 +300,52 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"view.maskOverlay" => {
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

View File

@ -23,6 +23,11 @@ pub enum Gesture {
Spot {
points: Vec<Point>,
},
/// 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<Point> {
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<Pos2> = (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}),
);
}
}

View File

@ -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),

View File

@ -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| {

View File

@ -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"),

View File

@ -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,

View File

@ -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);
}

View File

@ -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%) |
<!-- /parity:summary -->
## 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).
<!-- Sections below hold one row per id. Keep the column order: Id | Feature | Tier | Status | Evidence | Notes. -->
@ -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