From 7c27c3a920890833b48366291c63a72be4bf9173 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:03:28 -0400 Subject: [PATCH] People MVP: list named faces from XMP in the sidebar and filter by person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only and names-only: the left panel gets a People section built from the named Face regions read from XMP, with photo counts (once per photo, case insensitive, pets and unnamed faces left out). A click filters the grid via a new `person` field on `library.filter`, also reachable as a `person:` search token and shown as a filter chip. The list is cached on the catalog revision. No face detection, identity or writing regions back yet. Parity rows for the People list, filter and view are 🟑 with that scope noted. Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/query.rs | 47 +++++++++++++++++++++++++++++-- crates/catalog/src/tests.rs | 44 +++++++++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/view.rs | 4 ++- crates/ui-egui/src/lib.rs | 12 ++++++++ crates/ui-egui/src/panels/left.rs | 25 ++++++++++++++++ docs/parity.md | 18 ++++++------ 7 files changed, 138 insertions(+), 14 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 72c7ffb1b..b7165bb15 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -39,6 +39,8 @@ pub struct Filter { pub date: Option, /// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`). pub keyword: Option, + /// A person: photos with a named face region of this name (case-insensitive), as read from XMP. + pub person: Option, pub camera: Option, /// Lens (case-insensitive substring). pub lens: Option, @@ -113,6 +115,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), + "person" | "who" => has_person(p, val), "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), "date" => p.date().starts_with(val), @@ -125,6 +128,12 @@ fn token_matches(p: &Photo, tok: &str) -> bool { hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t)) } +/// Whether `p` has a named face region called `name` (case-insensitive). +fn has_person(p: &Photo, name: &str) -> bool { + let name = name.trim().to_lowercase(); + p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name)) +} + impl Filter { /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { @@ -152,9 +161,14 @@ impl Filter { if let Some(e) = self.edited { v.push(if e { "edited".into() } else { "unedited".into() }); } - for (name, val) in - [("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)] - { + for (name, val) in [ + ("keyword", &self.keyword), + ("person", &self.person), + ("camera", &self.camera), + ("lens", &self.lens), + ("date", &self.date), + ("imported", &self.imported), + ] { if let Some(x) = val { v.push(format!("{name} {x}")); } @@ -267,6 +281,11 @@ impl Filter { { return false; } + if let Some(n) = &self.person + && !has_person(p, n) + { + return false; + } if let Some(c) = &self.camera && !p.meta.camera.eq_ignore_ascii_case(c) { @@ -330,6 +349,28 @@ impl Catalog { } m.into_iter().collect() } + + /// The people named on faces in the library (MWG regions read from XMP) with the number of + /// photos each appears in, most photos first, then by name. Names that differ only in case are + /// one person, shown as first seen. A person twice in one photo counts once. + pub fn people(&self) -> Vec<(String, usize)> { + let mut m: std::collections::HashMap = Default::default(); + for p in self.photos().filter(|p| p.in_library()) { + let mut seen: Vec = Vec::new(); + for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + if seen.contains(&key) { + continue; + } + m.entry(key.clone()).or_insert_with(|| (name.to_string(), 0)).1 += 1; + seen.push(key); + } + } + let mut v: Vec<(String, usize)> = m.into_values().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))); + v + } } #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 86442d2a9..9add629ef 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -297,3 +297,47 @@ fn folder_identity_ignores_spelling() { assert!(folder_within("/a/b", "/")); assert!(folder_within("C:\\x", "c:\\")); } + +/// People come from named *face* regions: counted once per photo, case-insensitive, most photos +/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match. +#[test] +fn people_from_named_face_regions() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut c = Catalog::new(); + let mut add = |name: &str, regions: Vec| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.regions = regions; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let a = add( + "a.jpg", + vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)], + ); + let b = + add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]); + let d = add("d.jpg", vec![region(Some("John Roe"), RegionKind::Face)]); + let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]); + add("f.jpg", vec![]); + assert_eq!( + c.people(), + vec![("Jane Doe".to_string(), 2), ("John Roe".to_string(), 2), ("Sam".to_string(), 1)], + "once per photo, pets and unnamed faces left out" + ); + + let q = |f: Filter| c.query(&f, &Sort::default()); + assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2); + let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() }); + got.sort(); + assert_eq!(got, vec![b, d]); + assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person"); + assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case"); + assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a)); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 74e5d5b0f..debe6f68c 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -263,7 +263,7 @@ pub fn specs() -> Vec { "Filter", [], None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, 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?, person?, camera?}", always, |s, p| { let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index b64ff04ac..757adfe94 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -163,7 +163,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(e) = f.edited { add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null})); } - for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] { + for (name, key, val) in + [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword), ("Person", "person", &f.person)] + { if let Some(x) = val { add(format!("{name}: {x}"), json!({key: Null})); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8addc61b5..449bf4df6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -789,6 +789,7 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, + people: Option<(u64, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, @@ -835,6 +836,17 @@ impl Caches { } } } + /// The people named on faces, with photo counts. + pub fn people(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + match &self.people { + Some((r, t)) if *r == cat.revision => t.clone(), + _ => { + let t = std::sync::Arc::new(cat.people()); + self.people = Some((cat.revision, t.clone())); + t + } + } + } /// All Photos / Picks / Recently Deleted counts. pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { if let Some((r, c)) = self.counts diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 7d7037435..12477757b 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -134,6 +134,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } + people_section(app, ui); keywords_section(app, ui); ui.add_space(10.0); if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { @@ -664,6 +665,30 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig false } +/// "People": the names on faces read from XMP (MWG regions), with photo counts. A click filters the +/// grid to that person; a second click clears it. (Names only for now: no face detection or +/// identity yet, and LightCraft does not write regions.) +fn people_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let people = app.caches.people(&app.session.catalog); + if people.is_empty() { + return; + } + ui.add_space(10.0); + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "People", t.semibold(13.5), t.text_label); + for (name, count) in people.iter() { + let sel = app.session.filter.person.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(name)); + // a long name must not run into the count + let shown = if name.chars().count() > 22 { format!("{}…", name.chars().take(21).collect::()) } else { name.clone() }; + let resp = row(app, ui, &format!("person:{name}"), Icon::Subject, &shown, Some(*count), sel, 0.0).on_hover_text(name); + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(name) }; + let _ = app.run("library.filter", json!({"person": v})); + } + } +} + /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. diff --git a/docs/parity.md b/docs/parity.md index fc94d08a7..fd9f57ea3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,9 +22,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | βœ… | 🟑 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 11 | 3 | 1 | 1 | 3/5 (60%) | 5/5 (100%) | -| B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) | +| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | -| D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 4/4 (100%) | +| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel β€” global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) | @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | β€” | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | β€” | β€” | -| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 15 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 31 | 9 | 40 | 9 | β€” | 21/22 (95%) | -| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) | +| Lightroom Classic extras | 31 | 10 | 39 | 9 | β€” | 21/22 (95%) | +| **Total** | 387 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (βœ… = 1, 🟑 = Β½, 🚫 left out): **79.2%** of 507 in-scope rows β€” P0 98.5% of 200 Β· P1 95.6% of 149 Β· P2 39.2% of 158. +Weighted completion (βœ… = 1, 🟑 = Β½, 🚫 left out): **79.6%** of 507 in-scope rows β€” P0 98.5% of 200 Β· P1 95.6% of 149 Β· P2 40.5% of 158. ## Top gaps @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | βœ… | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | βœ… | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | βœ… | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | +| LR-LIB-PEOPLE | People / faces | P2 | 🟑 | `cmd:library.filter`, `crates/ui-egui/src/panels/left.rs` (`people_section`), `crates/catalog/src/query.rs` (`people`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only) are listed with photo counts in the left panel's People section; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip); no face detection, no identity/suggestions, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | βœ… | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | βœ… | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | βœ… | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | @@ -195,7 +195,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | -| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | +| LR-FILT-PEOPLE | People filter | P2 | 🟑 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only); no unnamed / suggested people | | LR-FILT-CULL | Culling-score filters | P2 | βœ… | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | | LR-FILT-SORT | Sort | P0 | βœ… | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | βœ… | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar β†’ Presets: apply, save current filter, delete (right-click); saved with the library | @@ -724,7 +724,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | βœ… | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (βŒ₯1–βŒ₯9); keyword painter (Keywords panel β–Έ Paint: click photos in the grid to toggle a keyword, Esc stops) | | LRC-LIB-METADATA | Metadata panel & presets | P1 | βœ… | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo β†’ Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | βœ… | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel β–Έ Quick Develop steps (exposure β…“ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | +| LRC-LIB-PEOPLE | People view | P2 | 🟑 | `cmd:library.filter`, `crates/ui-egui/src/panels/left.rs` (`people_section`) | a People list in the sidebar (names and counts) that filters the grid; no face-cluster grid or per-person view yet | | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | βœ… | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo β–Έ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | βœ… | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File β†’ Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous β€” look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu β†’ Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only β€” Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike |