LR-LIB-CULL: assisted culling — focus scores, bursts, best-of-burst

lightcraft-pipeline cull.rs: focus (Laplacian energy of the sharpest 16-px blocks at 512 px,
0–100, calibrated on the CC0 corpus), clipping share and an 8×8 look-alike signature.
photo.analyze scores the selection (else the view), groups look-alike shots taken within 10 s
into bursts and marks each burst's sharpest; optionally rejects photos below a focus score and
picks each burst's best (one undo step). Scores live on the photo (Analysis, Op::SetAnalysis):
rule fields Focus and Best of Similar Shots, Info panel line. Photo ▸ Assisted Culling….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 02:58:52 -04:00
co-authored by Claude Opus 5.5
parent 1b4f623e55
commit 26b00014a8
13 changed files with 390 additions and 8 deletions
+9
View File
@@ -149,6 +149,11 @@ pub enum Op {
id: PhotoId,
captured: Option<String>,
},
/// Assisted culling scores.
SetAnalysis {
id: PhotoId,
analysis: Option<crate::Analysis>,
},
/// Rename a photo: its file name and the source it points to. Applying the op never touches
/// the disk — the engine moves the file before it commits (and on undo/redo).
SetFile {
@@ -490,6 +495,10 @@ impl Catalog {
s.collapsed = collapsed;
old
}
Op::SetAnalysis { id, analysis } => {
let p = self.photo_mut(id)?;
Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
}
Op::SetCaptured { id, captured } => {
if let Some(c) = &captured
&& stacks::iso_seconds(c).is_none()
+17
View File
@@ -197,6 +197,22 @@ pub struct Photo {
/// unedited, and Reset returns to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_look: Option<Arc<DevelopSettings>>,
/// Assisted culling scores (`None` until analysed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<Analysis>,
}
/// What assisted culling measured on a photo.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Analysis {
/// Focus: 0 (blurred) .. 100 (crisp), from the detail in the photo's sharpest area.
pub sharpness: f32,
/// Exposure: share of clipped pixels, 0..1 (shadows + highlights).
pub clipped: f32,
/// Similar-shot group (burst), when the photo has neighbours that look alike.
pub group: Option<u32>,
/// The best photo of its group.
pub best: bool,
}
impl Photo {
@@ -229,6 +245,7 @@ impl Photo {
copy_of: None,
copy_name: None,
import_look: None,
analysis: None,
}
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
+4
View File
@@ -80,6 +80,8 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
("hasGps", "Has GPS", Kind::Bool),
("virtualCopy", "Virtual Copy", Kind::Bool),
("album", "Album", Kind::Number),
("sharpness", "Focus (assisted culling)", Kind::Number),
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
];
/// The operators for a field kind: (id, label).
@@ -316,6 +318,8 @@ impl Rule {
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
"megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
"bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true),
"album" => {
let id = number(value).map(|v| crate::AlbumId(v as u64));
id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is")
+96
View File
@@ -0,0 +1,96 @@
//! Assisted culling: score photos for focus and clipping, group similar shots taken close
//! together (bursts) and mark the sharpest of each group; optionally reject blurry ones and pick
//! each group's best. Scores are kept on the photos (rules / smart albums: `sharpness`,
//! `bestOfGroup`).
use lightcraft_catalog::{Analysis, Flag, Op, PhotoId};
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;
fn secs(iso: &str) -> Option<i64> {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = 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);
// 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();
for id in ids {
match s.source_now(id, crate::media::SourceLevel::Thumb) {
Ok(src) => {
let t = s.catalog.photo(id).and_then(|p| p.captured.as_deref().and_then(secs));
use lightcraft_pipeline::cull;
rows.push((id, t, cull::sharpness(&src), cull::clipped(&src), cull::signature(&src)));
}
Err(e) => failed.push(json!([id.0, e])),
}
}
// bursts: consecutive (by capture time) look-alike shots
rows.sort_by_key(|r| (r.1.unwrap_or(i64::MAX), r.0));
let mut group_of: Vec<Option<u32>> = vec![None; rows.len()];
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 g = *group_of[i - 1].get_or_insert_with(|| {
next += 1;
next - 1
});
group_of[i] = Some(g);
}
}
let mut best: std::collections::HashMap<u32, (usize, f32)> = Default::default();
for (i, g) in group_of.iter().enumerate() {
if let Some(g) = g {
let e = best.entry(*g).or_insert((i, -1.0));
if rows[i].2 > e.1 {
*e = (i, rows[i].2);
}
}
}
let mut ops = Vec::new();
let (mut rejected, mut picked) = (0, 0);
let mut out = Vec::new();
for (i, (id, _, sharp, clip, _)) in rows.iter().enumerate() {
let g = group_of[i];
let is_best = g.is_some_and(|g| best.get(&g).is_some_and(|b| b.0 == i));
ops.push(Op::SetAnalysis { id: *id, analysis: Some(Analysis { sharpness: *sharp, clipped: *clip, group: g, best: is_best }) });
let flag = s.catalog.photo(*id).map(|p| p.flag).unwrap_or_default();
if reject_below.is_some_and(|t| *sharp < t) && flag != Flag::Reject {
ops.push(Op::SetFlag { id: *id, flag: Flag::Reject });
rejected += 1;
} else if pick_best && is_best && flag == Flag::None {
ops.push(Op::SetFlag { id: *id, flag: Flag::Pick });
picked += 1;
}
out.push(json!({"id": id.0, "sharpness": (sharp * 10.0).round() / 10.0, "clipped": clip, "group": g, "best": is_best}));
}
if !ops.is_empty() {
s.commit("Assisted Culling", Op::Batch { ops })?;
}
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
}
pub fn specs() -> Vec<CommandSpec> {
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
)]
}
+2
View File
@@ -8,6 +8,7 @@ mod before;
mod browse;
mod color;
pub(crate) mod convert;
mod cull;
mod develop;
mod edit;
mod export;
@@ -123,6 +124,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(manage::specs());
v.extend(previews::specs());
v.extend(lut_profiles::specs());
v.extend(cull::specs());
v.extend(convert::specs());
v.extend(convert::edit_specs());
v.extend(merge::specs());
+73
View File
@@ -409,3 +409,76 @@ fn folders_rename_and_move_with_their_photos() {
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
let _ = std::fs::remove_dir_all(&root);
}
/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an
/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected.
#[test]
fn assisted_culling_groups_bursts_and_scores_focus() {
let dir = temp_dir("cull");
let scene = lightcraft_scenes::demo_library()[0].render(480, 320);
let soften = |img: &lightcraft_raster::Rgb32f, r: usize| {
let (w, h) = (img.width, img.height);
let mut out = img.clone();
for y in 0..h {
for x in 0..w {
let (mut acc, mut n) = ([0f32; 3], 0.0);
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
let c = img.data[yy * w + xx];
acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]];
n += 1.0;
}
}
out.data[y * w + x] = acc.map(|v| v / n);
}
}
out
};
let save = |img: &lightcraft_raster::Rgb32f, name: &str| {
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();
};
save(&soften(&scene, 3), "a_soft.png");
save(&scene, "b_sharp.png");
save(&soften(&scene, 6), "c_softer.png");
save(&lightcraft_scenes::demo_library()[7].render(480, 320), "d_other.png");
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;
for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() {
let id = by_name(&s, n);
s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap();
}
let other = by_name(&s, "d_other.png");
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap();
let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap();
assert_eq!(r["groups"], 1, "{r}");
let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap();
let (soft, sharp, softer, odd) = (a(&s, "a_soft.png"), a(&s, "b_sharp.png"), a(&s, "c_softer.png"), a(&s, "d_other.png"));
eprintln!("sharpness: sharp {} soft {} softer {} other {}", sharp.sharpness, soft.sharpness, softer.sharpness, odd.sharpness);
assert!(sharp.sharpness > soft.sharpness && soft.sharpness > softer.sharpness);
assert!(sharp.best && !soft.best && sharp.group.is_some() && sharp.group == softer.group);
assert!(odd.group.is_none(), "an unrelated photo isn't in the burst");
assert_eq!(s.catalog.photo(by_name(&s, "b_sharp.png")).unwrap().flag, lightcraft_catalog::Flag::Pick);
// reject the blurry ones (by score), as a rule too
let cut = (soft.sharpness + sharp.sharpness) / 2.0;
s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": cut})).unwrap();
assert_eq!(s.catalog.photo(by_name(&s, "c_softer.png")).unwrap().flag, lightcraft_catalog::Flag::Reject);
s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "bestOfGroup", "op": "is", "value": true}]}})).unwrap();
let vis = s.visible_cloned();
assert!(vis.contains(&by_name(&s, "b_sharp.png")) && !vis.contains(&by_name(&s, "a_soft.png")) && vis.contains(&other));
let _ = std::fs::remove_dir_all(&dir);
}
+138
View File
@@ -0,0 +1,138 @@
//! Assisted culling measurements on a decoded source (any size; measured on a fixed-size copy so
//! scores compare across photos): focus, clipping, and a tiny signature for finding similar
//! shots (bursts). Classical image statistics — no learned models.
use lightcraft_raster::Rgb32f;
/// Long edge the measurements run at.
const EDGE: usize = 512;
/// Display-ish luminance (sRGB-encoded) of `img` resampled to fit `EDGE` (box filter).
fn luma(img: &Rgb32f) -> (Vec<f32>, usize, usize) {
let (w, h) = (img.width.max(1), img.height.max(1));
let s = (EDGE as f64 / w.max(h) as f64).min(1.0);
let (ow, oh) = (((w as f64 * s).round() as usize).max(8), ((h as f64 * s).round() as usize).max(8));
let mut out = vec![0f32; ow * oh];
for (y, row) in out.chunks_mut(ow).enumerate() {
let (y0, y1) = (y * h / oh, ((y + 1) * h / oh).max(y * h / oh + 1).min(h));
for (x, v) in row.iter_mut().enumerate() {
let (x0, x1) = (x * w / ow, ((x + 1) * w / ow).max(x * w / ow + 1).min(w));
let mut acc = 0.0f32;
for yy in y0..y1 {
for xx in x0..x1 {
let c = img.data[yy * w + xx];
acc += 0.2627 * c[0] + 0.678 * c[1] + 0.0593 * c[2];
}
}
let l = acc / ((y1 - y0) * (x1 - x0)) as f32;
*v = lightcraft_color::transfer::linear_to_srgb(l.clamp(0.0, 1.0));
}
}
(out, ow, oh)
}
/// Focus 0..100: detail energy (Laplacian) of the sharpest areas — the 90th percentile of
/// 16 × 16-pixel blocks, so a sharp subject on a soft background still reads as sharp.
pub fn sharpness(img: &Rgb32f) -> f32 {
let (l, w, h) = luma(img);
let b = 16;
let mut blocks = Vec::new();
for by in (1..h.saturating_sub(1)).step_by(b) {
for bx in (1..w.saturating_sub(1)).step_by(b) {
let mut e = 0.0f32;
let mut n = 0;
for y in by..(by + b).min(h - 1) {
for x in bx..(bx + b).min(w - 1) {
let c = l[y * w + x];
let lap = 4.0 * c - l[y * w + x - 1] - l[y * w + x + 1] - l[(y - 1) * w + x] - l[(y + 1) * w + x];
e += lap * lap;
n += 1;
}
}
if n > 0 {
blocks.push(e / n as f32);
}
}
}
if blocks.is_empty() {
return 0.0;
}
let k = (blocks.len() as f32 * 0.9) as usize;
let k = k.min(blocks.len() - 1);
let (_, v, _) = blocks.select_nth_unstable_by(k, f32::total_cmp);
// map energy to 0..100: a crisp, ordinary photo at 512 px reads ≈ 50–80 (calibrated on the
// CC0 corpus), soft ones well below
100.0 * (1.0 - (-*v / 0.012).exp())
}
/// Share of clipped pixels (crushed blacks + blown highlights), 0..1.
pub fn clipped(img: &Rgb32f) -> f32 {
let n = img.data.len().max(1);
let c = img.data.iter().filter(|p| p.iter().any(|v| *v >= 0.985) || p.iter().all(|v| *v <= 0.002)).count();
c as f32 / n as f32
}
/// A tiny look-alike signature: 8 × 8 luminance, normalised to zero mean and unit norm.
pub fn signature(img: &Rgb32f) -> [f32; 64] {
let (l, w, h) = luma(img);
let mut s = [0f32; 64];
for (i, v) in s.iter_mut().enumerate() {
let (cx, cy) = (i % 8, i / 8);
let (x0, x1) = (cx * w / 8, (cx + 1) * w / 8);
let (y0, y1) = (cy * h / 8, (cy + 1) * h / 8);
let mut acc = 0.0;
for y in y0..y1 {
for x in x0..x1 {
acc += l[y * w + x];
}
}
*v = acc / ((x1 - x0) * (y1 - y0)).max(1) as f32;
}
let mean = s.iter().sum::<f32>() / 64.0;
s.iter_mut().for_each(|v| *v -= mean);
let norm = s.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-6);
s.iter_mut().for_each(|v| *v /= norm);
s
}
/// Similarity of two signatures: 1 = the same picture, 0 = unrelated.
pub fn similarity(a: &[f32; 64], b: &[f32; 64]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>().clamp(-1.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn blur(img: &Rgb32f, r: usize) -> Rgb32f {
let (w, h) = (img.width, img.height);
let mut out = img.clone();
for y in 0..h {
for x in 0..w {
let mut acc = [0f32; 3];
let mut n = 0.0;
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
let c = img.data[yy * w + xx];
acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]];
n += 1.0;
}
}
out.data[y * w + x] = acc.map(|v| v / n);
}
}
out
}
#[test]
fn blur_lowers_focus_and_signatures_match() {
let img = lightcraft_scenes::demo_library()[0].render(480, 320);
let soft = blur(&img, 4);
let (a, b) = (sharpness(&img), sharpness(&soft));
assert!(a > b + 10.0, "sharp {a} vs blurred {b}");
assert!(similarity(&signature(&img), &signature(&soft)) > 0.95, "the same picture");
let other = lightcraft_scenes::demo_library()[5].render(480, 320);
assert!(similarity(&signature(&img), &signature(&other)) < 0.9);
assert!(clipped(&img) < 0.5);
}
}
+1
View File
@@ -24,6 +24,7 @@
pub mod auto;
pub mod colorops;
pub mod cull;
pub mod finish;
pub mod geometry;
pub mod local;
+5
View File
@@ -115,6 +115,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("app.about", "About LightCraft", None, "Help"),
("app.systemInfo", "System Info…", None, "Help"),
("app.whatsNew", "What's New", None, "Help"),
("dialog.cull", "Assisted Culling…", None, "Photo"),
("app.help", "LightCraft Help", Some("F1"), "Help"),
("app.discord", "Join the ArtCraft Discord…", None, "Help"),
("app.feedback", "Send Feedback…", None, "Help"),
@@ -731,6 +732,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.toast(&ctx, format!("{name} opened for editing; it is stacked with the original"));
Ok(r)
}
"dialog.cull" => {
app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true });
Ok(Value::Null)
}
"app.whatsNew" => {
app.ui.dialog = Some(Dialog::WhatsNew);
Ok(json!({"text": crate::panels::dialogs::WHATS_NEW}))
+20
View File
@@ -36,6 +36,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::AllMetadata { .. } => "All Metadata",
Dialog::SystemInfo { .. } => "System Info",
Dialog::WhatsNew => "What's New",
Dialog::Cull { .. } => "Assisted Culling",
Dialog::SmartRules { id: None, .. } => "New Smart Album",
Dialog::SmartRules { .. } => "Edit Smart Album",
Dialog::AutoStack { .. } => "Auto-Stack by Capture Time",
@@ -80,6 +81,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim),
);
}
Dialog::Cull { reject_below, pick_best } => {
let n = app.session.selection.ids.len();
let scope = if n > 1 { format!("the {n} selected photos") } else { format!("the {} photos in view", app.session.visible_cloned().len()) };
ui.label(egui::RichText::new(format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).")).color(t.text_label));
ui.add_space(6.0);
let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text("Reject below focus").step_by(1.0));
crate::widgets::register(ui.ctx(), "field:cullReject", r.rect);
ui.label(egui::RichText::new(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, "Pick the sharpest photo of each burst");
crate::widgets::register(ui.ctx(), "check:cullPick", r.rect);
ui.label(egui::RichText::new("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.").color(t.text_dim));
}
Dialog::WhatsNew => {
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
for line in WHATS_NEW.lines() {
@@ -767,6 +780,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})),
Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
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::SmartRules { id, name, rules } => {
let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() };
match id {
+11
View File
@@ -493,6 +493,17 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
}
if let Some(a) = p.analysis {
let mut line = format!("Focus {:.0}", a.sharpness);
if a.clipped > 0.02 {
line.push_str(&format!(" · {:.0}% clipped", a.clipped * 100.0));
}
if a.group.is_some() {
line.push_str(if a.best { " · best of its burst" } else { " · in a burst" });
}
ui.add_space(4.0);
ui.label(egui::RichText::new(line).color(t.text_dim));
}
// offline originals / smart previews
if let lightcraft_catalog::Source::File { path } = &p.source {
let online = std::path::Path::new(path).exists();
+6
View File
@@ -384,6 +384,12 @@ 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.
Cull {
reject_below: f32,
pick_best: bool,
},
/// Help ▸ System Info: (label, value) rows.
SystemInfo {
rows: Vec<(String, String)>,
+8 -8
View File
@@ -21,10 +21,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
<!-- parity:summary -->
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---:|---:|---:|---:|---:|---:|
| A. Import (IMP) | 10 | 1 | 2 | 1 | 3/4 (75%) | 4/4 (100%) |
| B. Library management (LIB) | 22 | 0 | 3 | 2 | 9/9 (100%) | 9/9 (100%) |
| A. Import (IMP) | 10 | 2 | 1 | 1 | 3/4 (75%) | 4/4 (100%) |
| B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) |
| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) |
| D. Search & filter (FILT) | 10 | 0 | 3 | 0 | 4/4 (100%) | 4/4 (100%) |
| D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 4/4 (100%) |
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
| G. Profiles (PROF) | 6 | 1 | 3 | 0 | 3/3 (100%) | 2/3 (67%) |
@@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| 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 | 24 | 8 | 47 | 9 | — | 20/21 (95%) |
| **Total** | 374 | 23 | 103 | 37 | 193/198 (97%) | 136/144 (94%) |
| **Total** | 375 | 25 | 100 | 37 | 193/198 (97%) | 136/144 (94%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 32.6% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 33.9% of 158.
<!-- /parity:summary -->
## Top gaps
@@ -110,7 +110,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched) |
## B. Library management (LIB)
@@ -140,7 +140,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| 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” |
| 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 | ⬜ | | |
| 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-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`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” |
@@ -181,7 +181,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers |
| LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) |
| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | |
| LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | |
| LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums |
| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order |
| LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library |