From 439cbd6ca0732fbfde1445e1c86999996b6476be Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:01:01 -0400 Subject: [PATCH] LRC-DEV-SOFTPROOF, MENU-CTX-MASK: soft proofing with gamut warnings; mask component menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft proofing (S in the loupe; S in grids stays Expand/Collapse Stack): - pipeline Proof {space, destWarning, displayWarning}: gamut-map into the proof space, show it in the output space; red/blue gamut warnings (neutral clipping excluded). CPU only; the GPU path declines proofs. - RenderJob::with_proof; paper-white surround and Proof Preview label; Soft Proofing strip under the histogram (profile, warnings, Create Proof Copy = a named virtual copy). view.softProof for control/MCP. - photo.virtualCopy takes an optional name. Mask components: MaskComponent.name; mask.component {component, action: invert|duplicate|delete|rename|op}; right-click / … menu per component, double-click rename, Intersect button. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/wire.rs | 1 + crates/develop/src/lib.rs | 1 + crates/develop/src/settings.rs | 3 + crates/engine/src/cmd/masks.rs | 61 ++++++++++++++++-- crates/engine/src/cmd/organize.rs | 13 ++-- crates/engine/src/media.rs | 15 +++++ crates/engine/src/tests.rs | 32 ++++++++++ crates/gpu/src/lib.rs | 4 +- crates/gpu/tests/equivalence.rs | 31 +++++++-- crates/pipeline/src/finish.rs | 95 +++++++++++++++++++++------- crates/pipeline/src/lib.rs | 19 ++++-- crates/pipeline/src/masks.rs | 5 +- crates/pipeline/src/output.rs | 43 +++++++++++++ crates/pipeline/src/tests.rs | 43 +++++++++++++ crates/pipeline/src/tests_local.rs | 2 +- crates/pipeline/src/visualize.rs | 2 +- crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 28 ++++++++ crates/ui-egui/src/panels/detail.rs | 8 ++- crates/ui-egui/src/panels/edit.rs | 52 ++++++++++++++- crates/ui-egui/src/panels/masking.rs | 73 ++++++++++++++++++++- crates/ui-egui/src/shortcuts.rs | 3 + crates/ui-egui/src/state.rs | 8 +++ crates/ui-egui/src/tests_masking.rs | 43 +++++++++++++ docs/parity.md | 12 ++-- 25 files changed, 540 insertions(+), 59 deletions(-) diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 8c85a0058..2e546eb23 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -92,6 +92,7 @@ impl WireJob { // workers render previews (exports run in-process) space: lightcraft_engine::pipeline::OutputSpace::Srgb, depth: lightcraft_engine::pipeline::OutputDepth::U8, + proof: None, } } diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index 69888e527..f0ffffd23 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -105,6 +105,7 @@ mod tests { s.masks.push(Mask { id: 1, components: vec![MaskComponent { + name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Radial { diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 78125bbb0..eeb6ff048 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -786,6 +786,9 @@ pub enum MaskShape { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MaskComponent { + /// A name given in the Masking panel (Rename); `None` shows the shape's kind. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, #[serde(default)] pub op: MaskOp, #[serde(default)] diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 0b0f2288c..fcf918c47 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec { masks.push(Mask { id: next, name: name.unwrap_or_else(|| format!("Mask {next}")), - components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], ..Default::default() }); *active = Some(next); @@ -102,7 +102,7 @@ pub fn specs() -> Vec { let mid = mask_id(p, active, "mask.addComponent")?; masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| { let i = find(masks, mid, "mask.addComponent")?; - masks[i].components.push(MaskComponent { op, invert: bool_or(p, "invert", false), shape }); + masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape }); Ok(()) }) } @@ -144,12 +144,17 @@ pub fn specs() -> Vec { } }; let m = &mut masks[i]; - if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) = + if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) = m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. })) { strokes.push(stroke); } else { - m.components.push(MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }); + m.components.push(MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Brush { strokes: vec![stroke] }, + }); } Ok(()) }) @@ -316,6 +321,54 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "mask.component", + "Edit Mask Component", + [], + None, + "{id?, component: index, action: invert|duplicate|delete|rename|op, name? (rename; empty clears), op?: add|subtract|intersect} — one component of a mask (deleting the last one deletes the mask)", + has_active, + |s, p| { + let c = "mask.component"; + let mid = mask_id(p, s.active_mask, c)?; + let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize; + let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string(); + let op: Option = match p.get("op") { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?), + None => None, + }; + let label = match action.as_str() { + "invert" => "Invert Component", + "duplicate" => "Duplicate Component", + "delete" => "Delete Component", + "rename" => "Rename Component", + "op" => "Change Component Mode", + a => return Err(bad(c, format!("unknown action {a:?} (invert|duplicate|delete|rename|op)"))), + }; + masks_edit(s, c, label, |masks, active| { + let i = find(masks, mid, c)?; + let comps = &mut masks[i].components; + let comp = comps.get_mut(k).ok_or_else(|| bad(c, "no such component"))?; + match action.as_str() { + "invert" => comp.invert = !comp.invert, + "rename" => comp.name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string), + "op" => comp.op = op.ok_or_else(|| bad(c, "missing op"))?, + "duplicate" => { + let copy = comp.clone(); + comps.insert(k + 1, copy); + } + _ => { + comps.remove(k); + if comps.is_empty() { + masks.remove(i); + *active = masks.last().map(|m| m.id); + } + } + } + Ok(()) + }) + } + ), cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| { let mid = mask_id(p, s.active_mask, "mask.move")?; let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index a4b42ba0d..494785628 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -4,7 +4,7 @@ use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection}; +use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection, str_param}; use crate::{Result, Selection, Session}; /// Stacks containing any of `targets`, in first-seen order. @@ -65,8 +65,8 @@ fn copy_number(name: Option<&str>) -> u32 { /// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and /// metadata, added to the same albums and stacked right after it (the stack is expanded so the -/// copy is visible). -fn virtual_copy(s: &mut Session, src: PhotoId) -> Result> { +/// copy is visible). `name` replaces the default "Copy N". +fn virtual_copy(s: &mut Session, src: PhotoId, name: Option<&str>) -> Result> { let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) }; let master = orig.copy_of.unwrap_or(src); let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0); @@ -74,7 +74,7 @@ fn virtual_copy(s: &mut Session, src: PhotoId) -> Result> { let mut c = (*orig).clone(); c.id = id; c.copy_of = Some(master); - c.copy_name = Some(format!("Copy {n}")); + c.copy_name = Some(name.map_or_else(|| format!("Copy {n}"), str::to_string)); c.versions.clear(); c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }]; c.deleted = false; @@ -108,13 +108,14 @@ pub fn specs() -> Vec { "Create Virtual Copy", ["Photo"], Some("Cmd+'"), - "{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"", + "{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"", has_selection, |s, p| { let targets = s.targets(p); + let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string); let mut made = Vec::new(); for t in targets { - if let Some(id) = virtual_copy(s, t)? { + if let Some(id) = virtual_copy(s, t, name.as_deref())? { made.push(id); } } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index c12819853..7be9b20f2 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -374,6 +374,20 @@ impl RenderJob { self } + /// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is + /// never kept as the photo's cached preview. + pub fn with_proof(mut self, proof: Option) -> Self { + if self.request.proof != proof { + let k = |p: Option| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f); + self.key ^= k(self.request.proof) ^ k(proof); + self.request.proof = proof; + } + if proof.is_some() { + self.view_cache = None; + } + self + } + /// Reuse `stages` across this view's renders. pub fn with_stages(mut self, stages: Arc) -> Self { self.stages = Some(stages); @@ -432,6 +446,7 @@ pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: & if gpu && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) + && req.proof.is_none() && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) { return r; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b5d36fcf7..baf1b5b76 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -639,3 +639,35 @@ fn quick_develop_adds_to_each_photo() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.develop_of(PhotoId(ids[0])).unwrap().light.exposure, before[0], "one undo step"); } + +#[test] +fn mask_components_invert_duplicate_rename_change_mode_and_delete() { + use lightcraft_develop::MaskOp; + let mut s = demo(); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.addComponent", &json!({"op": "subtract", "kind": "linear"})).unwrap(); + let mask = |s: &Session| active_dev(s).masks[0].clone(); + let id = mask(&s).id; + s.execute("mask.component", &json!({"component": 1, "action": "invert"})).unwrap(); + assert!(mask(&s).components[1].invert); + s.execute("mask.component", &json!({"component": 1, "action": "op", "op": "intersect"})).unwrap(); + assert_eq!(mask(&s).components[1].op, MaskOp::Intersect); + s.execute("mask.component", &json!({"component": 0, "action": "rename", "name": " Face "})).unwrap(); + assert_eq!(mask(&s).components[0].name.as_deref(), Some("Face")); + s.execute("mask.component", &json!({"component": 0, "action": "duplicate"})).unwrap(); + assert_eq!(mask(&s).components.len(), 3); + assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face")); + // a cleared name falls back to the kind; one undo step each + s.execute("mask.component", &json!({"component": 1, "action": "rename", "name": ""})).unwrap(); + assert_eq!(mask(&s).components[1].name, None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face")); + assert!(s.execute("mask.component", &json!({"component": 9, "action": "invert"})).is_err()); + assert!(s.execute("mask.component", &json!({"component": 0, "action": "explode"})).is_err()); + // deleting the last component deletes the mask + for _ in 0..3 { + s.execute("mask.component", &json!({"id": id, "component": 0, "action": "delete"})).unwrap(); + } + assert!(active_dev(&s).masks.is_empty()); + assert_eq!(s.active_mask, None); +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7b07603dd..3342a0f80 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -184,8 +184,8 @@ pub fn stage_bytes(stages: &StageCache) -> usize { pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { #[cfg(not(target_arch = "wasm32"))] { - // the kernel writes 8-bit output: high-bit-depth exports render on the CPU - if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 { + // the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU + if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { return None; } let gpu = device()?; diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index d56de0544..cda8774f7 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -180,6 +180,7 @@ fn cases() -> Vec<(&'static str, Edit)> { s.masks = vec![ Mask { components: vec![MaskComponent { + name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, @@ -190,11 +191,13 @@ fn cases() -> Vec<(&'static str, Edit)> { Mask { components: vec![ MaskComponent { + name: None, 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 }, }, MaskComponent { + name: None, op: MaskOp::Intersect, invert: true, shape: MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 1.0) }, @@ -223,12 +226,18 @@ fn cases() -> Vec<(&'static str, Edit)> { let erase = BrushStroke { points: vec![Point::new(0.5, 0.4)], size: 0.03, erase: true, ..Default::default() }; s.masks = vec![ Mask { - components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Brush { strokes: vec![stroke, erase] }, + }], adjust: lightcraft_develop::LocalAdjustments { exposure: 0.7, whites: 20.0, blacks: -20.0, dehaze: 30.0, ..Default::default() }, ..Default::default() }, Mask { components: vec![MaskComponent { + name: None, op: MaskOp::Add, invert: false, shape: MaskShape::LuminanceRange { lo: 0.5, hi: 0.8, lo_feather: 0.1, hi_feather: 0.1 }, @@ -238,6 +247,7 @@ fn cases() -> Vec<(&'static str, Edit)> { }, Mask { components: vec![MaskComponent { + name: None, op: MaskOp::Add, invert: false, shape: MaskShape::ColorRange { samples: vec![[0.6, -0.05, -0.08]], refine: 50.0 }, @@ -252,8 +262,9 @@ fn cases() -> Vec<(&'static str, Edit)> { s.masks = vec![ Mask { components: vec![ - MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Sky }, + MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }, MaskComponent { + name: None, op: MaskOp::Subtract, invert: false, shape: MaskShape::Radial { center: Point::new(0.7, 0.2), rx: 0.1, ry: 0.1, angle: 0.0, feather: 30.0, invert: false }, @@ -263,12 +274,12 @@ fn cases() -> Vec<(&'static str, Edit)> { ..Default::default() }, Mask { - components: vec![MaskComponent { op: MaskOp::Intersect, invert: false, shape: MaskShape::Subject }], + components: vec![MaskComponent { name: None, op: MaskOp::Intersect, invert: false, shape: MaskShape::Subject }], adjust: lightcraft_develop::LocalAdjustments { exposure: 0.5, ..Default::default() }, ..Default::default() }, Mask { - components: vec![MaskComponent { op: MaskOp::Add, invert: true, shape: MaskShape::Background }], + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape: MaskShape::Background }], adjust: lightcraft_develop::LocalAdjustments { saturation: -60.0, ..Default::default() }, invert: true, ..Default::default() @@ -278,7 +289,11 @@ 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() }; + let m = |shape, adjust| Mask { + components: vec![MaskComponent { name: None, 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() }), @@ -301,7 +316,7 @@ fn cases() -> Vec<(&'static str, Edit)> { st(&[(0.5, 0.58)], true, true), ]; s.masks = vec![Mask { - components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }], + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }], adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() }, ..Default::default() }]; @@ -452,6 +467,7 @@ fn overlays_match() { Mask { id: 1, components: vec![MaskComponent { + name: None, 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 }, @@ -460,13 +476,14 @@ fn overlays_match() { }, Mask { id: 2, - components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }], + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }], ..Default::default() }, Mask { id: 3, visible: false, components: vec![MaskComponent { + name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 93a1cef73..aeb0adf81 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -167,6 +167,8 @@ pub struct FinishParams { pub to_out: [[f32; 3]; 3], pub out_luma: [f32; 3], pub out_trc: OutputTrc, + /// Soft proofing (CPU only; the GPU path declines proof renders). + pub proof: Option, pub hl: f32, pub sh: f32, pub clar: f32, @@ -232,6 +234,7 @@ impl FinishParams { to_out: space.from_working(), out_luma: space.luma(), out_trc: space.trc(), + proof: None, hl: (s.light.highlights / 100.0) as f32, sh: (s.light.shadows / 100.0) as f32, clar, @@ -254,9 +257,10 @@ impl FinishParams { } } -pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> Rgba8 { +pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, proof: Option) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); - let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + fp.proof = proof.map(|pr| pr.params(space)); let trc = fp.out_trc; let data = finish_with(p, &fp, false, |e| match trc { OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255], @@ -269,10 +273,20 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So } /// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries). -pub(crate) fn finish_deep(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, depth: OutputDepth) -> DeepImage { +#[allow(clippy::too_many_arguments)] +pub(crate) fn finish_deep( + p: &Prepared, + s: &DevelopSettings, + frame: &Frame, + info: &SourceInfo, + space: OutputSpace, + depth: OutputDepth, + proof: Option, +) -> DeepImage { use lightcraft_color::transfer::srgb_to_linear; let (w, h) = (p.img.width, p.img.height); - let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + fp.proof = proof.map(|pr| pr.params(space)); let trc = fp.out_trc; let samples = match depth { OutputDepth::F32Linear => { @@ -507,25 +521,25 @@ pub(crate) fn finish_with( } } - // --- gamut map to the output space (desaturate towards luminance until in range) - let m = to_out; - let mut r = [ - m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2], - m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2], - m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2], - ]; - let yy = (out_luma[0] * r[0] + out_luma[1] * r[1] + out_luma[2] * r[2]).clamp(0.0, 1.0); - let mut t = 1.0f32; - for c in r { - if c < 0.0 { - t = t.min(yy / (yy - c).max(1e-9)); - } else if c > 1.0 { - t = t.min((1.0 - yy) / (c - yy).max(1e-9)); + // --- gamut map to the output space (desaturate towards luminance until in range); + // soft proofing maps into the proof space first and shows that in the output space + let mut warn = None; + let mut r = match &fp.proof { + Some(pp) => { + let q0 = mul3(&pp.to_proof, d); + let (q, t) = gamut_map(q0, pp.luma); + if pp.dest_warning && out_of_gamut(q0, t) { + warn = Some(crate::output::PROOF_DEST_WARNING); + } + mul3(&pp.proof_to_out, q) } + None => mul3(to_out, d), + }; + let (mapped, t) = gamut_map(r, *out_luma); + if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { + warn = Some(crate::output::PROOF_DISPLAY_WARNING); } - if t < 1.0 { - r = r.map(|c| yy + (c - yy) * t); - } + r = mapped; // --- encode, curves, grain let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) }; @@ -553,12 +567,49 @@ pub(crate) fn finish_with( } None => e, }; - *px = store(e); + *px = store(warn.unwrap_or(e)); } }); out } +/// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for +/// the gamut warnings (tolerates rounding at the gamut boundary). +const GAMUT_WARN: f32 = 0.995; + +/// For the gamut warnings: `r` needed desaturating by `t` to fit, and isn't simply a neutral +/// beyond white or below black (that's clipping, which the clipping warnings show). +#[inline] +fn out_of_gamut(r: [f32; 3], t: f32) -> bool { + let (lo, hi) = (r[0].min(r[1]).min(r[2]), r[0].max(r[1]).max(r[2])); + t < GAMUT_WARN && (hi - lo) > 0.02 * hi.abs().max(1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0)) +} + +#[inline] +fn mul3(m: &[[f32; 3]; 3], d: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2], + m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2], + m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2], + ] +} + +/// Desaturate linear `r` towards its luminance (weights `luma`) until every channel is in 0..1; +/// returns the mapped colour and the chroma scale used (1 = already in gamut). +#[inline] +pub fn gamut_map(r: [f32; 3], luma: [f32; 3]) -> ([f32; 3], f32) { + let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, 1.0); + let mut t = 1.0f32; + for c in r { + if c < 0.0 { + t = t.min(yy / (yy - c).max(1e-9)); + } else if c > 1.0 { + t = t.min((1.0 - yy) / (c - yy).max(1e-9)); + } + } + if t < 1.0 { (r.map(|c| yy + (c - yy) * t), t) } else { (r, 1.0) } +} + /// 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] diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index d34a9843a..fb361c3cc 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -41,7 +41,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}; +pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc, Proof}; pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -93,11 +93,22 @@ pub struct RenderRequest { pub space: OutputSpace, /// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports). pub depth: OutputDepth, + /// Soft proofing (CPU only; see [`Proof`]). + pub proof: Option, } impl RenderRequest { pub fn fit(max_w: usize, max_h: usize) -> Self { - Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb, depth: OutputDepth::U8 } + Self { + max_w, + max_h, + quality: Quality::Full, + apply_crop: true, + overlay: Overlay::None, + space: OutputSpace::Srgb, + depth: OutputDepth::U8, + proof: None, + } } } @@ -398,13 +409,13 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); } if req.depth != OutputDepth::U8 { - let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth); + let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth, req.proof); let image = deep.to_rgba8(); let histogram = Histogram::of_srgb8(&image); lap("finish (deep)", &mut t); return Rendered { image, histogram, deep: Some(deep) }; } - let image = finish::finish(&prep, s, frame, info, req.space); + let image = finish::finish(&prep, s, frame, info, req.space, req.proof); lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 4accbcd74..5c6e003cb 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -364,7 +364,7 @@ mod tests { let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0); assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); - let m = Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: true, shape }], ..Default::default() }; + let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() }; let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0); assert!(e[0].alpha.get(50, 50) < 0.01); } @@ -377,7 +377,7 @@ mod tests { let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; let m = Mask { - components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], ..Default::default() }; let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0); @@ -428,6 +428,7 @@ mod tests { let f = Frame::new(w, h, &Default::default(), true); let soft = Mask { components: vec![MaskComponent { + name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 644fc8661..6f2e353ce 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -138,6 +138,49 @@ impl OutputSpace { } } +/// Soft proofing: render as if the result were converted to `space` (colours outside its gamut +/// are mapped into it, then shown in the render's own space), optionally painting what the +/// destination can't hold (`dest_warning`, red) and what the display can't show +/// (`display_warning`, blue) — Lightroom's destination and monitor gamut warnings. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Proof { + pub space: OutputSpace, + pub dest_warning: bool, + pub display_warning: bool, +} + +/// Warning colours (sRGB-encoded): destination gamut red, display gamut blue. +pub const PROOF_DEST_WARNING: [f32; 3] = [1.0, 0.0, 0.0]; +pub const PROOF_DISPLAY_WARNING: [f32; 3] = [0.0, 0.25, 1.0]; + +/// [`Proof`] as the per-pixel stage uses it. +#[derive(Clone, Copy, Debug)] +pub struct ProofParams { + /// Linear Rec.2020 → linear proof RGB, its luminance weights, linear proof → linear output RGB. + pub to_proof: [[f32; 3]; 3], + pub luma: [f32; 3], + pub proof_to_out: [[f32; 3]; 3], + pub dest_warning: bool, + pub display_warning: bool, +} + +impl Proof { + pub fn key(self) -> u64 { + (self.space as u64 + 1) | (self.dest_warning as u64) << 8 | (self.display_warning as u64) << 9 + } + + pub fn params(self, out: OutputSpace) -> ProofParams { + ProofParams { + to_proof: self.space.from_working(), + luma: self.space.luma(), + proof_to_out: self.space.rgb_space().to_space(&out.rgb_space()).to_f32(), + dest_warning: self.dest_warning, + display_warning: self.display_warning, + } + } +} + /// Sample format of a render. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index bfdfae378..61e0de7c1 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -106,6 +106,7 @@ fn mask_brightens_only_inside() { let mut s = DevelopSettings::default(); s.masks.push(Mask { components: vec![MaskComponent { + name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 10.0, invert: false }, @@ -287,3 +288,45 @@ fn refine_saturation_tames_a_contrast_curve() { let y = |p: [u8; 4]| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32; assert!((y(full) - y(refined)).abs() < 2.0); } + +#[test] +fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { + use crate::{OutputSpace, Proof}; + let src = scene(); + let mut s = DevelopSettings::default(); + s.color.saturation = 100.0; + s.color.vibrance = 100.0; + let info = SourceInfo { raw: true, ..Default::default() }; + let req = |space, proof| RenderRequest { space, proof, ..RenderRequest::fit(120, 120) }; + let red = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[..3] == [255, 0, 0]).count(); + let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count(); + + // proofing sRGB on an sRGB render changes nothing but the warning + let plain = render(&src, &info, &s, &req(OutputSpace::Srgb, None)).image; + let same = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image; + assert_eq!(plain, same); + let warned = + render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false }))).image; + let n = red(&warned); + assert!(n > 0 && n < warned.len(), "{n}"); + // …and nothing is flagged on an unsaturated edit of a neutral ramp + let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]); + let g = render( + &grey, + &info, + &DevelopSettings::default(), + &RenderRequest { proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true }), ..RenderRequest::fit(64, 8) }, + ); + assert_eq!(red(&g.image) + blue(&g.image), 0); + + // a wide-gamut render proofed for sRGB loses the colours sRGB can't hold + let wide = render(&src, &info, &s, &req(OutputSpace::DisplayP3, None)).image; + let proofed = render(&src, &info, &s, &req(OutputSpace::DisplayP3, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image; + assert_ne!(wide, proofed); + // proofing a wider space than the display: what the display can't show is flagged blue + let pro = + render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true }))) + .image; + assert!(blue(&pro) > 0); + assert!(red(&pro) < n, "ProPhoto holds more than sRGB"); +} diff --git a/crates/pipeline/src/tests_local.rs b/crates/pipeline/src/tests_local.rs index 3ccf961b5..f91756cf2 100644 --- a/crates/pipeline/src/tests_local.rs +++ b/crates/pipeline/src/tests_local.rs @@ -22,7 +22,7 @@ fn noise(x: usize, y: usize, k: u32) -> f32 { 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.masks.push(Mask { id: 1, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() }); s } diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 6b85113e1..99e45449a 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -310,7 +310,7 @@ mod tests { 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() }); + s.masks.push(Mask { id: 7, components: vec![MaskComponent { name: None, 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 }; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 855c02d19..805b862cc 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -130,6 +130,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.zoom100", "---", "view.clipping", + "view.softProof", "view.maskOverlay", "view.maskOverlayMode", "view.maskOverlayColor", @@ -256,6 +257,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), + "view.softProof" => Some(u.soft_proof), "view.maskOverlay" => Some(u.mask_overlay), "view.maskPins" => Some(u.mask_pins), "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7fbee3cb..5112b531e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -37,6 +37,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), + // in grids S expands/collapses stacks (the engine command it shadows) + ("view.softProof", "Soft Proofing", Some("S"), "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) @@ -422,6 +424,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {on?, space?, destWarning?, displayWarning?}; no params toggles + let has = |k: &str| p.get(k).is_some(); + if let Some(s) = p.get("space").and_then(Value::as_str) { + match lightcraft_engine::pipeline::OutputSpace::parse(s) { + Some(sp) => app.ui.proof.space = sp, + None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), + } + } + if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { + app.ui.proof.dest_warning = b; + } + if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { + app.ui.proof.display_warning = b; + } + app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { + Some(b) => b, + None if has("space") || has("destWarning") || has("displayWarning") => app.ui.soft_proof, + None => !app.ui.soft_proof, + }; + if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } + let pr = app.ui.proof; + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) + } "view.histogram" => { app.ui.histogram = !app.ui.histogram; Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index f0aa267f2..ecbb0e10a 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -186,7 +186,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; - let job = job.with_overlay(view_overlay(app, &d)); + let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); app.renderer.request(Slot::Main, job, 100); } // hovering a preset or profile: the photo with that look, shown instead of the loupe render @@ -249,6 +249,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; + // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it + if app.ui.soft_proof && !fullscreen { + p.rect_filled(canvas, 0.0, Color32::from_gray(238)); + let label = format!("Proof Preview · {}", app.ui.proof.space.label()); + p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); + } let shown; if split { let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index d3a1afdd0..ece118c65 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -2,7 +2,7 @@ //! sections, built from the engine's control specs. use egui::epaint::{Mesh, Vertex}; -use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId; use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls}; use lightcraft_geom::Point; @@ -80,6 +80,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.ui.histogram { histogram(app, ui, id); } + if app.ui.soft_proof { + soft_proofing(app, ui, id); + } // header: Edit + Auto / B&W / HDR let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover()); ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, "Edit", t.semibold(15.0), t.text); @@ -513,6 +516,53 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } +// ------------------------------------------------------------------------------ soft proofing + +/// The Soft Proofing strip under the histogram: proof profile, gamut warnings, Create Proof Copy. +fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + use lightcraft_engine::pipeline::OutputSpace; + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("Soft Proofing").color(t.text).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut dest = app.ui.proof.dest_warning; + let r = ui + .toggle_value(&mut dest, RichText::new("■").color(Color32::from_rgb(255, 40, 40))) + .on_hover_text("Show destination gamut warning"); + register(ui.ctx(), "button:proofDestWarning", r.rect); + let mut disp = app.ui.proof.display_warning; + let r2 = + ui.toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))).on_hover_text("Show display gamut warning"); + register(ui.ctx(), "button:proofDisplayWarning", r2.rect); + if dest != app.ui.proof.dest_warning || disp != app.ui.proof.display_warning { + let _ = app.run("view.softProof", json!({"destWarning": dest, "displayWarning": disp})); + } + }); + }); + ui.horizontal(|ui| { + ui.label(RichText::new("Profile:").color(t.text_label)); + let cur = app.ui.proof.space; + egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(cur.label()).show_ui(ui, |ui| { + for s in OutputSpace::ALL { + if ui.selectable_label(cur == s, s.label()).clicked() { + let _ = app.run("view.softProof", json!({"space": s})); + } + } + }); + }); + let r = ui.button("Create Proof Copy"); + register(ui.ctx(), "button:createProofCopy", r.rect); + if r.clicked() { + let name = format!("Proof Copy ({})", app.ui.proof.space.label()); + if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { + app.toast(ui.ctx(), "Proof copy created"); + } + } + }); + divider(ui); +} + // ------------------------------------------------------------------------------ tone curve fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index ce7837b56..0f501f9fc 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -200,7 +200,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { divider(ui); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 8 }).show(ui, |ui| { for (i, c) in m.components.iter().enumerate() { - let (label, icon) = kind_label(&c.shape); + let (kind, icon) = kind_label(&c.shape); + let label = c.name.clone().unwrap_or_else(|| kind.to_string()); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); paint(ui.painter(), r, icon, t.text_label); @@ -209,7 +210,35 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { lightcraft_develop::MaskOp::Subtract => "− ", lightcraft_develop::MaskOp::Intersect => "∩ ", }; - ui.label(format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" })); + if let Some((_, _, name)) = app.ui.renaming_component.as_mut().filter(|(mid, k, _)| *mid == m.id && *k == i) { + // inline rename: Enter (or leaving the field) commits, Escape cancels + let r = ui.add(egui::TextEdit::singleline(name).desired_width(ui.available_width() - 30.0).id_salt(("compRename", m.id, i))); + register(ui.ctx(), format!("componentRename:{i}"), r.rect); + if !r.has_focus() && !r.lost_focus() { + r.request_focus(); + } + if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) { + app.ui.renaming_component = None; + } else if r.lost_focus() { + let name = name.trim().to_string(); + app.ui.renaming_component = None; + if Some(&name) != c.name.as_ref() { + let _ = app.run("mask.component", json!({"id": m.id, "component": i, "action": "rename", "name": name})); + } + } + return; + } + let resp = ui.add(egui::Label::new(format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" })).sense(Sense::click())); + register(ui.ctx(), format!("component:{i}"), resp.rect); + if resp.double_clicked() { + app.ui.renaming_component = Some((m.id, i, label.clone())); + } + resp.context_menu(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let more = ui.small_button("…").on_hover_text("Component options"); + register(ui.ctx(), format!("button:componentMenu{i}"), more.rect); + egui::Popup::menu(&more).show(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); + }); }); range_controls(app, ui, i, &c.shape); } @@ -219,6 +248,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add")); let sub = text_button(ui, "maskSubComp", "Subtract", false); egui::Popup::menu(&sub).show(|ui| component_menu(app, ui, "subtract")); + let int = text_button(ui, "maskIntComp", "Intersect", false); + egui::Popup::menu(&int).show(|ui| component_menu(app, ui, "intersect")); if text_button(ui, "maskInvert", "Invert", m.invert).clicked() { let _ = app.run("mask.invert", json!({})); } @@ -470,6 +501,40 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape } } +/// The right-click / "…" menu of one component of the selected mask. +fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: usize, label: &str, count: usize) { + let mut run = |ui: &mut egui::Ui, text: &str, p: serde_json::Value| { + if ui.button(text).clicked() { + let mut p = p; + p["id"] = json!(mask); + p["component"] = json!(k); + let _ = app.run("mask.component", p); + ui.close(); + } + }; + run(ui, "Invert", json!({"action": "invert"})); + run(ui, &format!("Duplicate \"{label}\""), json!({"action": "duplicate"})); + if k > 0 { + ui.menu_button("Mode", |ui| { + for (op, text) in [("add", "Add"), ("subtract", "Subtract"), ("intersect", "Intersect")] { + run(ui, text, json!({"action": "op", "op": op})); + } + }); + } + ui.menu_button("Intersect with", |ui| component_menu(app, ui, "intersect")); + ui.menu_button("Subtract", |ui| component_menu(app, ui, "subtract")); + ui.separator(); + if ui.button("Rename…").clicked() { + app.ui.renaming_component = Some((mask, k, label.to_string())); + ui.close(); + } + let del = if count == 1 { format!("Delete \"{label}\" (and the mask)") } else { format!("Delete \"{label}\"") }; + if ui.button(del).clicked() { + let _ = app.run("mask.component", json!({"id": mask, "component": k, "action": "delete"})); + ui.close(); + } +} + fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { for (kind, label) in [ ("brush", "Brush"), @@ -479,6 +544,10 @@ fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { ("subject", "Subject"), ("luminanceRange", "Luminance Range"), ] { + // painting only adds or erases + if kind == "brush" && op == "intersect" { + continue; + } if ui.button(label).clicked() { if kind == "brush" { app.ui.tool = "brush".into(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index eb1ca5aa0..ae275ac2f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -170,6 +170,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { cull(app, "photo.label", json!({"label": l}), false); + } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { + // S in a grid: expand / collapse the stack (Lightroom's Library binding) + let _ = app.run("stack.toggle", json!({})); } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { let d = if f == "library.next" { 1 } else { -1 }; let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index eb35afa62..3bd16407e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -199,6 +199,9 @@ pub struct UiState { pub single_panel: bool, pub show_clipping: bool, pub histogram: bool, + /// Soft proofing (S in the loupe): render as `proof` would hold the photo. + pub soft_proof: bool, + pub proof: lightcraft_engine::pipeline::Proof, /// 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, @@ -237,6 +240,8 @@ pub struct UiState { /// A mask being renamed in the Masks list: its id and the edited name. #[serde(skip)] pub renaming_mask: Option<(u32, String)>, + /// A mask component being renamed inline: (mask id, component index, name). + pub renaming_component: Option<(u32, usize, String)>, /// Close the window on the next frame (File → Quit). #[serde(skip)] pub quit: bool, @@ -472,6 +477,8 @@ impl Default for UiState { single_panel: false, show_clipping: false, histogram: true, + soft_proof: false, + proof: lightcraft_engine::pipeline::Proof { dest_warning: false, ..Default::default() }, mask_overlay: true, mask_overlay_mode: "color".into(), mask_overlay_color: [230, 30, 40], @@ -489,6 +496,7 @@ impl Default for UiState { search: String::new(), focus_search: false, renaming_mask: None, + renaming_component: None, quit: false, dragging_photos: None, curve_channel: "parametric".into(), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 463fb2985..4cbead74a 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -565,3 +565,46 @@ fn reference_view_pins_a_photo_beside_the_active_one() { h.settle(SETTLE); assert!(h.app.renderer.textures.get(&crate::render::Slot::Compare(0)).is_some_and(|t| t.photo == first), "the reference is drawn"); } + +#[test] +fn soft_proofing_flags_out_of_gamut_colours_and_makes_proof_copies() { + let mut h = detail("panel.edit"); + if h.app.ui.right != RightPanel::Edit { + exec(&mut h, "panel.edit", json!({})); + } + assert_eq!(h.app.ui.right, RightPanel::Edit); + h.app.renderer.keep_pixels = true; + exec(&mut h, "develop.set", json!({"control": "color.saturation", "value": 100})); + exec(&mut h, "develop.set", json!({"control": "color.vibrance", "value": 100})); + let red = |h: &Headless| { + let t = h.app.renderer.textures.get(&crate::render::Slot::Main).expect("loupe render"); + t.pixels.as_ref().expect("pixels kept").pixels.iter().filter(|c| c.r() == 255 && c.g() == 0 && c.b() == 0).count() + }; + h.settle(SETTLE); + let before = red(&h); + // S in the loupe: soft proofing on; the warning is the proof's own setting + let r = h.request("ui.key", json!({"key": "s"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.soft_proof); + let st = exec(&mut h, "view.softProof", json!({"space": "srgb", "destWarning": true})); + assert_eq!(st, json!({"on": true, "space": "srgb", "destWarning": true, "displayWarning": false})); + h.settle(SETTLE); + assert!(red(&h) > before, "out-of-gamut colours are painted red"); + let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"space": "cmyk"}}), T); + assert_ne!(r["ok"], true); + // Create Proof Copy: a virtual copy named after the proof + let n = h.app.session.catalog.photos().count(); + let r = h.request("ui.clickWidget", json!({"id": "button:createProofCopy"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.catalog.photos().count(), n + 1); + let copy = (**h.app.session.catalog.photos().max_by_key(|p| p.id.0).unwrap()).clone(); + assert_eq!(copy.copy_name.as_deref(), Some("Proof Copy (sRGB)")); + // S again turns it off; in a grid S is Expand/Collapse Stack (the copy made a stack) + h.request("ui.key", json!({"key": "s"}), T); + assert!(!h.app.ui.soft_proof); + exec(&mut h, "view.photoGrid", json!({})); + let stack = h.app.session.catalog.stack_of(copy.id).cloned().expect("stacked with its original"); + h.request("ui.key", json!({"key": "s"}), T); + assert!(!h.app.ui.soft_proof); + assert_ne!(h.app.session.catalog.stack_of(copy.id).unwrap().collapsed, stack.collapsed, "S toggled the stack"); +} diff --git a/docs/parity.md b/docs/parity.md index 63ec338a5..0da044e7b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -45,12 +45,12 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) | -| Y. Menus | 80 | 1 | 4 | 8 | 46/47 (98%) | 23/23 (100%) | +| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 29 | 8 | 42 | 9 | — | 20/21 (95%) | -| **Total** | 382 | 25 | 93 | 37 | 193/198 (97%) | 136/144 (94%) | +| Lightroom Classic extras | 29 | 9 | 41 | 9 | — | 20/21 (95%) | +| **Total** | 383 | 25 | 92 | 37 | 194/198 (98%) | 136/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **78.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 38.3% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 500 in-scope rows — P0 99.0% of 198 · P1 96.2% of 144 · P2 38.6% of 158. ## Top gaps @@ -584,7 +584,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | -| MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`) | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; components: add/subtract only (no per-component menu) | +| MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first | | MENU-CTX-VERSION | Version menu | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (`versions`) | restore, update with current settings, rename, set as before, delete | @@ -713,7 +713,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | | LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | -| LRC-DEV-SOFTPROOF | Soft proofing | P2 | ⬜ | | | +| LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings, Create Proof Copy (a named virtual copy). Missing: printer ICC profiles, rendering intent, Simulate Paper & Ink | | LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | `cmd:develop.sync`, `cmd:develop.autoSync`, `cmd:develop.pastePrevious` | Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner | | LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | `cmd:develop.matchExposure` | Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's | | LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | |