diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 79d256352..bac31f488 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -24,6 +24,9 @@ pub struct Filter { pub rating_op: RatingOp, pub flag: Option, pub label: Option, + /// Only these photos (Find Similar results…); empty = no constraint. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub only: Vec, /// Any of these labels (the filter bar's multi-select); empty = no constraint. #[serde(skip_serializing_if = "Vec::is_empty")] pub labels: Vec, @@ -210,6 +213,9 @@ impl Filter { if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) { return false; } + if !self.only.is_empty() && !self.only.contains(&p.id) { + return false; + } if let Some(want) = &self.merged { match merged_kind(&p.file_name) { Some(k) if want == "any" || want == k => {} diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 928e4cd39..51049797e 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -83,14 +83,59 @@ fn analyze(s: &mut Session, p: &Value) -> Result { Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed})) } +/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by +/// content), most similar first; the view is filtered to them. +fn find_similar(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.findSimilar"; + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?; + let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32; + let all: Vec = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect(); + let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> { + let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?; + if let Some(v) = s.signatures.get(&key) { + return Some(*v); + } + let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?; + let v = lightcraft_pipeline::cull::signature(&src); + s.signatures.insert(key, v); + Some(v) + }; + let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?; + let mut hits: Vec<(PhotoId, f32)> = Vec::new(); + for q in all { + if let Some(v) = sig(s, q) { + let sim = lightcraft_pipeline::cull::similarity(&me, &v); + if sim >= min { + hits.push((q, sim)); + } + } + } + hits.sort_by(|a, b| b.1.total_cmp(&a.1)); + if p.get("filter").and_then(Value::as_bool).unwrap_or(true) { + s.filter.only = hits.iter().map(|h| h.0).collect(); + } + Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::>()})) +} + pub fn specs() -> Vec { - vec![cmd!( - "photo.analyze", - "Assisted Culling", - [], - None, - "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}", - always, - analyze - )] + vec![ + cmd!( + "library.findSimilar", + "Find Similar Photos", + ["Photo"], + None, + "{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}", + always, + find_similar + ), + cmd!( + "photo.analyze", + "Assisted Culling", + [], + None, + "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}", + always, + analyze + ), + ] } diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index c1bbb51d6..4d955ac5b 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -638,6 +638,80 @@ pub fn specs() -> Vec { Ok(json!({"changed": n})) } ), + cmd!( + "develop.quickAdjust", + "Quick Develop", + [], + None, + "{control: develop control id, delta, ids?} — add `delta` to the control on every target photo (each from its own value; Quick Develop) in one undo step → {changed}", + has_selection, + |s, p| { + let c = "develop.quickAdjust"; + let ctl = str_param(p, "control").ok_or_else(|| bad(c, "missing control"))?.to_string(); + let spec = controls::find(&ctl).ok_or_else(|| bad(c, format!("unknown control `{ctl}`")))?; + let delta = f64_req(p, "delta", c)?; + let label = format!("Quick Develop: {}", spec.label); + let ops: Vec = s + .targets(p) + .into_iter() + .filter_map(|id| s.develop_of(id).map(|d| (id, d))) + .filter_map(|(id, d)| { + let mut nd = (*d).clone(); + let v = controls::get(&nd, &ctl).unwrap_or(spec.default); + controls::set(&mut nd, &ctl, v + delta); + if ctl == "wb.temp" || ctl == "wb.tint" { + nd.wb.mode = WbMode::Custom; + } + s.develop_op(id, nd, &label) + }) + .collect(); + let n = ops.len(); + if n > 0 { + s.commit(&label, Op::Batch { ops })?; + } + Ok(json!({"changed": n})) + } + ), + cmd!( + "develop.matchExposure", + "Match Total Exposures", + ["Photo"], + None, + "{ids?} — set each selected photo's Exposure so its total exposure (shutter × ISO ÷ aperture², plus the slider) equals the active photo's; photos without exposure data are skipped → {changed, skipped}", + has_selection, + |s, p| { + let c = "develop.matchExposure"; + let reference = active(s, c)?; + // log2 of the light the photo gathered (shutter s × ISO / f-number²) + let gathered = |s: &Session, id: PhotoId| -> Option { + let m = &s.catalog.photo(id)?.meta; + let t = match m.shutter.split_once('/') { + Some((a, b)) => a.trim().parse::().ok()? / b.trim().parse::().ok()?, + None => m.shutter.trim().trim_end_matches('s').trim().parse::().ok()?, + }; + let (n, iso) = (m.aperture? as f64, m.iso? as f64); + (t > 0.0 && n > 0.0 && iso > 0.0).then(|| (t * iso / (n * n)).log2()) + }; + let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?; + let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0); + let ids: Vec = s.targets(p).into_iter().filter(|x| *x != reference).collect(); + let (mut ops, mut skipped) = (Vec::new(), Vec::new()); + for id in ids { + let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else { + skipped.push(id.0); + continue; + }; + let mut nd = (*d).clone(); + nd.light.exposure = (r_slider + r_ev - ev).clamp(-5.0, 5.0); + ops.extend(s.develop_op(id, nd, "Match Total Exposures")); + } + let n = ops.len(); + if n > 0 { + s.commit("Match Total Exposures", Op::Batch { ops })?; + } + Ok(json!({"changed": n, "skipped": skipped})) + } + ), cmd!( "develop.autoSync", "Auto Sync", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 026186e2f..6fd399c24 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -126,6 +126,8 @@ pub struct Session { pub filter_presets: Vec, /// Saved colour-label name sets. pub label_sets: Vec, + /// Look-alike signatures by content key (Find Similar). + pub signatures: std::collections::HashMap, /// Imported `.cube` LUT profiles. pub lut_profiles: Vec, /// The target album B adds to (`None` = the Quick Collection). @@ -193,6 +195,7 @@ impl Session { metadata_presets: Vec::new(), filter_presets: Vec::new(), label_sets: Vec::new(), + signatures: Default::default(), lut_profiles: Vec::new(), target_album: None, auto_import_seen: Default::default(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e9cee0675..b5d36fcf7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -586,3 +586,56 @@ fn leaving_an_edited_photo_keeps_an_auto_version() { let autos = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.iter().filter(|v| v.auto).count(); assert_eq!(autos, crate::AUTO_VERSIONS); } + +#[test] +fn match_total_exposures() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + // reference 1/100 f/4 ISO 100, slider +0.5; other 1/50 f/4 ISO 100 (one stop more light) + for (i, (sh, ap, iso)) in [("1/100", 4.0f32, 100u32), ("1/50", 4.0, 100), ("", 4.0, 100)].iter().enumerate() { + let mut m = s.catalog.photo(PhotoId(ids[i])).unwrap().meta.clone(); + m.shutter = sh.to_string(); + m.aperture = Some(*ap); + m.iso = Some(*iso); + s.commit("t", lightcraft_catalog::Op::SetMeta { id: PhotoId(ids[i]), meta: Box::new(m) }).unwrap(); + } + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.selection.active = Some(PhotoId(ids[0])); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap(); + let r = s.execute("develop.matchExposure", &json!({})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["skipped"].clone()), (Some(1), json!([ids[2]]))); + let e = s.develop_of(PhotoId(ids[1])).unwrap().light.exposure; + assert!((e - (-0.5)).abs() < 1e-9, "one stop more light → one stop less exposure: {e}"); +} + +#[test] +fn find_similar_filters_to_look_alikes() { + let mut s = demo(); + let id = s.catalog.photos().next().unwrap().id; + let r = s.execute("library.findSimilar", &json!({"id": id.0, "similarity": 0.5})).unwrap(); + let hits: Vec = r["photos"].as_array().unwrap().iter().map(|h| h["id"].as_u64().unwrap()).collect(); + assert_eq!(hits[0], id.0, "the photo itself is the closest"); + assert!(hits.len() < s.catalog.len(), "not everything looks alike"); + let vis = s.visible_cloned(); + assert!(!vis.is_empty() && vis.iter().all(|v| hits.contains(&v.0)), "the view shows only them"); + s.execute("library.clearFilter", &json!({})).unwrap(); + assert!(s.visible_cloned().len() > vis.len()); +} + +#[test] +fn quick_develop_adds_to_each_photo() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap(); + s.execute("library.select", &json!({"ids": ids})).unwrap(); + let before: Vec = ids.iter().map(|i| s.develop_of(PhotoId(*i)).unwrap().light.exposure).collect(); + s.execute("develop.quickAdjust", &json!({"control": "light.exposure", "delta": 1.0 / 3.0})).unwrap(); + for (i, id) in ids.iter().enumerate() { + let e = s.develop_of(PhotoId(*id)).unwrap().light.exposure; + assert!((e - before[i] - 1.0 / 3.0).abs() < 0.02, "{e} vs {}", before[i]); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.develop_of(PhotoId(ids[0])).unwrap().light.exposure, before[0], "one undo step"); +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index b01d691a1..d3a1afdd0 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -102,6 +102,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); let n = app.session.selection.ids.len(); + if n > 1 && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { + quick_develop(app, ui, n); + } if app.session.auto_sync && n > 1 { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { ui.horizontal(|ui| { @@ -897,3 +900,37 @@ fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { p.add(mesh); p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); } + +/// Quick Develop (grid with several photos selected): relative steps applied to every selected +/// photo from its own value. +fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { + ui.label(egui::RichText::new(format!("Quick Develop · {n} photos")).color(t.text_label).size(12.5)); + ui.add_space(4.0); + let rows: [(&str, &str, f64, f64); 9] = [ + ("Exposure", "light.exposure", 1.0 / 3.0, 1.0), + ("Contrast", "light.contrast", 5.0, 20.0), + ("Highlights", "light.highlights", 5.0, 20.0), + ("Shadows", "light.shadows", 5.0, 20.0), + ("Whites", "light.whites", 5.0, 20.0), + ("Blacks", "light.blacks", 5.0, 20.0), + ("Clarity", "effects.clarity", 5.0, 20.0), + ("Vibrance", "color.vibrance", 5.0, 20.0), + ("Temp", "wb.temp", 100.0, 500.0), + ]; + egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { + for (label, ctl, small, big) in rows { + ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim)); + for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] { + let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0))); + crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect); + if r.on_hover_text(format!("{label} {d:+} on every selected photo")).clicked() { + let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d})); + } + } + ui.end_row(); + } + }); + }); +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index bd8e2c92a..115f6c77d 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -592,6 +592,16 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button("Open in Detail").clicked() { let _ = app.run("view.detail", json!({})); } + if ui.button("Find Similar Photos").clicked() { + match app.run("library.findSimilar", json!({"id": id.0})) { + Ok(r) => { + let n = r["photos"].as_array().map_or(0, Vec::len); + app.ui.view = crate::state::ViewMode::PhotoGrid; + app.toast(ui.ctx(), format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" })); + } + Err(e) => app.toast(ui.ctx(), e), + } + } if ui.button("Set as Reference Photo").clicked() { let _ = app.run("photo.setReference", json!({"id": id.0})); } diff --git a/docs/parity.md b/docs/parity.md index b40efe31e..63ec338a5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 80 | 1 | 4 | 8 | 46/47 (98%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 25 | 8 | 46 | 9 | — | 20/21 (95%) | -| **Total** | 378 | 25 | 97 | 37 | 193/198 (97%) | 136/144 (94%) | +| Lightroom Classic extras | 29 | 8 | 42 | 9 | — | 20/21 (95%) | +| **Total** | 382 | 25 | 93 | 37 | 193/198 (97%) | 136/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **78.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 35.8% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **78.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 38.3% of 158. ## Top gaps @@ -703,19 +703,19 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | | 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; metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings); no copyright status field | -| LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | | +| 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-COMMENTS | Comments panel | P2 | ⬜ | | | -| LRC-LIB-VISUALSEARCH | Find similar photos | 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` | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. No missing-photos collection | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | -| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ⬜ | | | +| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | | LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | | LRC-DEV-SOFTPROOF | Soft proofing | P2 | ⬜ | | | | LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | `cmd:develop.sync`, `cmd:develop.autoSync`, `cmd:develop.pastePrevious` | Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner | -| LRC-DEV-MATCHEXP | Match total exposures | P2 | ⬜ | | | +| LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | `cmd:develop.matchExposure` | Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's | | LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | | | LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | `cmd:develop.targeted` (`target`: curve / hue / sat / lum) | | | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets |