From 7f2375458c8958b25dcb559f7ad478b5652f8ed0 Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:06:56 +0200 Subject: [PATCH 1/5] LR-LIB-CULL: adjustable burst gap and similarity in Assisted Culling (#2275) photo.analyze takes burstGap (seconds, 0..86400, default 10) and similarity (0..1, default 0.93); values out of range are refused before anything is measured. The Assisted Culling dialog gets both sliders and remembers them for the session. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/cull.rs | 33 +++++++++++++++---- crates/engine/src/tests_import.rs | 48 ++++++++++++++++++++++++++++ crates/ui-egui/locales/de.json | 3 ++ crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/panels/dialogs.rs | 19 +++++++++-- crates/ui-egui/src/state.rs | 10 +++++- docs/parity-checklist.md | 5 +-- 7 files changed, 107 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 030cb6e93..92a9c3b7c 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -9,10 +9,12 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, cmd}; use crate::{Result, Session}; -/// Seconds between shots of the same burst at most. -const BURST_GAP: i64 = 10; -/// Signature similarity for "the same scene". -const SAME: f32 = 0.93; +/// Seconds between shots of the same burst at most (default of `burstGap`). +const BURST_GAP: f64 = 10.0; +/// Signature similarity for "the same scene" (default of `similarity`). +const SAME: f64 = 0.93; +/// The longest `burstGap` accepted: a day. +const MAX_GAP: f64 = 86_400.0; fn secs(iso: &str) -> Option { let d = lightcraft_meta::DateTime::parse_iso(iso)?; @@ -23,6 +25,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result { let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); + let (gap, same) = burst_params(p)?; // measure (thumbnail-level sources: fast, and enough for focus at 512 px) let mut rows: Vec<(PhotoId, Option, f32, f32, [f32; 64])> = Vec::new(); let mut failed = Vec::new(); @@ -42,8 +45,8 @@ fn analyze(s: &mut Session, p: &Value) -> Result { let mut next = 1u32; for i in 1..rows.len() { let (a, b) = (&rows[i - 1], &rows[i]); - let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP); - if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME { + let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y.saturating_sub(x)).unsigned_abs() as f64 <= gap); + if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= same { let g = *group_of[i - 1].get_or_insert_with(|| { next += 1; next - 1 @@ -83,6 +86,22 @@ fn analyze(s: &mut Session, p: &Value) -> Result { Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed})) } +/// `burstGap` (seconds, 0..=a day) and `similarity` (0..=1) of `photo.analyze`, refused before +/// anything is measured when they are not numbers in range. +fn burst_params(p: &Value) -> Result<(f64, f32)> { + const C: &str = "photo.analyze"; + let num = |key: &str, d: f64, max: f64| -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(d), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (0.0..=max).contains(&x) => Ok(x), + _ => Err(super::bad(C, format!("`{key}` must be a number from 0 to {max}"))), + }, + } + }; + Ok((num("burstGap", BURST_GAP, MAX_GAP)?, num("similarity", SAME, 1.0)? as f32)) +} + /// 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 { @@ -133,7 +152,7 @@ pub fn specs() -> Vec { "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}", + "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool, burstGap?: seconds 0..86400 (10), similarity?: 0..1 (0.93)} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots (signature similarity at least `similarity`) taken at most `burstGap` seconds apart 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/tests_import.rs b/crates/engine/src/tests_import.rs index 13f02ce77..f581f11dd 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -822,6 +822,54 @@ fn assisted_culling_groups_bursts_across_month_end() { let _ = std::fs::remove_dir_all(&dir); } +/// Assisted culling: `burstGap` and `similarity` move the burst limits (#2275), and values out of +/// range are refused before anything is measured or stored. +#[test] +fn assisted_culling_burst_limits_are_adjustable() { + let dir = temp_dir("cull-limits"); + for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] { + let img = lightcraft_scenes::demo_library()[0].render(w, h); + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join(name), png).unwrap(); + } + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png")); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()) }).unwrap(); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()) }).unwrap(); + // 15 s apart: past the default 10 s, inside a 20 s gap + assert_eq!(s.execute("photo.analyze", &json!({"ids": ids})).unwrap()["groups"], 0); + assert_eq!(s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20})).unwrap()["groups"], 1); + // the look-alike pair is a burst at the default similarity, not when it must be identical + let strict = s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20, "similarity": 1.0})).unwrap(); + assert_eq!(strict["groups"], 0, "{strict}"); + // bad values change nothing + let undo_before = s.commits(); + for bad in + [json!({"burstGap": -1}), json!({"burstGap": 86401}), json!({"burstGap": "10"}), json!({"similarity": 1.5}), json!({"similarity": -0.1})] + { + let mut p = bad.clone(); + p["ids"] = json!(ids); + assert!(s.execute("photo.analyze", &p).is_err(), "{bad} must be refused"); + } + assert_eq!(s.commits(), undo_before); + let _ = std::fs::remove_dir_all(&dir); +} + /// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). #[test] fn dust_spots_are_found_and_healed() { diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 000aea2d4..9d2d75680 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1205,6 +1205,9 @@ "Refresh View": "Ansicht aktualisieren", "Reject": "Ablehnen", "Reject below focus": "Unter Schärfegrenzwert ablehnen", + "Burst: shots at most this far apart": "Serie: Aufnahmen höchstens so weit auseinander", + "Burst: how alike (loose … strict)": "Serie: wie ähnlich (locker … streng)", + "Lower it when a panning or moving burst splits into several groups.": "Senken, wenn eine Mitzieher- oder Bewegungsserie in mehrere Gruppen zerfällt.", "Rejected": "Abgelehnt", "Rejects": "Abgelehnte Fotos", "Reliability": "Zuverlässigkeit", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 743bd4600..229b34dc6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1443,7 +1443,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option edit_in(app, &ctx, p, false), "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), "dialog.cull" => { - app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); + let (burst_gap, similarity) = app.ui.cull_burst.unwrap_or((10.0, 0.93)); + app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity }); Ok(Value::Null) } "app.whatsNew" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 5562e94d1..621492ecb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -336,7 +336,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::RichText::new(crate::i18n::tr_format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"], scope = crate::i18n::tr(scope))).color(t.text_dim), ); } - Dialog::Cull { reject_below, pick_best } => { + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { let n = app.session.selection.ids.len(); let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) }; ui.label(egui::RichText::new(crate::i18n::tr_format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", scope = scope)).color(t.text_label)); @@ -346,6 +346,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." })).color(t.text_dim)); let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); crate::widgets::register(ui.ctx(), "check:cullPick", r.rect); + ui.add_space(6.0); + let r = ui.add( + egui::Slider::new(burst_gap, 1.0..=600.0) + .logarithmic(true) + .text(crate::i18n::tr("Burst: shots at most this far apart")) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(|s| s.trim().trim_end_matches('s').trim().parse().ok()), + ); + crate::widgets::register(ui.ctx(), "field:cullGap", r.rect); + let r = ui.add(egui::Slider::new(similarity, 0.70..=0.99).text(crate::i18n::tr("Burst: how alike (loose … strict)")).step_by(0.01).fixed_decimals(2)); + crate::widgets::register(ui.ctx(), "field:cullSimilar", r.rect); + ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim)); ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); } Dialog::WhatsNew => { @@ -1870,8 +1882,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::panels::denoise::install(app, info, *accepted), Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), - Dialog::Cull { reject_below, pick_best } => { - let mut p = json!({"pickBest": pick_best}); + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { + app.ui.cull_burst = Some((*burst_gap, *similarity)); + let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); if *reject_below > 0.0 { p["rejectBelow"] = json!(reject_below); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f8c8cc0d4..3388bad3b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -728,6 +728,10 @@ pub struct UiState { /// The last person page left: the People view offers a way back to it next to its title. #[serde(skip)] pub last_person: Option, + /// Assisted Culling's burst settings last confirmed (gap in seconds, similarity), for this + /// session; `None` until the dialog is first confirmed (then 10 s, 0.93). + #[serde(skip)] + pub cull_burst: Option<(f32, f32)>, /// "More" faces the user hid with ×, for this session: (photo, region index). #[serde(skip)] pub dismissed_faces: std::collections::HashSet<(u64, usize)>, @@ -1089,10 +1093,13 @@ pub enum Dialog { /// Help ▸ What's New. WhatsNew, /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best - /// of each burst. + /// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are + /// a burst. Cull { reject_below: f32, pick_best: bool, + burst_gap: f32, + similarity: f32, }, /// Help ▸ System Info: (label, value) rows. DenoiseModel { @@ -1313,6 +1320,7 @@ impl Default for UiState { person_page: None, person_from: None, last_person: None, + cull_burst: None, dismissed_faces: Default::default(), unnamed_selected: Default::default(), unnamed_name: String::new(), diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..9cce61b9a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** patch (LR-LIB-CULL: adjustable burst limits) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -116,7 +116,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | -| LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | +| LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s, both limits adjustable in the dialog and as `burstGap` / `similarity`, #2275) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | | LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | `cmd:album.toggleTarget`, `cmd:album.clearQuick` | B in the grids | | LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `cmd:photo.delete`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; deleting a copy dissolves or renumbers the stack (#1493); no “Set Copy as Master” | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-19 | patch | LR-LIB-CULL: Assisted Culling's burst gap and similarity are adjustable (dialog, `photo.analyze` `burstGap` / `similarity`; #2275). Statuses and feature counts unchanged. | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | From b8c41ecc4e46e012146da4a091072a1eb32adf49 Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:05:51 +0200 Subject: [PATCH 2/5] LR-LIB-CULL: gap sliders read back the text they show (#2275) The Assisted Culling and Auto-Stack gap sliders showed "1 min 30 s" but their parser only took plain seconds. parse_gap reads s / min / h / day units (and plain numbers) for both. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 56 +++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 621492ecb..88770884d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -327,7 +327,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .logarithmic(true) .smallest_positive(1.0) .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()), + .custom_parser(crate::panels::dialogs::parse_gap), ); crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); @@ -352,7 +352,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .logarithmic(true) .text(crate::i18n::tr("Burst: shots at most this far apart")) .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(|s| s.trim().trim_end_matches('s').trim().parse().ok()), + .custom_parser(crate::panels::dialogs::parse_gap), ); crate::widgets::register(ui.ctx(), "field:cullGap", r.rect); let r = ui.add(egui::Slider::new(similarity, 0.70..=0.99).text(crate::i18n::tr("Burst: how alike (loose … strict)")).step_by(0.01).fixed_decimals(2)); @@ -1585,6 +1585,38 @@ fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: i } /// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` +/// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). +pub fn parse_gap(text: &str) -> Option { + let mut total = 0.0; + let mut seen = false; + let mut words = text.split_whitespace().peekable(); + while let Some(word) = words.next() { + // "90s" as well as "90 s" + let split = word.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(word.len()); + let (num, unit) = word.split_at(split); + let n: f64 = num.parse().ok().filter(|n: &f64| n.is_finite())?; + let unit = if unit.is_empty() { + match words.peek() { + Some(u) if u.parse::().is_err() => words.next().unwrap_or_default(), + _ => "s", + } + } else { + unit + }; + let scale = match unit.trim_end_matches('.').to_lowercase().as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => 1.0, + "m" | "min" | "mins" | "minute" | "minutes" => 60.0, + "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0, + "d" | "day" | "days" => 86400.0, + _ => return None, + }; + total += n * scale; + seen = true; + } + seen.then_some(total) +} + /// `90` → `1 min 30 s`. pub fn fmt_gap(v: f64) -> String { let v = v.round() as u64; @@ -2330,3 +2362,23 @@ fn only_shows(dlg: &Dialog) -> bool { | Dialog::ManageEffects { .. } ) } + +#[cfg(test)] +mod gap_tests { + use super::{fmt_gap, parse_gap}; + + #[test] + fn gap_text_reads_back_what_it_shows() { + for v in [0.0, 1.0, 12.0, 59.0, 60.0, 90.0, 600.0, 3599.0, 3600.0, 3900.0, 86340.0] { + // past an hour the text shows whole minutes + assert_eq!(parse_gap(&fmt_gap(v)), Some(v), "{}", fmt_gap(v)); + } + assert_eq!(parse_gap("1 day"), Some(86400.0)); + assert_eq!(parse_gap("45"), Some(45.0)); + assert_eq!(parse_gap(" 45s "), Some(45.0)); + assert_eq!(parse_gap("2min 5s"), Some(125.0)); + for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { + assert_eq!(parse_gap(bad), None, "{bad:?}"); + } + } +} From 23feaa476093c174586ca050066ef227789196a6 Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:33:20 +0200 Subject: [PATCH 3/5] LR-LIB-CULL: gap sliders read back a translated "1 day" (#2275) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 88770884d..26e198133 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1588,6 +1588,10 @@ fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: i /// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` /// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). pub fn parse_gap(text: &str) -> Option { + // the one translated text `fmt_gap` writes ("1 Tag") + if text.trim() == crate::i18n::tr("1 day") { + return Some(86400.0); + } let mut total = 0.0; let mut seen = false; let mut words = text.split_whitespace().peekable(); @@ -2380,5 +2384,11 @@ mod gap_tests { for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { assert_eq!(parse_gap(bad), None, "{bad:?}"); } + // a day reads back in the UI language too ("1 Tag") + crate::i18n::set_language(crate::i18n::Locale::De); + let shown = fmt_gap(86400.0); + let back = parse_gap(&shown); + crate::i18n::set_language(crate::i18n::Locale::En); + assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); } } From 6f056745e1d70d086360a4990f40347db52a0e8c Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:20:54 +0200 Subject: [PATCH 4/5] LR-LIB-CULL: the culling dialog's value fields take any burst gap and similarity photo.analyze accepts (#2275) The burst-gap and similarity sliders still drag over 1 s-10 min and 0.70-0.99, but their value fields no longer clamp to that: a typed gap of 0 s to a day ("1800", "30 min", "1 day") and a similarity of 0 to 1.0 are kept and sent to photo.analyze as typed; values past the API range are held at its limits. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 135 +++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index ae399e64d..d90e3285a 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -348,16 +348,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); crate::widgets::register(ui.ctx(), "check:cullPick", r.rect); ui.add_space(6.0); + // the sliders drag over the usual range; their value fields take whatever + // `photo.analyze` accepts (a gap of 0 s to a day, a similarity of 0 to 1) let r = ui.add( egui::Slider::new(burst_gap, 1.0..=600.0) .logarithmic(true) + .smallest_positive(1.0) + .clamping(egui::SliderClamping::Never) .text(crate::i18n::tr("Burst: shots at most this far apart")) .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) .custom_parser(crate::panels::dialogs::parse_gap), ); crate::widgets::register(ui.ctx(), "field:cullGap", r.rect); - let r = ui.add(egui::Slider::new(similarity, 0.70..=0.99).text(crate::i18n::tr("Burst: how alike (loose … strict)")).step_by(0.01).fixed_decimals(2)); + let r = ui.add( + egui::Slider::new(similarity, 0.70..=0.99) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: how alike (loose … strict)")) + .step_by(0.01) + .fixed_decimals(2), + ); crate::widgets::register(ui.ctx(), "field:cullSimilar", r.rect); + clamp_cull_burst(burst_gap, similarity); ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim)); ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); } @@ -1624,6 +1635,25 @@ pub fn parse_gap(text: &str) -> Option { seen.then_some(total) } +/// The longest burst gap `photo.analyze` accepts: a day. +pub const MAX_BURST_GAP: f32 = 86_400.0; + +/// Keep what was typed in the Assisted Culling value fields within what `photo.analyze` accepts +/// (a gap of 0 s to a day, a similarity of 0 to 1), not within the sliders' drag range. +pub fn clamp_cull_burst(gap: &mut f32, similarity: &mut f32) { + *gap = if gap.is_finite() { gap.clamp(0.0, MAX_BURST_GAP) } else { 10.0 }; + *similarity = if similarity.is_finite() { similarity.clamp(0.0, 1.0) } else { 0.93 }; +} + +/// The `photo.analyze` parameters of the Assisted Culling dialog, its values as they are. +pub fn cull_params(reject_below: f32, pick_best: bool, burst_gap: f32, similarity: f32) -> serde_json::Value { + let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); + if reject_below > 0.0 { + p["rejectBelow"] = json!(reject_below); + } + p +} + /// `90` → `1 min 30 s`. pub fn fmt_gap(v: f64) -> String { let v = v.round() as u64; @@ -1923,11 +1953,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result Ok(serde_json::Value::Null), Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { app.ui.cull_burst = Some((*burst_gap, *similarity)); - let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); - if *reject_below > 0.0 { - p["rejectBelow"] = json!(reject_below); - } - app.run("photo.analyze", p) + app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity)) } Dialog::SmartRules { id, name, rules, parent } => { // refused before anything changes (not even the name) @@ -2399,4 +2425,101 @@ mod gap_tests { crate::i18n::set_language(crate::i18n::Locale::En); assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); } + + #[test] + fn gaps_past_the_slider_read_back_too() { + // the Assisted Culling slider drags to 10 min; its value field takes up to a day + for (v, text) in [(1800.0, "30 min"), (5400.0, "1 h 30 min"), (86400.0, "1 day")] { + assert_eq!(fmt_gap(v), text); + assert_eq!(parse_gap(text), Some(v), "{text}"); + } + assert_eq!(parse_gap("1800"), Some(1800.0)); + assert_eq!(parse_gap("0"), Some(0.0)); + } +} + +#[cfg(test)] +mod cull_dialog_tests { + use std::time::Duration; + + use serde_json::json; + + use super::{clamp_cull_burst, cull_params}; + use crate::headless::Headless; + use crate::state::Dialog; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + #[test] + fn typed_values_are_kept_within_what_photo_analyze_accepts() { + for (gap, sim) in [(0.0, 0.0), (1800.0, 1.0), (86400.0, 0.5), (0.5, 0.995)] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), (gap, sim), "in range: kept as typed"); + } + for ((gap, sim), want) in [((-5.0, -0.1), (0.0, 0.0)), ((90000.0, 1.5), (86400.0, 1.0)), ((f32::NAN, f32::INFINITY), (10.0, 0.93))] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), want); + } + assert_eq!(cull_params(0.0, true, 1800.0, 1.0), json!({"pickBest": true, "burstGap": 1800.0, "similarity": 1.0})); + assert_eq!(cull_params(20.0, false, 0.0, 0.7)["rejectBelow"], 20.0); + } + + /// Click the value field right of a slider registered as `id`, type `text`, Enter. + fn type_value(h: &mut Headless, id: &str, text: &str) { + let r = h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")); + let spacing = h.view.ctx.global_style().spacing.clone(); + let x = r.left() + spacing.slider_width + spacing.item_spacing.x + 6.0; + assert_eq!(h.request("ui.click", json!({"x": x, "y": r.center().y}), T)["ok"], true); + h.step(); + for key in ["a", "Backspace"] { + assert_eq!(h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T)["ok"], true); + } + assert_eq!(h.request("ui.text", json!({"text": text}), T)["ok"], true); + assert_eq!(h.request("ui.key", json!({"key": "Enter"}), T)["ok"], true); + h.step(); + h.step(); + } + + fn burst(h: &Headless) -> (f32, f32) { + match &h.app.ui.dialog { + Some(Dialog::Cull { burst_gap, similarity, .. }) => (*burst_gap, *similarity), + d => panic!("{d:?}"), + } + } + + #[test] + fn the_culling_value_fields_take_values_past_the_sliders() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.cull", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + assert_eq!(burst(&h), (10.0, 0.93)); + // half an hour and "only identical shots": past the sliders' 10 min and 0.99 + type_value(&mut h, "field:cullGap", "30 min"); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h), (1800.0, 1.0)); + // a day is as far as photo.analyze goes; more is held there + type_value(&mut h, "field:cullGap", "2 days"); + assert_eq!(burst(&h).0, 86400.0); + type_value(&mut h, "field:cullGap", "0"); + assert_eq!(burst(&h), (0.0, 1.0)); + type_value(&mut h, "field:cullGap", "1800"); + assert_eq!(burst(&h).0, 1800.0); + // OK hands them to photo.analyze as typed (it refuses values out of its range) + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert_eq!(h.app.ui.cull_burst, Some((1800.0, 1.0))); + // and the next opening starts from them, not from the sliders' range + h.app.run("dialog.cull", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(burst(&h), (1800.0, 1.0)); + } } From 6b3cc777c147c2cef960c2ed897e268af21e02db Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:49:20 +0200 Subject: [PATCH 5/5] LR-LIB-CULL: a typed similarity keeps its thousandths (#2275) The similarity slider stepped and showed whole hundredths, and egui applies both to a typed value too, so 0.995 became 1.00 before photo.analyze saw it. It now steps by a thousandth and shows two or three decimals: dragging still lands on round values, and a typed 0.995 is kept. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d90e3285a..205e9d1c6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -364,8 +364,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Slider::new(similarity, 0.70..=0.99) .clamping(egui::SliderClamping::Never) .text(crate::i18n::tr("Burst: how alike (loose … strict)")) - .step_by(0.01) - .fixed_decimals(2), + // a typed 0.995 is kept (whole hundredths would round it to 1.00) + .step_by(0.001) + .min_decimals(2) + .max_decimals(3), ); crate::widgets::register(ui.ctx(), "field:cullSimilar", r.rect); clamp_cull_burst(burst_gap, similarity); @@ -2511,6 +2513,13 @@ mod cull_dialog_tests { assert_eq!(burst(&h), (0.0, 1.0)); type_value(&mut h, "field:cullGap", "1800"); assert_eq!(burst(&h).0, 1800.0); + // a similarity between the slider's hundredths is kept to the thousandth + type_value(&mut h, "field:cullSimilar", "0.995"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "0.9951"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h).1, 1.0); // OK hands them to photo.analyze as typed (it refuses values out of its range) let r = h.request("ui.dialog.confirm", json!({}), T); assert_eq!(r["ok"], true, "{r}");