LRC-LIB-MISSING: library.missing checks the files off the UI thread

From the control channel (an agent, `mcp --connect`) the command ran in
Session::execute on the UI thread and stat-ed every photo's file, which on
a large library on a network share froze the window for minutes. The app
now checks the files on a worker thread under a "Missing Photos" row of the
activity stack (counted, with ✕), answers the request with the same
[{id, path}] when the check ends, lets a second request share the running
check, and hands an answer nobody was waiting for to the next request.
The command itself asks the session's existence check, the one the
availability cache uses.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-27 17:58:16 -04:00
co-authored by Claude Sonnet 5.5
parent 37b2e523ad
commit d99fb7bc0d
12 changed files with 409 additions and 11 deletions
+43 -3
View File
@@ -41,7 +41,47 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
if cfg!(target_arch = "wasm32") {
return Vec::new();
}
missing_with(&s.catalog, |f| Path::new(f).exists())
let exists = s.media.availability.probe();
missing_with(&s.catalog, |f| exists(f))
}
/// Every in-scope photo with its file ([`checked_path`]), without touching the disk: what a
/// sweep ([`sweep`]) goes through, so a caller can take the list on its own thread and check
/// the files on another.
pub fn checked(cat: &Catalog) -> Vec<(PhotoId, String)> {
cat.photos().filter_map(|p| checked_path(p).map(|f| (p.id, f.to_string()))).collect()
}
/// How many files [`sweep`] checks between two reports of its progress.
const PROGRESS_EVERY: usize = 64;
/// The files among `files` that `exists` doesn't find, in order: [`missing`] for a caller that
/// checks on a thread of its own. `stop` is asked before every check (`None`: it said yes, and
/// the rest was not looked at); `progress` is told how many files are done every few dozen.
pub fn sweep(
files: Vec<(PhotoId, String)>,
exists: impl Fn(&str) -> bool,
stop: impl Fn() -> bool,
progress: impl Fn(usize),
) -> Option<Vec<(PhotoId, String)>> {
let mut gone = Vec::new();
for (done, (id, file)) in files.into_iter().enumerate() {
if stop() {
return None;
}
if done % PROGRESS_EVERY == 0 {
progress(done);
}
if !exists(&file) {
gone.push((id, file));
}
}
Some(gone)
}
/// `library.missing`'s answer: `[{id, path}]`.
pub fn missing_json(gone: Vec<(PhotoId, String)>) -> Value {
Value::Array(gone.into_iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect())
}
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
@@ -418,8 +458,8 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "library.missing", "Missing Photos", [], None, "{} → [{id, path}] — photos whose original file isn't where the library expects it", always, |s, _| {
Ok(Value::Array(missing(s).into_iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect()))
cmd!(query "library.missing", "Missing Photos", [], None, "{} → [{id, path}] — photos whose original file isn't where the library expects it (every file is looked at: on a network share that takes minutes, so the desktop app, asked over its control channel, checks on a worker thread, with a row in the activity stack that can stop it, and answers when it is done)", always, |s, _| {
Ok(missing_json(missing(s)))
}),
cmd!(
"photo.relink",
+49
View File
@@ -1255,6 +1255,55 @@ fn missing_photos_skip_local_browse_records() {
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
}
/// `library.missing` asks the session's existence check (the one the availability cache and
/// the tests replace), and a sweep on another thread answers what the command does: the same
/// files in the same order, progress on the way, and a stop that leaves the rest unchecked.
#[test]
fn missing_sweep_answers_what_the_command_does_and_can_stop() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use std::sync::atomic::{AtomicUsize, Ordering};
let mut s = Session::new();
let mut ops = Vec::new();
for i in 0..200u64 {
let path = format!("/share/IMG_{i:04}.jpg");
let p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
s.commit("Add", Op::Batch { ops }).unwrap();
// the first hundred files are gone
s.media.availability.set_probe(std::sync::Arc::new(|f| !f.contains("_00")));
let asked = s.execute("library.missing", &json!({})).unwrap();
assert_eq!(asked.as_array().map(Vec::len), Some(100), "the command used the session's check: {asked}");
let files = crate::cmd::missing::checked(&s.catalog);
assert_eq!(files.len(), 200);
let reports = AtomicUsize::new(0);
let probe = s.media.availability.probe();
let swept = crate::cmd::missing::sweep(
files.clone(),
|f| probe(f),
|| false,
|_| {
reports.fetch_add(1, Ordering::Relaxed);
},
);
assert_eq!(crate::cmd::missing::missing_json(swept.unwrap()), asked);
assert!(reports.load(Ordering::Relaxed) >= 2, "progress is reported on the way");
// stopped after 10 files: nothing is answered, and the rest is not looked at
let looked = AtomicUsize::new(0);
let stopped = crate::cmd::missing::sweep(
files,
|_| {
looked.fetch_add(1, Ordering::Relaxed);
true
},
|| looked.load(Ordering::Relaxed) >= 10,
|_| {},
);
assert_eq!((stopped, looked.load(Ordering::Relaxed)), (None, 10));
}
/// `library.browse` follows `library.source`'s rule for the active photo (issue #1447): a library
/// photo the folder doesn't show gives way to the folder's first photo; one it shows stays.
#[test]
+1
View File
@@ -65,6 +65,7 @@ impl LightcraftApp {
drop_pending: vec![],
copied: None,
pending_picks: vec![],
missing: Default::default(),
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
host_mods: egui::Modifiers::NONE,
+1
View File
@@ -18,6 +18,7 @@ impl LightcraftApp {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Later => {}
control::Outcome::Screenshot { target, headless } => {
if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) {
let _ = reply.send(serde_json::json!({"ok": false, "error": error}));
+18 -2
View File
@@ -59,6 +59,8 @@ impl ControlRequest {
pub enum Outcome {
Done(Value),
/// Answered later, by whoever holds the request's `reply` (a task that is still running).
Later,
/// A capture was admitted: `target` pins the destination identity until the write.
Screenshot {
target: Option<lightcraft_engine::originals::WriteTarget>,
@@ -72,11 +74,19 @@ pub enum Outcome {
},
}
/// A reply that says it worked: `result`.
pub(crate) fn success(v: Value) -> Value {
json!({"ok": true, "result": v})
}
/// A reply that says it failed: `error`.
pub(crate) fn failure(e: impl std::fmt::Display) -> Value {
json!({"ok": false, "error": e.to_string()})
}
fn ok(v: Value) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
Outcome::Done(success(v))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
Outcome::Done(failure(e))
}
fn wrap(r: Result<Value, String>) -> Outcome {
match r {
@@ -329,6 +339,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
"engine.execute" | "ui.menu.invoke" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
if id == "library.missing" {
// looks at every photo's file: on a worker thread, answered when it is done
let started = crate::missing_sweep::ask(app, req.reply.clone());
ctx.request_repaint();
return started;
}
let r = app.run(id, params);
ctx.request_repaint();
wrap(r)
+12 -4
View File
@@ -466,13 +466,21 @@ impl Headless {
}
}
/// Queue a control-protocol request without running a frame: the answer comes on the returned
/// channel once a frame ([`Self::step`]) has taken the request up, and the work it starts is done.
pub(crate) fn send(&mut self, method: &str, params: Value) -> Result<std::sync::mpsc::Receiver<Value>, Value> {
let (req, rx) = ControlRequest::new(method, params);
self.control.send(req).map_err(|_| json!({"ok": false, "error": "control channel closed"}))?;
Ok(rx)
}
/// Send a control-protocol request (see [`crate::control`]) and run frames until it is
/// answered, then until its input has been consumed. Returns `{"ok": …, "result"|"error": …}`.
pub fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Value {
let (req, rx) = ControlRequest::new(method, params);
if self.control.send(req).is_err() {
return json!({"ok": false, "error": "control channel closed"});
}
let rx = match self.send(method, params) {
Ok(rx) => rx,
Err(e) => return e,
};
let t0 = Instant::now();
let reply = loop {
self.step();
+4
View File
@@ -22,6 +22,7 @@ pub mod icons;
pub mod lightroom_import;
pub mod links;
pub mod menus;
mod missing_sweep;
mod model_setup;
pub mod panels;
pub mod pick;
@@ -225,6 +226,9 @@ pub struct LightcraftApp {
pub copied: Option<String>,
/// Native file dialogs up for commands (`pick`): each command runs again when its closes.
pub(crate) pending_picks: Vec<pick::Pending>,
/// `library.missing` requests from the control channel and the check that answers them on a
/// worker thread ([`missing_sweep`]).
pub(crate) missing: missing_sweep::State,
/// Modifiers announced for synthetic input (held from a button down to its release). This is
/// only the app's synthetic ownership; the real host state is tracked separately in
/// `host_mods`, so a canceled synthetic gesture never clears a physically held key.
+96
View File
@@ -0,0 +1,96 @@
//! `library.missing` in the app. The engine's command looks at every photo's file on the calling
//! thread, which here is the UI's: on a share with hundreds of thousands of photos that froze the
//! window for minutes. A request from the control channel (and so an agent, `mcp --connect`) goes
//! the way of the person's own long jobs instead: the files are checked on a worker thread, under
//! a row in the activity stack that counts them and can stop the check, and the request is
//! answered with the same `[{id, path}]` once the check ends. A second request meanwhile joins the
//! check that is running and gets its answer, rather than starting another walk of the share.
//!
//! A check on a big share can outlast what a caller waits for (the control server gives a request
//! 60 s). An answer that nobody was waiting for any more is kept for a few minutes and given to
//! the next request, so that asking again after a timeout is not another walk of the share that
//! times out again.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::time::Duration;
use web_time::Instant;
use lightcraft_catalog::PhotoId;
use lightcraft_engine::cmd::missing;
use serde_json::Value;
use crate::LightcraftApp;
use crate::control::{self, Outcome};
/// The activity stack's label (the command's name, already translated).
pub const LABEL: &str = "Missing Photos";
/// The activity row's kind.
const KIND: &str = "missing";
/// How long an answer nobody was waiting for is kept.
const KEEP: Duration = Duration::from_secs(10 * 60);
/// The requests waiting for a check, and the answer kept for the next one.
#[derive(Default)]
pub struct State {
/// Each request with the library ([`lightcraft_engine::Session::library_generation`]) it asked about.
waiting: Vec<(u64, Sender<Value>)>,
/// A finished check's answer that no request received: its library and when it was kept.
kept: Option<(u64, Instant, Value)>,
}
/// Answer `library.missing` from the control channel: give the answer kept from a check nobody
/// waited for, or start the check, or join the one running for this library. The answer goes to
/// `reply` when the check ends.
pub fn ask(app: &mut LightcraftApp, reply: Sender<Value>) -> Outcome {
let library = app.session.library_generation();
if let Some((kept_for, at, answer)) = app.missing.kept.take()
&& kept_for == library
&& at.elapsed() < KEEP
{
return Outcome::Done(answer);
}
app.missing.waiting.push((library, reply));
if app.tasks.is_running(LABEL) && app.missing.waiting.iter().filter(|(l, _)| *l == library).count() > 1 {
return Outcome::Later;
}
let files = missing::checked(&app.session.catalog);
let total = files.len() as u64;
let exists = app.session.media.availability.probe();
let cancel = Arc::new(AtomicBool::new(false));
let flag = cancel.clone();
let work = move |task: lightcraft_engine::activity::TaskHandle| {
task.progress(0, total);
// (a panic is this check's failure, answered to the callers: they would wait for it in vain)
lightcraft_engine::guard::catch("Missing Photos", || {
missing::sweep(files, |f| exists(f), || flag.load(Ordering::Relaxed), |done| task.progress(done as u64, total))
})
};
let done = move |app: &mut LightcraftApp, _ctx: &egui::Context, checked: Result<Option<Vec<(PhotoId, String)>>, String>| {
let (answer, complete) = match checked {
_ if app.session.library_generation() != library => {
(control::failure("Missing Photos: another library was opened before the check ended"), false)
}
Err(e) => (control::failure(e), false),
Ok(None) => (control::failure("Missing Photos: the check was stopped"), false),
Ok(Some(gone)) => (control::success(missing::missing_json(gone)), true),
};
let (theirs, others): (Vec<_>, Vec<_>) = std::mem::take(&mut app.missing.waiting).into_iter().partition(|(l, _)| *l == library);
app.missing.waiting = others;
// (a request whose caller gave up has no one to send to)
let received = theirs.into_iter().fold(false, |received, (_, waiting)| waiting.send(answer.clone()).is_ok() || received);
if complete && !received {
app.missing.kept = Some((library, Instant::now(), answer));
}
};
if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, KIND, cancel, work, done) {
for (_, waiting) in std::mem::take(&mut app.missing.waiting) {
let _ = waiting.send(control::failure(&e));
}
}
Outcome::Later
}
+1
View File
@@ -50,6 +50,7 @@ mod masking;
mod masking_layout;
mod menubar;
mod merge_preview;
mod missing_sweep;
mod model_setup;
mod offline;
mod open_lightroom;
+173
View File
@@ -0,0 +1,173 @@
//! `library.missing` from the control channel (an agent, `mcp --connect`) looks at every photo's
//! file, which on a share with hundreds of thousands of photos took minutes on the UI thread and
//! froze the window: the files are checked on a worker thread under a row of the activity stack.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::{Value, json};
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(60);
const PHOTOS: u64 = 40;
/// Photos `IMG_0000` … on a share; the first ten are gone. The existence check waits for `open`
/// (a share that answers slowly), at most `guard`, and counts its calls, and those made on the UI thread.
struct Share {
open: Arc<AtomicBool>,
on_ui: Arc<AtomicUsize>,
calls: Arc<AtomicUsize>,
}
fn app_on_a_slow_share(guard: Duration) -> (Headless, Share) {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() });
for i in 0..PHOTOS {
let path = format!("/share/IMG_{i:04}.jpg");
let p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let share = Share { open: Arc::new(AtomicBool::new(false)), on_ui: Arc::new(AtomicUsize::new(0)), calls: Arc::new(AtomicUsize::new(0)) };
let (ui, open, on_ui, calls) = (std::thread::current().id(), share.open.clone(), share.on_ui.clone(), share.calls.clone());
let deadline = Instant::now() + guard;
app.session.media.availability.set_probe(Arc::new(move |f| {
calls.fetch_add(1, Ordering::SeqCst);
if std::thread::current().id() == ui {
on_ui.fetch_add(1, Ordering::SeqCst);
}
// (a hang guard: the check goes on after it, so a regression fails the test instead of hanging it)
while !open.load(Ordering::SeqCst) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
!f.contains("_000")
}));
(Headless::new(app, [1200.0, 800.0], 1.0), share)
}
fn ask(h: &mut Headless) -> Receiver<Value> {
h.send("engine.execute", json!({"command": "library.missing"})).unwrap()
}
/// Frames until `rx` has an answer.
fn answer(h: &mut Headless, rx: &Receiver<Value>) -> Value {
let t0 = Instant::now();
loop {
h.step();
if let Ok(v) = rx.try_recv() {
return v;
}
assert!(t0.elapsed() < T, "never answered");
std::thread::sleep(Duration::from_millis(1));
}
}
fn missing_rows(h: &Headless) -> Vec<lightcraft_engine::activity::TaskInfo> {
h.app.session.activity.list().into_iter().filter(|r| r.kind == "missing").collect()
}
#[test]
fn library_missing_is_checked_off_the_ui_thread_and_answered_when_done() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let t0 = Instant::now();
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
// the share is still answering nothing: the window took its frames all the same
assert!(t0.elapsed() < Duration::from_secs(2), "the frames waited for the files: {:?}", t0.elapsed());
assert!(rx.try_recv().is_err(), "no answer before the files are checked");
let rows = missing_rows(&h);
assert_eq!(rows.len(), 1, "{rows:?}");
assert!(rows[0].cancellable, "the row can stop the check");
assert_eq!(rows[0].label, crate::missing_sweep::LABEL);
share.open.store(true, Ordering::SeqCst);
let reply = answer(&mut h, &rx);
assert_eq!(share.on_ui.load(Ordering::SeqCst), 0, "a file was looked at on the UI thread");
assert_eq!(reply["ok"], true, "{reply}");
let ids: Vec<u64> = reply["result"].as_array().unwrap().iter().map(|r| r["id"].as_u64().unwrap()).collect();
assert_eq!(ids, (1..=10).collect::<Vec<_>>(), "the same [{{id, path}}] the command gives");
assert_eq!(reply["result"][0]["path"], "/share/IMG_0000.jpg");
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
assert!(missing_rows(&h).is_empty(), "the row goes with the check");
}
/// A request that comes while a check runs gets that check's answer: the share isn't walked twice.
#[test]
fn a_second_request_joins_the_check_that_is_running() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let (first, second) = (ask(&mut h), ask(&mut h));
for _ in 0..3 {
h.step();
}
assert_eq!(missing_rows(&h).len(), 1, "one check for both");
share.open.store(true, Ordering::SeqCst);
let (a, b) = (answer(&mut h, &first), answer(&mut h, &second));
assert_eq!(a["ok"], true, "{a}");
assert_eq!(a, b);
assert_eq!(share.on_ui.load(Ordering::SeqCst), 0);
}
/// ✕ on the row (or `activity.cancel`) stops the check; the request is told so.
#[test]
fn stopping_the_check_answers_the_request_with_an_error() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
let id = missing_rows(&h)[0].id;
let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T);
assert_eq!(r["ok"], true, "{r}");
share.open.store(true, Ordering::SeqCst);
let reply = answer(&mut h, &rx);
assert_eq!(reply["ok"], false, "{reply}");
assert!(reply["error"].as_str().is_some_and(|e| e.contains("stopped")), "{reply}");
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
assert!(missing_rows(&h).is_empty());
}
/// The control server gives a request 60 s, a check on a big share takes longer: the caller asks
/// again after its timeout and is given the answer the check ended with, not another walk.
#[test]
fn an_answer_nobody_waited_for_is_given_to_the_next_request() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let gave_up = ask(&mut h);
for _ in 0..3 {
h.step();
}
drop(gave_up);
share.open.store(true, Ordering::SeqCst);
assert!(h.step_until(T, |h| h.app.tasks.is_empty()), "the check ends without a caller");
assert_eq!(share.calls.load(Ordering::SeqCst), PHOTOS as usize);
let again = ask(&mut h);
let reply = answer(&mut h, &again);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"].as_array().map(Vec::len), Some(10), "{reply}");
assert_eq!(share.calls.load(Ordering::SeqCst), PHOTOS as usize, "no second walk of the share");
// given once: the next request checks again
let third = ask(&mut h);
assert_eq!(answer(&mut h, &third)["ok"], true);
assert_eq!(share.calls.load(Ordering::SeqCst), 2 * PHOTOS as usize);
}
/// A check that panics is a failure told to the caller, not a request left waiting for its timeout.
#[test]
fn a_check_that_panics_is_answered_with_an_error() {
let (mut h, _share) = app_on_a_slow_share(Duration::from_secs(3));
h.app.session.media.availability.set_probe(Arc::new(|_| panic!("a file system that panics")));
let rx = ask(&mut h);
let reply = answer(&mut h, &rx);
assert_eq!(reply["ok"], false, "{reply}");
assert!(reply["error"].as_str().is_some_and(|e| e.contains("failed unexpectedly")), "{reply}");
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
// and the next request is checked afresh
h.app.session.media.availability.set_probe(Arc::new(|_| true));
let rx = ask(&mut h);
assert_eq!(answer(&mut h, &rx)["result"], json!([]));
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LRC-LIB-MISSING: `library.missing` from the control channel checks the files on a worker thread instead of the UI thread; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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
@@ -717,7 +717,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people
| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |
| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) |
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike |
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/missing_sweep.rs`, `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike; `library.missing` from the control channel or `mcp --connect` checks the files on a worker thread (a "Missing Photos" row in the activity stack, ✕ stops it) and answers when it ends, so a large library on a network share never stalls the window; the same request meanwhile shares the running check |
| LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) |
| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) |
| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW |
@@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
| 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |
| 2026-10-22 | patch | LRC-LIB-MISSING: `library.missing` from the control channel (an agent, `mcp --connect`) checks the photos' files on a worker thread under a "Missing Photos" row in the activity stack (counts the files, ✕ stops it) and is answered when the check ends, instead of freezing the window while the UI thread looked at every file (minutes on a large library on a network share); status unchanged (✅) |
+8
View File
@@ -50,6 +50,14 @@
stay available in All Metadata; PSD edit copies keep their capture date. Original exports with Remove Location Info enabled
are refused when a PSD or PSB embeds GPS, since LightCraft cannot strip metadata from a byte-for-byte original copy.
### Asking for the missing photos no longer freezes the window
- `library.missing` (the control channel and `mcp --connect`) looked at every photo's file on the thread that draws
the window, so on a library of hundreds of thousands of photos on a network share the app stopped answering for
minutes. The files are now checked in the background: a "Missing Photos" row in the activity stack counts them and
its ✕ stops the check, and the request is answered with the same list when the check ends. A second request meanwhile
shares the running check. A caller that stops waiting (the control server gives a request 60 s) gets the answer of
that check when it asks again within ten minutes, instead of another walk of the share.
### Choose where a Lightroom catalog goes
- File ▸ Import Lightroom Catalog… now asks first: merge the catalog's photos, collections and edits into the
library open now, or create a new library for them, named after the catalog and placed next to the open library