mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
People MVP: list named faces from XMP in the sidebar and filter by person
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
f5f8bc5d34
commit
7c27c3a920
@@ -39,6 +39,8 @@ pub struct Filter {
|
||||
pub date: Option<String>,
|
||||
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
|
||||
pub keyword: Option<String>,
|
||||
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
|
||||
pub person: Option<String>,
|
||||
pub camera: Option<String>,
|
||||
/// Lens (case-insensitive substring).
|
||||
pub lens: Option<String>,
|
||||
@@ -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<String, (String, usize)> = Default::default();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
let mut seen: Vec<String> = 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)]
|
||||
|
||||
@@ -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<Region>| {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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();
|
||||
|
||||
@@ -163,7 +163,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
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}));
|
||||
}
|
||||
|
||||
@@ -789,6 +789,7 @@ mod drop_tests {
|
||||
#[derive(Default)]
|
||||
pub struct Caches {
|
||||
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
|
||||
people: Option<(u64, std::sync::Arc<Vec<(String, usize)>>)>,
|
||||
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
|
||||
counts: Option<(u64, LibraryCounts)>,
|
||||
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
|
||||
@@ -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<Vec<(String, usize)>> {
|
||||
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
|
||||
|
||||
@@ -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::<String>()) } 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.
|
||||
|
||||
+9
-9
@@ -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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 |
|
||||
|
||||
Reference in New Issue
Block a user