From 7f40a7a7d9f62a0d50203cd33371a51cedc0d156 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:49:13 -0400 Subject: [PATCH 1/8] LR-EXP-COLORSPACE: export in sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB or Rec. 2020 RenderRequest gains an output space (default sRGB; previews stay sRGB). The per-pixel stage converts scene-linear Rec.2020 to the target primaries, gamut-maps into the target gamut and encodes with the target curve, on CPU and GPU (WGSL finish kernel reads the matrix, luma weights and curve from its parameter block). sRGB output is byte-identical to before on the CPU. Export embeds our own generated ICC profile for the space; AVIF stays sRGB. ExportOptions.colorSpace, export dialog and MCP export schema. Tests: P3 colour survives a P3 export (decoded through the embedded profile), every space/format round-trips its profile, GPU equivalence per space. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/lightcraft-web/src/wire.rs | 2 + crates/color/src/lib.rs | 2 +- crates/engine/src/export.rs | 130 ++++++++++++++++++-- crates/engine/src/media.rs | 10 ++ crates/gpu/src/ctx.rs | 3 +- crates/gpu/src/params.rs | 9 ++ crates/gpu/src/render.rs | 2 +- crates/gpu/src/wgsl/finish.wgsl | 32 ++++- crates/gpu/tests/equivalence.rs | 29 +++++ crates/mcp/src/tools.rs | 2 + crates/pipeline/Cargo.toml | 1 + crates/pipeline/src/finish.rs | 80 +++++++++--- crates/pipeline/src/lib.rs | 10 +- crates/pipeline/src/output.rs | 175 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 19 +++ docs/mcp.md | 2 +- docs/parity.md | 48 ++++---- 18 files changed, 500 insertions(+), 57 deletions(-) create mode 100644 crates/pipeline/src/output.rs diff --git a/Cargo.lock b/Cargo.lock index e6d6f3f3..0c1362b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2208,6 +2208,7 @@ dependencies = [ "proptest", "rayon", "serde", + "serde_json", ] [[package]] diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 6dbe81b2..9dc5dd31 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -88,6 +88,8 @@ impl WireJob { quality: if self.draft { Quality::Draft } else { Quality::Full }, apply_crop: self.apply_crop, overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1), + // workers render previews (exports run in-process) + space: lightcraft_engine::pipeline::OutputSpace::Srgb, } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 77d06185..da9ea700 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -118,7 +118,7 @@ pub const PROPHOTO: RgbSpace = RgbSpace { name: "ProPhoto RGB", r: Xy::new(0.734699, 0.265301), g: Xy::new(0.159597, 0.840403), b: Xy::new(0.036598, 0.000105), white: D50 }; /// The 1998 "Adobe RGB" primaries (a public colour-space definition; name used descriptively). pub const ADOBE_RGB: RgbSpace = - RgbSpace { name: "Adobe RGB (1998)", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 }; + RgbSpace { name: "Adobe RGB (1998) compatible", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 }; /// The develop pipeline's working space: linear Rec.2020 / D65. pub const WORKING: RgbSpace = REC2020; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 107a501b..7c27b50a 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1,9 +1,11 @@ -//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile, -//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop +//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF in sRGB, Display P3, Adobe RGB +//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the +//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop //! app, CLI, MCP and the web build share it; writing the file is the caller's job. use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc}; use lightcraft_meta::{DateTime, Gps, Metadata}; +pub use lightcraft_pipeline::OutputSpace; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; @@ -198,6 +200,8 @@ pub struct ExportOptions { pub remove_location: bool, /// Text watermark (none when absent or the text is empty). pub watermark: Option, + /// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile). + pub color_space: OutputSpace, } impl Default for ExportOptions { @@ -213,12 +217,14 @@ impl Default for ExportOptions { metadata: MetadataPolicy::All, remove_location: false, watermark: None, + color_space: OutputSpace::Srgb, } } } impl ExportOptions { - /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`). + /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, + /// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`). pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); @@ -243,9 +249,15 @@ impl ExportOptions { _ => None, } .filter(|w: &Watermark| !w.text.trim().is_empty()), + color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), } } + /// The colour space the file is actually written in (AVIF: sRGB). + pub fn effective_space(&self) -> OutputSpace { + if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space } + } + /// Output file name for photo `stem` at 1-based position `seq` in a batch. pub fn file_name(&self, stem: &str, seq: usize) -> String { let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; @@ -304,7 +316,8 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount } } -/// Encode a rendered (display-referred sRGB) image according to `o`. Resizing to `long_edge` is the +/// Encode a rendered display-referred image according to `o` (its pixels must already be in +/// `o.effective_space()`). Resizing to `long_edge` is the /// caller's job (render at that size); sharpening is applied here. pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { encode_with_metadata(img, o, None) @@ -314,10 +327,12 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { let mut img = img.clone(); output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let space = o.effective_space(); if let Some(wm) = &o.watermark { - draw_watermark(&mut img, wm); + let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() }; + draw_watermark(&mut img, &wm); } - let profile = icc::write_named(NamedSpace::Srgb); + let profile = icc::write_named(named_space(space)); let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() }; @@ -358,6 +373,31 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.map_err(|e| e.to_string()) } +/// The codecs' name of an output space (for its ICC profile). +pub fn named_space(s: OutputSpace) -> NamedSpace { + match s { + OutputSpace::Srgb => NamedSpace::Srgb, + OutputSpace::DisplayP3 => NamedSpace::DisplayP3, + OutputSpace::AdobeRgb => NamedSpace::AdobeRgb, + OutputSpace::ProPhoto => NamedSpace::ProPhoto, + OutputSpace::Rec2020 => NamedSpace::Rec2020, + } +} + +/// An 8-bit sRGB colour expressed in `space` (encoded with its curve). +pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { + if space == OutputSpace::Srgb { + return c; + } + let lin = c.map(lightcraft_color::transfer::decode_srgb8); + let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32(); + let t = space.trc(); + std::array::from_fn(|i| { + let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]; + (t.encode(v) * 255.0 + 0.5) as u8 + }) +} + /// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds. fn parse_shutter(s: &str) -> Option { let s = s.trim().trim_end_matches(['s', '"']).trim(); @@ -414,7 +454,7 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI let full = p.width.max(p.height).max(1) as usize; let size = o.long_edge.map_or(full, |l| l as usize); let meta = export_metadata(p, o); - let r = session.render_now(id, size, size)?; + let r = session.render_export(id, size, o.effective_space())?; let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?; Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height }) } @@ -516,4 +556,80 @@ mod tests { assert_eq!(o.long_edge, Some(2048)); assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg"); } + + /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. + fn p3_green(space: OutputSpace) -> Rgba8 { + use lightcraft_color::{DISPLAY_P3, REC2020}; + let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]); + let src = lightcraft_raster::Rgb32f::filled(16, 16, c); + let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image + } + + /// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the + /// recognised space of the embedded profile. + fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let m = d.space.to_space(&lightcraft_color::SRGB); + (m.apply_f32(d.image.get(8, 8)), d.space.named) + } + + #[test] + fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() { + let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; + let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap()); + assert_eq!(named, Some(NamedSpace::DisplayP3)); + assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}"); + let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap()); + assert_eq!(named, Some(NamedSpace::Srgb)); + assert!(s.iter().all(|v| *v > -0.002), "{s:?}"); + // the green itself is about the same brightness either way + assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}"); + } + + #[test] + fn every_space_embeds_its_own_profile_and_round_trips() { + let grey = { + let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]); + let base = lightcraft_pipeline::RenderRequest::fit(16, 16); + move |space| { + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }) + .image + } + }; + let reference = + decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0; + for space in OutputSpace::ALL { + for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let o = ExportOptions { format, color_space: space, ..Default::default() }; + let bytes = encode_image(&grey(space), &o).unwrap(); + let (px, named) = decoded_in_srgb(&bytes); + assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}"); + // a neutral grey decodes to the same linear value whatever the output space + for c in 0..3 { + assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}"); + } + } + let icc = icc::write_named(named_space(space)); + let info = icc::parse(&icc).expect("our profile parses"); + assert_eq!(info.named, Some(named_space(space))); + assert!(info.description.as_deref().is_some_and(|d| !d.is_empty())); + } + let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_space(), OutputSpace::Srgb); + let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"})); + assert_eq!(o.color_space, OutputSpace::DisplayP3); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb); + } + + #[test] + fn srgb_watermark_colour_is_converted() { + assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]); + for s in OutputSpace::ALL { + assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}"); + assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}"); + } + let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); + assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); + } } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e10b31a1..98adc839 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -518,6 +518,16 @@ impl crate::Session { r.rendered } + /// Synchronous render for export: like [`Self::render_now`], into colour space `space`. + pub fn render_export(&mut self, id: PhotoId, max: usize, space: lightcraft_pipeline::OutputSpace) -> Result { + let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?; + job.request.space = space; + job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f); + let r = job.run(); + self.accept(&r); + r.rendered + } + /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result, String> { let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 010ff7ba..dad0a42b 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -125,8 +125,7 @@ fn constants() -> String { use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N}; let mut s = String::new(); let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices(); - let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32(); - for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab), ("TO_SRGB", to_srgb)] { + for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] { let rows: Vec = m.iter().map(|r| format!("vec3({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect(); s += &format!("const {name} = array, 3>({});\n", rows.join(", ")); } diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index b18461a2..279692c9 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -66,6 +66,10 @@ const FIELDS: &[(&str, usize)] = &[ ("CALIB", 1), ("CALIB_M", 9), ("SHADOW_TINT", 1), + ("OUT_M", 9), + ("OUT_Y", 3), + ("OUT_TRC", 1), + ("OUT_GAMMA", 1), ]; /// `(name, index)` of every field (for the WGSL constants). @@ -212,6 +216,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.fs("CALIB_M", m.as_flattened()); } p.f("SHADOW_TINT", fp.shadow_tint); + p.fs("OUT_M", fp.to_out.as_flattened()); + p.fs("OUT_Y", &fp.out_luma); + let (trc, gamma) = fp.out_trc.code(); + p.u("OUT_TRC", trc); + p.f("OUT_GAMMA", gamma); if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0174da93..8f0bf08f 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -460,7 +460,7 @@ pub fn render( lap("masks", &mut t, &mut cx); // 5. per-pixel stage - let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air); + let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() }; let (p, aux) = finish_block(&fp, &terms, &present); let aux = gpu.upload(&aux); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d405e949..71b20d5b 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -218,6 +218,26 @@ fn enc8(v: f32) -> u32 { return u32(clamp(v, 0.0, 1.0) * 255.0 + 0.5); } +// sRGB-curve-encoded value → the output space's own curve (`OutputTrc`: 0 sRGB, 1 gamma, 2 Rec.709). +fn out_encode(v: f32) -> f32 { + let kind = pu(F_OUT_TRC); + if (kind == 0u) { + return v; + } + let e = clamp(v, 0.0, 1.0); + var l = e / 12.92; + if (e > 0.04045) { + l = pow((e + 0.055) / 1.055, 2.4); + } + if (kind == 1u) { + return pow(l, 1.0 / pf(F_OUT_GAMMA)); + } + if (l < 0.018) { + return l * 4.5; + } + return 1.099 * pow(l, 0.45) - 0.099; +} + @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) gid: vec3) { let w = pu(F_W); @@ -399,9 +419,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } - // --- gamut map to sRGB (desaturate towards luminance until in range) - var r = mul3(TO_SRGB, d); - let yy = clamp(0.2126 * r.x + 0.7152 * r.y + 0.0722 * r.z, 0.0, 1.0); + // --- gamut map to the output space (desaturate towards luminance until in range) + let om = array, 3>( + vec3(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)), + vec3(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)), + vec3(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)), + ); + var r = mul3(om, d); + let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0); var tg = 1.0; for (var k = 0u; k < 3u; k++) { let cc = r[k]; @@ -439,5 +464,6 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e + k; } + e = vec3(out_encode(e.x), out_encode(e.y), out_encode(e.z)); out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u); } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index d6fdab54..61911476 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -462,3 +462,32 @@ fn red_eye_matches() { }; check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600)); } + +fn saturated(s: &mut DevelopSettings) { + typical(s); + s.color.saturation = 70.0; + s.color.vibrance = 40.0; + s.curve.highlights = 30.0; + s.curve.shadows = -20.0; + s.grain.amount = 30.0; +} + +#[test] +fn output_spaces_match() { + if !gpu() { + return; + } + // Export colour spaces: matrix to the target primaries, gamut mapping into the target gamut and + // its encoding curve, on both renderers. Saturated edits push colours to the gamut edges. + use lightcraft_pipeline::OutputSpace; + let src = scene(0, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + for space in OutputSpace::ALL { + for (name, edit) in [("typical", typical as Edit), ("saturated + curves + grain", saturated as Edit)] { + let mut s = DevelopSettings::default(); + edit(&mut s); + let req = RenderRequest { space, ..RenderRequest::fit(640, 640) }; + check(&format!("{space:?} {name}"), &src, &raw, &s, &req); + } + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 663c8f08..57f289c1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -188,6 +188,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "naming": {"type": "string", "description": "e.g. {name}-{seq}"}, "metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]}, "removeLocation": {"type": "boolean"}, + "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} }), &[], @@ -534,6 +535,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "metadata", "removeLocation", "watermark", + "colorSpace", ], ), ) diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index ff997b5c..27d77816 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -20,3 +20,4 @@ serde = { workspace = true } lightcraft-scenes = { workspace = true } proptest = { workspace = true } image = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 0ab6199e..83f1c033 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -1,8 +1,8 @@ //! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass. +use lightcraft_color::luminance_2020; use lightcraft_color::spline::{Lut1, MonotoneCurve}; use lightcraft_color::transfer::linear_to_srgb; -use lightcraft_color::{REC2020, SRGB, luminance_2020}; use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle}; use lightcraft_geom::Point; use lightcraft_raster::Rgba8; @@ -10,6 +10,7 @@ use lightcraft_raster::Rgba8; use crate::colorops::ColorOps; use crate::geometry::Frame; use crate::local::log_lum; +use crate::output::{OutputSpace, OutputTrc}; use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; @@ -153,7 +154,11 @@ pub struct FinishParams { /// Refine Saturation as 0..1 (1 = the curves' own saturation). pub refine_sat: f32, pub vig: Option, - pub to_srgb: [[f32; 3]; 3], + /// Linear Rec.2020 → linear output RGB, the output's luminance weights (gamut mapping) and its + /// encoding curve (see [`crate::output`]). + pub to_out: [[f32; 3]; 3], + pub out_luma: [f32; 3], + pub out_trc: OutputTrc, pub hl: f32, pub sh: f32, pub clar: f32, @@ -178,8 +183,18 @@ pub struct FinishParams { } impl FinishParams { - /// Parameters for a `w × h` render; `ev`/`air_pre` as in [`crate::Prepared`]. - pub fn new(s: &DevelopSettings, frame: &Frame, info: &SourceInfo, w: usize, h: usize, px_per_long: f64, air_pre: f32) -> FinishParams { + /// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`]. + #[allow(clippy::too_many_arguments)] + pub fn new( + s: &DevelopSettings, + frame: &Frame, + info: &SourceInfo, + w: usize, + h: usize, + px_per_long: f64, + air_pre: f32, + space: OutputSpace, + ) -> FinishParams { let effects = s.section_enabled("effects"); let (clar, tex, dehaze) = if effects { ((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32) @@ -205,7 +220,9 @@ impl FinishParams { curves: curve_luts(&s.curve), refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32, vig: if effects { vignette(s) } else { None }, - to_srgb: REC2020.to_space(&SRGB).to_f32(), + to_out: space.from_working(), + out_luma: space.luma(), + out_trc: space.trc(), hl: (s.light.highlights / 100.0) as f32, sh: (s.light.shadows / 100.0) as f32, clar, @@ -228,10 +245,45 @@ impl FinishParams { } } -pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo) -> Rgba8 { +pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> 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); - let FinishParams { tone, ops, curves, vig, to_srgb, hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev, grain, .. } = &fp; + let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let trc = fp.out_trc; + let data = finish_with(p, &fp, |e| match trc { + OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255], + t => { + let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0))))); + [x[0], x[1], x[2], 255] + } + }); + Rgba8 { width: w, height: h, data } +} + +/// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB +/// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding. +pub(crate) fn finish_with(p: &Prepared, fp: &FinishParams, store: impl Fn([f32; 3]) -> T + Sync + Send) -> Vec { + let (w, h) = (p.img.width, p.img.height); + let FinishParams { + tone, + ops, + curves, + vig, + to_out, + out_luma, + hl, + sh, + clar, + tex, + dehaze, + sharpen, + sharpen_mask, + air, + air_pre, + gain, + ev, + grain, + .. + } = fp; let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) = (*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev); let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); @@ -240,8 +292,8 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So let aspect = w as f32 / h as f32; let srgb = srgb_lut(); - let mut out = Rgba8::new(w, h); - for_rows(&mut out.data, w, |y, row| { + let mut out = vec![T::default(); w * h]; + for_rows(&mut out, w, |y, row| { for (x, px) in row.iter_mut().enumerate() { let i = y * w + x; let raw = p.img.data[i]; @@ -388,14 +440,14 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So } } - // --- gamut map to sRGB (desaturate towards luminance until in range) - let m = to_srgb; + // --- 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 = (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]).clamp(0.0, 1.0); + 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 { @@ -427,7 +479,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e.map(|v| v + k); } - *px = [enc(e[0]), enc(e[1]), enc(e[2]), 255]; + *px = store(e); } }); out diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 548febbd..0b759654 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -11,7 +11,7 @@ //! clarity, local adjustments (masks) //! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation //! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh) -//! 5. display — gamut map to sRGB, encode, tone curves (parametric + point), vignette, grain +//! 5. display — gamut map to the output space (sRGB unless [`RenderRequest::space`] says otherwise), encode, tone curves (parametric + point), vignette, grain //! //! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a //! 60 MP export look alike. @@ -29,6 +29,7 @@ pub mod geometry; pub mod local; pub mod masks; pub mod optics; +pub mod output; pub mod profiles; pub mod redeye; pub mod spots; @@ -37,6 +38,7 @@ pub mod transform; pub mod upright; pub mod visualize; +pub use output::{OutputSpace, OutputTrc}; pub use visualize::Overlay; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -84,11 +86,13 @@ pub struct RenderRequest { pub apply_crop: bool, /// A diagnostic view drawn over the result (e.g. Point Color's "visualize range"). pub overlay: Overlay, + /// Colour space of the result (exports; previews stay sRGB). + pub space: OutputSpace, } 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 } + Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb } } } @@ -344,7 +348,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende if let Some((a, c)) = shared { c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); } - let image = finish::finish(&prep, s, frame, info); + let image = finish::finish(&prep, s, frame, info, req.space); lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs new file mode 100644 index 00000000..fc532bb7 --- /dev/null +++ b/crates/pipeline/src/output.rs @@ -0,0 +1,175 @@ +//! Output encoding: the colour space (primaries + transfer curve) and sample format a render is +//! delivered in. Previews are always 8-bit sRGB; exports may ask for a wide-gamut space and/or +//! 16-bit or linear float samples. +//! +//! The per-pixel stage works in scene-linear Rec.2020 and ends by converting to the target's +//! primaries, gamut mapping into the *target* gamut, and encoding. Tone curves and grain operate on +//! sRGB-curve-encoded values of the target primaries (identical to the sRGB path when the target is +//! sRGB); afterwards the values are re-encoded with the target's own curve. + +use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, PROPHOTO, REC2020, RgbSpace, SRGB}; +use serde::{Deserialize, Serialize}; + +/// The RGB space an image is rendered into. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OutputSpace { + #[default] + Srgb, + DisplayP3, + /// Primaries and gamma of the published Adobe RGB (1998) specification ("compatible": our own + /// profile, built from the published numbers). + AdobeRgb, + ProPhoto, + Rec2020, +} + +/// The encoding curve of an [`OutputSpace`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OutputTrc { + /// IEC 61966-2-1 (sRGB, Display P3). + Srgb, + /// Pure power law: encoded = linear^(1/g). + Gamma(f32), + /// ITU-R BT.709 / BT.2020 OETF. + Rec709, +} + +impl OutputTrc { + /// Linear → encoded (input clamped to 0..1). + pub fn encode(self, v: f32) -> f32 { + let v = v.clamp(0.0, 1.0); + match self { + OutputTrc::Srgb => lightcraft_color::transfer::linear_to_srgb(v), + OutputTrc::Gamma(g) => v.powf(1.0 / g), + OutputTrc::Rec709 => { + if v < 0.018 { + v * 4.5 + } else { + 1.099 * v.powf(0.45) - 0.099 + } + } + } + } + + /// Encoded → linear (input clamped to 0..1). + pub fn decode(self, e: f32) -> f32 { + let e = e.clamp(0.0, 1.0); + match self { + OutputTrc::Srgb => lightcraft_color::transfer::srgb_to_linear(e), + OutputTrc::Gamma(g) => e.powf(g), + OutputTrc::Rec709 => { + if e < 0.081 { + e / 4.5 + } else { + ((e + 0.099) / 1.099).powf(1.0 / 0.45) + } + } + } + } + + /// Code for the GPU kernel: 0 sRGB, 1 gamma, 2 Rec.709. + pub fn code(self) -> (u32, f32) { + match self { + OutputTrc::Srgb => (0, 1.0), + OutputTrc::Gamma(g) => (1, g), + OutputTrc::Rec709 => (2, 1.0), + } + } +} + +impl OutputSpace { + pub const ALL: [OutputSpace; 5] = [OutputSpace::Srgb, OutputSpace::DisplayP3, OutputSpace::AdobeRgb, OutputSpace::ProPhoto, OutputSpace::Rec2020]; + + pub fn rgb_space(self) -> RgbSpace { + match self { + OutputSpace::Srgb => SRGB, + OutputSpace::DisplayP3 => DISPLAY_P3, + OutputSpace::AdobeRgb => ADOBE_RGB, + OutputSpace::ProPhoto => PROPHOTO, + OutputSpace::Rec2020 => REC2020, + } + } + + pub fn trc(self) -> OutputTrc { + match self { + OutputSpace::Srgb | OutputSpace::DisplayP3 => OutputTrc::Srgb, + OutputSpace::AdobeRgb => OutputTrc::Gamma(563.0 / 256.0), + OutputSpace::ProPhoto => OutputTrc::Gamma(1.8), + OutputSpace::Rec2020 => OutputTrc::Rec709, + } + } + + /// User-facing name. + pub fn label(self) -> &'static str { + match self { + OutputSpace::Srgb => "sRGB", + OutputSpace::DisplayP3 => "Display P3", + OutputSpace::AdobeRgb => "Adobe RGB (1998) compatible", + OutputSpace::ProPhoto => "ProPhoto RGB", + OutputSpace::Rec2020 => "Rec. 2020", + } + } + + pub fn parse(s: &str) -> Option { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + Some(match k.as_str() { + "srgb" => OutputSpace::Srgb, + "displayp3" | "p3" => OutputSpace::DisplayP3, + "adobergb" | "adobergb1998" | "adobergb1998compatible" | "widegamutrgb" => OutputSpace::AdobeRgb, + "prophoto" | "prophotorgb" | "rommrgb" => OutputSpace::ProPhoto, + "rec2020" | "bt2020" | "itur2020" => OutputSpace::Rec2020, + _ => return None, + }) + } + + /// Linear Rec.2020 → linear target RGB (Bradford-adapted for D50 targets), row-major. + pub fn from_working(self) -> [[f32; 3]; 3] { + REC2020.to_space(&self.rgb_space()).to_f32() + } + + /// Luminance weights of the target's linear RGB, as the gamut mapper uses them. sRGB keeps the + /// rounded BT.709 weights the pipeline has always used (byte-identical previews and exports). + pub fn luma(self) -> [f32; 3] { + match self { + OutputSpace::Srgb => [0.2126, 0.7152, 0.0722], + o => o.rgb_space().luma().map(|v| v as f32), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trcs_invert() { + for o in OutputSpace::ALL { + let t = o.trc(); + for i in 0..=100 { + let x = i as f32 / 100.0; + assert!((t.decode(t.encode(x)) - x).abs() < 1e-5, "{o:?} {x}"); + } + } + } + + #[test] + fn parse_and_serde_names() { + for o in OutputSpace::ALL { + let s = serde_json::to_value(o).unwrap(); + assert_eq!(OutputSpace::parse(s.as_str().unwrap()), Some(o)); + assert_eq!(OutputSpace::parse(o.label()), Some(o), "{}", o.label()); + } + } + + #[test] + fn white_maps_to_white() { + for o in OutputSpace::ALL { + let m = o.from_working(); + for row in m { + assert!((row.iter().sum::() - 1.0).abs() < 1e-4, "{o:?}"); + } + assert!((o.luma().iter().sum::() - 1.0).abs() < 1e-4); + } + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0ac33308..ac684fc7 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -109,6 +109,24 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")], &mut opts.format, ); + if opts.format == F::Avif { + ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim)); + } else { + use lightcraft_engine::export::OutputSpace as C; + choices( + ui, + "Color space", + "exportColorSpace", + &[ + (C::Srgb, "sRGB"), + (C::DisplayP3, "P3"), + (C::AdobeRgb, "Adobe RGB"), + (C::ProPhoto, "ProPhoto"), + (C::Rec2020, "Rec.2020"), + ], + &mut opts.color_space, + ); + } if matches!(opts.format, F::Jpeg | F::Avif) { let mut q = opts.quality as f64; if num(ui, &QUALITY, &mut q) { @@ -292,6 +310,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), diff --git a/docs/mcp.md b/docs/mcp.md index 02bdcb09..8beaa832 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | -| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | +| `export {path, id?, longEdge?, quality?, colorSpace?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | Tools taking `id` make that photo active first; without it they act on the active photo. diff --git a/docs/parity.md b/docs/parity.md index 19d1af2d..f8e1321c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -55,41 +55,39 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on. -1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / - Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. -2. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") +1. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") and offer built-in export presets (small JPEG, full JPEG, TIFF). Medium value, low effort. -3. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. -4. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is +2. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. +3. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is rendered but has no UI. Low effort. -5. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. -6. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask +4. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. +5. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. -7. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local +6. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. -8. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, +7. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, feather/opacity sliders, `[`/`]` size keys. Medium effort. -9. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. -10. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. -11. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; +8. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +9. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. +10. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; live preview while hovering presets/profiles/versions. Medium effort. -12. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). -13. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -14. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), +11. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). +12. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +13. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), XMP prefs, cache, GPU. Medium effort. -15. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination +14. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination album; browse folders before adding. Medium effort. -16. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in +15. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. -17. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +16. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). -18. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -19. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. -20. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). -21. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — +17. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +18. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +19. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). +20. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — missing develop controls (calibration matters for XMP interop). -22. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. -23. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +21. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +22. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -444,7 +442,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only | | LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate | -| LR-EXP-COLORSPACE | Output colour space | P0 | ⬜ | `crates/engine/src/export.rs` | sRGB only | +| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) | | LR-EXP-HDR | HDR output | P2 | ⬜ | | | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | From ca8db6e028bb2a1854cc0b6186e1e7feb2f8eca3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:58:27 -0400 Subject: [PATCH 2/8] LR-MASK-OVERLAY + LR-MASK-PINS: rendered mask overlay (5 views, colour/opacity, O / Shift+O) and draggable pins per component The selected mask's evaluated alpha is drawn by the renderer as Overlay::Mask (CPU and GPU share the alpha the render used; hidden masks are evaluated on demand), kept out of the view-preview cache. Pins for radial/linear/brush components select their mask on click and move the component on drag; view.maskPins hides them. CPU/GPU equivalence and headless keyboard/pointer tests. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/wire.rs | 2 +- crates/gpu/src/render.rs | 27 +++- crates/gpu/tests/equivalence.rs | 37 ++++++ crates/pipeline/src/lib.rs | 16 ++- crates/pipeline/src/masks.rs | 69 +++++----- crates/pipeline/src/visualize.rs | 172 ++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menubar.rs | 3 + crates/ui-egui/src/menus.rs | 58 ++++++++- crates/ui-egui/src/panels/detail.rs | 184 ++++++++++++++++----------- crates/ui-egui/src/panels/masking.rs | 60 +++++++++ crates/ui-egui/src/shortcuts.rs | 5 + crates/ui-egui/src/state.rs | 13 ++ crates/ui-egui/src/tests_masking.rs | 91 +++++++++++++ docs/parity.md | 10 +- 15 files changed, 627 insertions(+), 123 deletions(-) create mode 100644 crates/ui-egui/src/tests_masking.rs diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 6dbe81b2..835721c6 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -47,7 +47,7 @@ pub struct WireJob { pub stages: Option, /// The request's diagnostic overlay (`Overlay::to_parts`). #[serde(default)] - pub overlay: (u8, f32), + pub overlay: (u8, f64), } impl WireJob { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0174da93..59a5370a 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -135,6 +135,14 @@ impl<'a> Cx<'a> { out } + /// `len` values of `b` from `offset` (both in 32-bit words), as a new buffer (recorded). + pub fn slice(&mut self, b: &Buf, offset: usize, len: usize) -> Buf { + let out = self.gpu.buffer(len); + let enc = self.enc.get_or_insert_with(|| self.gpu.encoder()); + enc.copy_buffer_to_buffer(b.raw(), (offset * 4) as u64, out.raw(), 0, (len * 4) as u64); + out + } + pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f { Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) } } @@ -481,13 +489,30 @@ pub fn render( ], groups2(w, h, [16, 16]), ); + // the alpha a mask overlay shows: copied out before the readback below submits + let overlay_mask = req.overlay.mask(s).map(|m| { + let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()); + match (list.map(|m| m.id).position(|id| id == m.id), masks.as_ref()) { + (Some(mi), Some(all)) => Ok(cx.slice(all, mi * n, n)), + _ => Err(m), + } + }); let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t, &mut cx); let mut image = image; - lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan); + let overlay_mask = overlay_mask.map(|r| match r { + Ok(b) => cx.read_plane(&b, w, h), + // a hidden mask: evaluate it on the CPU + Err(m) => { + let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(&lin, w, h))).clone(); + let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone(); + lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32) + } + }); + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); Some(Rendered { image, histogram }) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index d6fdab54..0a15922c 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -414,6 +414,43 @@ fn overlays_match() { eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0); assert!(differ < 0.002, "spots t={t}: {differ}"); } + // Mask overlays: the alpha the GPU evaluated (or, for a hidden mask, the CPU's) drawn the same way. + let mut s = DevelopSettings::default(); + let stroke = BrushStroke { points: vec![Point::new(0.2, 0.3), Point::new(0.6, 0.5)], size: 0.05, ..Default::default() }; + s.masks = vec![ + Mask { + id: 1, + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false }, + }], + ..Default::default() + }, + Mask { + id: 2, + components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }], + ..Default::default() + }, + Mask { + id: 3, + visible: false, + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, + }], + ..Default::default() + }, + ]; + s.masks[0].adjust.exposure = 0.5; + use lightcraft_pipeline::{MaskView, Overlay}; + for (id, view) in + [(1, MaskView::Color), (2, MaskView::ColorOnBw), (2, MaskView::WhiteOnBlack), (3, MaskView::ImageOnWhite), (1, MaskView::ImageOnBlack)] + { + let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) }; + check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req); + } } #[test] diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 548febbd..ad6b56e7 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -37,7 +37,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use visualize::Overlay; +pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; use std::any::Any; @@ -349,10 +349,22 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); let mut image = image; - visualize::apply(&mut image, req.overlay, &plan); + let mask = overlay_alpha(req.overlay, &plan, &prep); + visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); Rendered { image, histogram } } +/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a +/// fresh evaluation. +fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + let m = o.mask(&plan.settings)?; + if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { + return Some(e.alpha.clone()); + } + let ev = plan.settings.light.exposure as f32; + Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev)) +} + /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" /// (default look, keeping the crop so framing matches). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 8f1796a7..33af94d3 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -11,6 +11,8 @@ use crate::for_rows; use crate::geometry::Frame; pub struct Evaluated { + /// The mask's id ([`Mask::id`]). + pub id: u32, pub alpha: Plane, pub adjust: LocalAdjustments, } @@ -21,44 +23,49 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 { t * t * (3.0 - 2.0 * t) } +/// The visible masks with components, evaluated in order. pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec { masks .iter() .filter(|m| m.visible && !m.components.is_empty()) - .map(|m| { - let mut alpha = Plane::new(w, h); - let mut first = true; - for comp in &m.components { - let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev); - if comp.invert { - c.data.iter_mut().for_each(|v| *v = 1.0 - *v); - } - if first && comp.op != MaskOp::Intersect { - alpha = c; - first = false; - continue; - } - for (a, c) in alpha.data.iter_mut().zip(&c.data) { - *a = match comp.op { - MaskOp::Add => a.max(*c), - MaskOp::Subtract => *a * (1.0 - c), - MaskOp::Intersect => *a * c, - }; - } - first = false; - } - if m.invert { - alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); - } - let amt = (m.adjust.amount / 100.0) as f32; - if (amt - 1.0).abs() > 1e-6 { - alpha.data.iter_mut().for_each(|v| *v *= amt); - } - Evaluated { alpha, adjust: m.adjust } - }) + .map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev), adjust: m.adjust }) .collect() } +/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and +/// scaled by its amount. +pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane { + let mut alpha = Plane::new(w, h); + let mut first = true; + for comp in &m.components { + let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev); + if comp.invert { + c.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + if first && comp.op != MaskOp::Intersect { + alpha = c; + first = false; + continue; + } + for (a, c) in alpha.data.iter_mut().zip(&c.data) { + *a = match comp.op { + MaskOp::Add => a.max(*c), + MaskOp::Subtract => *a * (1.0 - c), + MaskOp::Intersect => *a * c, + }; + } + first = false; + } + if m.invert { + alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + let amt = (m.adjust.amount / 100.0) as f32; + if (amt - 1.0).abs() > 1e-6 { + alpha.data.iter_mut().for_each(|v| *v *= amt); + } + alpha +} + /// Per-pixel positions in long-edge units for output pixel centres. fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(Point, usize) -> f32 + Sync + Send) { let m = frame.out_to_norm(w, h); diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index e8e47e06..6b85113e 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -6,6 +6,9 @@ //! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white, //! so dust spots and specks stand out as white dots. The scale is relative to the image (the //! same spots show at any preview size); the threshold slider (0..100) raises sensitivity. +//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour +//! tint on a black-and-white image, the image on black / white, or the alpha as white on black +//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`]. use std::borrow::Cow; @@ -13,7 +16,7 @@ use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; use lightcraft_color::transfer::srgb_to_linear; use lightcraft_color::{REC2020, SRGB}; use lightcraft_develop::DevelopSettings; -use lightcraft_raster::Rgba8; +use lightcraft_raster::{Plane, Rgba8}; use crate::colorops::PointK; use crate::{Plan, for_rows}; @@ -27,23 +30,99 @@ pub enum Overlay { PointColorRange(u8), /// Visualize Spots with a threshold 0..100 (higher = more sensitive). Spots(u8), + /// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in + /// `color` at `opacity` (0..100; the colour views only). + Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 }, +} + +/// How [`Overlay::Mask`] draws the mask. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MaskView { + /// The photo with the mask tinted in the overlay colour. + #[default] + Color, + /// A black-and-white photo with the mask tinted in the overlay colour. + ColorOnBw, + /// The photo where the mask is, black elsewhere. + ImageOnBlack, + /// The photo where the mask is, white elsewhere. + ImageOnWhite, + /// The mask itself: white on black. + WhiteOnBlack, +} + +impl MaskView { + pub const ALL: [MaskView; 5] = [MaskView::Color, MaskView::ColorOnBw, MaskView::ImageOnBlack, MaskView::ImageOnWhite, MaskView::WhiteOnBlack]; + + /// Stable name (control channel, settings). + pub fn name(self) -> &'static str { + match self { + MaskView::Color => "color", + MaskView::ColorOnBw => "colorOnBw", + MaskView::ImageOnBlack => "imageOnBlack", + MaskView::ImageOnWhite => "imageOnWhite", + MaskView::WhiteOnBlack => "whiteOnBlack", + } + } + + pub fn label(self) -> &'static str { + match self { + MaskView::Color => "Color Overlay", + MaskView::ColorOnBw => "Color Overlay on B&W", + MaskView::ImageOnBlack => "Image on Black", + MaskView::ImageOnWhite => "Image on White", + MaskView::WhiteOnBlack => "White on Black", + } + } + + pub fn parse(s: &str) -> Option { + MaskView::ALL.into_iter().find(|v| v.name() == s) + } + + /// The next view (Shift+O cycles them). + pub fn next(self) -> MaskView { + let i = MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0); + MaskView::ALL[(i + 1) % MaskView::ALL.len()] + } + + fn index(self) -> u64 { + MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0) as u64 + } +} + +/// [`Overlay::Mask`]'s fields in 51 bits (exact in an `f64`): id 16, view 4, colour 24, opacity 7. +fn pack_mask(id: u16, view: MaskView, color: [u8; 3], opacity: u8) -> u64 { + let rgb = (color[0] as u64) << 16 | (color[1] as u64) << 8 | color[2] as u64; + id as u64 | view.index() << 16 | rgb << 20 | (opacity.min(100) as u64) << 44 +} + +fn unpack_mask(v: u64) -> Overlay { + let rgb = (v >> 20) & 0xff_ffff; + Overlay::Mask { + id: (v & 0xffff) as u16, + view: MaskView::ALL.get(((v >> 16) & 0xf) as usize).copied().unwrap_or_default(), + color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8], + opacity: ((v >> 44) & 0x7f).min(100) as u8, + } } impl Overlay { /// A plain (kind, value) pair, e.g. for sending a request to a web worker. - pub fn to_parts(self) -> (u8, f32) { + pub fn to_parts(self) -> (u8, f64) { match self { Overlay::None => (0, 0.0), - Overlay::PointColorRange(i) => (1, i as f32), - Overlay::Spots(t) => (2, t as f32), + Overlay::PointColorRange(i) => (1, i as f64), + Overlay::Spots(t) => (2, t as f64), + Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), } } /// Inverse of [`Overlay::to_parts`] (unknown kinds: no overlay). - pub fn from_parts(kind: u8, v: f32) -> Overlay { + pub fn from_parts(kind: u8, v: f64) -> Overlay { match kind { 1 => Overlay::PointColorRange(v as u8), 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), + 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), _ => Overlay::None, } } @@ -54,6 +133,15 @@ impl Overlay { Overlay::None => 0, Overlay::PointColorRange(i) => 0x1000 + i as u64, Overlay::Spots(t) => 0x2000 + t as u64, + Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), + } + } + + /// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components). + pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> { + match self { + Overlay::Mask { id, .. } => s.masks.iter().find(|m| m.id == id as u32 && !m.components.is_empty()), + _ => None, } } } @@ -70,8 +158,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } } -/// Draw overlay `o` over `img` (rendered with `plan`). -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { +/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask +/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { match o { Overlay::None => {} Overlay::PointColorRange(i) => { @@ -80,9 +169,37 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { } } Overlay::Spots(t) => spots(img, t, plan.px_per_long), + Overlay::Mask { view, color, opacity, .. } => { + if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + mask_view(img, a, view, color, opacity); + } + } } } +/// Draw mask alpha `a` over `img` as `view`. +pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) { + let op = opacity.min(100) as f32 / 100.0; + let col = color.map(|c| c as f32); + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let a = a.data[y * w + x].clamp(0.0, 1.0); + let p = [px[0] as f32, px[1] as f32, px[2] as f32]; + let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let out: [f32; 3] = match view { + MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op), + MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op), + MaskView::ImageOnBlack => p.map(|v| v * a), + MaskView::ImageOnWhite => p.map(|v| v * a + 255.0 * (1.0 - a)), + MaskView::WhiteOnBlack => [255.0 * a; 3], + }; + let o = out.map(|v| v.round().clamp(0.0, 255.0) as u8); + *px = [o[0], o[1], o[2], 255]; + } + }); +} + /// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. const SPOT_SIGMA: f64 = 0.006; @@ -184,4 +301,45 @@ mod tests { let e = render(&src, &info, &s, &RenderRequest::fit(32, 16)).image.data[8 * 32 + 4]; assert!(e[1].abs_diff(px[1]) > 10, "{e:?} vs {px:?}"); } + + #[test] + fn mask_overlay_shows_the_evaluated_alpha() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = Rgb32f::from_fn(80, 40, |x, _| if x < 40 { [0.18, 0.18, 0.18] } else { [0.05, 0.1, 0.3] }); + let info = SourceInfo::default(); + let shape = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { id: 7, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], ..Default::default() }); + let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; + let shot = |s: &DevelopSettings, view: MaskView| { + let o = Overlay::Mask { id: 7, view, color: [255, 0, 0], opacity: 100 }; + render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image + }; + let (inside, outside) = (20 * 80 + 20, 20 * 80 + 70); + let c = shot(&s, MaskView::Color); + assert_eq!(c.data[inside], [255, 0, 0, 255], "full tint inside the mask"); + assert_eq!(c.data[outside], plain.data[outside], "untouched outside"); + let bw = shot(&s, MaskView::WhiteOnBlack); + assert_eq!((bw.data[inside], bw.data[outside]), ([255; 4], [0, 0, 0, 255])); + let ib = shot(&s, MaskView::ImageOnBlack); + assert_eq!((ib.data[inside], ib.data[outside]), (plain.data[inside], [0, 0, 0, 255])); + let iw = shot(&s, MaskView::ImageOnWhite); + assert_eq!(iw.data[outside], [255; 4]); + let g = shot(&s, MaskView::ColorOnBw).data[outside]; + assert!(g[0] == g[1] && g[1] == g[2], "grey outside: {g:?}"); + // a hidden mask still shows (it's the one being edited) + s.masks[0].visible = false; + assert_eq!(shot(&s, MaskView::WhiteOnBlack).data[inside], [255; 4]); + // an unknown mask draws nothing + let o = Overlay::Mask { id: 9, view: MaskView::WhiteOnBlack, color: [0; 3], opacity: 50 }; + assert_eq!(render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image, plain); + // parts round trip (web worker requests) and distinct cache keys + let o = Overlay::Mask { id: 513, view: MaskView::ImageOnWhite, color: [12, 200, 255], opacity: 65 }; + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::Mask { id: 513, view: MaskView::Color, color: [12, 200, 255], opacity: 65 }.key()); + assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color); + assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71aed1e8..1371a880 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,9 @@ pub mod state; pub mod theme; pub mod widgets; +#[cfg(test)] +mod tests_masking; + use std::sync::mpsc::{Receiver, Sender}; use lightcraft_engine::Session; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1b56db04..0026ebe4 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -97,6 +97,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "view.clipping", "view.maskOverlay", + "view.maskOverlayMode", + "view.maskPins", "view.cropOverlay", "---", "@Sort", @@ -197,6 +199,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), "view.maskOverlay" => Some(u.mask_overlay), + "view.maskPins" => Some(u.mask_pins), "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4d31a002..fe287a1e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -34,6 +34,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.clipping", "Show Clipping", Some("J"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) + ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), + ("view.maskOverlayColor", "Mask Overlay Color", None, ""), + ("view.maskPins", "Show Mask Pins", None, "View"), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.back", "Back to Grid", Some("Escape"), ""), @@ -96,6 +100,21 @@ fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str let _ = app.session.end_interaction(); } +/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). +pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { + if let Some(s) = v.as_str() { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 { + return None; + } + let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); + return Some([c(0)?, c(2)?, c(4)?]); + } + let a = v.as_array()?; + let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); + Some([c(0)?, c(1)?, c(2)?]) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { let ctx = egui::Context::default(); @@ -199,8 +218,43 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.mask_overlay = !app.ui.mask_overlay; - Ok(Value::Null) + app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); + Ok(json!({"maskOverlay": app.ui.mask_overlay})) + } + "view.maskOverlayMode" => { + use lightcraft_engine::pipeline::MaskView; + let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + let next = match p.get("mode").and_then(Value::as_str) { + Some(m) => match MaskView::parse(m) { + Some(v) => v, + None => { + let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); + return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); + } + }, + None => cur.next(), + }; + app.ui.mask_overlay_mode = next.name().into(); + app.ui.mask_overlay = true; + app.toast(&ctx, next.label()); + Ok(json!({"mode": next.name()})) + } + "view.maskOverlayColor" => { + if let Some(c) = p.get("color") { + match parse_rgb(c) { + Some(rgb) => app.ui.mask_overlay_color = rgb, + None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), + } + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; + } + let [r, g, b] = app.ui.mask_overlay_color; + Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) + } + "view.maskPins" => { + app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); + Ok(json!({"maskPins": app.ui.mask_pins})) } "view.visualizeSpots" => { // like Lightroom's A: opens the Remove tool with the view on, or toggles it there diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 54e2575e..6491ae41 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -40,8 +40,11 @@ pub enum Gesture { start: (f32, f32), at: Pos2, }, + /// Dragging component `comp` of `mask`: its pin (`handle` 0) or a linear gradient's start (1) + /// or end (2). MaskHandle { mask: u32, + comp: usize, handle: u8, }, /// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates). @@ -313,9 +316,22 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon } } -/// The diagnostic overlay the loupe shows (Point Color's visualized range). -fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { - use lightcraft_pipeline::Overlay; +/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range, +/// Visualize Spots). +pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { + use lightcraft_pipeline::{MaskView, Overlay}; + if app.ui.right == RightPanel::Masking + && app.ui.mask_overlay + && let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)) + && !m.components.is_empty() + { + return Overlay::Mask { + id: m.id.min(u16::MAX as u32) as u16, + view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), + color: app.ui.mask_overlay_color, + opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8, + }; + } if app.ui.right == RightPanel::Remove && app.ui.visualize_spots { return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8); } @@ -594,26 +610,55 @@ fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { // ------------------------------------------------------------------------ masks +/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. +pub(crate) fn component_pin(shape: &MaskShape) -> Option { + match shape { + MaskShape::Radial { center, .. } => Some(*center), + MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), + MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), + _ => None, + } +} + +/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. +fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape { + let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); + match shape.clone() { + MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + MaskShape::Linear { start, end } => match handle { + 1 => MaskShape::Linear { start: at, end }, + 2 => MaskShape::Linear { start, end: at }, + _ => MaskShape::Linear { start: mv(start), end: mv(end) }, + }, + MaskShape::Brush { mut strokes } => { + for st in &mut strokes { + st.points.iter_mut().for_each(|p| *p = mv(*p)); + } + MaskShape::Brush { strokes } + } + s => s, + } +} + +/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask +/// component (click selects its mask, drag moves the component), and brush painting. The mask +/// itself shows as a rendered overlay ([`view_overlay`]). fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90); + let tint = { + let [r, g, b] = app.ui.mask_overlay_color; + Color32::from_rgba_unmultiplied(r, g, b, 110) + }; let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); let p = &ui.painter_at(clip); + // (mask, component, handle, screen position) of everything that can be grabbed + let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); for m in &d.masks { let sel = Some(m.id) == active; - for c in &m.components { + for (ci, c) in m.components.iter().enumerate() { match &c.shape { - MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => { - for s in strokes { - let r = (s.size * long) as f32; - for q in &s.points { - p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red }); - } - } - } - MaskShape::Radial { center, rx, ry, angle, .. } => { - let c0 = map.screen(*center); + MaskShape::Radial { center, rx, ry, angle, .. } if sel => { let pts: Vec = (0..64) .map(|i| { let a = i as f64 / 64.0 * std::f64::consts::TAU; @@ -623,35 +668,31 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) }) .collect(); - if sel && app.ui.mask_overlay { - p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE)); - } - p.add(egui::Shape::closed_line( - pts, - Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 })), - )); - pin(p, c0, sel); - if sel { - register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0))); - } + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230)))); } - MaskShape::Linear { start, end } => { + MaskShape::Linear { start, end } if sel => { let (a, b) = (map.screen(*start), map.screen(*end)); let dir = (b - a).normalized(); let perp = vec2(-dir.y, dir.x) * 3000.0; for (q, alpha) in [(a, 230), (b, 110)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 }))); + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); } - pin(p, a + (b - a) * 0.5, sel); - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - } + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, *q)); } } _ => {} } + if app.ui.mask_pins + && let Some(at) = component_pin(&c.shape) + { + let q = map.screen(at); + pin(p, q, sel); + register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 0, q)); + } } } // brush tool @@ -671,9 +712,10 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), } } + // the stroke being painted (the render shows it once it's committed) if let Some(Gesture::Brush { points }) = &app.gesture { for q in points { - p.circle_filled(map.screen(*q), r, red); + p.circle_filled(map.screen(*q), r, tint); } } if (resp.drag_stopped() || resp.clicked()) @@ -682,57 +724,53 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); let _ = app.run( "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}), + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": app.ui.brush_erase, "autoMask": app.ui.brush_auto_mask}), ); } return; } - // drag pins/handles of the active mask - let Some(mid) = active else { return }; - let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return }; - let Some(comp) = m.components.first() else { return }; - if resp.drag_started() + // the grip under the pointer: the selected mask's first, then the nearest + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.3.distance(q) < 10.0) + .min_by(|a, b| { + (Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + if resp.clicked() && let Some(q) = resp.interact_pointer_pos() + && let Some((mid, ..)) = hit(q) + && Some(mid) != active { - let handle = match &comp.shape { - MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0), - MaskShape::Linear { start, end } => { - if map.screen(*start).distance(q) < 14.0 { - Some(1) - } else if map.screen(*end).distance(q) < 14.0 { - Some(2) - } else { - Some(0) - } + let _ = app.run("mask.select", json!({"id": mid})); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { + // anywhere on the photo drags the selected linear gradient + let m = d.masks.iter().find(|m| Some(m.id) == active)?; + matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) + }); + if let Some((mask, comp, handle)) = grip { + if Some(mask) != active { + let _ = app.run("mask.select", json!({"id": mask})); } - _ => None, - }; - if let Some(h) = handle { let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h }); + app.gesture = Some(Gesture::MaskHandle { mask, comp, handle }); } } if resp.dragged() - && let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + && let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + && let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape) { let n = map.norm(q); - let dn = { - let q0 = q - resp.drag_delta(); - let n0 = map.norm(q0); - Point::new(n.x - n0.x, n.y - n0.y) - }; - let new_shape = match comp.shape.clone() { - MaskShape::Radial { center, rx, ry, angle, feather, invert } => { - MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert } - } - MaskShape::Linear { start, end } => match handle { - 1 => MaskShape::Linear { start: n, end }, - 2 => MaskShape::Linear { start, end: n }, - _ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) }, - }, - s => s, - }; - let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape})); + let n0 = map.norm(q - resp.drag_delta()); + let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n); + let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); } if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { app.gesture = None; diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index eea553e8..7c4c2500 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -142,6 +142,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("mask.select", json!({"id": m.id})); } } + if !d.masks.is_empty() { + ui.add_space(6.0); + overlay_options(app, ui); + } }); let Some(mid) = app.session.active_mask else { return }; let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return }; @@ -202,6 +206,62 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = Stroke::NONE; } +/// Overlay colours offered as swatches (the colour of the selected one is used for the tint). +const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]]; + +/// How the selected mask is shown: overlay mode, colour, opacity, pins. +fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use lightcraft_pipeline::MaskView; + let t = Tokens::get(ui.ctx()); + let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Overlay").color(t.text_dim)); + let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label); + egui::Popup::menu(&r).show(|ui| { + for v in MaskView::ALL { + if ui.selectable_label(v == view, v.label()).clicked() { + let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()})); + } + } + }); + }); + let colored = matches!(view, MaskView::Color | MaskView::ColorOnBw); + if colored { + ui.horizontal(|ui| { + for c in OVERLAY_COLORS { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); + ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2])); + if app.ui.mask_overlay_color == c { + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + } + if resp.clicked() { + let _ = app.run("view.maskOverlayColor", json!({"color": c})); + } + } + }); + } + let spec = ControlSpec { + id: "ui.maskOverlayOpacity", + label: "Opacity", + section: Section::Light, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let out = slider(ui, &spec, app.ui.mask_overlay_opacity as f64, colored && app.ui.mask_overlay, None); + if let Some(v) = out.value { + app.ui.mask_overlay_opacity = v as f32; + } + let mut pins = app.ui.mask_pins; + if ui.checkbox(&mut pins, "Show Pins").changed() { + let _ = app.run("view.maskPins", json!({"show": pins})); + } +} + fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { for (kind, label) in [ ("brush", "Brush"), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 01f91fae..06580217 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -179,6 +179,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { continue; } + // Shift+O cycles the mask overlay mode while masking (the crop overlay elsewhere) + if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking { + let _ = app.run("view.maskOverlayMode", json!({})); + continue; + } let _ = app.run(&f, json!({})); match f.as_str() { "photo.pick" => app.toast(ctx, "Flagged as Pick"), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e3ba282..cb358392 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -97,7 +97,13 @@ pub struct UiState { pub single_panel: bool, pub show_clipping: bool, pub histogram: bool, + /// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles), + /// in which colour and opacity (0..100, colour views), and whether pins are drawn. pub mask_overlay: bool, + pub mask_overlay_mode: String, + pub mask_overlay_color: [u8; 3], + pub mask_overlay_opacity: f32, + pub mask_pins: bool, pub crop_overlay: CropOverlay, pub show_filenames: bool, pub search: String, @@ -113,6 +119,8 @@ pub struct UiState { pub brush_feather: f32, pub brush_flow: f32, pub brush_erase: bool, + /// Brush Auto Mask: dabs stick to areas like the one under the brush centre. + pub brush_auto_mask: bool, pub remove_size: f32, /// Selected Point Color sample. pub point_color: usize, @@ -199,6 +207,10 @@ impl Default for UiState { show_clipping: false, histogram: true, mask_overlay: true, + mask_overlay_mode: "color".into(), + mask_overlay_color: [230, 30, 40], + mask_overlay_opacity: 50.0, + mask_pins: true, crop_overlay: CropOverlay::Thirds, show_filenames: true, search: String::new(), @@ -210,6 +222,7 @@ impl Default for UiState { brush_feather: 50.0, brush_flow: 60.0, brush_erase: false, + brush_auto_mask: false, remove_size: 0.02, point_color: 0, point_color_visualize: false, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs new file mode 100644 index 00000000..ca60889b --- /dev/null +++ b/crates/ui-egui/src/tests_masking.rs @@ -0,0 +1,91 @@ +//! Headless tests of the Masking and Remove tools on the photo: overlay keys, pins, spot editing. + +use std::time::Duration; + +use lightcraft_develop::MaskShape; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::RightPanel; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn detail(panel: &str) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn pointer(h: &mut Headless, events: serde_json::Value) { + let r = h.request("ui.pointer", json!({"events": events}), T); + assert_eq!(r["ok"], true, "{r}"); +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.active().expect("active photo"); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +#[test] +fn mask_overlay_keys_and_pins() { + use lightcraft_pipeline::{MaskView, Overlay}; + let mut h = detail("panel.masking"); + assert_eq!(h.app.ui.right, RightPanel::Masking); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]})); + assert_eq!(h.app.session.active_mask, Some(2)); + // the loupe asks the renderer for the selected mask's overlay + let d = develop(&h); + let o = crate::panels::detail::view_overlay(&h.app, &d); + assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 }); + // O toggles it, Shift+O cycles the mode (and leaves the crop overlay alone) + h.request("ui.key", json!({"key": "o"}), T); + assert!(!h.app.ui.mask_overlay); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + h.request("ui.key", json!({"key": "o"}), T); + let crop = h.app.ui.crop_overlay; + h.request("ui.key", json!({"key": "o", "shift": true}), T); + assert_eq!(h.app.ui.mask_overlay_mode, "colorOnBw"); + assert_eq!(h.app.ui.crop_overlay, crop); + exec(&mut h, "view.maskOverlayMode", json!({"mode": "whiteOnBlack"})); + exec(&mut h, "view.maskOverlayColor", json!({"color": "#2870f0", "opacity": 80})); + assert_eq!((h.app.ui.mask_overlay_color, h.app.ui.mask_overlay_opacity), ([0x28, 0x70, 0xf0], 80.0)); + let r = h.request("engine.execute", json!({"command": "view.maskOverlayMode", "params": {"mode": "nope"}}), T); + assert_eq!(r["ok"], false, "{r}"); + // clicking another mask's pin selects that mask + pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "up", "x": 0.3, "y": 0.4}])); + assert_eq!(h.app.session.active_mask, Some(1)); + // dragging a pin moves its component (one undo step) + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "drag", "x": 0.35, "y": 0.45}, {"kind": "drag", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]), + ); + let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.5).abs() < 0.02 && (center.y - 0.6).abs() < 0.02, "{center:?}"); + // dragging the linear gradient's pin (a non-selected mask) selects and moves it + pointer( + &mut h, + json!([{"kind": "down", "x": 0.7, "y": 0.4}, {"kind": "drag", "x": 0.72, "y": 0.4}, {"kind": "drag", "x": 0.8, "y": 0.4}, {"kind": "up", "x": 0.8, "y": 0.4}]), + ); + assert_eq!(h.app.session.active_mask, Some(2)); + let MaskShape::Linear { start, end } = develop(&h).masks[1].components[0].shape.clone() else { panic!("linear") }; + assert!((start.x - 0.8).abs() < 0.02 && (end.x - 0.8).abs() < 0.02 && (start.y - 0.2).abs() < 0.02, "{start:?} {end:?}"); + // hidden pins can't be grabbed + exec(&mut h, "view.maskPins", json!({"show": false})); + pointer(&mut h, json!([{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}])); + assert_eq!(h.app.session.active_mask, Some(2)); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index 97cada6d..489a445e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | -| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) | +| K. Masking (MASK) | 8 | 8 | 7 | 0 | 5/8 (63%) | 3/5 (60%) | | L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) | | M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | | N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | | Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 208 | 103 | 189 | 37 | 152/198 (77%) | 48/144 (33%) | +| **Total** | 210 | 101 | 189 | 37 | 153/198 (77%) | 49/144 (34%) | ## Top gaps @@ -57,8 +57,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. -2. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask - kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. 3. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. 4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, @@ -348,8 +346,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets | -| LR-MASK-OVERLAY | Mask overlay | P0 | 🟡 | `cmd:view.maskOverlay`, `crates/ui-egui/src/panels/detail.rs` | brush dabs + shape outlines only; no rendered alpha, colour or mode options | -| LR-MASK-PINS | Pins | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | handles drawn; no show/hide-pins option | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; no auto-show on hover | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | From 464d4926b997765bbff069027f739c3a98cc8438 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:03:33 -0400 Subject: [PATCH 3/8] LR-PREF-GENERAL + LR-IMP-RAWDEFAULT + LR-VIEW-FULLSCREEN/NAVIGATOR/INFOOVERLAY: Settings dialog, import defaults, view modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings (Cmd+, / app menu on macOS, Edit elsewhere) with General, Import, Performance and Interface tabs. App settings (startup view, delete confirmation, GPU, preview size, filmstrip/grid badges, last library) live in UiState.settings (ui.json); library settings (raw / non-raw / per-camera import presets, XMP, thumbnail cache size) go through library.preferences into prefs.json. Import applies the matching default preset; the photo remembers it as its import look (unedited; Reset returns to it). Open Library… switches libraries at runtime. View modes: F full-screen preview (photo on black, arrows step, I cycles the info overlay, Esc exits), Cmd+Shift+F window full screen, Cmd+I cycles the loupe info overlay (off / file + date / exposure), Navigator mini map while zoomed (click/drag pans). Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/src/main.rs | 33 ++- apps/lightcraft/src/native_menu.rs | 26 +- crates/catalog/src/model.rs | 18 +- crates/engine/src/cmd/develop.rs | 7 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/prefs.rs | 91 +++++++ crates/engine/src/import.rs | 70 +++++- crates/engine/src/lib.rs | 8 + crates/engine/src/library.rs | 39 ++- crates/engine/src/media.rs | 11 +- crates/engine/src/tests_prefs.rs | 134 +++++++++++ crates/engine/src/tests_xmp.rs | 9 +- crates/ui-egui/src/headless.rs | 120 ++++++++++ crates/ui-egui/src/lib.rs | 35 +++ crates/ui-egui/src/menubar.rs | 27 ++- crates/ui-egui/src/menus.rs | 68 ++++++ crates/ui-egui/src/panels/detail.rs | 161 ++++++++++++- crates/ui-egui/src/panels/dialogs.rs | 14 +- crates/ui-egui/src/panels/grid.rs | 6 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 332 ++++++++++++++++++++++++++ crates/ui-egui/src/render.rs | 10 + crates/ui-egui/src/shortcuts.rs | 9 + crates/ui-egui/src/state.rs | 118 +++++++++ docs/parity.md | 45 ++-- 26 files changed, 1327 insertions(+), 68 deletions(-) create mode 100644 crates/engine/src/cmd/prefs.rs create mode 100644 crates/engine/src/tests_prefs.rs create mode 100644 crates/ui-egui/src/panels/settings.rs diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 00ef74ad..a4247941 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -159,6 +159,7 @@ fn services(originals: Originals, backend: Option, ctx: egui::Context) lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), reveal: None, + pick_folder: None, } } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index b2ad7236..76017967 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -3,7 +3,8 @@ //! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control ] [files or folders…]` //! //! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else -//! `$LIGHTCRAFT_LIBRARY`, else `~/Pictures/LightCraft Library`; a new library starts with the +//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else +//! `~/Pictures/LightCraft Library`; a new library starts with the //! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command //! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes //! nothing (demo photos unless files are given; used by the README showcase scripts). @@ -56,16 +57,13 @@ fn config_dir() -> Option { } } -fn load_prefs(app: &mut LightcraftApp) { +/// The saved UI state and app settings (`/ui.json`), if any. +fn load_prefs() -> Option { if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return; - } - if let Some(p) = config_dir().map(|d| d.join("ui.json")) - && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) - { - app.ui = ui.sanitized(); + return None; } + let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?; + serde_json::from_slice::(&bytes).ok().map(UiState::sanitized) } fn save_prefs(app: &LightcraftApp) { @@ -82,6 +80,7 @@ fn save_prefs(app: &LightcraftApp) { fn services() -> Services { Services { + pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))), reveal: Some(Box::new(|path: &str| { let status = if cfg!(target_os = "macos") { std::process::Command::new("open").args(["-R", path]).status() @@ -190,7 +189,7 @@ fn main() -> eframe::Result { let mut files = Vec::new(); let mut seed_demo = true; let mut in_memory = false; - let mut library_dir = lightcraft_engine::library::default_dir(); + let mut library_dir: Option = None; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { @@ -213,6 +212,16 @@ fn main() -> eframe::Result { _ => files.push(a), } } + let prefs = load_prefs(); + // --library, else the library last opened from Settings, else the default location + let library_dir = library_dir.or_else(|| { + prefs + .as_ref() + .map(|u| u.settings.library_path.clone()) + .filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none()) + .map(Into::into) + }); + let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("LightCraft") @@ -230,7 +239,9 @@ fn main() -> eframe::Result { Box::new(move |cc| { let session = open_session(in_memory, library_dir, seed_demo && files.is_empty()); let mut app = LightcraftApp::new(session, services()); - load_prefs(&mut app); + if let Some(ui) = prefs { + app.ui = ui; + } app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index abba999f..4286de77 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -19,6 +19,9 @@ use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu use serde_json::Value; const QUIT: &str = "app.quit"; +/// Settings… (the app menu on macOS; Edit elsewhere). +const SETTINGS: &str = "app.settings"; +const SETTINGS_KEY: &str = "Cmd+,"; /// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo /// or the active mask): left to the egui handler, which knows the context. const CONTEXTUAL: &[&str] = &["X", "Delete"]; @@ -117,6 +120,7 @@ fn accelerator(sc: &str) -> Option { "=" => Code::Equal, "-" => Code::Minus, "'" => Code::Quote, + "," => Code::Comma, "Delete" => Code::Backspace, _ => return None, }; @@ -161,6 +165,14 @@ fn structure_of(bar: &[(String, Vec)]) -> String { s } +/// Drop a trailing separator (left behind when an item moved to the app menu). +fn tidy_separators(mut nodes: Vec) -> Vec { + while matches!(nodes.last(), Some(MenuNode::Separator)) { + nodes.pop(); + } + nodes +} + impl NativeMenu { /// Build the menu bar and install it as the application menu (main thread, after launch). pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu { @@ -185,10 +197,13 @@ impl NativeMenu { // the application menu let app_menu = Submenu::new("LightCraft", true); let about = MenuItem::with_id("app.about", "About LightCraft", true, None); + let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY)); let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); let _ = app_menu.append_items(&[ &about, &PredefinedMenuItem::separator(), + &settings, + &PredefinedMenuItem::separator(), &PredefinedMenuItem::services(None), &PredefinedMenuItem::separator(), &PredefinedMenuItem::hide(Some("Hide LightCraft")), @@ -208,8 +223,10 @@ impl NativeMenu { &PredefinedMenuItem::separator(), ]); } - // About lives in the app menu on macOS - let nodes: Vec = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect(); + // About and Settings live in the app menu on macOS + let nodes: Vec = + nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS)).cloned().collect(); + let nodes = tidy_separators(nodes); self.append_nodes(&sub, &nodes); if title == "Window" { let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); @@ -281,6 +298,7 @@ impl NativeMenu { .filter_map(|i| i.shortcut.clone()) .filter(|sc| !(self.text_focus && yields_to_text(sc))) .collect::>(); + app.native_shortcuts.insert(SETTINGS_KEY.to_string()); } /// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus @@ -291,8 +309,8 @@ impl NativeMenu { ctx.send_viewport_cmd(egui::ViewportCommand::Close); continue; } - if key == "app.about" { - let _ = app.run("app.about", serde_json::json!({})); + if key == "app.about" || key == SETTINGS { + let _ = app.run(&key, serde_json::json!({})); continue; } if let Some(it) = self.items.get(&key) { diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 5d4f46d9..553fddf0 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -181,6 +181,11 @@ pub struct Photo { /// Virtual copies' name ("Copy 1"). #[serde(default, skip_serializing_if = "Option::is_none")] pub copy_name: Option, + /// The settings import gave the photo when a user default (a raw/JPEG default preset, see + /// the engine's import defaults) changed them from [`Photo::camera_defaults`]. They count as + /// unedited, and Reset returns to them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub import_look: Option>, } impl Photo { @@ -211,11 +216,20 @@ impl Photo { embedded_lens: None, copy_of: None, copy_name: None, + import_look: None, } } - /// The develop settings import gives this photo (raws: as-shot white balance and the raw - /// defaults; embedded lens corrections on when the file has them). + /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's + /// default preset applied on top of them ([`Photo::import_look`]). pub fn import_defaults(&self) -> DevelopSettings { + match &self.import_look { + Some(l) => (**l).clone(), + None => self.camera_defaults(), + } + } + /// The built-in defaults for this photo, before any user default preset: raws start from + /// their as-shot white balance; embedded lens corrections on when the file has them. + pub fn camera_defaults(&self) -> DevelopSettings { let mut d = match self.as_shot_wb { Some((t, tint)) => DevelopSettings::for_raw(t, tint), None => DevelopSettings::default(), diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 7b805ae2..c912f7d9 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -128,7 +128,12 @@ pub fn specs() -> Vec { .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) .filter_map(|(id, d)| { - let fresh = DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() }; + // back to the photo's import defaults when a default preset gave it its look + let look = s.catalog.photo(id).and_then(|p| p.import_look.clone()); + let fresh = match look { + Some(l) => (*l).clone(), + None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() }, + }; s.develop_op(id, fresh, "Reset") }) .collect(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8a385ec5..642f1e1b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ mod library; mod masks; mod merge; mod organize; +mod prefs; mod preset_files; mod query; mod xmp; @@ -111,6 +112,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(query::specs()); v.extend(xmp::specs()); v.extend(preset_files::specs()); + v.extend(prefs::specs()); v }) } diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs new file mode 100644 index 00000000..01a157ff --- /dev/null +++ b/crates/engine/src/cmd/prefs.rs @@ -0,0 +1,91 @@ +//! Library preferences (Settings → Import / Performance): develop defaults applied on import and +//! the thumbnail cache budget. Saved in the library's `prefs.json` (see [`crate::library`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; +use crate::import::{CameraDefault, ImportDefaults}; +use crate::{Result, Session}; + +const ID: &str = "library.preferences"; + +fn current(s: &Session) -> Value { + json!({ + "xmp": s.xmp, + "import": s.import_defaults, + "cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) }, + "persistent": s.library.is_some(), + }) +} + +/// `"default"`, `""` or null = no preset; anything else must name an existing preset. +fn preset_ref(s: &Session, v: &Value) -> Result> { + match v { + Value::Null => Ok(None), + Value::String(x) if x.is_empty() || x == "default" => Ok(None), + Value::String(x) if s.presets.iter().any(|p| p.id == *x) => Ok(Some(x.clone())), + Value::String(x) => Err(bad(ID, format!("unknown preset `{x}` (see preset.list)"))), + other => Err(bad(ID, format!("preset must be a preset id or \"default\", not {other}"))), + } +} + +fn prefs(s: &mut Session, p: &Value) -> Result { + let mut d: ImportDefaults = s.import_defaults.clone(); + let mut changed = false; + if let Some(i) = p.get("import") { + if let Some(v) = i.get("rawPreset") { + d.raw_preset = preset_ref(s, v)?; + } + if let Some(v) = i.get("otherPreset") { + d.other_preset = preset_ref(s, v)?; + } + if let Some(v) = i.get("perCamera").and_then(Value::as_bool) { + d.per_camera = v; + } + if let Some(a) = i.get("cameras").and_then(Value::as_array) { + d.cameras.clear(); + for c in a { + let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + if camera.is_empty() { + return Err(bad(ID, "each camera default needs `camera` (make + model)")); + } + let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?; + d.cameras.push(CameraDefault { camera, preset }); + } + } + changed = true; + } + // set or clear one camera's default: {camera: {camera: "Make Model", preset: id|null}} + if let Some(c) = p.get("camera") { + let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + if camera.is_empty() { + return Err(bad(ID, "`camera.camera` (make + model) is required")); + } + let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?; + d.cameras.retain(|x| !x.camera.eq_ignore_ascii_case(&camera)); + if c.get("remove").and_then(Value::as_bool) != Some(true) { + d.cameras.push(CameraDefault { camera, preset }); + } + changed = true; + } + s.import_defaults = d; + if let Some(mb) = p.get("cacheMb") { + let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?; + s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?; + } else if changed { + s.save_prefs()?; + } + Ok(current(s)) +} + +pub fn specs() -> Vec { + vec![cmd!( + "library.preferences", + "Library Preferences", + [], + None, + "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}]}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}", + always, + prefs + )] +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index e07199d6..e016238a 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use lightcraft_catalog::{Op, Photo, PhotoId, Source}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::Session; use crate::media::ProbeInfo; @@ -59,14 +59,74 @@ pub struct ImportReport { /// The develop settings a photo gets on import: raws start from their as-shot white balance with /// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the -/// camera intended). +/// camera intended); a user default preset ([`ImportDefaults`]) goes on top. pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings { p.import_defaults() } -/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)? +/// Does the photo still look as the camera rendered it (its embedded camera preview is then a +/// fair stand-in)? Not when a default preset changed it on import. pub fn has_import_look(p: &Photo) -> bool { - *p.develop == import_defaults(p) + *p.develop == p.camera_defaults() +} + +/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`). +/// Presets are referenced by id; a preset that no longer exists is ignored. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImportDefaults { + /// Preset applied to raw files (`None` = the LightCraft default). + pub raw_preset: Option, + /// Use a camera's own default (below) when the photo's camera has one. + pub per_camera: bool, + /// Per-camera raw defaults, keyed by the camera's make + model as shown in Info + /// (`"Canon EOS R5"`). + pub cameras: Vec, + /// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none). + pub other_preset: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CameraDefault { + /// Make + model (`Meta::camera`). + pub camera: String, + /// Preset id; `None` = the LightCraft default for this camera. + pub preset: Option, +} + +impl ImportDefaults { + /// The preset id that applies to a photo of this kind and camera, if any. + pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> { + if !raw { + return self.other_preset.as_deref(); + } + if self.per_camera + && let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera)) + { + return c.preset.as_deref(); + } + self.raw_preset.as_deref() + } +} + +/// Give a freshly imported photo its default settings: the camera defaults, then the matching +/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look. +pub fn apply_import_defaults(s: &Session, p: &mut Photo) { + let base = p.camera_defaults(); + let raw = p.kind == lightcraft_catalog::MediaKind::Raw; + let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); + match preset { + Some(pr) => { + let look = std::sync::Arc::new(pr.apply(&base, 1.0)); + p.develop = look.clone(); + p.import_look = (*look != base).then_some(look); + } + None => { + p.develop = std::sync::Arc::new(base); + p.import_look = None; + } + } } pub fn is_supported(path: &Path) -> bool { @@ -236,7 +296,7 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; - p.develop = std::sync::Arc::new(import_defaults(&p)); + apply_import_defaults(s, &mut p); let raw = p.kind == lightcraft_catalog::MediaKind::Raw; let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) .and_then(|f| std::fs::read_to_string(f).ok()) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f022268e..0cc79500 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,6 +97,10 @@ pub struct Session { pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option, + /// Develop defaults applied on import (persisted in prefs.json). + pub import_defaults: import::ImportDefaults, + /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). + pub cache_mb: u32, } impl Default for Session { @@ -130,6 +134,8 @@ impl Session { library: None, xmp: sidecar::XmpPrefs::default(), last_export: None, + import_defaults: import::ImportDefaults::default(), + cache_mb: 0, } } @@ -346,4 +352,6 @@ mod tests_merge; #[cfg(test)] mod tests_organize; #[cfg(test)] +mod tests_prefs; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 5225a00a..544769db 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -5,7 +5,7 @@ //! LightCraft Library/ //! catalog.snap catalog.log (lightcraft-catalog journal) //! presets.json view.json (user presets + favourites; last source/filter/sort/selection) -//! prefs.json (library preferences: XMP sidecars) +//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export) //! thumbs/ (rendered thumbnail cache, safe to delete) //! Originals/ (photos imported with "copy into library") //! ``` @@ -96,11 +96,16 @@ impl Library { } #[derive(Default, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, rename_all = "camelCase")] struct PrefsFile { xmp: crate::sidecar::XmpPrefs, /// Parameters of the last export (for Export with Previous). + #[serde(alias = "last_export")] last_export: Option, + /// Develop defaults for imported photos. + import: crate::import::ImportDefaults, + /// Thumbnail disk cache budget (MB, 0 = default). + cache_mb: u32, } fn presets_json(presets: &[Preset]) -> String { @@ -156,6 +161,8 @@ impl Session { let prefs = read_json::(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; self.last_export = prefs.last_export; + self.import_defaults = prefs.import; + self.cache_mb = prefs.cache_mb; // view state if let Some(v) = read_json::(files.as_mut(), "view.json") { self.source = v.source; @@ -171,8 +178,10 @@ impl Session { self.selection = Selection::single(*first); } let presets_written = presets_json(&self.presets); + // photo ids are per library: drop decoded sources of the previous one + self.media.clear_sources(); if on_disk { - self.media.attach_disk_cache(&dir.join("thumbs")); + self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes()); } let view_written = self.view_json(); self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written }); @@ -252,11 +261,33 @@ impl Session { /// Save the library preferences (no-op for in-memory sessions). pub fn save_prefs(&mut self) -> Result<()> { - let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone() }).unwrap_or_default(); + let v = serde_json::to_vec_pretty(&PrefsFile { + xmp: self.xmp, + last_export: self.last_export.clone(), + import: self.import_defaults.clone(), + cache_mb: self.cache_mb, + }) + .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}"))) } + /// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default). + pub fn cache_bytes(&self) -> u64 { + if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 } + } + + /// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which + /// trims it to the new size. Saved with the library preferences. + pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> { + self.cache_mb = mb; + if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) { + let dir = lib.thumbs_dir(); + self.media.attach_disk_cache(&dir, self.cache_bytes()); + } + self.save_prefs() + } + /// Compact the log into a snapshot now. pub fn compact_library(&mut self) -> Result<()> { self.persist()?; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e10b31a1..d33cae32 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -128,8 +128,15 @@ impl Default for MediaCache { impl MediaCache { /// Keep rendered thumbnails on disk in `dir` as well. - pub fn attach_disk_cache(&mut self, dir: &std::path::Path) { - self.rendered = Arc::new(PreviewCache::with_disk(RENDERED_MEM_BYTES, dir, DISK_CACHE_BYTES)); + pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { + self.rendered = Arc::new(PreviewCache::with_disk(RENDERED_MEM_BYTES, dir, disk_bytes)); + } + + /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). + pub fn clear_sources(&mut self) { + self.thumbs = Lru::new(THUMB_SOURCE_BYTES); + self.previews.clear(); + self.full = None; } pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs new file mode 100644 index 00000000..2c6802b2 --- /dev/null +++ b/crates/engine/src/tests_prefs.rs @@ -0,0 +1,134 @@ +//! Library preferences: import defaults (raw / other / per camera) applied by import, and their +//! persistence in prefs.json. + +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::MediaKind; +use lightcraft_develop::Preset; +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-prefs-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn user_preset(id: &str, settings: serde_json::Value) -> Preset { + Preset { id: id.into(), name: id.into(), group: "User Presets".into(), settings, favorite: false, builtin: false } +} + +fn with_presets(s: &mut Session) { + s.presets.push(user_preset("user.rawlook", json!({"light": {"exposure": 0.5}, "effects": {"clarity": 20.0}}))); + s.presets.push(user_preset("user.canonlook", json!({"light": {"contrast": 35.0}}))); + s.presets.push(user_preset("user.jpeglook", json!({"color": {"vibrance": 15.0}}))); +} + +fn dng(dir: &Path, name: &str, make: &str, model: &str) { + let meta = lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + std::fs::write(dir.join(name), crate::tests_xmp::synthetic_dng_with(None, meta)).unwrap(); +} + +fn png(path: &Path) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i * 7) as u8, (i * 3) as u8, 90, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn photo_named<'a>(s: &'a Session, name: &str) -> &'a lightcraft_catalog::Photo { + s.catalog.photos().find(|p| p.file_name == name).unwrap_or_else(|| panic!("{name} not imported")) +} + +#[test] +fn raw_and_other_defaults_are_applied_on_import() { + let src = temp_dir("apply"); + dng(&src, "nikon.dng", "Nikon", "Z 9"); + dng(&src, "canon.dng", "Canon", "EOS R5"); + png(&src.join("plain.png")); + let mut s = Session::new().with_fs(); + with_presets(&mut s); + let r = s + .execute( + "library.preferences", + &json!({"import": {"rawPreset": "user.rawlook", "otherPreset": "user.jpeglook", "perCamera": true, + "cameras": [{"camera": "Canon EOS R5", "preset": "user.canonlook"}]}}), + ) + .unwrap(); + assert_eq!(r["import"]["rawPreset"], "user.rawlook", "{r}"); + assert!(s.execute("library.preferences", &json!({"import": {"rawPreset": "nope"}})).is_err(), "unknown presets are rejected"); + + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(s.catalog.len(), 3); + let nikon = photo_named(&s, "nikon.dng"); + assert_eq!(nikon.kind, MediaKind::Raw); + assert_eq!(nikon.meta.camera, "Nikon Z 9"); + assert_eq!((nikon.develop.light.exposure, nikon.develop.effects.clarity), (0.5, 20.0), "global raw default"); + assert_eq!(nikon.develop.detail.sharpen_amount, 40.0, "raw camera defaults stay underneath the preset"); + assert!(!nikon.is_edited(), "the default look counts as unedited"); + assert!(!crate::import::has_import_look(nikon), "a preset look isn't what the embedded preview shows"); + let canon = photo_named(&s, "canon.dng"); + assert_eq!((canon.develop.light.contrast, canon.develop.light.exposure), (35.0, 0.0), "per-camera default wins"); + let plain = photo_named(&s, "plain.png"); + assert_eq!((plain.develop.color.vibrance, plain.develop.light.exposure), (15.0, 0.0), "non-raw default"); + + // editing then Reset returns to the default look + let id = nikon.id; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap(); + assert!(s.catalog.photo(id).unwrap().is_edited()); + s.execute("develop.reset", &json!({})).unwrap(); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.develop.light.exposure, 0.5); + assert!(!p.is_edited()); + + // per-camera off: the Canon gets the global raw default; clearing it gives the plain defaults + let mut s2 = Session::new().with_fs(); + with_presets(&mut s2); + s2.execute("library.preferences", &json!({"camera": {"camera": "canon eos r5", "preset": "user.canonlook"}})).unwrap(); + s2.execute("library.import", &json!({"paths": [src.join("canon.dng").to_string_lossy()]})).unwrap(); + let c = s2.catalog.photos().next().unwrap(); + assert_eq!(c.develop.light.contrast, 0.0, "per-camera defaults are off unless enabled"); + assert!(c.import_look.is_none()); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn preferences_persist_with_the_library() { + let lib = temp_dir("lib"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + with_presets(&mut s); + s.persist().unwrap(); // presets.json + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + s.execute( + "library.preferences", + &json!({"import": {"rawPreset": "user.rawlook", "perCamera": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}), + ) + .unwrap(); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.xmp.auto_write); + assert_eq!(s.import_defaults.raw_preset.as_deref(), Some("user.rawlook")); + assert!(s.import_defaults.per_camera); + assert_eq!(s.import_defaults.cameras.len(), 1); + assert_eq!(s.import_defaults.preset_for(true, "Canon EOS R5"), Some("user.canonlook")); + assert_eq!(s.cache_mb, 512); + assert_eq!(s.cache_bytes(), 512 << 20); + let r = s.execute("library.preferences", &json!({})).unwrap(); + assert_eq!(r["cacheMb"], 512); + assert_eq!(r["persistent"], true); + // older prefs.json files (snake_case `last_export`, no import section) still load + std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.last_export, Some(json!({"format": "png"}))); + assert_eq!(s.import_defaults, Default::default()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 2997d793..d919cfcf 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -174,6 +174,11 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() { } fn synthetic_dng(xmp: &str) -> Vec { + synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) +} + +/// A tiny Bayer DNG written by our own DNG writer (optional embedded XMP, camera metadata). +pub(crate) fn synthetic_dng_with(xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec { use lightcraft_raw::*; let (w, h) = (32usize, 24usize); let cfa = Cfa::bayer("RGGB").unwrap(); @@ -203,9 +208,9 @@ fn synthetic_dng(xmp: &str) -> Vec { wb_multipliers: None, linearized: false, opcodes: OpcodeLists::default(), - metadata: lightcraft_meta::Metadata::default(), + metadata, }; - write_dng(&raw, &DngWriteOptions { xmp: Some(xmp.to_string()), ..Default::default() }).unwrap() + write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap() } #[test] diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f4c70cec..d5d71500 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -373,6 +373,126 @@ mod tests { h.settle(SETTLE); } + /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through + /// the engine; the delete confirmation guards ⌫. + #[test] + fn settings_dialog_by_keyboard_and_clicks() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() })); + for tab in ["import", "performance", "interface", "general"] { + let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + assert_eq!(r["ok"], true, "{tab}: {r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); + } + // General: confirm before delete, startup view + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); + assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); + // Interface: filmstrip names off, grid badges always + h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); + assert!(!h.app.ui.settings.film_names); + h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); + assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + // Performance: the thumbnail cache size goes to the library preferences + h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); + h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); + assert_eq!(h.app.session.cache_mb, 512); + h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t); + assert_eq!(h.app.ui.settings.preview_edge, 3840); + // Import: per-camera defaults + h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); + h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); + assert!(h.app.session.import_defaults.per_camera); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1300, 820]); + // Escape closes; ⌫ now asks first + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.dialog, None); + let active = h.app.session.active().unwrap(); + h.request("ui.key", json!({"key": "delete"}), t); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); + assert!(!h.app.session.catalog.photo(active).unwrap().deleted); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(active).unwrap().deleted); + // app settings survive a save/load round trip of the UI state (ui.json) + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap().sanitized(); + assert_eq!(back.settings, h.app.ui.settings); + assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load"); + h.settle(SETTLE); + } + + /// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the + /// overlay in Detail; ⇧⌘F asks the host for window full screen. + #[test] + fn full_screen_preview_and_info_overlay_by_keyboard() { + use crate::state::InfoOverlay; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t); + h.request("ui.key", json!({"key": "f"}), t); + assert!(h.app.ui.fullscreen); + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1])); + let right = h.app.ui.right; + h.request("ui.key", json!({"key": "i"}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic); + assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen"); + let img = h.snapshot(SETTLE); + // no chrome: the frame's corners are black + for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] { + let c = img.pixels[y * 1000 + x]; + assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}"); + } + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay")); + h.request("ui.key", json!({"key": "escape"}), t); + assert!(!h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered"); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(h.app.window_is_fullscreen); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(!h.app.window_is_fullscreen); + h.settle(SETTLE); + } + + /// The Navigator appears when zoomed in; clicking it pans to that point. + #[test] + fn navigator_pans_the_zoomed_loupe() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit"); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed"); + h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)"); + h.settle(SETTLE); + let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap(); + let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7); + h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan); + h.request("engine.execute", json!({"command": "view.navigator"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71aed1e8..22e12e96 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ pub type PickFiles = Box Vec>; pub type SaveFile = Box Option>; pub type WriteFn = Box Result<(), String>>; pub type PngEncode = Box Vec>; +/// A folder chooser (`None` = cancelled). +pub type PickFolder = Box Option>; /// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux). pub type RevealFn = Box Result<(), String>>; @@ -49,6 +51,8 @@ pub struct Services { pub png: Option, /// Show a file in the system file manager (desktop only). pub reveal: Option, + /// Choose a folder (Settings → General → Open Library…; desktop only). + pub pick_folder: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -94,6 +98,10 @@ pub struct LightcraftApp { pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, + /// The window is in full screen (as last reported by the host, or as last requested). + pub window_is_fullscreen: bool, + /// The GPU preference last applied (`app.gpu`), to apply Settings changes once. + gpu_applied: Option, } impl LightcraftApp { @@ -124,6 +132,8 @@ impl LightcraftApp { gesture: None, loupe_shown: None, merge: merge::MergeState::default(), + window_is_fullscreen: false, + gpu_applied: None, } } @@ -296,6 +306,7 @@ impl LightcraftApp { } self.last_time = now; self.drain_control(ctx); + self.apply_settings(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); } @@ -317,6 +328,21 @@ impl LightcraftApp { } } + /// Apply settings that act outside the UI state: GPU rendering and window full screen. + fn apply_settings(&mut self, ctx: &egui::Context) { + if self.gpu_applied != Some(self.ui.settings.gpu) { + self.gpu_applied = Some(self.ui.settings.gpu); + let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + } + if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { + self.window_is_fullscreen = fs; + } + if let Some(on) = self.ui.window_fullscreen.take() { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); + self.window_is_fullscreen = on; + } + } + /// Inject synthetic events (pointer events one per frame; key sequences up to the release). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if self.synthetic.is_empty() { @@ -340,6 +366,15 @@ impl LightcraftApp { return; } let t0 = now_ms(); + if self.ui.fullscreen { + // full-screen preview: the photo alone on black + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); + panels::dialogs::show(self, &ctx); + panels::toast(self, &ctx); + self.widgets = widgets::take_registry(&ctx); + self.perf.frame_ms = now_ms() - t0; + return; + } // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, // right panels and left panel run to the bottom; the bottom bar sits between them). panels::topbar::show(self, ui); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1b56db04..2bfeb904 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -54,6 +54,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ &[ "file.addPhotos", "---", + "app.openLibrary", + "---", "dialog.newAlbum", "dialog.newFolder", "dialog.newSmartAlbum", @@ -70,7 +72,19 @@ const LAYOUT: &[(&str, &[&str])] = &[ ), ( "Edit", - &["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"], + &[ + "edit.undo", + "edit.redo", + "---", + "develop.copy", + "dialog.copySettings", + "develop.paste", + "---", + "library.selectAll", + "library.selectNone", + "---", + "app.settings", + ], ), ( "View", @@ -84,6 +98,11 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.leftPanel", "view.filmstrip", "view.histogram", + "view.navigator", + "view.infoOverlay", + "---", + "view.fullScreenPreview", + "view.enterFullScreen", "---", "view.showOriginal", "view.beforeAfter", @@ -202,6 +221,9 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), + "view.navigator" => Some(u.navigator), + "view.fullScreenPreview" => Some(u.fullscreen), + "view.enterFullScreen" => Some(app.window_is_fullscreen), "panel.edit" => panel(RightPanel::Edit), "panel.crop" => panel(RightPanel::Crop), "panel.remove" => panel(RightPanel::Remove), @@ -401,6 +423,9 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result Option { if app.ui.dialog.is_some() { app.ui.dialog = None; + } else if app.ui.fullscreen { + app.ui.fullscreen = false; } else if !app.ui.tool.is_empty() { app.ui.tool.clear(); } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { @@ -136,6 +144,50 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.fullscreen = !app.ui.fullscreen; + if app.ui.fullscreen { + app.ui.zoom = Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + } + Ok(json!({"fullscreen": app.ui.fullscreen})) + } + "view.enterFullScreen" => { + // applied by the host's frame logic (it knows the window's current state) + let on = p.get("on").and_then(Value::as_bool); + app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); + Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) + } + "view.infoOverlay" => { + app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { + Some(m) => match serde_json::from_value(json!(m)) { + Ok(v) => v, + Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), + }, + None => app.ui.info_overlay.next(), + }; + let label = match app.ui.info_overlay { + crate::state::InfoOverlay::Off => "Info Overlay Off", + crate::state::InfoOverlay::Basic => "Info Overlay: File", + crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", + }; + app.toast(&ctx, label); + Ok(json!({"infoOverlay": app.ui.info_overlay})) + } + "view.navigator" => { + app.ui.navigator = !app.ui.navigator; + Ok(json!({"navigator": app.ui.navigator})) + } + "app.settings" => { + let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); + if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { + return Some(Err(format!("unknown settings tab `{tab}` (general|import|performance|interface)"))); + } + app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); + Ok(Value::Null) + } + "app.openLibrary" => crate::panels::settings::open_library(app, p), "view.filmstrip" => { app.ui.filmstrip = !app.ui.filmstrip; Ok(Value::Null) @@ -451,6 +503,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { } "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), "view.compare" => app.session.catalog.len() > 1, + "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, + "app.openLibrary" => app.services.pick_folder.is_some(), "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") => app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none(), _ => true, @@ -493,6 +547,20 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { v } +/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead +/// of deleting; true when it did (the dialog's OK runs `photo.delete`). +pub fn confirm_delete(app: &mut LightcraftApp) -> bool { + if !app.ui.settings.confirm_delete { + return false; + } + let count = app.session.targets(&json!({})).len(); + if count == 0 { + return false; + } + app.ui.dialog = Some(Dialog::ConfirmDelete { count }); + true +} + /// Reveal the active photo's original in the system file manager. fn show_in_finder(app: &mut LightcraftApp) -> Result { let id = app.session.active().ok_or("no photo selected")?; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 54e2575e..b51a9d40 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -119,10 +119,12 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let full = ui.max_rect(); - let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 }; + let fullscreen = app.ui.fullscreen; + let show_film = app.ui.filmstrip && !fullscreen; + let film_h = if show_film { t.film_h } else { 0.0 }; let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h)); app.canvas_rect = Some(canvas); - if app.ui.filmstrip { + if show_film { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { @@ -131,11 +133,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; let d = (*photo.develop).clone(); - let crop_tool = app.ui.right == RightPanel::Crop; + // the full-screen preview shows the photo only: no tool overlays + let right = if fullscreen { RightPanel::None } else { app.ui.right }; + let crop_tool = right == RightPanel::Crop; let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref()); let aspect = frame.aspect() as f32; let ppp = ui.ctx().pixels_per_point(); - let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 }); + let area = canvas.shrink(if fullscreen { + 0.0 + } else if crop_tool { + 48.0 + } else { + 24.0 + }); + let max_edge = app.ui.settings.preview_edge.clamp(512, 8192) as f32; let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); @@ -163,7 +174,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); let scale = if interacting { 0.6 } else { 1.0 }; - let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize; + let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(max_edge) as usize; 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 }; @@ -183,7 +194,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); let na = nf.aspect() as f32; let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); - let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize; + let nw = (nr.width().max(nr.height()) * ppp).min(max_edge) as usize; let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); @@ -253,22 +264,119 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); } - if app.ui.show_clipping && !show_before { + if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); } register(ui.ctx(), "canvas:image", img_rect); let map = CanvasMap::new(&frame, img_rect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); - match app.ui.right { + info_overlay(app, &p, canvas, &photo); + match right { RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), _ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect), } + // drawn and hit-tested above the loupe and its tools: clicks on it pan + navigator(app, ui, canvas, img_rect, id); resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); } +/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date +/// and size, or with the camera and exposure. +fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { + use crate::state::InfoOverlay; + let lines: Vec = match app.ui.info_overlay { + InfoOverlay::Off => return, + InfoOverlay::Basic => { + let date = photo.captured.as_deref().unwrap_or(&photo.imported); + vec![photo.file_name.clone(), format!("{} · {} × {}", pretty_date(date), photo.width, photo.height)] + } + InfoOverlay::Exposure => { + let m = &photo.meta; + let mut exp = Vec::new(); + if !m.shutter.is_empty() { + exp.push(format!("{} s", m.shutter)); + } + if let Some(a) = m.aperture { + exp.push(format!("f/{a:.1}")); + } + if let Some(i) = m.iso { + exp.push(format!("ISO {i}")); + } + if let Some(f) = m.focal_mm { + exp.push(format!("{f:.0} mm")); + } + let camera = [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "); + let mut v = vec![photo.file_name.clone()]; + v.push(if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") }); + if !camera.is_empty() { + v.push(camera); + } + v + } + }; + let t = Tokens::get(p.ctx()); + let mut y = canvas.top() + 12.0; + for (i, l) in lines.iter().enumerate() { + let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) }; + let g = p.layout_no_wrap(l.clone(), font, Color32::WHITE); + let at = pos2(canvas.left() + 14.0, y); + // a soft shadow keeps the text readable on bright photos + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g.clone(), Color32::WHITE); + y += g.size().y + 3.0; + } + register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); +} + +/// `2026-09-30T12:00:00` → `2026-09-30 12:00`. +fn pretty_date(iso: &str) -> String { + let d = iso.replacen('T', " ", 1); + d.get(..16).map(str::to_string).unwrap_or(d) +} + +/// Size of the Navigator mini map (points). +const NAV_W: f32 = 180.0; + +/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the +/// visible region outlined; click or drag on it to pan. +fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) { + let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; + if !app.ui.navigator || !zoomed || app.ui.fullscreen { + return; + } + let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); + let aspect = img.width() / img.height().max(1.0); + let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) }; + let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h)); + let p = ui.painter_at(canvas); + p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); + match tex { + Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), + None => p.rect_filled(frame, 0.0, Color32::from_gray(40)), + }; + // the visible part of the photo, in normalized image coordinates + let vis = canvas.intersect(img); + let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height()); + let (a, b) = (n(vis.min), n(vis.max)); + let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height()); + let view = Rect::from_min_max(to_nav(a), to_nav(b)); + p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + register(ui.ctx(), "canvas:navigator", frame); + let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag()); + if (resp.clicked() || resp.dragged()) + && let Some(q) = resp.interact_pointer_pos() + { + // centre the view on the point under the pointer + let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0); + let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); + app.ui.pan = (u, v); + } +} + /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. const PREFETCH_PRIORITY: u32 = 4; @@ -316,6 +424,9 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon /// The diagnostic overlay the loupe shows (Point Color's visualized range). fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::Overlay; + if app.ui.fullscreen { + return Overlay::None; + } if app.ui.right == RightPanel::Remove && app.ui.visualize_spots { return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8); } @@ -890,7 +1001,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } else if resp.hovered() { p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6)); } - if let Some(ph) = app.session.catalog.photo(*id) { + let names = app.ui.settings.film_names; + if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() }; p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim); @@ -906,6 +1018,11 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { if sel { p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); } + if app.ui.settings.film_badges + && let Some(ph) = app.session.catalog.photo(*id) + { + film_badges(p, &t, fr, ph); + } if let Some(st) = app.session.catalog.stack_of(*id) { let text = if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; @@ -931,6 +1048,32 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }); } +/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). +fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let edited = ph.is_edited(); + if ph.rating == 0 && ph.flag == Flag::None && !edited { + return; + } + let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom()); + p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); + let y = bar.center().y; + let mut x = bar.left() + 3.0; + for i in 0..ph.rating { + paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star); + } + x += ph.rating as f32 * 9.0 + 2.0; + match ph.flag { + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick), + Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), + Flag::None => {} + } + if edited { + paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label); + } +} + /// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. /// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response) { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0ac33308..a234e41e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -28,6 +28,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", Dialog::Export { .. } => "Export", Dialog::Merge { opts } => opts.title(), + Dialog::Settings { .. } => "Settings", + Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::About => "About LightCraft", Dialog::Shortcuts => "Keyboard Shortcuts", }; @@ -189,6 +191,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, "Folder", |ui| ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY))); } Dialog::Merge { opts } => crate::merge::body(app, ui, opts), + Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), + Dialog::ConfirmDelete { count } => { + let what = if *count == 1 { "this photo".to_string() } else { format!("these {count} photos") }; + ui.label(format!("Move {what} to Recently Deleted?")); + ui.label(egui::RichText::new("They can be restored from Recently Deleted until it is emptied.").color(t.text_dim)); + } Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); @@ -231,12 +239,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } ui.add_space(4.0); ui.horizontal(|ui| { - let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts); + let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. }); if !informational && ui.button("Cancel").clicked() { close = true; } let ok = match dlg { Dialog::Merge { .. } => "Merge", + Dialog::ConfirmDelete { .. } => "Delete", _ if informational => "Close", _ => "OK", }; @@ -295,7 +304,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), - Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null), + Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 9886c8f1..c949eda6 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -203,7 +203,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(br, 2.0, Color32::from_gray(26)); p.galley(br.min + vec2(4.0, 1.5), g, t.text_label); } - let show_badges = resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None; + let show_badges = match app.ui.settings.grid_badges { + crate::state::GridBadges::Auto => resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None, + crate::state::GridBadges::Always => true, + crate::state::GridBadges::Never => false, + }; if show_badges { let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom()); if resp.hovered() || selected { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 76cbb011..8b96d690 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -11,6 +11,7 @@ pub mod left; pub mod masking; pub mod presets; pub mod right; +pub mod settings; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs new file mode 100644 index 00000000..2f05aa02 --- /dev/null +++ b/crates/ui-egui/src/panels/settings.rs @@ -0,0 +1,332 @@ +//! The Settings dialog (⌘,): General, Import, Performance, Interface. +//! +//! Changes apply immediately (no OK/Cancel). Where they are stored: +//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, +//! preview size, filmstrip/grid badges, last library) and Auto Advance live in the UI state, +//! saved by the host in its config folder (`ui.json`); +//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the +//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`, +//! so they travel with the library. + +use egui::RichText; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::{GridBadges, PREVIEW_EDGES, StartupView}; +use crate::theme::Tokens; +use crate::widgets::register; + +/// (id, label) of the tabs, in order. +pub const TABS: &[(&str, &str)] = &[("general", "General"), ("import", "Import"), ("performance", "Performance"), ("interface", "Interface")]; + +/// Thumbnail cache sizes offered (MB). +const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192]; + +const LABEL_W: f32 = 150.0; + +/// The dialog body for `tab` (the tab bar switches `tab`). +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(560.0); + ui.set_min_height(330.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (id, label) in TABS { + if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { + *tab = id.to_string(); + } + } + }); + ui.separator(); + ui.add_space(2.0); + match tab.as_str() { + "import" => import_tab(app, ui, &t), + "performance" => performance_tab(app, ui, &t), + "interface" => interface_tab(app, ui, &t), + _ => general_tab(app, ui, &t), + } +} + +fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.add_space(4.0); + ui.label(RichText::new(text).font(t.semibold(12.5)).color(t.text)); +} + +fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(LABEL_W); + ui.label(RichText::new(label).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.label(RichText::new(text).size(11.0).color(t.text_dim)); +} + +/// A checkbox addressable as `check:{id}`; true when toggled. +fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { + let r = ui.checkbox(value, label); + register(ui.ctx(), format!("check:{id}"), r.rect); + r.changed() +} + +/// Mutually exclusive buttons (`button:{id}-{index}`). +fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { + let mut changed = false; + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (v, l)) in options.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v { + *value = *v; + changed = true; + } + } + changed +} + +// ------------------------------------------------------------------------------------- General + +fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, "Library"); + let location = match &app.session.library { + Some(l) => l.dir.display().to_string(), + None => "In memory — nothing is saved".to_string(), + }; + row(ui, t, "Location", |ui| { + ui.label(RichText::new(location).color(t.text)); + }); + row(ui, t, "", |ui| { + let can = app.services.pick_folder.is_some(); + let r = ui.add_enabled(can, egui::Button::new("Open Library…")); + register(ui.ctx(), "button:settingsOpenLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.openLibrary", json!({})); + } + if !can { + hint(ui, t, "not available here"); + } + }); + heading(ui, t, "Startup"); + row(ui, t, "Open in", |ui| { + choices( + ui, + "settingsStartup", + &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail")], + &mut app.ui.settings.startup_view, + ); + }); + heading(ui, t, "Culling"); + check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); + check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); +} + +// -------------------------------------------------------------------------------------- Import + +/// A preset picker: `None` = `none_label`. Returns the new choice when it changed. +fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option> { + let name = |pid: &str| app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| format!("{pid} (missing)")); + let text = current.map(name).unwrap_or_else(|| none_label.to_string()); + let mut out = None; + let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| { + if ui.selectable_label(current.is_none(), none_label).clicked() { + out = Some(None); + } + let mut group = ""; + for p in &app.session.presets { + if p.group != group { + group = &p.group; + ui.label(RichText::new(group).size(10.5).weak()); + } + if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() { + out = Some(Some(p.id.clone())); + } + } + }); + register(ui.ctx(), format!("combo:{id}"), r.response.rect); + out.filter(|v| v.as_deref() != current) +} + +/// Cameras of the raws in the library plus those with a stored default, sorted. +fn cameras(app: &LightcraftApp) -> Vec { + let mut v: Vec = app + .session + .catalog + .photos() + .filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty()) + .map(|p| p.meta.camera.clone()) + .chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone())) + .collect(); + v.sort_by_key(|c| c.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v +} + +fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let d = app.session.import_defaults.clone(); + heading(ui, t, "Raw defaults"); + hint(ui, t, "Settings new raw photos start from. Changing them doesn't touch photos already in the library."); + row(ui, t, "Raw photos", |ui| { + if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") { + let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}})); + } + }); + let mut per = d.per_camera; + row(ui, t, "", |ui| { + if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") { + let _ = app.run("library.preferences", json!({"import": {"perCamera": per}})); + } + }); + if per { + let cams = cameras(app); + if cams.is_empty() { + hint(ui, t, "No raw photos yet: cameras appear here once their photos are in the library."); + } + egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| { + for (i, cam) in cams.iter().enumerate() { + let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam)); + row(ui, t, cam, |ui| { + // "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default) + const RAW_DEFAULT: &str = "\u{1}raw"; + let current = match entry { + None => Some(RAW_DEFAULT), + Some(e) => e.preset.as_deref(), + }; + let mut pick = None; + let label = match current { + Some(RAW_DEFAULT) => "Same as raw default".to_string(), + None => "LightCraft Default".to_string(), + Some(pid) => app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| pid.to_string()), + }; + let id = format!("settingsCamera-{i}"); + let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(current == Some(RAW_DEFAULT), "Same as raw default").clicked() { + pick = Some(json!({"camera": cam, "remove": true})); + } + if ui.selectable_label(current.is_none(), "LightCraft Default").clicked() { + pick = Some(json!({"camera": cam, "preset": null})); + } + for p in &app.session.presets { + if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() { + pick = Some(json!({"camera": cam, "preset": p.id})); + } + } + }); + register(ui.ctx(), format!("combo:{id}"), r.response.rect); + if let Some(c) = pick { + let _ = app.run("library.preferences", json!({"camera": c})); + } + }); + } + }); + } + heading(ui, t, "Other images (JPEG, PNG, TIFF, HEIC…)"); + row(ui, t, "Non-raw photos", |ui| { + if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") { + let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); + } + }); + heading(ui, t, "XMP sidecars"); + let mut xmp = app.session.xmp; + if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { + let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write})); + } + row(ui, t, "Sidecar names", |ui| { + use lightcraft_engine::sidecar::SidecarNaming as N; + let mut n = xmp.naming; + if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) { + let naming = if n == N::Full { "full" } else { "stem" }; + let _ = app.run("library.xmpPreferences", json!({"naming": naming})); + } + }); + if app.session.library.is_none() { + hint(ui, t, "In-memory session: these settings last until LightCraft quits."); + } +} + +// --------------------------------------------------------------------------------- Performance + +fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::gpu; + heading(ui, t, "Rendering"); + check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); + let status = if !gpu::available() { + "No usable GPU found: rendering on the CPU".to_string() + } else { + format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) + }; + hint(ui, t, &status); + row(ui, t, "Preview size", |ui| { + let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect(); + let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); + choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge); + }); + hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower."); + heading(ui, t, "Thumbnail cache"); + let cur = (app.session.cache_bytes() >> 20) as u32; + row(ui, t, "Size limit", |ui| { + let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")]; + let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 }; + if choices(ui, "settingsCache", &opts, &mut v) { + let _ = app.run("library.preferences", json!({"cacheMb": v})); + } + }); + let used = app.session.media.rendered.disk().map(|d| d.size()); + row(ui, t, "In use", |ui| { + ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text)); + let r = ui.button("Clear Cache"); + register(ui.ctx(), "button:settingsClearCache", r.rect); + if r.clicked() { + let _ = app.run("library.clearPreviews", json!({})); + } + }); +} + +// ---------------------------------------------------------------------------------- Interface + +fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, "Filmstrip"); + check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); + check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); + heading(ui, t, "Grid"); + row(ui, t, "Ratings & flags", |ui| { + choices( + ui, + "settingsGridBadges", + &[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")], + &mut app.ui.settings.grid_badges, + ); + }); + check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); + heading(ui, t, "Detail"); + check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); + row(ui, t, "Info overlay", |ui| { + use crate::state::InfoOverlay as I; + choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay); + }); +} + +// ------------------------------------------------------------------------------- Open Library… + +/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, +/// or a folder chosen in a dialog. Remembered as the library to open at launch. +pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => match app.services.pick_folder.as_mut() { + Some(pick) => match pick() { + Some(x) => x, + None => return Ok(Value::Null), + }, + None => return Err("no folder dialog on this platform".into()), + }, + }; + app.session.close_library().map_err(|e| e.to_string())?; + app.session.open_library(&path, false).map_err(|e| e.to_string())?; + app.renderer.forget_all(); + app.ui.compare = None; + app.ui.settings.library_path = path.clone(); + Ok(json!({"path": path, "photos": app.session.catalog.len()})) +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 3703dcb8..e5c771a6 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -176,6 +176,16 @@ impl Renderer { } /// Is a request for `slot` queued or running? + /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). + pub fn forget_all(&mut self) { + self.textures.clear(); + self.stages.clear(); + self.quick_tried.clear(); + self.prefetched.clear(); + self.pending.clear(); + self.queue.clear(); + } + pub fn is_pending(&self, slot: Slot) -> bool { self.pending.contains_key(&slot) } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 01f91fae..83a2bf78 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -41,6 +41,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { "[" => Some(Key::OpenBracket), "]" => Some(Key::CloseBracket), "'" => Some(Key::Quote), + "," => Some(Key::Comma), _ => None, }) } @@ -156,6 +157,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { _ => app.toast(ctx, "Unflagged"), } } else { + // in the full-screen preview (no panels) I cycles the info overlay instead + if f == "panel.info" && app.ui.fullscreen { + let _ = app.run("view.infoOverlay", json!({})); + continue; + } // Delete acts on what's being edited: the active mask in the Masking panel; never the // photo while retouching (spots are removed from their own panel). if f == "photo.delete" { @@ -170,6 +176,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { R::Remove | R::RedEye => continue, _ => {} } + if crate::menus::confirm_delete(app) { + continue; + } } // X is both reject (library) and swap crop aspect (crop tool) if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e3ba282..2cd09b7b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -75,6 +75,91 @@ pub enum CropOverlay { None, } +/// Which view the app opens in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartupView { + /// Whatever was showing at quit. + #[default] + Last, + Grid, + Detail, +} + +/// When grid cells show their rating / flag / edited badges. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridBadges { + /// On hover, on the selection, and on rated or flagged photos. + #[default] + Auto, + Always, + Never, +} + +/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InfoOverlay { + #[default] + Off, + /// File name, capture date and dimensions. + Basic, + /// File name, camera, lens and exposure (shutter, aperture, ISO, focal length). + Exposure, +} + +impl InfoOverlay { + pub fn next(self) -> InfoOverlay { + match self { + InfoOverlay::Off => InfoOverlay::Basic, + InfoOverlay::Basic => InfoOverlay::Exposure, + InfoOverlay::Exposure => InfoOverlay::Off, + } + } +} + +/// App-level preferences (Settings dialog). They belong to the app, not a library, and are +/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library +/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AppSettings { + /// Library opened at launch when no `--library` is given (empty = the default location). + pub library_path: String, + pub startup_view: StartupView, + /// Ask before moving photos to Recently Deleted (keyboard and menu). + pub confirm_delete: bool, + /// GPU rendering allowed (`app.gpu`). + pub gpu: bool, + /// Largest long edge (pixels) the loupe renders at. + pub preview_edge: u32, + /// Filmstrip: file names above the thumbnails. + pub film_names: bool, + /// Filmstrip: rating / flag / edited badges on the thumbnails. + pub film_badges: bool, + /// Grid: when to show the rating / flag / edited badges. + pub grid_badges: GridBadges, +} + +impl Default for AppSettings { + fn default() -> Self { + AppSettings { + library_path: String::new(), + startup_view: StartupView::Last, + confirm_delete: false, + gpu: true, + preview_edge: 2560, + film_names: true, + film_badges: true, + grid_badges: GridBadges::Auto, + } + } +} + +/// Preview sizes offered in Settings → Performance. +pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120]; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { @@ -128,6 +213,17 @@ pub struct UiState { pub filter_bar: bool, /// Culling: after a rating, flag or colour-label key, move to the next photo. pub auto_advance: bool, + /// Full-screen preview (F): the photo alone on black, no chrome. + pub fullscreen: bool, + /// Info overlay on the loupe. + pub info_overlay: InfoOverlay, + /// Navigator mini map in the loupe while zoomed in. + pub navigator: bool, + /// App preferences (Settings dialog). + pub settings: AppSettings, + /// Window full screen requested (⇧⌘F); the host applies it (`None` = no change pending). + #[serde(skip)] + pub window_fullscreen: Option, /// Compare view: (select, candidate) photo ids. #[serde(skip)] pub compare: Option<(u64, u64)>, @@ -177,6 +273,14 @@ pub enum Dialog { Merge { opts: crate::merge::MergeDialog, }, + /// Settings (preferences): `tab` = general | import | performance | interface. + Settings { + tab: String, + }, + /// Confirm moving photos to Recently Deleted. + ConfirmDelete { + count: usize, + }, About, Shortcuts, } @@ -218,6 +322,11 @@ impl Default for UiState { visualize_spots: false, spots_threshold: 50.0, auto_advance: false, + fullscreen: false, + info_overlay: InfoOverlay::Off, + navigator: true, + settings: AppSettings::default(), + window_fullscreen: None, filter_bar: false, compare: None, toast: None, @@ -256,6 +365,15 @@ impl UiState { self.thumb_size = self.thumb_size.clamp(90.0, 480.0); self.brush_size = self.brush_size.clamp(0.002, 0.5); self.dialog = None; + self.fullscreen = false; + if !crate::state::PREVIEW_EDGES.contains(&self.settings.preview_edge) { + self.settings.preview_edge = AppSettings::default().preview_edge; + } + match self.settings.startup_view { + StartupView::Last => {} + StartupView::Grid => self.view = ViewMode::PhotoGrid, + StartupView::Detail => self.view = ViewMode::Detail, + } self } } diff --git a/docs/parity.md b/docs/parity.md index 97cada6d..bf992c44 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,9 +21,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| -| A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) | +| A. Import (IMP) | 3 | 4 | 6 | 1 | 1/4 (25%) | 2/4 (50%) | | B. Library management (LIB) | 12 | 4 | 9 | 2 | 7/9 (78%) | 4/9 (44%) | -| C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) | +| C. Views & navigation (VIEW) | 12 | 2 | 3 | 0 | 7/9 (78%) | 4/4 (100%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | | F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | @@ -42,13 +42,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 4 | 6 | 8 | 0 | 2/7 (29%) | 2/7 (29%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) | +| V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 10 | 2 | 5 | 1 | 7/8 (88%) | 3/5 (60%) | -| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | -| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | +| Y. Menus | 50 | 18 | 17 | 8 | 38/47 (81%) | 10/23 (43%) | +| Z. Keyboard shortcuts (desktop) | 49 | 16 | 15 | 1 | 40/52 (77%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 208 | 103 | 189 | 37 | 152/198 (77%) | 48/144 (33%) | +| **Total** | 223 | 101 | 176 | 37 | 155/198 (78%) | 60/144 (42%) | ## Top gaps @@ -68,15 +68,12 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i live preview while hovering presets/profiles/versions. Medium effort. 7. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). 8. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -9. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), - XMP prefs, cache, GPU. Medium effort. 10. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination album; browse folders before adding. Medium effort. 11. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. 12. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). -13. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. 14. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. 15. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. 16. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). @@ -126,7 +123,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-DEVICE | Import from camera/card | P1 | ⬜ | | no device detection | | LR-IMP-AUTO | Watched-folder auto import | P2 | ⬜ | | | | LR-IMP-PRESET | Preset on import | P2 | ⬜ | | | -| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ⬜ | `crates/engine/src/import.rs` | raws get embedded lens corrections on import; no user raw-default setting | +| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; no browse-a-folder-without-adding view | @@ -175,14 +172,14 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | -| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ⬜ | | | +| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar | -| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | | +| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | all four layouts; "before" is always the original (no before-state from history) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | -| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | @@ -467,11 +464,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ⬜ | `cmd:library.xmpPreferences` | no settings dialog; only XMP preferences (by command) | -| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | 🟡 | `cmd:library.clearPreviews`, `cmd:library.compact` | bounded thumbnail cache; no UI for size/location | +| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs` | General / Import / Performance / Interface tabs; app settings in ui.json, library settings in prefs.json | +| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | -| LR-PREF-INTERFACE | Interface options | P1 | ⬜ | | | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | 🟡 | `cmd:app.gpu` | by command only | +| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference | | LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | @@ -520,7 +517,7 @@ visible menu bar yet: the menu model is only exposed through the control channel |---|---|---|---|---|---| | MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu | | MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | | -| MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL | +| MENU-APP-SETTINGS | Settings… | P0 | ✅ | `cmd:app.settings` | app menu on macOS, Edit menu elsewhere | | MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | | | MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | | | MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | | @@ -554,8 +551,8 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | | | MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | | | MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | | -| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ⬜ | | | -| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ⬜ | | | +| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ✅ | `cmd:view.fullScreenPreview` | | +| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ✅ | `cmd:view.enterFullScreen` | | | MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | `cmd:view.leftPanel` | | | MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | | MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | `cmd:panel.info` | | @@ -563,7 +560,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | | our History panel is `panel.activity` | | MENU-VIEW-VERSIONS | Show/Hide versions | P1 | ✅ | `cmd:panel.versions` | | | MENU-VIEW-HISTOGRAM | Show/Hide histogram | P0 | ✅ | `cmd:view.histogram` | | -| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ⬜ | | | +| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ✅ | `cmd:view.infoOverlay` | cycles | | MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | `cmd:view.showOriginal` | | | MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | | | MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | | @@ -622,7 +619,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CROP | Crop & rotate — C | P0 | ✅ | `cmd:panel.crop` | | | KEY-DETAIL | Detail — D | P0 | ✅ | `cmd:view.detail` | | | KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | | -| KEY-FULLSCREEN | Full-screen preview — F | P1 | ⬜ | | no command | +| KEY-FULLSCREEN | Full-screen preview — F | P1 | ✅ | `cmd:view.fullScreenPreview` | | | KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.photoGrid` | | | KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | | | KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | | @@ -636,7 +633,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | | | KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) | | KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ⬜ | | no command | -| KEY-PREFS | Settings — ⌘, | P0 | ⬜ | | no command | +| KEY-PREFS | Settings — ⌘, | P0 | ✅ | `cmd:app.settings` | | | KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command | | KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | | | KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | 🟡 | `cmd:view.maskOverlay`, `cmd:view.cropOverlay` | O toggles the mask overlay; crop overlays cycle on ⇧O | @@ -651,7 +648,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ⬜ | | ⇧O cycles crop overlays | | KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound | | KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) | -| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | | +| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ✅ | `cmd:view.enterFullScreen` | | | KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack | | KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid | | KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) | From 8f558d9c991a743f564c7158581b88f2177e377c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:07:02 -0400 Subject: [PATCH 4/8] LR-EXP-COLORSPACE: align ICC tag data on 4-byte boundaries The profile encoder packs tags back to back, so the odd-length description of the "Adobe RGB (1998) compatible" profile misaligned the copyright tag and macOS ImageIO ignored the profile in PNG iCCP (sips showed none). Tags are re-laid at aligned offsets; already aligned profiles (sRGB, P3, ProPhoto, Rec. 2020) are byte-identical. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/icc.rs | 58 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index c6ccff7c..7850e61b 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -146,7 +146,46 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec { }; p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)])); p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())])); - p.encode().unwrap_or_default() + align_tags(&p.encode().unwrap_or_default()) +} + +/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to +/// back, so a tag of odd length (e.g. a `mluc` description with an odd number of characters) +/// misaligns every following tag — which some readers (macOS ImageIO for PNG `iCCP`) reject. +fn align_tags(b: &[u8]) -> Vec { + let be = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize); + let Some(n) = be(128) else { return b.to_vec() }; + let table_end = 132 + 12 * n; + let tags: Option> = (0..n).map(|t| Some((be(132 + 12 * t + 4)?, be(132 + 12 * t + 8)?))).collect(); + let Some(tags) = tags.filter(|t| table_end <= b.len() && t.iter().all(|&(o, l)| o >= table_end && o + l <= b.len())) else { + return b.to_vec(); + }; + let mut out = b[..table_end].to_vec(); + let mut placed: Vec<((usize, usize), usize)> = Vec::new(); + for (t, &(off, len)) in tags.iter().enumerate() { + let new_off = match placed.iter().find(|(k, _)| *k == (off, len)) { + // tags sharing data keep sharing it + Some(&(_, o)) => o, + None => { + while !out.len().is_multiple_of(4) { + out.push(0); + } + let o = out.len(); + out.extend_from_slice(&b[off..off + len]); + placed.push(((off, len), o)); + o + } + }; + out[132 + 12 * t + 4..132 + 12 * t + 8].copy_from_slice(&(new_off as u32).to_be_bytes()); + } + while !out.len().is_multiple_of(4) { + out.push(0); + } + let size = out.len() as u32; + out[0..4].copy_from_slice(&size.to_be_bytes()); + // the profile ID (MD5) would no longer match: clear it (= "not computed") + out[84..100].fill(0); + out } /// A profile for one of the well-known spaces with its standard curve. @@ -221,6 +260,23 @@ mod tests { } } + #[test] + fn tags_are_aligned() { + for n in NamedSpace::ALL { + for trc in [n.trc(), Trc::Linear] { + let b = write_matrix_trc(&n.rgb_space(), &trc); + assert_eq!(b.len() % 4, 0, "{n:?}"); + assert_eq!(u32::from_be_bytes(b[0..4].try_into().unwrap()) as usize, b.len()); + let count = u32::from_be_bytes(b[128..132].try_into().unwrap()) as usize; + for t in 0..count { + let off = u32::from_be_bytes(b[132 + 12 * t + 4..132 + 12 * t + 8].try_into().unwrap()); + assert_eq!(off % 4, 0, "{n:?} tag {t}"); + } + assert!(parse(&b).is_some_and(|i| i.named == Some(n)), "{n:?}"); + } + } + } + #[test] fn linear_profile() { let bytes = write_matrix_trc(&PROPHOTO, &Trc::Linear); From 03291d6f8306a0a571e37f20d416bb6a87d34f8e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:07:02 -0400 Subject: [PATCH 5/8] LR-EXP-BITDEPTH: 16-bit PNG/TIFF, 32-bit float linear TIFF, 10-bit AVIF RenderRequest gains a sample format (OutputDepth: U8 for previews, U16, F32Linear); deep renders run the per-pixel stage with the exact sRGB curve and return Rendered::deep alongside the 8-bit image (GPU stays 8-bit: deep exports render on the CPU). Export encodes deep renders: 16-bit PNG and TIFF, 32-bit float linear TIFF with a linear ICC profile we generate, 10-bit AVIF (YCbCr planes into ravif). Output sharpening and watermarks work on deep images. ExportOptions.bitDepth with per-format defaults (TIFF 16-bit, others 8-bit), export dialog bit depth choice (remembered with the last export), MCP export schema. Tests: a smooth gradient has 1024 levels in a 16-bit TIFF vs 13 in 8-bit, float TIFF decodes to the same light as 16-bit, per-format depth rules, AVIF 10-bit. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/wire.rs | 1 + apps/lightcraft-web/src/workers.rs | 6 +- crates/codecs/src/encode.rs | 57 ++++-- crates/engine/src/export.rs | 270 ++++++++++++++++++++++++--- crates/engine/src/media.rs | 18 +- crates/gpu/src/lib.rs | 3 +- crates/gpu/src/render.rs | 2 +- crates/mcp/src/tools.rs | 2 + crates/pipeline/src/finish.rs | 37 +++- crates/pipeline/src/lib.rs | 18 +- crates/pipeline/src/output.rs | 60 ++++++ crates/ui-egui/src/panels/dialogs.rs | 13 +- docs/mcp.md | 2 +- docs/parity.md | 4 +- 14 files changed, 434 insertions(+), 59 deletions(-) diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 9dc5dd31..a84be413 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -90,6 +90,7 @@ impl WireJob { overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1), // workers render previews (exports run in-process) space: lightcraft_engine::pipeline::OutputSpace::Srgb, + depth: lightcraft_engine::pipeline::OutputDepth::U8, } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 9f282c16..50ae0f72 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -97,7 +97,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option { let histogram = Histogram::of_srgb8(&image); - return Ok((Rendered { image, histogram }, 0)); + return Ok((Rendered { image, histogram, deep: None }, 0)); } None => cache_miss = true, } @@ -275,7 +275,7 @@ impl Workers { Some(image) if hist.len() == 1025 => { let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec(); let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] }; - Ok(Rendered { image, histogram }) + Ok(Rendered { image, histogram, deep: None }) } _ => Err("render worker: bad image".to_string()), } @@ -344,7 +344,7 @@ impl RenderOffload for Workers { photo: job.photo, level: job.level, key: job.key, - rendered: Ok(Rendered { image, histogram }), + rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, }; diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 9bfc1f78..6dcb44ad 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -290,15 +290,26 @@ pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result Result> { img.validate()?; #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] { - let Samples::U8(s) = img.samples else { - return Err(Error::Encode("AVIF encoder needs 8-bit samples".into())); + let mut enc = ravif::Encoder::new() + .with_quality(quality.clamp(1, 100) as f32) + .with_alpha_quality(quality.clamp(1, 100) as f32) + .with_speed(speed.clamp(1, 10)); + if let Some(exif) = meta.exif { + enc = enc.with_exif(exif.to_vec()); + } + let s = match img.samples { + Samples::U8(s) => s, + Samples::U16(s) => return avif_10bit(&enc, img, s), + Samples::F32(_) => return Err(Error::Encode("AVIF encoder needs 8- or 16-bit samples".into())), }; let n = img.width as usize * img.height as usize; let ch = img.channels as usize; @@ -312,13 +323,6 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) _ => ravif::RGBA8::new(c[0], c[1], c[2], c[3]), }) .collect(); - let mut enc = ravif::Encoder::new() - .with_quality(quality.clamp(1, 100) as f32) - .with_alpha_quality(quality.clamp(1, 100) as f32) - .with_speed(speed.clamp(1, 10)); - if let Some(exif) = meta.exif { - enc = enc.with_exif(exif.to_vec()); - } let r = enc.encode_rgba(ravif::Img::new(&px[..], img.width as usize, img.height as usize)).map_err(|e| Error::Encode(e.to_string()))?; let _ = img.has_alpha(); Ok(r.avif_file) @@ -329,3 +333,32 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) Err(Error::Encode("AVIF encoding is not available in this build".into())) } } + +/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input). +#[cfg(all(feature = "avif", not(target_arch = "wasm32")))] +fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result> { + const K: [f32; 3] = [0.299, 0.587, 0.114]; + let n = img.width as usize * img.height as usize; + let ch = img.channels as usize; + let rgb = |c: &[u16]| -> [f32; 3] { + let v = |x: u16| x as f32 * (1023.0 / 65535.0); + match ch { + 1 | 2 => [v(c[0]); 3], + _ => [v(c[0]), v(c[1]), v(c[2])], + } + }; + let planes = s.chunks_exact(ch).take(n).map(|c| { + let [r, g, b] = rgb(c); + let y = K[0] * r + K[1] * g + K[2] * b; + let cb = (b - y) * (0.5 / (1.0 - K[2])) + 512.0; + let cr = (r - y) * (0.5 / (1.0 - K[0])) + 512.0; + [y, cb, cr].map(|v| v.round().clamp(0.0, 1023.0) as u16) + }); + let alpha_at = |c: &[u16]| if img.has_alpha() { c[ch - 1] } else { u16::MAX }; + let has_alpha = s.chunks_exact(ch).take(n).any(|c| alpha_at(c) != u16::MAX); + let alpha = has_alpha.then(|| s.chunks_exact(ch).take(n).map(move |c| ((alpha_at(c) as u32 * 1023 + 32767) / 65535) as u16)); + let r = enc + .encode_raw_planes_10_bit(img.width as usize, img.height as usize, planes, alpha, ravif::PixelRange::Full, ravif::MatrixCoefficients::BT601) + .map_err(|e| Error::Encode(e.to_string()))?; + Ok(r.avif_file) +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 7c27b50a..cbb3269b 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -3,9 +3,9 @@ //! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop //! app, CLI, MCP and the web build share it; writing the file is the caller's job. -use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc}; +use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, TiffCompression, encode, icc}; use lightcraft_meta::{DateTime, Gps, Metadata}; -pub use lightcraft_pipeline::OutputSpace; +pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace}; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; @@ -105,15 +105,49 @@ impl Default for Watermark { /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); -/// Draw `wm` onto `img` (straight sRGB alpha blending). +/// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { + let width = img.width; + watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + let p = &mut img.data[y * width + x]; + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + }); +} + +/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float +/// images are linear and blend in linear light). +pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { + let (width, trc) = (img.width, img.space.trc()); + match &mut img.samples { + DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let p = v[i + c] as f32; + v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; + } + }), + DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let target = trc.decode(col[c] as f32 / 255.0); + v[i + c] += (target - v[i + c]) * k; + } + }), + } +} + +/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for +/// every covered pixel (shadow pass first). +fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point}; let text = wm.text.trim(); - if text.is_empty() || img.width == 0 || img.height == 0 { + if text.is_empty() || width == 0 || height == 0 { return; } let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return }; - let short = img.width.min(img.height) as f32; + let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); let sf = font.as_scaled(PxScale::from(px)); // Lay out one line. @@ -131,7 +165,7 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { } let (tw, th) = (x, sf.ascent() - sf.descent()); let inset = wm.inset.clamp(0.0, 0.4) * short; - let (w, h) = (img.width as f32, img.height as f32); + let (w, h) = (width as f32, height as f32); use Anchor::*; let ox = match wm.anchor { TopLeft | Left | BottomLeft => inset, @@ -145,14 +179,10 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { }; let alpha = wm.opacity.clamp(0.0, 1.0); let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { - if gx < 0 || gy < 0 || gx >= img.width as i32 || gy >= img.height as i32 { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { return; } - let k = (cov * a).clamp(0.0, 1.0); - let p = &mut img.data[gy as usize * img.width + gx as usize]; - for c in 0..3 { - p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; - } + blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col); }; let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; @@ -202,6 +232,9 @@ pub struct ExportOptions { pub watermark: Option, /// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile). pub color_space: OutputSpace, + /// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the + /// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`]. + pub bit_depth: Option, } impl Default for ExportOptions { @@ -218,13 +251,14 @@ impl Default for ExportOptions { remove_location: false, watermark: None, color_space: OutputSpace::Srgb, + bit_depth: None, } } } impl ExportOptions { /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, - /// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`). + /// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`). pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); @@ -250,6 +284,31 @@ impl ExportOptions { } .filter(|w: &Watermark| !w.text.trim().is_empty()), color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), + bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), + } + } + + /// The sample format the file is written with (unsupported requests fall back to the closest). + pub fn effective_depth(&self) -> OutputDepth { + match (self.format, self.bit_depth) { + (ExportFormat::Jpeg | ExportFormat::Webp, _) => OutputDepth::U8, + (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, + (ExportFormat::Png, _) => OutputDepth::U8, + (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, + (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, + (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, + (ExportFormat::Avif, _) => OutputDepth::U8, + } + } + + /// The bit depths `format` offers (value, label); the first is its default. + pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] { + match format { + ExportFormat::Jpeg | ExportFormat::Webp => &[(8, "8-bit")], + ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], + ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], + ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], } } @@ -269,8 +328,43 @@ impl ExportOptions { /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { + let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); + if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { + for (p, v) in img.data.iter_mut().zip(out) { + for c in 0..3 { + p[c] = v[c].round().clamp(0.0, 255.0) as u8; + } + } + } +} + +/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). +pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { + let (w, h) = (img.width, img.height); + match &mut img.samples { + DeepSamples::U16(v) => { + let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.round().clamp(0.0, 65535.0) as u16; + } + } + } + DeepSamples::F32(v) => { + let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.max(0.0); + } + } + } + } +} + +/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. +fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { let (radius, base) = match target { - SharpenFor::None => return, + SharpenFor::None => return None, SharpenFor::Screen => (1usize, 0.35f32), SharpenFor::Matte => (2, 0.6), SharpenFor::Glossy => (1, 0.5), @@ -281,11 +375,9 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount SharpenAmount::Standard => 1.0, SharpenAmount::High => 1.5, }; - let (w, h) = (img.width, img.height); if w < 3 || h < 3 { - return; + return None; } - let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); let r = radius as isize; let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { let mut out = vec![[0.0; 3]; inp.len()]; @@ -307,13 +399,8 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount } out }; - let blur = box_pass(&box_pass(&src, true), false); - for (i, p) in img.data.iter_mut().enumerate() { - for c in 0..3 { - let v = src[i][c] + (src[i][c] - blur[i][c]) * k; - p[c] = v.round().clamp(0.0, 255.0) as u8; - } - } + let blur = box_pass(&box_pass(src, true), false); + Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect()) } /// Encode a rendered display-referred image according to `o` (its pixels must already be in @@ -398,6 +485,44 @@ pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { }) } +/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF, +/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image. +pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + match &r.deep { + Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta), + _ => encode_with_metadata(&r.image, o, meta), + } +} + +/// Encode a high-bit-depth image (see [`encode_rendered`]). +pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let mut img = img.clone(); + output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() }; + draw_watermark_deep(&mut img, &wm); + } + let profile = match img.samples { + DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), + DeepSamples::U16(_) => icc::write_named(named_space(img.space)), + }; + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() }; + let (w, h) = (img.width as u32, img.height as u32); + let e = match &img.samples { + DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), + DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), + }; + let r = match o.format { + ExportFormat::Png => encode::encode_png(&e, &meta), + ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta), + ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), + f => return Err(format!("{f:?} export is 8-bit only")), + }; + r.map_err(|e| e.to_string()) +} + /// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds. fn parse_shutter(s: &str) -> Option { let s = s.trim().trim_end_matches(['s', '"']).trim(); @@ -454,8 +579,8 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI let full = p.width.max(p.height).max(1) as usize; let size = o.long_edge.map_or(full, |l| l as usize); let meta = export_metadata(p, o); - let r = session.render_export(id, size, o.effective_space())?; - let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?; + let r = session.render_export(id, size, o.effective_space(), o.effective_depth())?; + let bytes = encode_rendered(&r, o, meta.as_ref())?; Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height }) } @@ -632,4 +757,97 @@ mod tests { let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); } + + /// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`. + fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered { + let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]); + let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) + } + + fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let mut v: Vec = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect(); + v.dedup(); + (v.len(), d) + } + + #[test] + fn sixteen_bit_tiff_and_png_have_no_banding() { + let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; + assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit"); + let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb); + assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); + let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap()); + let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb); + let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap()); + eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}"); + assert!(levels8 < 40, "{levels8}"); + assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}"); + assert_eq!(d16.space.named, Some(NamedSpace::Srgb)); + // the 8-bit preview of a deep render matches the 8-bit render + for (a, b) in r16.image.data.iter().zip(&r8.image.data) { + assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}"); + } + let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() }; + let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3); + let bytes = encode_rendered(&rp, &png, None).unwrap(); + assert_eq!(bytes[24], 16, "PNG IHDR bit depth"); + let (levels, d) = distinct_levels(&bytes); + assert!(levels > 10 * levels8, "{levels}"); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + // sharpening and watermarking work on deep images too + let o = ExportOptions { + sharpen: SharpenFor::Matte, + watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), + ..tiff(None) + }; + assert!(encode_rendered(&r16, &o, None).is_ok()); + } + + #[test] + fn float_tiff_is_linear_with_a_linear_profile() { + let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_depth(), OutputDepth::F32Linear); + let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto); + let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap()); + assert!(levels > 500, "{levels}"); + assert_eq!(df.space.named, Some(NamedSpace::ProPhoto)); + assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc); + // decodes to the same light as the 16-bit gamma-encoded export + let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() }; + let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap()); + for x in [0, 300, 700, 1023] { + let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]); + assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}"); + } + } + + #[test] + fn bit_depth_options_per_format() { + let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth(); + assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8); + assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16); + assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16)); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None); + for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif] { + let first = ExportOptions::bit_depths(f)[0].0; + let def = d(f, None); + assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn avif_ten_bit() { + let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() }; + let r = ramp(OutputDepth::U16, OutputSpace::Srgb); + match encode_rendered(&r, &o, None) { + Ok(b) => assert_eq!(&b[4..8], b"ftyp"), + Err(e) => assert!(e.contains("not available"), "{e}"), + } + } } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 98adc839..e0474452 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -277,7 +277,7 @@ impl RenderJob { photo: self.photo, level: self.level, key: self.key, - rendered: Ok(Rendered { image, histogram }), + rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, }; @@ -356,7 +356,7 @@ impl QuickJob { let done = |rendered: Result, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick }; let rendered = |image: Rgba8| { let histogram = Histogram::of_srgb8(&image); - Ok(Rendered { image, histogram }) + Ok(Rendered { image, histogram, deep: None }) }; for (cache, k) in &self.cached { if let Some(img) = cache.get(*k) { @@ -518,11 +518,19 @@ impl crate::Session { r.rendered } - /// Synchronous render for export: like [`Self::render_now`], into colour space `space`. - pub fn render_export(&mut self, id: PhotoId, max: usize, space: lightcraft_pipeline::OutputSpace) -> Result { + /// Synchronous render for export: like [`Self::render_now`], into colour space `space` with + /// sample format `depth`. + pub fn render_export( + &mut self, + id: PhotoId, + max: usize, + space: lightcraft_pipeline::OutputSpace, + depth: lightcraft_pipeline::OutputDepth, + ) -> Result { let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?; job.request.space = space; - job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f); + job.request.depth = depth; + job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); let r = job.run(); self.accept(&r); r.rendered diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ee88430b..21bc4229 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -119,7 +119,8 @@ pub fn adapter_name() -> Option { pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { #[cfg(not(target_arch = "wasm32"))] { - if !enabled() { + // the kernel writes 8-bit output: high-bit-depth exports render on the CPU + if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 { return None; } let gpu = device()?; diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 8f0bf08f..efdd1c50 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -488,7 +488,7 @@ pub fn render( lap("histogram", &mut t, &mut cx); let mut image = image; lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan); - Some(Rendered { image, histogram }) + Some(Rendered { image, histogram, deep: None }) } /// The white-balanced, retouched, denoised image. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 57f289c1..2f441eb9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -189,6 +189,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]}, "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, + "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, "watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} }), &[], @@ -536,6 +537,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "removeLocation", "watermark", "colorSpace", + "bitDepth", ], ), ) diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 83f1c033..688d5b89 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -10,7 +10,7 @@ use lightcraft_raster::Rgba8; use crate::colorops::ColorOps; use crate::geometry::Frame; use crate::local::log_lum; -use crate::output::{OutputSpace, OutputTrc}; +use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}; use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; @@ -249,7 +249,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So 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 trc = fp.out_trc; - let data = finish_with(p, &fp, |e| match trc { + let data = finish_with(p, &fp, false, |e| match trc { OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255], t => { let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0))))); @@ -259,9 +259,38 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So Rgba8 { width: w, height: h, data } } +/// [`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 { + 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 trc = fp.out_trc; + let samples = match depth { + OutputDepth::F32Linear => { + let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0)))); + DeepSamples::F32(v.into_flattened()) + } + _ => { + let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; + let v = finish_with(p, &fp, true, |e| match trc { + OutputTrc::Srgb => e.map(q), + t => e.map(|v| q(t.encode(srgb_to_linear(v.clamp(0.0, 1.0))))), + }); + DeepSamples::U16(v.into_flattened()) + } + }; + DeepImage { width: w, height: h, space, samples } +} + /// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB /// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding. -pub(crate) fn finish_with(p: &Prepared, fp: &FinishParams, store: impl Fn([f32; 3]) -> T + Sync + Send) -> Vec { +/// `exact`: encode with the exact sRGB curve instead of the (8/10-bit accurate) table. +pub(crate) fn finish_with( + p: &Prepared, + fp: &FinishParams, + exact: bool, + store: impl Fn([f32; 3]) -> T + Sync + Send, +) -> Vec { let (w, h) = (p.img.width, p.img.height); let FinishParams { tone, @@ -461,7 +490,7 @@ pub(crate) fn finish_with(p: &Prepared, fp: &FinishPar } // --- encode, curves, grain - let mut e = r.map(|v| encode_srgb(srgb, v)); + 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)) }; if let Some(l) = curves { let e0 = e; e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 0b759654..b2f3e81d 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -38,7 +38,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use output::{OutputSpace, OutputTrc}; +pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}; pub use visualize::Overlay; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -88,17 +88,22 @@ pub struct RenderRequest { pub overlay: Overlay, /// Colour space of the result (exports; previews stay sRGB). pub space: OutputSpace, + /// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports). + pub depth: OutputDepth, } 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 } + Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb, depth: OutputDepth::U8 } } } pub struct Rendered { + /// The result, 8-bit (for deep renders: `deep` reduced to 8 bits, display-encoded). pub image: Rgba8, pub histogram: Histogram, + /// The high-bit-depth result when [`RenderRequest::depth`] asks for one. + pub deep: Option, } /// Everything the per-pixel stage needs, precomputed at output resolution. @@ -348,13 +353,20 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende if let Some((a, c)) = shared { 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 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); lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); let mut image = image; visualize::apply(&mut image, req.overlay, &plan); - Rendered { image, histogram } + Rendered { image, histogram, deep: None } } /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index fc532bb7..644fc866 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -138,6 +138,66 @@ impl OutputSpace { } } +/// Sample format of a render. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OutputDepth { + /// 8-bit display-encoded ([`crate::Rendered::image`] only). + #[default] + U8, + /// 16-bit display-encoded RGB in [`crate::Rendered::deep`]. + U16, + /// 32-bit float *linear* RGB (target primaries, 0..1) in [`crate::Rendered::deep`]. + F32Linear, +} + +/// High-bit-depth RGB samples (3 per pixel, interleaved, row-major). +#[derive(Clone, Debug, PartialEq)] +pub enum DeepSamples { + U16(Vec), + F32(Vec), +} + +/// A high-bit-depth render ([`OutputDepth::U16`] / [`OutputDepth::F32Linear`]). +#[derive(Clone, Debug, PartialEq)] +pub struct DeepImage { + pub width: usize, + pub height: usize, + pub space: OutputSpace, + pub samples: DeepSamples, +} + +impl DeepImage { + /// The same image reduced to 8 bits, display-encoded with the space's curve. + pub fn to_rgba8(&self) -> lightcraft_raster::Rgba8 { + let mut out = lightcraft_raster::Rgba8::new(self.width, self.height); + let trc = self.space.trc(); + match &self.samples { + DeepSamples::U16(v) => { + for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) { + *p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba(); + } + } + DeepSamples::F32(v) => { + for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) { + *p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba(); + } + } + } + out + } +} + +trait IntoRgba { + fn into_rgba(self) -> [u8; 4]; +} + +impl IntoRgba for [u8; 3] { + fn into_rgba(self) -> [u8; 4] { + [self[0], self[1], self[2], 255] + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index ac684fc7..65a30f78 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -102,6 +102,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let n = app.session.selection.ids.len().max(1); ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); ui.add_space(4.0); + let before = opts.format; choices( ui, "Format", @@ -109,6 +110,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")], &mut opts.format, ); + if opts.format != before { + // each format starts at its own default depth (TIFF 16-bit, others 8-bit) + opts.bit_depth = None; + } + let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); + if depths.len() > 1 { + let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); + choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); + opts.bit_depth = Some(bd); + } if opts.format == F::Avif { ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim)); } else { @@ -310,7 +321,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), diff --git a/docs/mcp.md b/docs/mcp.md index 8beaa832..f343d88a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | -| `export {path, id?, longEdge?, quality?, colorSpace?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | +| `export {path, id?, longEdge?, quality?, colorSpace?, bitDepth?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | Tools taking `id` make that photo active first; without it they act on the active photo. diff --git a/docs/parity.md b/docs/parity.md index f8e1321c..a0bc6f78 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -86,7 +86,7 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 19. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). 20. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — missing develop controls (calibration matters for XMP interop). -21. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +21. **LR-EXP-COMPRESSION** (P1) — TIFF compression choice (None / LZW / Deflate; the encoder supports all three). 22. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -440,7 +440,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-TYPE | File types | P0 | 🟡 | `crates/engine/src/export.rs` (`ExportFormat`) | JPEG, PNG, TIFF, WebP, AVIF; no DNG, JXL or original (+XMP) | | LR-EXP-DIM | Output size | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (long edge / full size) | no short edge / width / height / megapixels, ppi, don't-enlarge | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | -| LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only | +| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) | | LR-EXP-HDR | HDR output | P2 | ⬜ | | | From 8c1c7282b2acc21aba5573b3367d773535fddb4e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:09:14 -0400 Subject: [PATCH 6/8] =?UTF-8?q?LR-MASK-BRUSH=20+=20LR-MASK-SLIDERS:=20Auto?= =?UTF-8?q?=20Mask=20brush=20and=20local=20Noise=20/=20Moir=C3=A9=20/=20De?= =?UTF-8?q?fringe=20on=20CPU=20and=20GPU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto Mask strokes weight each dab by similarity (log luminance + chromaticity) to the pixel under its centre and are refined by a cross guided filter on log luminance (max-combined, never removing paint). Local Noise smooths small-amplitude luminance detail (negative boosts it) and calms colour noise; Moiré blends chromaticity towards a blurred plane (appended to the GPU mask buffer); Defringe desaturates purple/green casts on edges. Pixel tests per slider and for the edge constraint, CPU/GPU equivalence cases, Auto Mask checkbox and Moiré/Defringe sliders in the panel. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/ctx.rs | 26 +++++- crates/gpu/src/params.rs | 4 + crates/gpu/src/render.rs | 123 ++++++++++++++++++++----- crates/gpu/src/wgsl/finish.wgsl | 42 +++++++-- crates/gpu/src/wgsl/map.wgsl | 45 +++++++++ crates/gpu/src/wgsl/mask.wgsl | 26 +++++- crates/gpu/tests/equivalence.rs | 31 +++++++ crates/pipeline/src/finish.rs | 60 ++++++++++-- crates/pipeline/src/lib.rs | 4 + crates/pipeline/src/local.rs | 35 +++++-- crates/pipeline/src/masks.rs | 132 +++++++++++++++++++++++++-- crates/pipeline/src/tests_local.rs | 109 ++++++++++++++++++++++ crates/ui-egui/src/panels/masking.rs | 6 ++ crates/ui-egui/src/tests_masking.rs | 17 ++++ docs/parity.md | 10 +- 15 files changed, 610 insertions(+), 60 deletions(-) create mode 100644 crates/pipeline/src/tests_local.rs diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 010ff7ba..b415d312 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -37,7 +37,22 @@ const MODULES: &[Module] = &[ Module { src: include_str!("wgsl/map.wgsl"), bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")], - entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample", "redeye_k"], + entries: &[ + "log_lum_k", + "dark_k", + "guided_pre", + "guided_ab", + "guided_apply", + "wb_k", + "nr_lum", + "chroma_k", + "nr_col", + "subsample", + "redeye_k", + "xguided_pre", + "xguided_ab", + "xguided_apply", + ], }, Module { src: include_str!("wgsl/mask.wgsl"), @@ -121,7 +136,7 @@ pub struct Gpu { /// WGSL constants shared by every module (generated from the CPU pipeline's values). fn constants() -> String { - use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_TERMS, SRGB_LUT_N}; + use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N}; use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N}; let mut s = String::new(); let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices(); @@ -134,9 +149,14 @@ fn constants() -> String { s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV)); let b = lightcraft_pipeline::geometry::BLANK; s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]); - s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N); + s += &format!( + "const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\nconst MASK_SUMS: u32 = {MASK_SUMS}u;\n", + crate::params::CURVE_N + ); s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS); s += &format!("const EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_WORDS); + use lightcraft_pipeline::masks::{AUTO_TOL_CHROMA, AUTO_TOL_EV}; + s += &format!("const AUTO_TOL_EV: f32 = {AUTO_TOL_EV:?};\nconst AUTO_TOL_CHROMA: f32 = {AUTO_TOL_CHROMA:?};\n"); s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT); for (i, h) in GRAIN_HASH.iter().enumerate() { s += &format!("const GRAIN_H{i}: u32 = {h}u;\n"); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index b18461a2..00ae96ba 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -31,6 +31,7 @@ const FIELDS: &[(&str, usize)] = &[ ("HAS_CLAR", 1), ("HAS_TEX", 1), ("HAS_DARK", 1), + ("HAS_CHROMA", 1), ("OPS_IDENTITY", 1), ("VIBRANCE", 1), ("SATURATION", 1), @@ -123,6 +124,8 @@ pub struct Present { pub clarity: bool, pub texture: bool, pub dark: bool, + /// The blurred chromaticity follows the mask planes in the `masks` buffer. + pub chroma: bool, } /// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs | @@ -166,6 +169,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.b("HAS_CLAR", present.clarity); p.b("HAS_TEX", present.texture); p.b("HAS_DARK", present.dark); + p.b("HAS_CHROMA", present.chroma); let ops = &fp.ops; p.b("OPS_IDENTITY", ops.is_identity()); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 59a5370a..492cb38c 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -44,6 +44,7 @@ struct Planes { clarity: Option<(u32, Arc)>, texture: Option<(u32, Arc)>, dark: Option<(u32, Arc, f32)>, + chroma: Option<(u32, Arc)>, } impl GpuStages { @@ -143,6 +144,12 @@ impl<'a> Cx<'a> { out } + /// Copy all of `src` into `dst` at `offset` (32-bit words; recorded). + pub fn copy_into(&mut self, src: &Buf, dst: &Buf, offset: usize) { + let enc = self.enc.get_or_insert_with(|| self.gpu.encoder()); + enc.copy_buffer_to_buffer(src.raw(), 0, dst.raw(), (offset * 4) as u64, (src.len * 4) as u64); + } + pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f { Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) } } @@ -469,7 +476,12 @@ pub fn render( // 5. per-pixel stage let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air); - let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() }; + let present = Present { + clarity: prep.clarity.is_some(), + texture: prep.texture.is_some(), + dark: prep.dark.is_some(), + chroma: masks.is_some() && prep.chroma.is_some(), + }; let (p, aux) = finish_block(&fp, &terms, &present); let aux = gpu.upload(&aux); let out = gpu.buffer(n); @@ -580,6 +592,8 @@ struct Prep { clarity: Option>, texture: Option>, dark: Option>, + /// Blurred chromaticity (local Moiré / Noise). + chroma: Option>, air: f32, } @@ -634,14 +648,58 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla } }, }; - Prep { log_l, base, clarity, texture, dark, air } + let chroma = sig.chroma.map(|sg| { + plane_at(&mut planes.chroma, sg, || { + let ch = cx.gpu.buffer(n * 3); + map(cx, "chroma_k", n, &[], [Some(lin), None, None], &ch); + gaussian(cx, &ch, w, h, 3, sg) + }) + }); + Prep { log_l, base, clarity, texture, dark, chroma, air } } /// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush). const MAX_DABS: usize = 4096; -/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated) -/// and their adjustment terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. +/// The `shape` kernel's brush data for `dabs`: stroke records (12 words: dab offset, dab count, +/// r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto) then the dab centres. +fn brush_aux(dabs: &[&lightcraft_pipeline::masks::BrushDabs], erase: bool) -> Vec { + let mut aux = Vec::new(); + let mut off = 12 * dabs.len(); + for d in dabs { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for q in &d.dabs { + (x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r)); + } + aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, (erase && d.erase) as u8 as f32]); + aux.extend([x0, y0, x1, y1].map(|v| v as f32)); + aux.push(d.auto as u8 as f32); + off += 2 * d.dabs.len(); + } + for d in dabs { + aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32])); + } + aux +} + +/// Cross guided filter (`masks::guided_cross`) of `p` steered by `guide`, max-combined with `p`. +fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf { + let n = w * h; + let (m0, m1) = (cx.gpu.buffer(2 * n), cx.gpu.buffer(2 * n)); + map(cx, "xguided_pre", n, &[0], [Some(guide), Some(p), None], &m0); + map(cx, "xguided_pre", n, &[1], [Some(guide), Some(p), None], &m1); + let (b0, b1) = (gaussian(cx, &m0, w, h, 2, sigma), gaussian(cx, &m1, w, h, 2, sigma)); + let ab = cx.gpu.buffer(2 * n); + map(cx, "xguided_ab", n, &[eps.to_bits()], [Some(&b0), Some(&b1), None], &ab); + let ab = gaussian(cx, &ab, w, h, 2, sigma); + let q = cx.gpu.buffer(n); + map(cx, "xguided_apply", n, &[], [Some(guide), Some(&ab), Some(p)], &q); + q +} + +/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated, +/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment +/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option, Vec<[f32; MASK_TERMS]>) { use lightcraft_develop::{MaskOp, MaskShape}; let s = &*plan.settings; @@ -657,7 +715,10 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho let [a, b, c, d, e, f] = frame.out_to_norm(w, h).0; let (kx, ky) = (frame.ow / long, frame.oh / long); let aff = [a * kx, b * ky, c * kx, d * ky, e * kx, f * ky].map(|v| (v as f32).to_bits()); - let alpha = cx.zeroed(n * list.len()); + let alpha = cx.zeroed(n * list.len() + if prep.chroma.is_some() { 3 * n } else { 0 }); + if let Some(ch) = &prep.chroma { + cx.copy_into(ch, &alpha, n * list.len()); + } let comp_plane = cx.gpu.buffer(n); for (mi, m) in list.iter().enumerate() { let mut first = true; @@ -673,6 +734,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho p.extend_from_slice(&aff); let f32s = |v: &[f64]| v.iter().map(|x| (*x as f32).to_bits()).collect::>(); let mut aux: Vec = Vec::new(); + let mut own: Option = None; let kind = match &comp.shape { MaskShape::Linear { start, end } => { let (a, b) = (frame.norm_to_long(*start), frame.norm_to_long(*end)); @@ -700,29 +762,46 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } MaskShape::Brush { strokes } => { let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect(); - if dabs.iter().map(|d| d.dabs.len()).sum::() <= MAX_DABS { - p.push(dabs.len() as u32); - let mut off = 11 * dabs.len(); - for d in &dabs { - let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for q in &d.dabs { - (x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r)); - } - aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, d.erase as u8 as f32]); - aux.extend([x0, y0, x1, y1].map(|v| v as f32)); - off += 2 * d.dabs.len(); - } - for d in &dabs { - aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32])); - } - Some(4) - } else { + if dabs.iter().map(|d| d.dabs.len()).sum::() > MAX_DABS { None + } else if dabs.iter().any(|d| d.auto) { + // Auto Mask: stroke by stroke (each one refined before it's combined) + let brush = cx.zeroed(n); + let stroke = cx.gpu.buffer(n); + for d in &dabs { + let mut q = p.clone(); + q[2] = 4; + q[3] = 0; + q.push(1); + let aux = cx.gpu.upload(&brush_aux(&[d], false)); + cx.run("shape", &q, &[Some(lin), Some(&prep.log_l), Some(&aux), Some(&stroke), Some(&brush)], groups2(w, h, [16, 16])); + let refined = d.auto.then(|| { + let (sigma, eps) = lightcraft_pipeline::masks::auto_refine(d.r); + guided_cross_max(cx, &prep.log_l, &stroke, w, h, sigma, eps) + }); + let op = if d.erase { 2 } else { 1 }; + cx.run( + "combine", + &[n as u32, 0, 0, op], + &[None, None, None, Some(refined.as_ref().unwrap_or(&stroke)), Some(&brush)], + groups1(n), + ); + } + if comp.invert { + cx.run("finalize", &[n as u32, 0, 0, 1, 1f32.to_bits()], &[None, None, None, Some(&stroke), Some(&brush)], groups1(n)); + } + own = Some(brush); + None + } else { + p.push(dabs.len() as u32); + aux = brush_aux(&dabs.iter().collect::>(), true); + Some(4) } } _ => None, }; let plane = match kind { + _ if own.is_some() => own, Some(k) => { p[2] = k; let aux = (!aux.is_empty()).then(|| cx.gpu.upload(&aux)); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d405e949..2ddda1b6 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -1,5 +1,6 @@ // The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it). -// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes), aux +// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the +// blurred chromaticity when HAS_CHROMA), aux // (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8). fn tone_apply(y: f32) -> f32 { @@ -239,7 +240,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let l0 = l_pre + ev; // --- local (mask) contributions - var lt: array; + var lt: array; var tint_on = false; var tint_dir = vec2(0.0, 0.0); var tint_amt = 0.0; @@ -250,18 +251,42 @@ fn main(@builtin(global_invocation_id) gid: vec3) { continue; } let t = pu(F_MASK_OFF) + m * MASK_TERMS; - for (var k = 0u; k < 14u; k++) { + for (var k = 0u; k < MASK_SUMS; k++) { lt[k] += a * aux[t + k]; } - if (aux[t + 14u] > 0.0) { + if (aux[t + MASK_SUMS] > 0.0) { tint_on = true; - tint_dir = vec2(aux[t + 15u], aux[t + 16u]); - tint_amt = a * aux[t + 17u]; + tint_dir = vec2(aux[t + MASK_SUMS + 1u], aux[t + MASK_SUMS + 2u]); + tint_amt = a * aux[t + MASK_SUMS + 3u]; } } let l_exp = lt[0]; let l_temp = lt[1]; let l_tint = lt[2]; + let l_noise = lt[14]; + + // --- local Moiré (and the colour part of Noise): chromaticity towards its blur + let mt = clamp(lt[15] + 0.5 * max(l_noise, 0.0), -1.0, 1.0); + if (mt != 0.0 && pu(F_HAS_CHROMA) != 0u) { + let raw = vec3(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]); + let y = lum2020(c); + let ch0 = raw / max(lum2020(raw), 1e-6); + let o = nm * n + 3u * i; + let cb = vec3(masks[o], masks[o + 1u], masks[o + 2u]); + c = max((ch0 + (cb - ch0) * mt) * y, vec3(0.0)); + } + // --- local Defringe: desaturate purple / green fringes along edges + let df = clamp(lt[16], 0.0, 1.0); + if (df > 0.0 && pu(F_HAS_TEX) != 0u) { + let yd = max(lum2020(c), 1e-6); + let purple = (min(c.x, c.z) - c.y) / yd; + let green = (c.y - max(c.x, c.z)) / yd; + let k = df * sstep(0.04, 0.3, abs(l_pre - tex[i])) * max(sstep(0.02, 0.2, purple), sstep(0.02, 0.2, green)); + if (k > 0.0) { + let y = lum2020(c); + c = c + (y - c) * k; + } + } // --- dehaze (scene linear) let dz = pf(F_DEHAZE) + lt[10]; @@ -334,6 +359,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk; } } + // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges + if (l_noise != 0.0 && pu(F_HAS_TEX) != 0u) { + let det = l_pre - tex[i]; + delta -= clamp(l_noise, -1.0, 1.0) * 0.9 * det * (1.0 - sstep(0.1, 0.5, abs(det))); + } if (delta != 0.0) { c = c * exp2(delta); } diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index 9a54e7f6..fc8eebec 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -70,6 +70,51 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3, @builtin(num_workgr dst[i] = b[2u * i] * a[i] + b[2u * i + 1u]; } +// Cross guided filter (`masks::guided_cross`), a: guide, b: input. P[1] = 0: (guide, input); +// 1: (guide·input, guide²). +@compute @workgroup_size(256) +fn xguided_pre(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let gi = a[i]; + let p = b[i]; + if (pu(1u) == 0u) { + dst[2u * i] = gi; + dst[2u * i + 1u] = p; + } else { + dst[2u * i] = gi * p; + dst[2u * i + 1u] = gi * gi; + } +} + +// Coefficients (a, b) from blurred means (a: guide, input) and correlations (b: guide·input, +// guide²). P[1] = eps. +@compute @workgroup_size(256) +fn xguided_ab(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let mi = a[2u * i]; + let mp = a[2u * i + 1u]; + let v = max(b[2u * i + 1u] - mi * mi, 0.0); + let k = (b[2u * i] - mi * mp) / (v + pf(1u)); + dst[2u * i] = k; + dst[2u * i + 1u] = mp - k * mi; +} + +// `max(p, clamp(a·guide + b, 0, 1))` (a: guide, b: blurred coefficients, c: the input p). +@compute @workgroup_size(256) +fn xguided_apply(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + dst[i] = max(c[i], clamp(b[2u * i] * a[i] + b[2u * i + 1u], 0.0, 1.0)); +} + // White balance: 3×3 matrix (P[1] = has matrix, P[2..11] row-major), clamped at 0. @compute @workgroup_size(256) fn wb_k(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { diff --git a/crates/gpu/src/wgsl/mask.wgsl b/crates/gpu/src/wgsl/mask.wgsl index 2075e186..c2d15b64 100644 --- a/crates/gpu/src/wgsl/mask.wgsl +++ b/crates/gpu/src/wgsl/mask.wgsl @@ -13,6 +13,19 @@ fn pos(x: u32, y: u32) -> vec2 { // 2 luminance range: lo, hi, lo feather, hi feather, ev // 3 colour range: tol, gain, sample count (OkLab triples in aux) // 4 brush: stroke count (stroke records + dabs in aux) +// `masks::chromaticity` of pixel i. +fn chroma_at(i: u32) -> vec3 { + let c = vec3(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]); + return c / max(lum2020(c), 1e-6); +} + +// `masks::auto_similarity`. +fn auto_similarity(l: f32, ch: vec3, rl: f32, rch: vec3) -> f32 { + let dl = (l - rl) / AUTO_TOL_EV; + let dc = length(ch - rch) / AUTO_TOL_CHROMA; + return 1.0 - sstep(0.5, 1.0, sqrt(dl * dl + dc * dc)); +} + @compute @workgroup_size(16, 16) fn shape(@builtin(global_invocation_id) g: vec3) { let w = pu(0u); @@ -64,11 +77,14 @@ fn shape(@builtin(global_invocation_id) g: vec3) { } v = 1.0 - sstep(tol * 0.5, tol, best); } else if (kind == 4u) { - // stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1 + // stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto let px = f32(g.x) + 0.5; let py = f32(g.y) + 0.5; + let lp = log_l[i]; + let chp = chroma_at(i); for (var k = 0u; k < pu(10u); k++) { - let o = 11u * k; + let o = 12u * k; + let auto_mask = aux[o + 11u] != 0.0; if (px < aux[o + 7u] || py < aux[o + 8u] || px > aux[o + 9u] || py > aux[o + 10u]) { continue; } @@ -89,6 +105,12 @@ fn shape(@builtin(global_invocation_id) g: vec3) { if (dd > hard) { a = 1.0 - sstep(hard, r, dd); } + if (auto_mask) { + let rx = min(u32(max(floor(aux[off + 2u * j]), 0.0)), w - 1u); + let ry = min(u32(max(floor(aux[off + 2u * j + 1u]), 0.0)), h - 1u); + let ri = ry * w + rx; + a *= auto_similarity(lp, chp, log_l[ri], chroma_at(ri)); + } keep *= 1.0 - a * flow; } let sa = min(1.0 - keep, aux[o + 5u]); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 0a15922c..349c82e1 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -275,6 +275,37 @@ fn cases() -> Vec<(&'static str, Edit)> { }, ]; }), + ("masks (local noise, moiré, defringe)", |s| { + let radial = MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.3, ry: 0.25, angle: 0.0, feather: 50.0, invert: false }; + let linear = MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.7) }; + let m = |shape, adjust| Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() }; + use lightcraft_develop::LocalAdjustments as L; + s.masks = vec![ + m(radial, L { noise: 80.0, moire: 60.0, defringe: 100.0, ..Default::default() }), + m(linear, L { noise: -60.0, moire: -40.0, ..Default::default() }), + ]; + }), + ("masks (auto mask brush)", |s| { + let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke { + points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(), + size: 0.06, + feather: 40.0, + flow: 80.0, + auto_mask, + erase, + ..Default::default() + }; + let strokes = vec![ + st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false), + st(&[(0.3, 0.2), (0.6, 0.25)], false, false), + st(&[(0.5, 0.58)], true, true), + ]; + s.masks = vec![Mask { + components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }], + adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() }, + ..Default::default() + }]; + }), ("calibration", |s| { s.calibration.shadows_tint = 40.0; s.calibration.red_hue = 50.0; diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 0ab6199e..8eb6a637 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -105,12 +105,15 @@ fn grain_noise(x: f32, y: f32, seed: u32) -> f32 { } /// Number of per-mask terms in [`mask_terms`]. -pub const MASK_TERMS: usize = 18; +pub const MASK_TERMS: usize = 21; + +/// Number of alpha-weighted terms at the start of [`mask_terms`]. +pub const MASK_SUMS: usize = 17; /// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order: /// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze, -/// saturation, hue, sharpness, then the colour overlay: on (0/1), cos and sin of its OkLCh hue, and -/// its strength. +/// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and +/// sin of its OkLCh hue, and its strength. pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { let (on, cos, sin, amt) = if j.color_sat > 0.0 { let hue = crate::colorops::oklch_hue_of_srgb_hue(j.color_hue); @@ -133,6 +136,9 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { (j.saturation / 100.0) as f32, (j.hue / 100.0) as f32 * 0.6, (j.sharpness / 100.0) as f32, + (j.noise / 100.0) as f32, + (j.moire / 100.0) as f32, + (j.defringe / 100.0) as f32, on, cos, sin, @@ -250,21 +256,42 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So let l0 = l_pre + ev; // --- local (mask) contributions: alpha-weighted sums of the masks' terms - let mut l = [0.0f32; 14]; + let mut l = [0.0f32; MASK_SUMS]; let mut tint_col: Option<([f32; 3], f32)> = None; for (m, t) in p.masks.iter().zip(&terms) { let a = m.alpha.data[i]; if a <= 0.0 { continue; } - for k in 0..14 { + for k in 0..MASK_SUMS { l[k] += a * t[k]; } - if t[14] > 0.0 { - tint_col = Some(([t[15], t[16], 0.0], a * t[17])); + if t[MASK_SUMS] > 0.0 { + tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3])); + } + } + let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] = + l; + + // --- local Moiré (and the colour part of Noise): chromaticity towards its blur + let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); + if mt != 0.0 + && let Some(cb) = &p.chroma_blur + { + let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]); + c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0)); + } + // --- local Defringe: desaturate purple / green fringes along edges + let df = l_defringe.clamp(0.0, 1.0); + if df > 0.0 + && let Some(b) = &p.texture_blur + { + let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]); + if k > 0.0 { + let y = luminance_2020(c); + c = c.map(|v| v + (y - v) * k); } } - let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp] = l; // --- dehaze (scene linear) let dz = dehaze + l_dehaze; @@ -334,6 +361,13 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; } } + // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges + if l_noise != 0.0 + && let Some(b) = &p.texture_blur + { + let det = l_pre - b.data[i]; + delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs())); + } if delta != 0.0 { let g = delta.exp2(); c = c.map(|v| v * g); @@ -433,6 +467,16 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So out } +/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from +/// its fine blur: high on strong edges with a purple or green cast. +#[inline] +pub fn defringe_weight(c: [f32; 3], det: f32) -> f32 { + let y = luminance_2020(c).max(1e-6); + let purple = (c[0].min(c[2]) - c[1]) / y; + let green = (c[1] - c[0].max(c[2])) / y; + smooth(0.04, 0.3, det.abs()) * smooth(0.02, 0.2, purple).max(smooth(0.02, 0.2, green)) +} + /// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its /// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is /// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation. diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index ad6b56e7..20fcbb86 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -110,6 +110,8 @@ pub(crate) struct Prepared { pub clarity_blur: Option>, pub texture_blur: Option>, pub dark: Option>, + /// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise. + pub chroma_blur: Option>, /// Airlight of `dark` (before exposure). pub air: f32, pub masks: Vec, @@ -402,3 +404,5 @@ pub(crate) fn for_rows(data: &mut [T], w: usize, f: impl Fn(usize, &mut mod tests; #[cfg(test)] mod tests_geometry; +#[cfg(test)] +mod tests_local; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 8efb4126..ae26888c 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -208,6 +208,8 @@ pub(crate) struct Planes { pub texture: Option<(u32, Arc)>, /// Dark channel and its airlight. pub dark: Option<(u32, Arc, f32)>, + /// Blurred chromaticity (local Moiré / Noise). + pub chroma: Option<(u32, Arc)>, } /// Reuse `slot` if it was computed at `sigma`, else compute and store it. @@ -232,6 +234,9 @@ pub fn dark_of(c: [f32; 3]) -> f32 { c[0].min(c[1]).min(c[2]) } +/// Radius of the local Moiré / colour-noise chromaticity blur, as a fraction of the long edge. +pub const CHROMA_SIGMA: f32 = 0.004; + /// Radii (px) of the spatial planes the settings need (`None` = not needed). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct PlaneSigmas { @@ -243,6 +248,8 @@ pub struct PlaneSigmas { pub texture: Option, /// Dehaze dark channel (Gaussian). pub dark: Option, + /// Chromaticity blur for local Moiré / colour noise (Gaussian). + pub chroma: Option, } pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas { @@ -256,10 +263,17 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS if q == Quality::Draft { sigma.min(24.0) } else { sigma } }); let clarity = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0)); - let texture = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness)) - .then(|| (0.0018 * ppl).max(0.6)); + // local Noise and Defringe read the fine detail band too + let texture = (s.effects.texture != 0.0 + || s.detail.sharpen_amount != 0.0 + || local_any(|a| a.texture) + || local_any(|a| a.sharpness) + || local_any(|a| a.noise) + || s.masks.iter().any(|m| m.adjust.defringe > 0.0)) + .then(|| (0.0018 * ppl).max(0.6)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); - PlaneSigmas { base, clarity, texture, dark } + let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); + PlaneSigmas { base, clarity, texture, dark, chroma } } /// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), @@ -267,9 +281,18 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS /// one alone scales poorly: the guided filters work on small subsampled grids). pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared { let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); - let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma } = plane_sigmas(s, px_per_long, q); + let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } = + plane_sigmas(s, px_per_long, q); - let Planes { base: sb, clarity: sc, texture: st, dark: sd, .. } = planes; + let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes; + let chroma_blur = chroma_sigma.map(|sg| match sch { + Some((k, c)) if *k == sg.to_bits() => c.clone(), + _ => { + let c = timed("chroma", || Arc::new(gaussian(&img.map(masks::chromaticity), sg))); + *sch = Some((sg.to_bits(), c.clone())); + c + } + }); let l = &log_l; let (base, (clarity_blur, (texture_blur, dark))) = par_join( || base_sigma.map(|sg| plane_at(sb, sg, || timed("base", || guided_fast(l, sg, BASE_EPS)))), @@ -302,7 +325,7 @@ pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_p }; let ev = s.light.exposure as f32; let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev)); - Prepared { img, log_l, base, clarity_blur, texture_blur, dark, air, masks, px_per_long } + Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long } } /// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel. diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 33af94d3..f02185f8 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -107,7 +107,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R if inv { 1.0 - a } else { a } }); } - MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, &mut out), + MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out), MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => { let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3)); for (v, l) in out.data.iter_mut().zip(&log_l.data) { @@ -188,6 +188,8 @@ pub struct BrushDabs { pub flow: f32, pub density: f32, pub erase: bool, + /// Auto Mask: paint only pixels like the one under each dab, then snap to edges. + pub auto: bool, } /// The dabs of stroke `s` in a `w × h` output. @@ -210,15 +212,90 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa } dabs.push(q); } - BrushDabs { dabs, r, hard, flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32, density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase } + BrushDabs { + dabs, + r, + hard, + flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32, + density: (s.density / 100.0).clamp(0.0, 1.0) as f32, + erase: s.erase, + auto: s.auto_mask, + } } -/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). -fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, out: &mut Plane) { +/// Auto Mask tolerances: a pixel takes a dab's paint fully up to half of these from the colour +/// under the dab centre, none beyond them (log2 luminance, chromaticity `rgb / Y`). +pub const AUTO_TOL_EV: f32 = 0.5; +pub const AUTO_TOL_CHROMA: f32 = 0.25; + +/// Chromaticity `rgb / Y` (as colour noise reduction uses it). +#[inline] +pub fn chromaticity(c: [f32; 3]) -> [f32; 3] { + let y = lightcraft_color::luminance_2020(c).max(1e-6); + [c[0] / y, c[1] / y, c[2] / y] +} + +/// Auto Mask: how much a pixel (log luminance `l`, chromaticity `ch`) is like the reference. +#[inline] +pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 { + let dl = (l - rl) / AUTO_TOL_EV; + let dc = ((ch[0] - rch[0]).powi(2) + (ch[1] - rch[1]).powi(2) + (ch[2] - rch[2]).powi(2)).sqrt() / AUTO_TOL_CHROMA; + 1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt()) +} + +/// The output pixel a dab at `d` samples its reference colour from. +#[inline] +pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize { + let x = (d.x.floor().max(0.0) as usize).min(w - 1); + let y = (d.y.floor().max(0.0) as usize).min(h - 1); + y * w + x +} + +/// Guided-filter refinement of an Auto Mask stroke of radius `r` px: (sigma px, epsilon EV²). The +/// stroke keeps the larger of its own and the refined alpha. +pub fn auto_refine(r: f64) -> (f32, f32) { + ((0.25 * r).max(1.0) as f32, 0.02) +} + +/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the +/// guide's. +pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { + use lightcraft_raster::blur::gaussian; + let mi = gaussian(guide, sigma); + let mp = gaussian(p, sigma); + let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma); + let cii = gaussian(&guide.map(|a| a * a), sigma); + let mut a = Plane::new(p.width, p.height); + let mut b = Plane::new(p.width, p.height); + for i in 0..p.data.len() { + let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0); + let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps); + a.data[i] = k; + b.data[i] = mp.data[i] - k * mi.data[i]; + } + let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma)); + let mut q = Plane::new(p.width, p.height); + for i in 0..q.data.len() { + q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0); + } + q +} + +/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). Auto Mask strokes weight +/// each dab by [`auto_similarity`] to the pixel under its centre and are refined with +/// [`guided_cross`] on log luminance. +fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, out: &mut Plane) { + if w == 0 || h == 0 { + return; + } for s in strokes { - let BrushDabs { dabs, r, hard, flow, density: dens, erase } = brush_dabs(s, frame, w, h); + let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h); let mut stroke_alpha = Plane::new(w, h); for d in &dabs { + let refc = auto.then(|| { + let j = dab_pixel(*d, w, h); + (log_l.data[j], chromaticity(img.data[j])) + }); let (x0, x1) = (((d.x - r).floor().max(0.0)) as usize, ((d.x + r).ceil().max(0.0) as usize).min(w)); let (y0, y1) = (((d.y - r).floor().max(0.0)) as usize, ((d.y + r).ceil().max(0.0) as usize).min(h)); for y in y0..y1 { @@ -227,13 +304,23 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, o if dd > r { continue; } - let a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) }; - let v = &mut stroke_alpha.data[y * w + x]; + let mut a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) }; + let i = y * w + x; + if let Some((rl, rch)) = refc { + a *= auto_similarity(log_l.data[i], chromaticity(img.data[i]), rl, rch); + } + let v = &mut stroke_alpha.data[i]; // flow accumulates within a stroke up to density *v = (*v + a * flow * (1.0 - *v)).min(dens); } } } + if auto { + let (sigma, eps) = auto_refine(r); + let q = guided_cross(log_l, &stroke_alpha, sigma, eps); + // the refinement fills gaps along edges; it never takes paint away + stroke_alpha = stroke_alpha.zip_map(&q, f32::max); + } for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) { *o = if erase { *o * (1.0 - a) } else { o.max(*a) }; } @@ -289,4 +376,35 @@ mod tests { assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); assert!(a.get(40, 5) < 0.01); } + + #[test] + fn auto_mask_stops_at_edges() { + // dark left half, bright right half; a stroke along the boundary, centred on the dark side + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); + let l = img.map(crate::local::log_lum); + let stroke = |auto_mask| BrushStroke { + points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], + size: 0.08, + feather: 0.0, + auto_mask, + ..Default::default() + }; + let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; + let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0); + let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0); + // without Auto Mask the brush spills over the edge; with it, it stays on the dark side + assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); + assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); + assert!(auto.get(110, 50) < 0.02); + assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); + assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); + // on a flat area Auto Mask paints like the plain brush + let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); + let fl = flat.map(crate::local::log_lum); + let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0); + let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0); + assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); + } } diff --git a/crates/pipeline/src/tests_local.rs b/crates/pipeline/src/tests_local.rs new file mode 100644 index 00000000..3ccf961b --- /dev/null +++ b/crates/pipeline/src/tests_local.rs @@ -0,0 +1,109 @@ +//! Pixel tests of the local (mask) Noise, Moiré and Defringe sliders. + +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; +use lightcraft_geom::Point; +use lightcraft_raster::{Rgb32f, Rgba8}; + +use crate::{RenderRequest, SourceInfo, render}; + +const W: usize = 240; +const H: usize = 120; + +/// A deterministic hash in −1..1. +fn noise(x: usize, y: usize, k: u32) -> f32 { + let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ k.wrapping_mul(0xcb1a_b31f); + v ^= v >> 13; + v = v.wrapping_mul(0x5bd1_e995); + v ^= v >> 15; + (v & 0xffff) as f32 / 32768.0 - 1.0 +} + +/// Settings with one mask covering the left half (x < 0.45), with `adjust`. +fn left_mask(adjust: LocalAdjustments) -> DevelopSettings { + let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.46, 0.5) }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { id: 1, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() }); + s +} + +fn shot(src: &Rgb32f, s: &DevelopSettings) -> Rgba8 { + render(src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image +} + +/// Standard deviation of `f(pixel)` over columns `x0..x1` (rows 4..H-4). +fn spread(img: &Rgba8, x0: usize, x1: usize, f: impl Fn([u8; 4]) -> f32) -> f32 { + let v: Vec = (4..H - 4).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f(img.data[y * W + x])).collect(); + let m = v.iter().sum::() / v.len() as f32; + (v.iter().map(|a| (a - m).powi(2)).sum::() / v.len() as f32).sqrt() +} + +fn luma(p: [u8; 4]) -> f32 { + 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 +} + +fn chroma(p: [u8; 4]) -> f32 { + p[0] as f32 - p[2] as f32 +} + +#[test] +fn local_noise_smooths_luminance_noise_inside_the_mask() { + let src = Rgb32f::from_fn(W, H, |x, y| [0.18 * (1.0 + 0.12 * noise(x, y, 1)); 3]); + let plain = shot(&src, &DevelopSettings::default()); + let base = spread(&plain, 10, 90, luma); + let on = shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() })); + let off = shot(&src, &left_mask(LocalAdjustments { noise: -100.0, ..Default::default() })); + let (smoothed, boosted) = (spread(&on, 10, 90, luma), spread(&off, 10, 90, luma)); + assert!(smoothed < 0.6 * base, "noise +100: {smoothed} vs {base}"); + assert!(boosted > 1.2 * base, "noise −100: {boosted} vs {base}"); + // outside the mask nothing changes + assert_eq!(spread(&on, 150, 230, luma), spread(&plain, 150, 230, luma)); + // the mean brightness is kept + let mean = |img: &Rgba8| (4..H - 4).flat_map(|y| (10..90).map(move |x| luma(img.data[y * W + x]))).sum::() / (80 * (H - 8)) as f32; + assert!((mean(&on) - mean(&plain)).abs() < 2.0); +} + +#[test] +fn local_moire_removes_colour_speckle_inside_the_mask() { + let src = Rgb32f::from_fn(W, H, |x, y| { + let n = 0.25 * noise(x, y, 2); + [0.18 * (1.0 + n), 0.18, 0.18 * (1.0 - n)] + }); + let plain = shot(&src, &DevelopSettings::default()); + let base = spread(&plain, 10, 90, chroma); + let on = shot(&src, &left_mask(LocalAdjustments { moire: 100.0, ..Default::default() })); + let reduced = spread(&on, 10, 90, chroma); + assert!(reduced < 0.4 * base, "moiré +100: {reduced} vs {base}"); + assert_eq!(spread(&on, 150, 230, chroma), spread(&plain, 150, 230, chroma)); + // luminance is kept + assert!((spread(&on, 10, 90, luma) - spread(&plain, 10, 90, luma)).abs() < 1.0); + // positive Noise also calms colour noise (less than Moiré) + let n = spread(&shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() })), 10, 90, chroma); + assert!(n < 0.8 * base && n > reduced, "{n}"); +} + +#[test] +fn local_defringe_desaturates_purple_edges_only() { + // dark | bright edge at x = 40 (inside the mask) and x = 200 (outside), each with a purple + // fringe column; a flat purple patch (no edge) at x 60..80 + let src = Rgb32f::from_fn(W, H, |x, _| { + let fringe = x == 39 || x == 40 || x == 199 || x == 200; + if fringe { + [0.25, 0.04, 0.3] + } else if (60..80).contains(&x) { + [0.2, 0.08, 0.22] + } else if (40..120).contains(&x) || x >= 200 { + [0.7; 3] + } else { + [0.02; 3] + } + }); + let s = left_mask(LocalAdjustments { defringe: 100.0, ..Default::default() }); + let (plain, on) = (shot(&src, &DevelopSettings::default()), shot(&src, &s)); + let sat = |p: [u8; 4]| p[0].max(p[2]) as f32 - p[1] as f32; + let at = |img: &Rgba8, x: usize| img.data[60 * W + x]; + assert!(sat(at(&plain, 40)) > 40.0, "a purple fringe to start with: {:?}", at(&plain, 40)); + assert!(sat(at(&on, 40)) < 0.5 * sat(at(&plain, 40)), "fringe in the mask: {:?} vs {:?}", at(&on, 40), at(&plain, 40)); + assert_eq!(at(&on, 200), at(&plain, 200), "outside the mask"); + let flat = |img: &Rgba8| sat(at(img, 70)); + assert!((flat(&on) - flat(&plain)).abs() <= 2.0, "flat purple kept: {} vs {}", flat(&on), flat(&plain)); +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 7c4c2500..2aad39ff 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -33,6 +33,8 @@ pub const LOCAL: &[ControlSpec] = &[ spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }), spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered), spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered), + spec("moire", "Moiré", -100.0, 100.0, 1.0, 0, Track::Centered), + spec("defringe", "Defringe", -100.0, 100.0, 1.0, 0, Track::Centered), ]; pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 { @@ -294,6 +296,10 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.brush_erase = true; } }); + let mut auto = app.ui.brush_auto_mask; + if ui.checkbox(&mut auto, "Auto Mask").on_hover_text("Paint only areas like the one under the brush").changed() { + app.ui.brush_auto_mask = auto; + } }); for (id, label, min, max, get) in [ ("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index ca60889b..74b360f2 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -89,3 +89,20 @@ fn mask_overlay_keys_and_pins() { assert_eq!(h.app.session.active_mask, Some(2)); h.settle(SETTLE); } + +#[test] +fn brush_strokes_carry_auto_mask() { + let mut h = detail("panel.masking"); + let r = h.request("ui.set", json!({"brushAutoMask": true}), T); + assert_eq!(r["ok"], true, "{r}"); + exec(&mut h, "tool.brush", json!({})); + assert_eq!(h.app.ui.tool, "brush"); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.4, "y": 0.5}, {"kind": "drag", "x": 0.5, "y": 0.5}, {"kind": "up", "x": 0.5, "y": 0.5}]), + ); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert!(strokes[0].auto_mask && strokes[0].points.len() >= 2, "{strokes:?}"); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index 489a445e..756e3f42 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | -| K. Masking (MASK) | 8 | 8 | 7 | 0 | 5/8 (63%) | 3/5 (60%) | +| K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) | | L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) | | M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | | N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | | Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 210 | 101 | 189 | 37 | 153/198 (77%) | 49/144 (34%) | +| **Total** | 212 | 99 | 189 | 37 | 155/198 (78%) | 49/144 (34%) | ## Top gaps @@ -57,8 +57,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. -3. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local - Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. 4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, feather/opacity sliders, `[`/`]` size keys. Medium effort. 5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. @@ -335,7 +333,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-OBJECTS | Object selection | P2 | ⬜ | | shape exists (falls back to the subject heuristic); no UI | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | -| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` | size/feather/flow/density/erase; Auto Mask flag not applied; no A/B brushes, no pressure | +| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | | LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified | @@ -345,7 +343,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | | -| LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | From 6228e8f477e123acaf1df0d92ab1049e8756b124 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:14:28 -0400 Subject: [PATCH 7/8] =?UTF-8?q?LR-PREF-PERFORMANCE:=20memory=20budget=20in?= =?UTF-8?q?=20Settings=20=E2=86=92=20Performance=20(app.memoryBudget)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 7 +++++++ crates/ui-egui/src/lib.rs | 10 ++++++++++ crates/ui-egui/src/panels/settings.rs | 5 +++++ crates/ui-egui/src/state.rs | 3 +++ docs/parity.md | 2 +- 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d5d71500..72fd66ba 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -404,6 +404,13 @@ mod tests { assert_eq!(h.app.session.cache_mb, 512); h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t); assert_eq!(h.app.ui.settings.preview_edge, 3840); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); + assert_eq!(h.app.ui.settings.memory_mb, 1024); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget"); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic"); // Import: per-camera defaults h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 22e12e96..ca0d7085 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,8 @@ pub struct LightcraftApp { pub window_is_fullscreen: bool, /// The GPU preference last applied (`app.gpu`), to apply Settings changes once. gpu_applied: Option, + /// The memory budget setting last applied (MB, 0 = automatic). + memory_applied: Option, } impl LightcraftApp { @@ -134,6 +136,7 @@ impl LightcraftApp { merge: merge::MergeState::default(), window_is_fullscreen: false, gpu_applied: None, + memory_applied: None, } } @@ -334,6 +337,13 @@ impl LightcraftApp { self.gpu_applied = Some(self.ui.settings.gpu); let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); } + let mb = self.ui.settings.memory_mb; + // automatic at startup: leave the engine's default alone + if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { + let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; + let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); + } + self.memory_applied = Some(mb); if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { self.window_is_fullscreen = fs; } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f05aa02..fad8ac51 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -264,6 +264,11 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge); }); hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower."); + row(ui, t, "Memory for caches", |ui| { + let auto = format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); + let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; + choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb); + }); heading(ui, t, "Thumbnail cache"); let cur = (app.session.cache_bytes() >> 20) as u32; row(ui, t, "Size limit", |ui| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2cd09b7b..ce5924fa 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -134,6 +134,8 @@ pub struct AppSettings { pub gpu: bool, /// Largest long edge (pixels) the loupe renders at. pub preview_edge: u32, + /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). + pub memory_mb: u32, /// Filmstrip: file names above the thumbnails. pub film_names: bool, /// Filmstrip: rating / flag / edited badges on the thumbnails. @@ -150,6 +152,7 @@ impl Default for AppSettings { confirm_delete: false, gpu: true, preview_edge: 2560, + memory_mb: 0, film_names: true, film_badges: true, grid_badges: GridBadges::Auto, diff --git a/docs/parity.md b/docs/parity.md index bf992c44..8bedebf9 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -468,7 +468,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), cache size in Settings | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference | | LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | From d91aaa4ca177df702e94619657d1c2301abbac50 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:20:06 -0400 Subject: [PATCH 8/8] LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS: select, move, resize, refresh and delete spots; brush keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine: spot.select / spot.update (move target or source, size, feather, opacity, mode) / spot.refreshSource (next-best source away from the current one) / spot.delete (selected spot); spot.add resolves the automatic source (so it has a pin and stays put at every size) and selects the new spot. Remove tool: pins for targets and the selected spot's source, click to select, drag to move; Size/Feather/Opacity sliders edit new and selected spots; ⌫ deletes the selected spot (never the photo), / refreshes its source, [ ] size and Shift+[ ] feather (Masking brush too). Command tests and headless pointer/keyboard tests. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 2 + crates/engine/src/cmd/masks.rs | 138 +++++++++++++++++++++++++--- crates/engine/src/cmd/query.rs | 1 + crates/engine/src/lib.rs | 5 + crates/engine/src/tests_spots.rs | 57 ++++++++++++ crates/pipeline/src/spots.rs | 48 ++++++++-- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/menus.rs | 38 ++++++++ crates/ui-egui/src/panels/detail.rs | 90 ++++++++++++++++-- crates/ui-egui/src/panels/right.rs | 57 ++++++++++-- crates/ui-egui/src/shortcuts.rs | 16 +++- crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/tests_masking.rs | 62 +++++++++++++ docs/parity.md | 10 +- 14 files changed, 490 insertions(+), 41 deletions(-) create mode 100644 crates/engine/src/tests_spots.rs diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 4cbfc22d..32239656 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -45,6 +45,7 @@ fn step(s: &mut Session, d: isize) -> Result { s.end_interaction()?; s.selection = Selection::single(vis[next]); s.active_mask = None; + s.active_spot = None; Ok(json!({"active": vis[next].0})) } @@ -169,6 +170,7 @@ pub fn specs() -> Vec { other => return Err(bad("library.select", format!("unknown mode `{other}`"))), } s.active_mask = None; + s.active_spot = None; Ok(json!({"selected": s.selection.ids.len()})) }), cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| { diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 0db1c58c..a09cecbb 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Add Remove Spot", [], None, - "{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy]}", + "{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}", has_active, |s, p| { let mode: SpotMode = @@ -265,31 +265,113 @@ pub fn specs() -> Vec { return Err(bad("spot.add", "missing points")); } let d = Spot::default(); - let spot = Spot { + let mut spot = Spot { mode, points: pts, - size: f64_or(p, "size", d.size), - feather: f64_or(p, "feather", d.feather), - opacity: f64_or(p, "opacity", d.opacity), + size: f64_or(p, "size", d.size).clamp(SPOT_SIZE.0, SPOT_SIZE.1), + feather: f64_or(p, "feather", d.feather).clamp(0.0, 100.0), + opacity: f64_or(p, "opacity", d.opacity).clamp(0.0, 100.0), source_offset: point(p, "source"), }; let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + if spot.source_offset.is_none() { + // resolve the automatic source now: it gets a pin and stays put at every size + spot.source_offset = pick_source(s, id, &dd, &spot, None); + } dd.spots.push(spot); let n = dd.spots.len(); s.set_develop(id, dd, "Remove")?; + s.active_spot = Some(n - 1); Ok(json!({"index": n - 1})) } ), - cmd!("spot.delete", "Delete Spot", [], None, "{index}", has_active, |s, p| { - let i = super::f64_req(p, "index", "spot.delete")? as usize; - let id = s.active().ok_or_else(|| bad("spot.delete", "no active photo"))?; - let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); - if i >= dd.spots.len() { - return Err(bad("spot.delete", "no such spot")); + cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| { + let n = spots_len(s); + s.active_spot = match p.get("index").and_then(Value::as_u64) { + Some(i) if (i as usize) < n => Some(i as usize), + Some(_) => return Err(bad("spot.select", "no such spot")), + None => None, + }; + Ok(json!({"activeSpot": s.active_spot})) + }), + cmd!( + "spot.update", + "Edit Spot", + [], + None, + "{index? (the selected spot), move?: [dx,dy] (the target, normalized), source?: [dx,dy] (offset from the target), moveSource?: [dx,dy], size?, feather?, opacity?, mode?}", + has_active, + |s, p| { + let c = "spot.update"; + let i = spot_index(s, p, c)?; + let mode: Option = match p.get("mode") { + Some(m) => Some(serde_json::from_value(m.clone()).map_err(|e| bad(c, e.to_string()))?), + None => None, + }; + spots_edit(s, c, "Edit Spot", |spots| { + let sp = &mut spots[i]; + if let Some(d) = point(p, "move") { + sp.points.iter_mut().for_each(|q| *q = Point::new(q.x + d.x, q.y + d.y)); + // the source moves along (its offset is relative to the target) + } + if let Some(o) = point(p, "source") { + sp.source_offset = Some(o); + } + if let Some(d) = point(p, "moveSource") { + let o = sp.source_offset.unwrap_or(Point::new(0.0, 0.0)); + sp.source_offset = Some(Point::new(o.x + d.x, o.y + d.y)); + } + if let Some(v) = p.get("size").and_then(Value::as_f64) { + sp.size = v.clamp(SPOT_SIZE.0, SPOT_SIZE.1); + } + if let Some(v) = p.get("feather").and_then(Value::as_f64) { + sp.feather = v.clamp(0.0, 100.0); + } + if let Some(v) = p.get("opacity").and_then(Value::as_f64) { + sp.opacity = v.clamp(0.0, 100.0); + } + if let Some(m) = mode { + sp.mode = m; + } + Ok(()) + })?; + Ok(json!({"index": i})) } - dd.spots.remove(i); - s.set_develop(id, dd, "Delete Spot")?; + ), + cmd!( + "spot.refreshSource", + "Refresh Source", + [], + None, + "{index? (the selected spot)} — picks the next-best source away from the current one", + has_active, + |s, p| { + let c = "spot.refreshSource"; + let i = spot_index(s, p, c)?; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let dd = (*s.develop_of(id).unwrap_or_default()).clone(); + let spot = dd.spots[i].clone(); + let next = pick_source(s, id, &dd, &spot, spot.source_offset).ok_or_else(|| bad(c, "no other source fits in the photo"))?; + spots_edit(s, c, "Refresh Source", |spots| { + spots[i].source_offset = Some(next); + Ok(()) + })?; + Ok(json!({"index": i, "source": [next.x, next.y]})) + } + ), + cmd!("spot.delete", "Delete Spot", [], None, "{index? (the selected spot)}", has_active, |s, p| { + let c = "spot.delete"; + let i = spot_index(s, p, c)?; + spots_edit(s, c, "Delete Spot", |spots| { + spots.remove(i); + Ok(()) + })?; + s.active_spot = match s.active_spot { + Some(a) if a == i => None, + Some(a) if a > i => Some(a - 1), + a => a, + }; ok() }), // ---- Red eye / pet eye @@ -358,6 +440,36 @@ pub fn specs() -> Vec { ] } +/// Spot radius limits (fraction of the long edge). +pub const SPOT_SIZE: (f64, f64) = (0.001, 0.25); + +fn spots_len(s: &Session) -> usize { + s.active().and_then(|id| s.develop_of(id)).map(|d| d.spots.len()).unwrap_or(0) +} + +/// `index` from the params, else the selected spot. +fn spot_index(s: &Session, p: &Value, c: &str) -> Result { + let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?; + if i >= spots_len(s) { + return Err(bad(c, "no such spot")); + } + Ok(i) +} + +fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec) -> Result<()>) -> Result<()> { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + f(&mut d.spots)?; + s.set_develop(id, d, label) +} + +/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`). +fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option) -> Option { + let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?; + let info = crate::media::source_info(s.catalog.photo(id)?); + lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid) +} + fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 }; c(&mut a.exposure, -4.0, 4.0); diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index b705791a..bbaf0dce 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -89,6 +89,7 @@ pub fn specs() -> Vec { "selection": s.selection, "visibleCount": n, "activeMask": s.active_mask, + "activeSpot": s.active_spot, "undo": s.undo.last().map(|u| u.label.clone()), "redo": s.redo.last().map(|u| u.label.clone()), "clipboard": s.clipboard.is_some(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f022268e..789a7b45 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -91,6 +91,8 @@ pub struct Session { depth: u32, /// Selected mask (Masking panel), by mask id. pub active_mask: Option, + /// Selected spot (Remove panel), by index into the active photo's spots. + pub active_spot: Option, /// The persistent library this session writes to (`None` = in-memory only). pub library: Option, /// XMP sidecar preferences (persisted with the library). @@ -127,6 +129,7 @@ impl Session { clock: Box::new(|| "2026-09-30T12:00:00".to_string()), depth: 0, active_mask: None, + active_spot: None, library: None, xmp: sidecar::XmpPrefs::default(), last_export: None, @@ -346,4 +349,6 @@ mod tests_merge; #[cfg(test)] mod tests_organize; #[cfg(test)] +mod tests_spots; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs new file mode 100644 index 00000000..16bfe52b --- /dev/null +++ b/crates/engine/src/tests_spots.rs @@ -0,0 +1,57 @@ +//! Remove tool commands: add (automatic source), select, move target/source, edit, refresh, delete. + +use serde_json::json; + +use crate::Session; + +fn spots(s: &Session) -> Vec { + s.develop_of(s.active().unwrap()).unwrap().spots.clone() +} + +#[test] +fn spots_select_move_edit_refresh_and_delete() { + let mut s = Session::with_demo(); + // a remove spot gets a resolved source (a pin to show) and is selected + let r = s.execute("spot.add", &json!({"points": [[0.4, 0.5]], "size": 0.02})).unwrap(); + assert_eq!(r["index"], 0); + assert_eq!(s.active_spot, Some(0)); + let src0 = spots(&s)[0].source_offset.expect("automatic source resolved"); + assert!(src0.x.hypot(src0.y) > 0.02, "the source is outside the spot: {src0:?}"); + s.execute("spot.add", &json!({"mode": "clone", "points": [[0.7, 0.3]], "source": [0.1, 0.0]})).unwrap(); + assert_eq!(s.active_spot, Some(1)); + assert_eq!(s.execute("library.state", &json!({})).unwrap()["activeSpot"], 1); + // select + s.execute("spot.select", &json!({"index": 0})).unwrap(); + assert_eq!(s.active_spot, Some(0)); + assert!(s.execute("spot.select", &json!({"index": 9})).is_err()); + // move the target (the source follows: its offset is relative), then the source alone + s.execute("spot.update", &json!({"move": [0.05, -0.1]})).unwrap(); + let sp = &spots(&s)[0]; + assert!((sp.points[0].x - 0.45).abs() < 1e-9 && (sp.points[0].y - 0.4).abs() < 1e-9); + assert_eq!(sp.source_offset, Some(src0)); + s.execute("spot.update", &json!({"moveSource": [0.01, 0.02]})).unwrap(); + let o = spots(&s)[0].source_offset.unwrap(); + assert!((o.x - src0.x - 0.01).abs() < 1e-9 && (o.y - src0.y - 0.02).abs() < 1e-9); + // size / feather / opacity of the selected spot (clamped), one undo step each + s.execute("spot.update", &json!({"size": 0.03, "feather": 20, "opacity": 150})).unwrap(); + let sp = &spots(&s)[0]; + assert_eq!((sp.size, sp.feather, sp.opacity), (0.03, 20.0, 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spots(&s)[0].size, 0.02); + // refresh: a different source, at least a spot radius away + let before = spots(&s)[0].source_offset.unwrap(); + let r = s.execute("spot.refreshSource", &json!({})).unwrap(); + let after = spots(&s)[0].source_offset.unwrap(); + assert!((after.x - before.x).hypot(after.y - before.y) > 0.01, "{r}"); + // delete the selected spot: the selection clears; deleting before a selected one shifts it + s.execute("spot.delete", &json!({})).unwrap(); + assert_eq!((spots(&s).len(), s.active_spot), (1, None)); + assert_eq!(spots(&s)[0].mode, lightcraft_develop::SpotMode::Clone); + assert!(s.execute("spot.delete", &json!({})).is_err(), "nothing selected"); + s.execute("spot.add", &json!({"points": [[0.2, 0.2]], "source": [0.05, 0.0]})).unwrap(); + s.execute("spot.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.active_spot, Some(0)); + // another photo: no selection + s.execute("library.next", &json!({})).unwrap(); + assert_eq!(s.active_spot, None); +} diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 3e79f67c..f9edff72 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -54,20 +54,56 @@ fn score(img: &Rgb32f, target: (f32, f32), source: (f32, f32), r: f32) -> f32 { s } -/// Pick a source offset (in output pixels) for a remove/heal spot. -pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) { - let mut best = (f32::MAX, (r * 2.5, 0.0)); +/// Candidate source offsets (output pixels) for a spot of radius `r` at `target`, best first +/// (candidates reaching outside the image are left out). +pub fn ranked_sources(img: &Rgb32f, target: (f32, f32), r: f32) -> Vec<(f32, f32)> { + let mut c: Vec<(f32, (f32, f32))> = Vec::new(); for ring in [2.4f32, 3.2, 4.2] { for i in 0..16 { let a = i as f32 / 16.0 * std::f32::consts::TAU; let d = (a.cos() * r * ring, a.sin() * r * ring); let s = score(img, target, (target.0 + d.0, target.1 + d.1), r); - if s < best.0 { - best = (s, d); + if s < f32::MAX { + c.push((s, d)); } } } - best.1 + c.sort_by(|a, b| a.0.total_cmp(&b.0)); + c.into_iter().map(|(_, d)| d).collect() +} + +/// Pick a source offset (in output pixels) for a remove/heal spot. +pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) { + ranked_sources(img, target, r).first().copied().unwrap_or((r * 2.5, 0.0)) +} + +/// A source offset (normalized, as [`Spot::source_offset`]) for `spot` on `src` developed with +/// `s`: the best match, or with `avoid` (the current offset) the best one at least a spot radius +/// away from it ("refresh source"). `None` when no candidate fits in the image. +pub fn pick_source( + src: &Rgb32f, + info: &crate::SourceInfo, + s: &lightcraft_develop::DevelopSettings, + spot: &Spot, + avoid: Option, +) -> Option { + let frame = crate::frame_for(src, info, s, true); + let (w, h) = frame.fit(512, 512); + if w == 0 || h == 0 { + return None; + } + let img = frame.sample(src, w, h); + let (to_out, to_norm) = (frame.norm_to_out(w, h), frame.out_to_norm(w, h)); + let t = to_out.apply(*spot.points.first()?); + let r = (spot.size * frame.px_per_long(w)).max(1.0) as f32; + let avoid = avoid.map(|o| { + let (a, b) = (to_out.apply(Point::new(0.0, 0.0)), to_out.apply(o)); + ((b.x - a.x) as f32, (b.y - a.y) as f32) + }); + let far = |d: &(f32, f32)| avoid.is_none_or(|a| ((d.0 - a.0).powi(2) + (d.1 - a.1).powi(2)).sqrt() > r); + let d = ranked_sources(&img, (t.x as f32, t.y as f32), r).into_iter().find(far)?; + let (n0, n1) = (to_norm.apply(t), to_norm.apply(Point::new(t.x + d.0 as f64, t.y + d.1 as f64))); + Some(Point::new(n1.x - n0.x, n1.y - n0.y)) } /// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 16a9240d..913cdab8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -101,6 +101,8 @@ fn key_from(name: &str) -> Option { "tab" => Some(egui::Key::Tab), "\\" | "backslash" => Some(egui::Key::Backslash), "/" | "slash" => Some(egui::Key::Slash), + "[" => Some(egui::Key::OpenBracket), + "]" => Some(egui::Key::CloseBracket), _ => None, }) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fe287a1e..df5d4d5f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -63,6 +63,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), ("tool.none", "No Tool", None, ""), + // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) + ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), + ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), + ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), + ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), @@ -100,6 +105,30 @@ fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str let _ = app.session.end_interaction(); } +/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's +/// (and its selected spot's) or the Masking brush's. +fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { + if app.ui.right == RightPanel::Remove { + app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); + app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); + if app.session.active_spot.is_some() { + let mut p = json!({}); + if k != 1.0 { + p["size"] = json!(app.ui.remove_size); + } + if df != 0.0 { + p["feather"] = json!(app.ui.remove_feather); + } + let _ = app.run("spot.update", p); + } + json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) + } else { + app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); + app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) + } +} + /// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { if let Some(s) = v.as_str() { @@ -252,6 +281,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let (k, df) = match id { + "brush.smaller" => (1.0 / 1.2, 0.0), + "brush.larger" => (1.2, 0.0), + "brush.featherLess" => (1.0, -10.0), + _ => (1.0, 10.0), + }; + Ok(adjust_brush(app, k, df)) + } "view.maskPins" => { app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); Ok(json!({"maskPins": app.ui.mask_pins})) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 6491ae41..9c77ebc6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -23,6 +23,11 @@ pub enum Gesture { Spot { points: Vec, }, + /// Remove tool: dragging spot `spot`'s target (or its source). + SpotMove { + spot: usize, + source: bool, + }, CropHandle { handle: u8, start: lightcraft_geom::Rect, @@ -795,19 +800,89 @@ fn pin(p: &egui::Painter, c: Pos2, sel: bool) { // ------------------------------------------------------------------------ remove +/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), +/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { let long = (map.rect.width().max(map.rect.height())) as f64; - let p = ui.painter(); - for s in &d.spots { + let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); + let active = app.session.active_spot.filter(|i| *i < d.spots.len()); + // (spot, is source, screen centre, radius) of everything that can be grabbed + let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); + for (i, s) in d.spots.iter().enumerate() { let r = (s.size * long) as f32; + let sel = Some(i) == active; + let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); for q in &s.points { - p.circle_stroke(map.screen(*q), r, Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); } + let Some(&t) = s.points.first() else { continue }; + let tq = map.screen(t); + if let Some(o) = s.source_offset.filter(|_| sel) { + let sq = map.screen(Point::new(t.x + o.x, t.y + o.y)); + for q in &s.points { + p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170))); + } + let dir = (tq - sq).normalized(); + p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200))); + register(ui.ctx(), format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); + grips.push((i, true, sq, r)); + } + pin(&p, tq, sel); + register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); + grips.push((i, false, tq, r)); } let r = (app.ui.remove_size as f64 * long) as f32; + // a target or source under `q`: the selected spot's first, then the nearest pin + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.2.distance(q) < g.3.max(8.0)) + .min_by(|a, b| { + (Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + let hover = resp.hover_pos().and_then(hit); if let Some(h) = resp.hover_pos() { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + if hover.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } else { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110))); + } + } + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some((spot, source, ..)) = press.and_then(hit) + { + if Some(spot) != active { + let _ = app.run("spot.select", json!({"index": spot})); + } + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + app.gesture = Some(Gesture::SpotMove { spot, source }); + } + if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() { + if resp.dragged() + && let Some(q) = resp.interact_pointer_pos() + { + let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta())); + let dn = [n.x - n0.x, n.y - n0.y]; + if dn != [0.0, 0.0] { + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn})); + } + } + if resp.drag_stopped() { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } + return; + } + if resp.clicked() + && let Some((spot, ..)) = press.and_then(hit) + { + let _ = app.run("spot.select", json!({"index": spot})); + return; } if let Some(q) = resp.interact_pointer_pos() && (resp.drag_started() || resp.dragged() || resp.clicked()) @@ -832,7 +907,10 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo _ => "remove", }; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - let _ = app.run("spot.add", json!({"mode": mode, "points": pts, "size": app.ui.remove_size})); + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); } } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 016c9a1d..d271ffd0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -213,20 +213,59 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(4.0); ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim)); }); - let spec = lightcraft_develop::ControlSpec { - id: "ui.removeSize", - label: "Size", + // brush settings; with a spot selected they edit that spot too + let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone()))); + if let Some((i, sp)) = &sel { + (app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32); + divider(ui); + let mode = match sp.mode { + lightcraft_develop::SpotMode::Heal => "Heal", + lightcraft_develop::SpotMode::Clone => "Clone", + lightcraft_develop::SpotMode::Remove => "Remove", + }; + super::edit::sub_title(ui, &format!("{mode} spot {} of {}", i + 1, d.spots.len())); + } + let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { + id, + label, section: lightcraft_develop::Section::Detail, - min: 1.0, - max: 100.0, - default: 20.0, + min, + max, + default, step: 1.0, decimals: 0, track: lightcraft_develop::Track::Plain, }; - let out = slider(ui, &spec, (app.ui.remove_size * 1000.0) as f64, true, None); - if let Some(v) = out.value { - app.ui.remove_size = (v / 1000.0) as f32; + let sliders = [ + (plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64), + (plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64), + (plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64), + ]; + for (spec, key, v) in sliders { + let out = slider(ui, &spec, v, true, None); + let scale = if key == "size" { 1000.0 } else { 1.0 }; + if let Some(v) = out.value { + match key { + "size" => app.ui.remove_size = (v / scale) as f32, + "feather" => app.ui.remove_feather = v as f32, + _ => app.ui.remove_opacity = v as f32, + } + } + if sel.is_some() { + super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale}))); + } + } + if sel.is_some() { + padded(ui, |ui| { + ui.horizontal(|ui| { + if text_button(ui, "spotRefresh", "Refresh Source (/)", false).clicked() { + let _ = app.run("spot.refreshSource", json!({})); + } + if text_button(ui, "spotDelete", "Delete (⌫)", false).clicked() { + let _ = app.run("spot.delete", json!({})); + } + }); + }); } // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out padded(ui, |ui| { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 06580217..ed4fcc8c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -15,6 +15,9 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("Shift+M", "version.create", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), + // Shift+[ / Shift+] arrive as { / } on most layouts + ("Shift+{", "brush.featherLess", "{}"), + ("Shift+}", "brush.featherMore", "{}"), ]; pub fn parse(s: &str) -> Option<(Modifiers, Key)> { @@ -167,7 +170,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } continue; } - R::Remove | R::RedEye => continue, + R::Remove => { + if app.session.active_spot.is_some() { + let _ = app.run("spot.delete", json!({})); + } + continue; + } + R::RedEye => continue, _ => {} } } @@ -179,6 +188,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { continue; } + // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) + if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() { + let _ = app.run("spot.refreshSource", json!({})); + continue; + } // Shift+O cycles the mask overlay mode while masking (the crop overlay elsewhere) if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking { let _ = app.run("view.maskOverlayMode", json!({})); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cb358392..cdca7f19 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -121,7 +121,10 @@ pub struct UiState { pub brush_erase: bool, /// Brush Auto Mask: dabs stick to areas like the one under the brush centre. pub brush_auto_mask: bool, + /// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100). pub remove_size: f32, + pub remove_feather: f32, + pub remove_opacity: f32, /// Selected Point Color sample. pub point_color: usize, /// Point Color "Visualize range": the selected sample's range in colour, the rest grey. @@ -224,6 +227,8 @@ impl Default for UiState { brush_erase: false, brush_auto_mask: false, remove_size: 0.02, + remove_feather: 50.0, + remove_opacity: 100.0, point_color: 0, point_color_visualize: false, eye: 0, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 74b360f2..75e946ff 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -106,3 +106,65 @@ fn brush_strokes_carry_auto_mask() { assert!(strokes[0].auto_mask && strokes[0].points.len() >= 2, "{strokes:?}"); h.settle(SETTLE); } + +#[test] +fn remove_spots_by_pointer_and_keyboard() { + let mut h = detail("panel.remove"); + assert_eq!(h.app.ui.right, RightPanel::Remove); + let spots = |h: &Headless| develop(h).spots; + // paint a spot: it's added with the brush's size/feather/opacity and selected + let r = h.request("ui.set", json!({"removeFeather": 30.0, "removeOpacity": 80.0}), T); + assert_eq!(r["ok"], true, "{r}"); + pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}])); + assert_eq!(spots(&h).len(), 1); + assert_eq!((spots(&h)[0].feather, spots(&h)[0].opacity), (30.0, 80.0)); + assert_eq!(h.app.session.active_spot, Some(0)); + // [ / ] resize the selected spot, Shift+[ / Shift+] feather it + let s0 = spots(&h)[0].size; + h.request("ui.key", json!({"key": "]"}), T); + assert!(spots(&h)[0].size > s0 * 1.1, "{} vs {s0}", spots(&h)[0].size); + h.request("ui.key", json!({"key": "["}), T); + h.request("ui.key", json!({"key": "["}), T); + assert!(spots(&h)[0].size < s0 * 0.9); + h.request("ui.key", json!({"key": "[", "shift": true}), T); + assert_eq!(spots(&h)[0].feather, 20.0); + h.request("ui.key", json!({"key": "]", "shift": true}), T); + assert_eq!(spots(&h)[0].feather, 30.0); + // / picks another source (and leaves the filmstrip alone) + let (src, film) = (spots(&h)[0].source_offset, h.app.ui.filmstrip); + h.request("ui.key", json!({"key": "/"}), T); + assert_ne!(spots(&h)[0].source_offset, src); + assert_eq!(h.app.ui.filmstrip, film); + // a second spot elsewhere; clicking the first one's pin selects it + pointer(&mut h, json!([{"kind": "down", "x": 0.7, "y": 0.3}, {"kind": "up", "x": 0.7, "y": 0.3}])); + assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(1))); + pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}])); + assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(0))); + // drag its target: it moves, its source offset stays + let src = spots(&h)[0].source_offset; + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "drag", "x": 0.32, "y": 0.6}, {"kind": "drag", "x": 0.4, "y": 0.6}, {"kind": "up", "x": 0.4, "y": 0.6}]), + ); + let sp = spots(&h)[0].clone(); + assert!((sp.points[0].x - 0.4).abs() < 0.01 && (sp.points[0].y - 0.6).abs() < 0.01, "{:?}", sp.points); + assert_eq!(sp.source_offset, src); + // drag its source to a fixed place + let o = sp.source_offset.unwrap(); + let (sx, sy) = (sp.points[0].x + o.x, sp.points[0].y + o.y); + pointer( + &mut h, + json!([{"kind": "down", "x": sx, "y": sy}, {"kind": "drag", "x": sx + 0.01, "y": sy}, {"kind": "drag", "x": 0.5, "y": 0.8}, {"kind": "up", "x": 0.5, "y": 0.8}]), + ); + let sp = spots(&h)[0].clone(); + let o = sp.source_offset.unwrap(); + assert!((sp.points[0].x + o.x - 0.5).abs() < 0.01 && (sp.points[0].y + o.y - 0.8).abs() < 0.01, "{o:?}"); + // ⌫ deletes the selected spot, not the photo + let photos = h.app.session.visible_cloned().len(); + h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!((spots(&h).len(), h.app.session.active_spot), (1, None)); + assert_eq!(h.app.session.visible_cloned().len(), photos); + h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(spots(&h).len(), 1, "nothing selected: nothing deleted"); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index 756e3f42..9d2fb880 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -29,7 +29,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | | G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | -| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | +| I. Remove / healing (REM) | 5 | 1 | 4 | 2 | 4/4 (100%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | | K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) | | L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | | Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 212 | 99 | 189 | 37 | 155/198 (78%) | 49/144 (34%) | +| **Total** | 214 | 97 | 189 | 37 | 157/198 (79%) | 49/144 (34%) | ## Top gaps @@ -57,8 +57,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. -4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, - feather/opacity sliders, `[`/`]` size keys. Medium effort. 5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. 6. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; live preview while hovering presets/profiles/versions. Medium effort. @@ -307,8 +305,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | | | LR-REM-GEN | Generative remove | OOS | 🚫 | | | | LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | | -| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | 🟡 | `cmd:spot.add` (`size`, `feather`, `opacity`), `crates/ui-egui/src/panels/right.rs` | UI has size only; no `[` `]` keys | -| LR-REM-SPOT-EDIT | Edit existing spots | P0 | 🟡 | `cmd:spot.delete` | no pin selection, no moving target/source, ⌫ deletes the photo instead | +| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | `cmd:spot.add` (`size`, `feather`, `opacity`), `cmd:brush.smaller`, `cmd:brush.larger`, `cmd:brush.featherLess`, `cmd:brush.featherMore`, `crates/ui-egui/src/panels/right.rs` | Size / Feather / Opacity sliders (for new spots and the selected one); `[` `]` size, ⇧`[` ⇧`]` feather (also the Masking brush) | +| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added | | LR-REM-VISUALIZE | Visualize spots | P1 | ⬜ | | | | LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | | LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | |