mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #2279: LR-LIB-CULL: adjustable burst gap and similarity in Assisted
# Conflicts: # docs/parity-checklist.md
This commit is contained in:
@@ -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<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
@@ -26,6 +28,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
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<i64>, f32, f32, [f32; 64])> = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
@@ -45,8 +48,8 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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
|
||||
@@ -86,6 +89,22 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<f64> {
|
||||
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<Value> {
|
||||
@@ -136,7 +155,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -1004,6 +1004,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<u64> = 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() {
|
||||
|
||||
@@ -1204,6 +1204,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",
|
||||
|
||||
@@ -1535,7 +1535,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
"photo.editInExternal" => 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" => {
|
||||
|
||||
@@ -335,7 +335,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();
|
||||
@@ -344,7 +344,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));
|
||||
@@ -354,6 +354,31 @@ 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);
|
||||
// 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)
|
||||
.clamping(egui::SliderClamping::Never)
|
||||
.text(crate::i18n::tr("Burst: how alike (loose … strict)"))
|
||||
// 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);
|
||||
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 => {
|
||||
@@ -1634,6 +1659,61 @@ 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<f64> {
|
||||
// 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();
|
||||
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::<f64>().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)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -1929,12 +2009,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
Dialog::DenoiseModel { info, accepted } => 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});
|
||||
if *reject_below > 0.0 {
|
||||
p["rejectBelow"] = json!(reject_below);
|
||||
}
|
||||
app.run("photo.analyze", p)
|
||||
Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => {
|
||||
app.ui.cull_burst = Some((*burst_gap, *similarity));
|
||||
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)
|
||||
@@ -2381,3 +2458,133 @@ fn only_shows(dlg: &Dialog) -> bool {
|
||||
| Dialog::ViewOptions
|
||||
) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date())
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
// 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)));
|
||||
}
|
||||
|
||||
#[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);
|
||||
// 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}");
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +813,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<String>,
|
||||
/// 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)>,
|
||||
@@ -1182,10 +1186,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 {
|
||||
@@ -1419,6 +1426,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(),
|
||||
|
||||
@@ -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” |
|
||||
@@ -822,6 +822,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-19 | patch | LR-LIB-STACK: the filmstrip's stack badge expands / collapses the stack like the grid's, and a badge click works on a stack that isn't selected (it is selected first). Statuses and feature counts unchanged. |
|
||||
| 2026-10-14 | minor | LR-VIEW-ZOOM: Visualize Spots, Point Color range and Visualize HDR render zoom windows; LRC-DEV-SOFTPROOF: output-space proofs on the GPU |
|
||||
| 2026-10-19 | patch | LR-LIB-RATING: a rating, flag or label that takes the active photo out of the view makes the photo after it active instead of leaving an unseen photo active (→ then went to the view's first photo), and Shift / Auto Advance doesn't step past it (#2312); status unchanged (✅) |
|
||||
| 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 (🟡) |
|
||||
|
||||
Reference in New Issue
Block a user