From 9e8d75300ff88c865752f8f2afd62ea0e4619a62 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:45:30 -0400 Subject: [PATCH] LR-EDIT-COLOR-MIXER-BW + LR-FILT-TYPE: Auto B&W mix, HDR / panorama type filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - develop.autoBwMix (B&W Mixer → Auto): each hue band's colourful pixels are pushed away from the image's mean lightness (light bands brighter, dark ones darker; bands with almost no colour untouched), clamped to ±60; switches to B&W; one undo step. - library.filter {merged: hdr | panorama | hdrPanorama | any}: Photo Merge results recognised by the "-HDR" / "-Pano" / "-HDR-Pano" names merges write; filter bar type picker lists HDR, Panoramas, HDR Panoramas. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/query.rs | 25 +++++++++++ crates/catalog/src/tests.rs | 11 +++++ crates/engine/src/cmd/develop.rs | 22 ++++++++++ crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/tests.rs | 13 ++++++ crates/engine/src/tests_merge.rs | 4 ++ crates/pipeline/src/auto.rs | 57 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/edit.rs | 6 +++ crates/ui-egui/src/panels/filterbar.rs | 16 +++++++- docs/parity.md | 12 +++--- 10 files changed, 159 insertions(+), 9 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index c820f93e1..f9d22cf70 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -44,6 +44,8 @@ pub struct Filter { pub imported: Option, /// Imported at or after this time (ISO; Recently Added). pub imported_from: Option, + /// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]). + pub merged: Option, /// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below. pub folder: Option, pub subfolders: bool, @@ -188,6 +190,12 @@ impl Filter { if self.edited.is_some_and(|e| e != p.is_edited()) { return false; } + if let Some(want) = &self.merged { + match merged_kind(&p.file_name) { + Some(k) if want == "any" || want == k => {} + _ => return false, + } + } if let Some(a) = self.album && !cat.album_contains(a, p) { @@ -311,3 +319,20 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool { let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false }; !rest.is_empty() && (deep || !rest.contains(['/', '\\'])) } + +/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`, +/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use): +/// `hdr`, `panorama` or `hdrPanorama`. +pub fn merged_kind(file_name: &str) -> Option<&'static str> { + let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase(); + let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' '); + if stem.ends_with("-hdr-pano") { + Some("hdrPanorama") + } else if stem.ends_with("-hdr") { + Some("hdr") + } else if stem.ends_with("-pano") { + Some("panorama") + } else { + None + } +} diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index caecf16fc..7eb7544be 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -216,3 +216,14 @@ fn folder_paths() { assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix"); assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false)); } + +#[test] +fn merge_results_are_recognised() { + use crate::query::merged_kind; + assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr")); + assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama")); + assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama")); + assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo"); + assert_eq!(merged_kind("hdr.jpg"), None); + assert_eq!(merged_kind("panorama.jpg"), None); +} diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 7b0a744b9..bb69b0c8b 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -193,6 +193,28 @@ pub fn specs() -> Vec { Ok(()) }) }), + cmd!( + "develop.autoBwMix", + "Auto B&W Mix", + [], + None, + "{} — sets the black & white mix from the photo's colours (and switches to B&W)", + has_active, + |s, _| { + let id = active(s, "develop.autoBwMix")?; + let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?; + let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default(); + let d = s.develop_of(id).unwrap_or_default(); + let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d); + edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| { + d.treatment = Treatment::Bw; + let b = &mut d.bw_mix; + [b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m; + Ok(()) + })?; + Ok(json!({"mix": m})) + } + ), cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| { let id = active(s, "develop.auto")?; let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?; diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 37198f74c..d267f79d2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -164,7 +164,7 @@ pub fn specs() -> Vec { "Filter", [], None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}", + "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}", always, |s, p| { let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 48f380d77..8906f9e89 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -421,3 +421,16 @@ fn filter_presets_save_and_apply() { s.execute("filter.deletePreset", &json!({"name": "Best"})).unwrap(); assert!(s.filter_presets.is_empty()); } + +#[test] +fn auto_bw_mix_separates_colours() { + let mut s = demo(); + s.execute("develop.autoBwMix", &json!({})).unwrap(); + let d = active_dev(&s); + assert_eq!(d.treatment, lightcraft_develop::Treatment::Bw); + let m = d.bw_mix.bands(); + assert!(m.iter().any(|v| *v != 0.0), "some band moved: {m:?}"); + assert!(m.iter().all(|v| v.abs() <= 60.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_ne!(active_dev(&s).treatment, lightcraft_develop::Treatment::Bw, "one undo step"); +} diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index 93791fba0..44a904a6c 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -60,6 +60,10 @@ fn hdr_merge_command_creates_and_imports_a_dng() { assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng")); let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap()); assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option"); + // the type filter finds merge results (and only them) + s.execute("library.filter", &json!({"merged": "hdr"})).unwrap(); + let found: std::collections::HashSet<_> = s.catalog.query(&s.filter, &Default::default()).into_iter().collect(); + assert_eq!(found, [id, id2].into_iter().collect()); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs index f2e86203f..c34d33110 100644 --- a/crates/pipeline/src/auto.rs +++ b/crates/pipeline/src/auto.rs @@ -58,6 +58,46 @@ pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTo } } +/// An automatic black & white mix (slider values, red … magenta) for `src` under `s`'s white +/// balance and tone: each hue band's colourful pixels are pushed away from the image's mean +/// lightness — bands brighter than average get brighter, darker ones darker — so areas that +/// differ only in colour stay apart in grey. Bands with almost no colourful pixels stay at 0. +pub fn auto_bw_mix(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> [f64; 8] { + use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; + let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box); + let base = DevelopSettings { wb: s.wb, light: s.light, ..DevelopSettings::default() }; + crate::local::scene_linear_pre(&mut img, info, &base); + let gain = 2f32.powf(base.light.exposure as f32); + let (mut mass, mut sum_l) = ([0f64; 8], [0f64; 8]); + let (mut all_l, mut n) = (0f64, 0usize); + for p in &img.data { + let lch = lab_to_lch(oklab_from_2020(p.map(|v| (v * gain).max(0.0)))); + all_l += lch[0] as f64; + n += 1; + let k = (lch[1] / 0.2).min(1.0) as f64; // as in the B&W conversion + if k < 0.05 { + continue; + } + let w = crate::colorops::band_weights(lch[2]); + for i in 0..8 { + mass[i] += w[i] as f64 * k; + sum_l[i] += w[i] as f64 * k * lch[0] as f64; + } + } + if n == 0 { + return [0.0; 8]; + } + let mean = all_l / n as f64; + let total: f64 = mass.iter().sum(); + std::array::from_fn(|i| { + if total <= 0.0 || mass[i] / total < 0.01 { + return 0.0; + } + let sep = sum_l[i] / mass[i] - mean; + (sep * 400.0).clamp(-60.0, 60.0).round() + }) +} + /// Grey-world white balance weighted towards mid-tone, low-chroma pixels. Returns (temp, tint). pub fn auto_wb(src: &Rgb32f, info: &SourceInfo) -> (f64, f64) { let img = lightcraft_raster::resample::fit(src, 256, 256, lightcraft_raster::resample::Filter::Box); @@ -134,3 +174,20 @@ mod tests { assert!((c[0] - c[2]).abs() < 0.02, "{c:?}"); } } + +#[cfg(test)] +mod bw_tests { + use super::*; + + #[test] + fn auto_bw_mix_pushes_light_and_dark_hues_apart() { + // left: a light yellow, right: a dark blue (scene-linear Rec. 2020) + let src = Rgb32f::from_fn(64, 32, |x, _| if x < 32 { [0.55, 0.5, 0.05] } else { [0.01, 0.02, 0.12] }); + let m = auto_bw_mix(&src, &SourceInfo::default(), &DevelopSettings::default()); + let (yellow, blue) = (m[2], m[5]); + assert!(yellow > 0.0 && blue < 0.0, "{m:?}"); + // an image without colour leaves the mix alone + let grey = Rgb32f::from_fn(16, 16, |x, _| [x as f32 / 16.0; 3]); + assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]); + } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 5fa492f3f..b10e51d15 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -730,6 +730,12 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { }); let b = bands[sel]; if crate::is_bw(d) { + ui.horizontal(|ui| { + ui.add_space(24.0); + if crate::widgets::text_button(ui, "bwAuto", "Auto", false).on_hover_text("Set the mix from the photo's colours").clicked() { + let _ = app.run("develop.autoBwMix", json!({})); + } + }); control(app, ui, d, &format!("bw.{b}"), true); } else { for k in ["hue", "sat", "lum"] { diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 64d585677..3d81637c4 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -188,12 +188,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { Some(MediaKind::Raw) => "Raw", Some(MediaKind::Video) => "Videos", }; + let kind_label = match f.merged.as_deref() { + Some("hdr") => "HDR", + Some("panorama") => "Panoramas", + Some("hdrPanorama") => "HDR Panoramas", + Some(_) => "Merged", + None => kind_label, + }; picker( app, ui, "kind", kind_label, - f.kind.is_some(), + f.kind.is_some() || f.merged.is_some(), [ ("All types", Value::Null, None), ("Photos", json!("image"), Some(MediaKind::Image)), @@ -201,7 +208,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("Videos", json!("video"), Some(MediaKind::Video)), ] .into_iter() - .map(|(l, v, k)| (l.to_string(), json!({"kind": v}), f.kind == k)) + .map(|(l, v, k)| (l.to_string(), json!({"kind": v, "merged": null}), f.kind == k && f.merged.is_none())) + .chain( + [("HDR", "hdr"), ("Panoramas", "panorama"), ("HDR Panoramas", "hdrPanorama")] + .into_iter() + .map(|(l, m)| (l.to_string(), json!({"kind": null, "merged": m}), f.merged.as_deref() == Some(m))), + ) .collect(), ); let edited_label = match f.edited { diff --git a/docs/parity.md b/docs/parity.md index 308f7f1f6..e37804821 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -24,9 +24,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) | | B. Library management (LIB) | 19 | 0 | 6 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) | -| D. Search & filter (FILT) | 8 | 2 | 3 | 0 | 4/4 (100%) | 3/4 (75%) | +| D. Search & filter (FILT) | 9 | 1 | 3 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 4 | 2 | 0 | 0 | 2/2 (100%) | 1/2 (50%) | -| F. Edit panel — global adjustments (EDIT) | 40 | 2 | 6 | 1 | 28/28 (100%) | 12/14 (86%) | +| F. Edit panel — global adjustments (EDIT) | 41 | 1 | 6 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 66 | 9 | 5 | 1 | 49/52 (94%) | 15/23 (65%) | | Lightroom Classic extras | 12 | 17 | 50 | 9 | — | 11/21 (52%) | -| **Total** | 323 | 51 | 126 | 37 | 193/198 (97%) | 110/144 (76%) | +| **Total** | 325 | 49 | 126 | 37 | 193/198 (97%) | 112/144 (78%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 86.1% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 86.8% of 144 · P2 18.4% of 158. ## Top gaps @@ -176,7 +176,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) | | LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) | | LR-FILT-LABEL | Colour-label filter | P1 | ✅ | `cmd:library.filter` (`label`), `crates/ui-egui/src/panels/filterbar.rs` | one label at a time; no “no label” choice | -| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`), `crates/ui-egui/src/panels/filterbar.rs` | photos / raw / videos, edited / unedited; no HDR/panorama/depth kinds | +| LR-FILT-TYPE | Type / edited filter | P1 | ✅ | `cmd:library.filter` (`kind`, `merged`, `edited`), `crates/ui-egui/src/panels/filterbar.rs`, `crates/catalog/src/query.rs` (`merged_kind`) | photos / raw / videos / HDR / panoramas / HDR panoramas (merge results by name), edited / unedited; no depth kind | | LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | | LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | | LR-FILT-LOCATION | Location filter | P2 | 🟡 | `cmd:library.filter` (`text`) | free-text match on the location field only | @@ -221,7 +221,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | -| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | 🟡 | `ctl:bw.*` | no auto mix | +| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | | LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*` | | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | |