LRC-DEV-SOFTPROOF, MENU-CTX-MASK: soft proofing with gamut warnings; mask component menu

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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 04:01:01 -04:00
1 parent db8a574fea
commit 439cbd6ca0
25 files changed
+540 -59

No files matched your search

+1
View File
@@ -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,
}
}
+1
View File
@@ -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 {
+3
View File
@@ -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<String>,
#[serde(default)]
pub op: MaskOp,
#[serde(default)]
+57 -4
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
}
};
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<CommandSpec> {
})
}
),
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<MaskOp> = 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();
+7 -6
View File
@@ -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<Option<PhotoId>> {
/// copy is visible). `name` replaces the default "Copy N".
fn virtual_copy(s: &mut Session, src: PhotoId, name: Option<&str>) -> Result<Option<PhotoId>> {
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<Option<PhotoId>> {
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<CommandSpec> {
"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);
}
}
+15
View File
@@ -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<lightcraft_pipeline::Proof>) -> Self {
if self.request.proof != proof {
let k = |p: Option<lightcraft_pipeline::Proof>| 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<StageCache>) -> Self {
self.stages = Some(stages);
@@ -432,6 +446,7 @@ pub fn develop(src: &Arc<Rgb32f>, 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;
+32
View File
@@ -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);
}
+2 -2
View File
@@ -184,8 +184,8 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
#[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()?;
+24 -7
View File
@@ -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) },
+73 -22
View File
@@ -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<crate::output::ProofParams>,
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<crate::Proof>) -> 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<crate::Proof>,
) -> 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<T: Copy + Default + Send>(
}
}
// --- 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<T: Copy + Default + Send>(
}
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]
+15 -4
View File
@@ -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<Proof>,
}
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);
+3 -2
View File
@@ -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) },
+43
View File
@@ -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")]
+43
View File
@@ -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");
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 };
+2
View File
@@ -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<bool> {
"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),
+28
View File
@@ -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<Re
app.ui.show_clipping = !app.ui.show_clipping;
Ok(Value::Null)
}
"view.softProof" => {
// {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)
+7 -1
View File
@@ -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);
+51 -1
View File
@@ -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) {
+71 -2
View File
@@ -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();
+3
View File
@@ -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) };
+8
View File
@@ -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(),
+43
View File
@@ -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");
}
+6 -6
View File
@@ -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.
<!-- /parity:summary -->
## 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.*` | |