Merge PR #2279: LR-LIB-CULL: adjustable burst gap and similarity in Assisted

# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
francoisgn
2026-10-22 22:28:41 +03:00
7 changed files with 305 additions and 18 deletions
+26 -7
View File
@@ -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
),
+48
View File
@@ -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() {
+3
View File
@@ -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",
+2 -1
View File
@@ -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" => {
+215 -8
View File
@@ -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));
}
}
+9 -1
View File
@@ -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(),
+2 -1
View File
@@ -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 (🟡) |