Merge pull request #3496 from insideTheFlux/fix/3468-find-similar-freeze

LRC-LIB-VISUALSEARCH: Find Similar Photos reads the photos off the UI thread
This commit is contained in:
FredApps
2026-11-05 04:37:33 +01:00
committed by GitHub
14 changed files with 852 additions and 54 deletions
+1
View File
@@ -40,6 +40,7 @@ mod print;
mod query;
pub mod recheck;
pub mod reload;
pub mod similar;
mod slideshow;
mod sync;
mod xmp;
+3 -40
View File
@@ -105,43 +105,6 @@ fn burst_params(p: &Value) -> Result<(f64, f32)> {
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> {
const C: &str = "library.findSimilar";
let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
if let Some(v) = s.signatures.get(&key) {
return Some(*v);
}
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let v = lightcraft_pipeline::cull::signature(&src);
s.signatures.insert(key, v);
Some(v)
};
let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?;
let mut hits: Vec<(PhotoId, f32)> = Vec::new();
for q in all {
if let Some(v) = sig(s, q) {
let sim = lightcraft_pipeline::cull::similarity(&me, &v);
if sim >= min {
hits.push((q, sim.clamp(0.0, 1.0)));
}
}
}
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {
s.shelved_filter = None;
s.filter.only = hits.iter().map(|h| h.0).collect();
// an empty `only` means "no constraint": no hits is its own state that shows no photos
s.filter.only_none = s.filter.only.is_empty();
}
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -149,9 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Similar Photos",
["Photo"],
None,
"{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}",
always,
find_similar
"{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}. In the app the photos are read on a worker thread under an activity row: a control request is answered with this when the search ends, and from a menu the answer is {background: true, count}",
super::similar::no_search_running,
super::similar::run
),
cmd!(
"photo.analyze",
+399
View File
@@ -0,0 +1,399 @@
//! Find Similar (`library.findSimilar`), split so the app can read the photos on a worker thread: the
//! first search reads a thumbnail-level source of every photo in the library, and in a large
//! library that froze the window for as long as it took (issue #3468). [`plan`] lists what is to be
//! read (no file-system calls), [`measure`] reads it without the session (cancellable, with
//! progress), [`keep`] adds what it found to the session's signatures and [`apply`] answers from
//! those. The command itself (the CLI, MCP without an app) does all of them at once.
//!
//! A search keeps no decoded source and learns nothing from one: it only looks, and in a large library
//! it would push out of memory what the grid and the loupe are showing.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, PoisonError};
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use super::bad;
use crate::media::{SourceLevel, SourceRef, content_key};
use crate::{Result, Session};
const C: &str = "library.findSimilar";
/// The kind of the row in the activity stack that a search on a worker thread holds.
pub const KIND: &str = "similar";
/// A content key and that content's look-alike signature.
pub type Signature = (String, [f32; 64]);
/// Whether a search can start (the command's `enabled`): one at a time, for what a second one would
/// read the first is reading.
pub fn no_search_running(s: &Session) -> std::result::Result<(), String> {
if s.activity.list().iter().any(|r| r.kind == KIND) { Err("a search for similar photos is already running".to_string()) } else { Ok(()) }
}
/// What a search asks.
#[derive(Debug)]
pub struct Search {
id: PhotoId,
min: f32,
filter: bool,
}
/// A content whose signature isn't known yet, and how to read it: from the first of its photos that
/// can be read (a copy that is offline gives way to the next).
struct Content {
key: String,
sources: Vec<SourceRef>,
}
/// What a search has to read: the searched photo first, as nothing can be compared without it.
pub struct Reads {
me: Option<Content>,
rest: Vec<Content>,
}
impl Reads {
/// How many contents are to be read.
pub fn len(&self) -> usize {
self.rest.len().saturating_add(usize::from(self.me.is_some()))
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// The search `p` asks for and what it has to read, once for each content not known yet. No
/// file-system calls.
pub fn plan(s: &mut Session, p: &Value) -> Result<(Search, Reads)> {
let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| bad(C, "no photo"))?;
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
let filter = p.get("filter").and_then(Value::as_bool).unwrap_or(true);
let photo = s.catalog.photo(id).ok_or_else(|| bad(C, "can't read the photo"))?;
let my_key = content_key(photo);
let me =
(!s.signatures.contains_key(&my_key)).then(|| Content { sources: vec![s.media.source_ref(photo, SourceLevel::Thumb)], key: my_key.clone() });
let mut rest: Vec<Content> = Vec::new();
let mut at = std::collections::HashMap::new();
for q in s.catalog.photos().filter(|q| q.in_library()) {
let key = content_key(q);
// (the searched photo's content is known by the time these are read, or the search has ended)
if key == my_key || s.signatures.contains_key(&key) {
continue;
}
let source = s.media.source_ref(q, SourceLevel::Thumb);
match at.get(&key).and_then(|i: &usize| rest.get_mut(*i)) {
Some(content) => content.sources.push(source),
None => {
at.insert(key.clone(), rest.len());
rest.push(Content { key, sources: vec![source] });
}
}
}
Ok((Search { id, min, filter }, Reads { me, rest }))
}
/// `content`'s signature, or `None` when none of its photos can be read or `cancel` is set first. A
/// decoder's panic is that photo's failure, not the search's.
fn signature_of(content: &Content, cancel: &AtomicBool) -> Option<[f32; 64]> {
content.sources.iter().find_map(|source| {
if cancel.load(Ordering::Relaxed) {
return None;
}
crate::guard::catch("Find Similar", || {
crate::memory::in_background(|| source.load_source()).ok().map(|decoded| lightcraft_pipeline::cull::signature(&decoded.image))
})
.ok()
.flatten()
})
}
/// Read what [`plan`] listed, several photos at once. Doesn't touch the session. `progress(done)` is
/// called from the threads doing the reading; once `cancel` is set no further file is started, and
/// what was read until then comes back. A searched photo that can't be read ends it there.
pub fn measure(reads: Reads, cancel: &AtomicBool, progress: &(dyn Fn(usize) + Sync)) -> Vec<Signature> {
let found = Mutex::new(Vec::with_capacity(reads.len().min(4096)));
let done = AtomicUsize::new(0);
let keep = |key: String, v: [f32; 64]| found.lock().unwrap_or_else(PoisonError::into_inner).push((key, v));
if let Some(me) = reads.me {
let Some(v) = signature_of(&me, cancel) else { return Vec::new() };
keep(me.key, v);
done.store(1, Ordering::Relaxed);
progress(1);
}
let lanes = crate::export::export_parallelism(reads.rest.len());
let queue = Mutex::new(reads.rest.into_iter());
let work = || {
while !cancel.load(Ordering::Relaxed) {
let next = queue.lock().unwrap_or_else(PoisonError::into_inner).next();
let Some(content) = next else { break };
if let Some(v) = signature_of(&content, cancel) {
keep(content.key, v);
}
progress(done.fetch_add(1, Ordering::Relaxed).saturating_add(1));
}
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::scope(|sc| {
for k in 1..lanes.max(1) {
// a lane that can't start leaves its photos to the others (and to this thread)
if std::thread::Builder::new().name(format!("similar-{k}")).spawn_scoped(sc, work).is_err() {
break;
}
}
work();
});
#[cfg(target_arch = "wasm32")]
{
let _ = lanes;
work();
}
found.into_inner().unwrap_or_else(PoisonError::into_inner)
}
/// Add what [`measure`] found to the signatures, also when the search was stopped: they hold for the
/// contents they were read from, and the next search doesn't read those again.
pub fn keep(s: &mut Session, found: Vec<Signature>) {
s.signatures.extend(found);
}
/// Answer `search` from the signatures known now, most similar first, and filter the view to the
/// photos found. A photo with none (it can't be read, or came after the [`plan`]) isn't compared.
pub fn apply(s: &mut Session, search: &Search) -> Result<Value> {
let known = s.catalog.photo(search.id).and_then(|photo| s.signatures.get(&content_key(photo)));
let me = *known.ok_or_else(|| bad(C, "can't read the photo"))?;
let mut hits: Vec<(PhotoId, f32)> = Vec::new();
for q in s.catalog.photos().filter(|q| q.in_library()) {
if let Some(v) = s.signatures.get(&content_key(q)) {
let sim = lightcraft_pipeline::cull::similarity(&me, v);
if sim >= search.min {
hits.push((q.id, sim.clamp(0.0, 1.0)));
}
}
}
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
if search.filter {
s.shelved_filter = None;
s.filter.only = hits.iter().map(|h| h.0).collect();
// an empty `only` means "no constraint": no hits is its own state that shows no photos
s.filter.only_none = s.filter.only.is_empty();
}
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
}
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
/// content), most similar first; the view is filtered to them.
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
let (search, reads) = plan(s, p)?;
let found = measure(reads, &AtomicBool::new(false), &|_| {});
keep(s, found);
apply(s, &search)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use lightcraft_catalog::{Op, Photo, Source};
use lightcraft_raster::Rgb32f;
use super::*;
/// Reads per file, the files that can't be read and the file whose decoder panics.
#[derive(Default)]
struct Files {
reads: Mutex<HashMap<String, usize>>,
unreadable: Mutex<Vec<String>>,
panics: Mutex<Option<String>>,
/// Set once this many reads have started.
cancel_after: Option<(usize, Arc<AtomicBool>)>,
}
impl Files {
fn reads_of(&self, path: &str) -> usize {
self.reads.lock().unwrap().get(path).copied().unwrap_or(0)
}
fn reads(&self) -> usize {
self.reads.lock().unwrap().values().sum()
}
}
/// A session of photos `/p/<name>.jpg`, each with its content hash when it has one. Odd numbers
/// in a name mean a picture that gets brighter to the right, others one that does downwards.
fn session(photos: &[(&str, Option<&str>)], files: Files) -> (Session, Arc<Files>) {
let files = Arc::new(files);
let mut s = Session::new();
for (i, (name, hash)) in photos.iter().enumerate() {
let source = Source::File { path: format!("/p/{name}.jpg") };
let mut p = Photo::new(PhotoId(i as u64 + 1), source, &format!("{name}.jpg"), "JPEG", 64, 64, "2026-01-01T00:00:00");
p.content_hash = hash.map(str::to_string);
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let seen = files.clone();
s.media.file_loader = Some(Arc::new(move |path: &str, _| {
let started = {
let mut reads = seen.reads.lock().unwrap();
*reads.entry(path.to_string()).or_default() += 1;
reads.values().sum::<usize>()
};
if let Some((after, cancel)) = &seen.cancel_after
&& started >= *after
{
cancel.store(true, Ordering::SeqCst);
}
if seen.panics.lock().unwrap().as_deref() == Some(path) {
panic!("a decoder fails on {path}");
}
if seen.unreadable.lock().unwrap().iter().any(|f| f == path) {
return Err(format!("{path}: gone"));
}
let across = path.bytes().filter(u8::is_ascii_digit).any(|b| b % 2 == 1);
let image = Rgb32f::from_fn(64, 64, |x, y| {
let v = if across { x } else { y };
[v as f32 / 64.0; 3]
});
Ok((image, Default::default()))
}));
(s, files)
}
fn ids(r: &Value) -> Vec<u64> {
r["photos"].as_array().unwrap().iter().map(|h| h["id"].as_u64().unwrap()).collect()
}
/// A search on a worker thread holds a row of its kind: the command is off until the row goes.
#[test]
fn a_running_search_turns_the_command_off() {
let (mut s, files) = session(&[("a1", None), ("b2", None)], Files::default());
let row = s.activity.start(KIND, "Find Similar Photos", crate::activity::Cancel::No);
let off = s.commands().into_iter().find(|c| c.id == C).is_some_and(|c| !c.enabled);
assert!(off, "the menu shows it off");
assert!(matches!(s.execute(C, &json!({"id": 1})), Err(crate::EngineError::Disabled(..))));
assert_eq!(files.reads(), 0);
drop(row);
assert!(s.execute(C, &json!({"id": 1})).is_ok());
}
#[test]
fn making_the_plan_reads_no_file() {
let (mut s, files) = session(&[("a1", None), ("b2", None), ("c1", None)], Files::default());
let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap();
assert_eq!(reads.len(), 3);
assert_eq!(files.reads(), 0);
}
#[test]
fn the_command_reads_each_photo_and_finds_the_ones_alike() {
let names: Vec<String> = (0..40).map(|i| format!("p{i}")).collect();
let photos: Vec<(&str, Option<&str>)> = names.iter().map(|n| (n.as_str(), None)).collect();
let (mut s, files) = session(&photos, Files::default());
let r = s.execute("library.findSimilar", &json!({"id": 1})).unwrap();
assert_eq!(files.reads(), 40, "each once");
assert!(ids(&r).contains(&1) && ids(&r).len() < 40, "{r}");
// known now: no file is read again
let again = s.execute("library.findSimilar", &json!({"id": 1})).unwrap();
assert_eq!(files.reads(), 40);
assert_eq!(ids(&again), ids(&r));
}
#[test]
fn a_content_is_read_once_and_an_unreadable_copy_gives_way_to_the_next() {
let files = Files::default();
files.unreadable.lock().unwrap().push("/p/copy2.jpg".into());
let photos = [("me1", None), ("copy2", Some("h")), ("copy4", Some("h")), ("copy6", Some("h"))];
let (mut s, files) = session(&photos, files);
let r = s.execute("library.findSimilar", &json!({"id": 1, "similarity": -1.0})).unwrap();
assert_eq!(files.reads_of("/p/copy2.jpg"), 1);
assert_eq!(files.reads_of("/p/copy4.jpg"), 1, "the next copy stands in");
assert_eq!(files.reads_of("/p/copy6.jpg"), 0, "its content is known by then");
assert_eq!(ids(&r).len(), 4, "every copy is compared: {r}");
}
#[test]
fn a_searched_photo_that_cannot_be_read_ends_the_search_before_any_other_is_read() {
let files = Files::default();
files.unreadable.lock().unwrap().push("/p/me1.jpg".into());
let (mut s, files) = session(&[("me1", None), ("b2", None), ("c1", None)], files);
let e = s.execute("library.findSimilar", &json!({"id": 1})).unwrap_err();
assert!(e.to_string().contains("can't read the photo"), "{e}");
assert_eq!(files.reads(), 1, "only the searched photo");
assert!(s.signatures.is_empty());
assert_eq!(s.filter, Default::default(), "the view stays as it was");
}
#[test]
fn a_photo_nobody_has_is_refused_at_once() {
let (mut s, files) = session(&[("a1", None)], Files::default());
assert!(matches!(plan(&mut s, &json!({"id": 99})), Err(crate::EngineError::BadParams { .. })));
assert_eq!(files.reads(), 0);
}
#[test]
fn a_photo_whose_decoder_panics_is_left_out_and_the_search_goes_on() {
let files = Files::default();
*files.panics.lock().unwrap() = Some("/p/bad3.jpg".into());
let (mut s, _) = session(&[("me1", None), ("bad3", None), ("ok1", None), ("ok5", None)], files);
let r = s.execute("library.findSimilar", &json!({"id": 1, "similarity": -1.0})).unwrap();
assert_eq!(ids(&r).len(), 3, "{r}");
assert!(!ids(&r).contains(&2), "{r}");
}
/// Stopped after some reads: what was read is kept, the view is left alone, and the next search
/// reads only the rest.
#[test]
fn a_stopped_search_keeps_what_it_read_and_the_next_reads_the_rest() {
let cancel = Arc::new(AtomicBool::new(false));
let files = Files { cancel_after: Some((5, cancel.clone())), ..Default::default() };
let names: Vec<String> = (0..64).map(|i| format!("p{i}")).collect();
let photos: Vec<(&str, Option<&str>)> = names.iter().map(|n| (n.as_str(), None)).collect();
let (mut s, _) = session(&photos, files);
let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap();
let found = measure(reads, &cancel, &|_| {});
let read = found.len();
assert!((1..64).contains(&read), "stopped along the way: {read}");
keep(&mut s, found);
assert_eq!(s.filter, Default::default());
let (_, rest) = plan(&mut s, &json!({"id": 1})).unwrap();
assert_eq!(rest.len(), 64 - read);
}
#[test]
fn a_stopped_search_reads_nothing_more() {
let (mut s, files) = session(&[("a1", None), ("b2", None), ("c1", None)], Files::default());
let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap();
assert!(measure(reads, &AtomicBool::new(true), &|_| {}).is_empty());
assert_eq!(files.reads(), 0);
}
/// What changed while the files were read: a photo deleted since isn't an answer, and one added
/// since has no signature to compare.
#[test]
fn photos_that_changed_during_the_search_are_answered_as_they_are_now() {
let (mut s, _) = session(&[("a1", None), ("b1", None), ("c1", None)], Files::default());
let (search, reads) = plan(&mut s, &json!({"id": 1, "similarity": -1.0})).unwrap();
let found = measure(reads, &AtomicBool::new(false), &|_| {});
s.catalog.apply(Op::RemovePhoto { id: PhotoId(2) }).unwrap();
let added = Photo::new(PhotoId(9), Source::File { path: "/p/new1.jpg".into() }, "new1.jpg", "JPEG", 64, 64, "2026-01-01T00:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(added) }).unwrap();
keep(&mut s, found);
let mut answer = ids(&apply(&mut s, &search).unwrap());
answer.sort_unstable();
assert_eq!(answer, [1, 3]);
}
#[test]
fn progress_counts_every_content_read_or_not() {
let files = Files::default();
files.unreadable.lock().unwrap().push("/p/b2.jpg".into());
let (mut s, _) = session(&[("a1", None), ("b2", None), ("c1", None), ("d2", None)], files);
let (_, reads) = plan(&mut s, &json!({"id": 1})).unwrap();
let top = AtomicUsize::new(0);
let found = measure(reads, &AtomicBool::new(false), &|done| {
top.fetch_max(done, Ordering::SeqCst);
});
assert_eq!(top.load(Ordering::SeqCst), 4);
assert_eq!(found.len(), 3);
}
}
+4
View File
@@ -306,6 +306,10 @@ impl LightcraftApp {
if self.shown_library == Some(generation) {
return;
}
// (the first library shown takes the place of none: nothing was searching another one)
if self.shown_library.is_some() {
crate::find_similar::library_changed(self);
}
self.shown_library = Some(generation);
if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) {
let path = l.dir.to_string_lossy().into_owned();
+6
View File
@@ -345,6 +345,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
ctx.request_repaint();
return started;
}
if id == "library.findSimilar" {
// reads every photo: on a worker thread, answered when it is done
let started = crate::find_similar::ask(app, &params, req.reply.clone());
ctx.request_repaint();
return started;
}
let r = app.run(id, params);
ctx.request_repaint();
wrap(r)
+110
View File
@@ -0,0 +1,110 @@
//! Find Similar Photos in the app. The first search reads every photo of the library, which on the
//! UI thread left the window not responding for as long as that took: hours in a library of tens of
//! thousands of raws (issue #3468). The photos are read on a worker thread instead, under a row in
//! the activity stack that counts them and can stop. What was read before a stop is kept, so the
//! next search goes on from there.
//!
//! From a menu the command answers at once and says how it ended in a toast. A request from the
//! control channel (an agent, `mcp --connect`) gets the same `{photos}` as ever, when the search
//! ends; one that gave up waiting can ask again and is answered from what was read.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use lightcraft_engine::cmd::similar;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::control::{self, Outcome};
/// The activity stack's label (the menu item's name, already translated).
pub const LABEL: &str = "Find Similar Photos";
/// How a search ended.
enum End {
/// The command's answer.
Found(Value),
/// Nothing was asked of the view any more: why.
Dropped(&'static str),
Failed(String),
}
/// Start a search for `p`: `end` runs on the UI thread when it is over. How many contents it has to
/// read, or why it can't start.
fn start(app: &mut LightcraftApp, p: &Value, end: impl FnOnce(&mut LightcraftApp, &egui::Context, End) + Send + 'static) -> Result<usize, String> {
similar::no_search_running(&app.session).map_err(|why| format!("library.findSimilar: {why}"))?;
let (search, reads) = similar::plan(&mut app.session, p).map_err(|e| e.to_string())?;
let count = reads.len();
let library = app.session.library_generation();
let cancel = Arc::new(AtomicBool::new(false));
let (flag, stopped) = (cancel.clone(), cancel.clone());
let work = move |task: lightcraft_engine::activity::TaskHandle| {
let total = count as u64;
task.progress(0, total);
lightcraft_engine::guard::catch(LABEL, || similar::measure(reads, &flag, &|done| task.progress(done as u64, total)))
};
let done = move |app: &mut LightcraftApp, ctx: &egui::Context, found: Result<Vec<similar::Signature>, String>| {
let ended = match found {
_ if app.session.library_generation() != library => End::Dropped("another library was opened before the search ended"),
Err(e) => End::Failed(e),
Ok(found) => {
similar::keep(&mut app.session, found);
if stopped.load(Ordering::Relaxed) {
End::Dropped("the search was stopped")
} else {
match app.session.execute_fn("library.findSimilar", |s| similar::apply(s, &search)) {
Ok(answer) => End::Found(answer),
Err(e) => End::Failed(e.to_string()),
}
}
}
};
end(app, ctx, ended);
};
crate::tasks::spawn_tracked(app, LABEL, similar::KIND, cancel, work, done)?;
Ok(count)
}
/// `library.findSimilar` from a menu: start reading → `{background: true, count}`. The grid shows
/// what it found, and a toast says how many.
pub fn command(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
let count = start(app, p, |app, ctx, end| match end {
End::Found(answer) => {
let n = answer["photos"].as_array().map_or(0, Vec::len);
app.ui.view = crate::state::ViewMode::PhotoGrid;
app.toast(ctx, crate::i18n::tr_format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }, n = n));
}
// (whoever stopped it knows; a search of another library has nothing to say about this one)
End::Dropped(_) => {}
End::Failed(e) => app.toast(ctx, e),
})?;
Ok(json!({"background": true, "count": count}))
}
/// `library.findSimilar` from the control channel: `reply` gets the command's answer when the search
/// ends, and the view stays where the agent left it.
pub fn ask(app: &mut LightcraftApp, p: &Value, reply: Sender<Value>) -> Outcome {
let started = start(app, p, move |_, _, end| {
let answer = match end {
End::Found(answer) => control::success(answer),
End::Dropped(why) => control::failure(format!("library.findSimilar: {why}")),
End::Failed(e) => control::failure(e),
};
// (a caller that gave up has no one to send to; what was read stays known for its next request)
let _ = reply.send(answer);
});
match started {
Ok(_) => Outcome::Later,
Err(e) => Outcome::Done(control::failure(e)),
}
}
/// Another library was opened: stop a search of the one before, which has nothing left to find (it
/// could go on reading for hours). Its row goes when the photo being read is done.
pub fn library_changed(app: &LightcraftApp) {
for row in app.session.activity.list().into_iter().filter(|r| r.kind == similar::KIND) {
// (a row that has just ended can't be stopped: nothing to do)
let _ = app.session.activity.cancel(row.id);
}
}
+1
View File
@@ -24,6 +24,7 @@ pub mod dialogs;
pub mod docking;
mod drop_overlay;
pub mod export_task;
mod find_similar;
mod focus_scan;
mod frame_rate;
pub mod headless;
+4
View File
@@ -158,6 +158,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
// the files are read in the background (a raw's decode takes a second or more)
return crate::reload::command(app, p);
}
if id == "library.findSimilar" {
// the photos are read in the background (the first search reads the whole library)
return Some(crate::find_similar::command(app, p));
}
if let Some(language) = language_from_command(id) {
app.ui.language = language;
// Immediately, not on the next frame: the reply and anything else run this frame
+5 -12
View File
@@ -1325,18 +1325,11 @@ fn context_menu_open_and_edit(app: &mut LightcraftApp, ui: &mut egui::Ui, id: Ph
}
});
});
if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() {
match app.run("library.findSimilar", json!({"id": id.0})) {
Ok(r) => {
let n = r["photos"].as_array().map_or(0, Vec::len);
app.ui.view = crate::state::ViewMode::PhotoGrid;
app.toast(
ui.ctx(),
crate::i18n::tr_format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }, n = n),
);
}
Err(e) => app.toast(ui.ctx(), e),
}
let can_search = lightcraft_engine::cmd::similar::no_search_running(&app.session).is_ok();
if ui.add_enabled(can_search, egui::Button::new(crate::i18n::tr("Find Similar Photos"))).clicked()
&& let Err(e) = app.run("library.findSimilar", json!({"id": id.0}))
{
app.toast(ui.ctx(), e);
}
let live = super::compare::live(app, id);
let set_reference = ui
+1
View File
@@ -31,6 +31,7 @@ mod empty_library;
mod error_kinds;
mod filmstrip;
mod filmstrip_drag;
mod find_similar;
mod focus_point;
mod folder_drop;
mod grading;
+301
View File
@@ -0,0 +1,301 @@
//! Find Similar Photos reads every photo of the library the first time, which left the window not
//! responding for as long as that took (issue #3468): the photos are read 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 lightcraft_raster::Rgb32f;
use serde_json::{Value, json};
use crate::find_similar::LABEL;
use crate::headless::Headless;
use crate::state::ViewMode;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(60);
const PHOTOS: u64 = 12;
/// How long a read waits for a drive that isn't opened, and how long the frames may take while it does.
const GUARD: Duration = Duration::from_secs(20);
const FRAMES: Duration = Duration::from_secs(15);
/// The photos' files: a read waits for `open` (a slow drive), at most `guard`, and is counted, as
/// are the reads made on the UI thread.
struct Drive {
open: Arc<AtomicBool>,
reads: Arc<AtomicUsize>,
on_ui: Arc<AtomicUsize>,
}
fn app_on_a_slow_drive(guard: Duration) -> (Headless, Drive) {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() });
for i in 0..PHOTOS {
let path = format!("/drive/IMG_{i:04}.arw");
let p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.arw"), "ARW", 64, 64, "2026-01-01T00:00:00");
app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let drive = Drive { open: Arc::new(AtomicBool::new(false)), reads: Arc::new(AtomicUsize::new(0)), on_ui: Arc::new(AtomicUsize::new(0)) };
let (ui, open, reads, on_ui) = (std::thread::current().id(), drive.open.clone(), drive.reads.clone(), drive.on_ui.clone());
let deadline = Instant::now() + guard;
app.session.media.file_loader = Some(Arc::new(move |path: &str, _| {
// (the grid reads the photos for its thumbnails too, on threads of its own)
let thread = std::thread::current();
if thread.name().is_some_and(|n| n.starts_with("similar-") || n.contains(LABEL)) {
reads.fetch_add(1, Ordering::SeqCst);
}
if thread.id() == ui {
on_ui.fetch_add(1, Ordering::SeqCst);
}
// (a hang guard: the read 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));
}
// even photos get brighter to the right, odd ones downwards
let across = path.bytes().rfind(u8::is_ascii_digit).is_some_and(|d| d % 2 == 0);
let image = Rgb32f::from_fn(64, 64, |x, y| {
let v = if across { x } else { y };
[v as f32 / 64.0; 3]
});
Ok((image, Default::default()))
}));
(Headless::new(app, [1200.0, 800.0], 1.0), drive)
}
fn rows(h: &Headless) -> Vec<lightcraft_engine::activity::TaskInfo> {
h.app.session.activity.list().into_iter().filter(|r| r.kind == "similar").collect()
}
/// The row's wait is over once the task has gone.
fn until_done(h: &mut Headless) {
assert!(h.step_until(T, |h| h.app.tasks.is_empty()), "the search never ended");
}
fn ask(h: &mut Headless) -> Receiver<Value> {
h.send("engine.execute", json!({"command": "library.findSimilar", "params": {"id": 1}})).unwrap()
}
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));
}
}
/// The issue's report: from the menu, the command answers at once while the files are read, the
/// window takes its frames, the row counts and can stop, and the grid shows what was found.
#[test]
fn a_search_from_the_menu_reads_off_the_ui_thread_and_the_window_goes_on() {
let (mut h, drive) = app_on_a_slow_drive(GUARD);
h.app.ui.view = ViewMode::Detail;
let t0 = Instant::now();
let r = h.app.run("library.findSimilar", json!({"id": 1})).unwrap();
assert_eq!(r, json!({"background": true, "count": PHOTOS}));
for _ in 0..3 {
h.step();
}
// nothing has been read yet: the command and the frames waited for none of it
assert!(t0.elapsed() < FRAMES, "the window waited for the files: {:?}", t0.elapsed());
assert_eq!(h.app.session.filter, Default::default(), "no answer before the files are read");
let row = &rows(&h)[0];
assert_eq!(row.label, LABEL);
assert_eq!(row.total, PHOTOS);
assert!(row.cancellable, "the row can stop the search");
drive.open.store(true, Ordering::SeqCst);
until_done(&mut h);
assert_eq!(drive.on_ui.load(Ordering::SeqCst), 0, "a file was read on the UI thread");
assert_eq!(drive.reads.load(Ordering::SeqCst), PHOTOS as usize);
assert!(rows(&h).is_empty(), "the row goes with the search");
let shown = h.app.session.filter.only.len();
assert_eq!(shown, PHOTOS as usize / 2, "the photos alike are shown");
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid);
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.starts_with(&format!("{shown} similar photos")), "{toast}");
}
/// One search at a time: the menu row is off while one runs and on again after it, and a second
/// request is refused without touching the first.
#[test]
fn a_second_search_waits_for_the_first() {
// what the menu bar draws the row from
let row_is_on = |h: &Headless| crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "library.findSimilar").is_some_and(|e| e.enabled);
let (mut h, drive) = app_on_a_slow_drive(GUARD);
assert!(row_is_on(&h));
h.app.run("library.findSimilar", json!({"id": 1})).unwrap();
assert!(!row_is_on(&h), "the row is off while one runs");
let e = h.app.run("library.findSimilar", json!({"id": 2})).unwrap_err();
assert!(e.contains("already running"), "{e}");
assert_eq!(rows(&h).len(), 1);
drive.open.store(true, Ordering::SeqCst);
until_done(&mut h);
assert!(row_is_on(&h));
assert!(h.app.session.filter.only.contains(&PhotoId(1)), "the first search answered");
}
/// ✕ on the row: the view stays as it was, nothing is said, and the next search doesn't read again
/// what was read.
#[test]
fn stopping_leaves_the_view_alone_and_the_next_search_reads_only_the_rest() {
let (mut h, drive) = app_on_a_slow_drive(GUARD);
h.app.ui.view = ViewMode::Detail;
h.app.run("library.findSimilar", json!({"id": 1})).unwrap();
// stopped while the searched photo is being read: that one read is all it does
assert!(h.step_until(T, |_| drive.reads.load(Ordering::SeqCst) == 1), "the first read never started");
h.app.session.activity.cancel(rows(&h)[0].id).unwrap();
drive.open.store(true, Ordering::SeqCst);
until_done(&mut h);
assert!(rows(&h).is_empty());
assert_eq!(h.app.session.filter, Default::default());
assert_eq!(h.app.ui.view, ViewMode::Detail);
assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast);
assert_eq!(drive.reads.load(Ordering::SeqCst), 1);
let r = h.app.run("library.findSimilar", json!({"id": 1})).unwrap();
assert_eq!(r["count"], PHOTOS - 1, "what was read isn't read again");
until_done(&mut h);
assert_eq!(drive.reads.load(Ordering::SeqCst), PHOTOS as usize);
assert_eq!(h.app.session.filter.only.len(), PHOTOS as usize / 2);
}
/// Nothing to read: the answer comes on the next frames, with no photo read.
#[test]
fn a_search_of_what_is_known_reads_nothing() {
let (mut h, drive) = app_on_a_slow_drive(Duration::ZERO);
drive.open.store(true, Ordering::SeqCst);
h.app.run("library.findSimilar", json!({"id": 1})).unwrap();
until_done(&mut h);
let reads = drive.reads.load(Ordering::SeqCst);
let r = h.app.run("library.findSimilar", json!({"id": 2})).unwrap();
assert_eq!(r["count"], 0);
until_done(&mut h);
assert_eq!(drive.reads.load(Ordering::SeqCst), reads);
assert!(h.app.session.filter.only.contains(&PhotoId(2)));
}
/// A refusal comes at once and starts nothing (no photo active, a photo the library doesn't have).
#[test]
fn a_search_that_cannot_start_leaves_no_row() {
let (mut h, drive) = app_on_a_slow_drive(Duration::ZERO);
assert!(h.app.run("library.findSimilar", json!({"id": 999})).is_err());
assert!(h.app.run("library.findSimilar", json!({"id": "x"})).is_err());
assert!(h.app.tasks.is_empty());
assert!(rows(&h).is_empty());
assert_eq!(drive.reads.load(Ordering::SeqCst), 0);
}
/// A searched photo that can't be read says so where the person is.
#[test]
fn a_photo_that_cannot_be_read_says_so() {
let (mut h, _) = app_on_a_slow_drive(Duration::ZERO);
h.app.session.media.file_loader = Some(Arc::new(|path: &str, _| Err(format!("{path}: gone"))));
h.app.run("library.findSimilar", json!({"id": 1})).unwrap();
until_done(&mut h);
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("can't read the photo"), "{toast}");
assert_eq!(h.app.session.filter, Default::default());
}
/// A control request is answered when the search ends, with the command's `{photos}`, and the window
/// takes its frames meanwhile. An agent's view isn't switched for it.
#[test]
fn a_control_request_is_answered_when_the_search_ends() {
let (mut h, drive) = app_on_a_slow_drive(GUARD);
h.app.ui.view = ViewMode::Detail;
let t0 = Instant::now();
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
assert!(t0.elapsed() < FRAMES, "the frames waited for the files: {:?}", t0.elapsed());
assert!(rx.try_recv().is_err(), "no answer before the files are read");
assert_eq!(rows(&h).len(), 1);
drive.open.store(true, Ordering::SeqCst);
let reply = answer(&mut h, &rx);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"]["photos"][0]["id"], 1, "{reply}");
assert_eq!(reply["result"]["photos"].as_array().unwrap().len(), PHOTOS as usize / 2, "{reply}");
assert_eq!(drive.on_ui.load(Ordering::SeqCst), 0);
assert_eq!(h.app.ui.view, ViewMode::Detail);
assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast);
}
/// `activity.cancel` stops the search; the request is told so.
#[test]
fn stopping_answers_the_request_with_an_error() {
let (mut h, drive) = app_on_a_slow_drive(GUARD);
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
let id = rows(&h)[0].id;
let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T);
assert_eq!(r["ok"], true, "{r}");
drive.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_eq!(h.app.session.filter, Default::default());
}
/// A request that comes while a search runs is refused at once, not left to wait.
#[test]
fn a_second_request_is_refused_at_once() {
let (mut h, drive) = app_on_a_slow_drive(GUARD);
let first = ask(&mut h);
for _ in 0..3 {
h.step();
}
let second = ask(&mut h);
let refused = answer(&mut h, &second);
assert_eq!(refused["ok"], false, "{refused}");
assert!(refused["error"].as_str().is_some_and(|e| e.contains("already running")), "{refused}");
drive.open.store(true, Ordering::SeqCst);
assert_eq!(answer(&mut h, &first)["ok"], true);
}
/// A caller whose request timed out asks again: what was read stays known, so it isn't read again.
#[test]
fn a_request_nobody_waits_for_any_more_leaves_what_it_read() {
let (mut h, drive) = app_on_a_slow_drive(Duration::ZERO);
drive.open.store(true, Ordering::SeqCst);
drop(ask(&mut h));
// (a frame takes the request and starts the search)
h.step();
until_done(&mut h);
let reads = drive.reads.load(Ordering::SeqCst);
assert_eq!(reads, PHOTOS as usize);
let rx = ask(&mut h);
assert_eq!(answer(&mut h, &rx)["ok"], true);
assert_eq!(drive.reads.load(Ordering::SeqCst), reads);
}
/// Another library was opened: the search stops, and neither its answer nor what it read reaches the
/// new library.
#[test]
fn another_library_stops_the_search_and_gets_nothing_from_it() {
let (mut h, drive) = app_on_a_slow_drive(GUARD);
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
let dir = std::env::temp_dir().join(format!("lc-ui-similar-other-{}", std::process::id()));
h.app.session.open_library(&dir, false).unwrap();
h.step();
drive.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("another library")), "{reply}");
let read = drive.reads.load(Ordering::SeqCst);
assert!(read < PHOTOS as usize, "it stopped reading: {read}");
assert!(h.app.session.signatures.is_empty());
assert_eq!(h.app.session.filter, Default::default());
let _ = std::fs::remove_dir_all(dir);
}
+1
View File
@@ -61,6 +61,7 @@ guard.detail(&file_name);
| Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state |
| Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) |
| Reload from Disk | `crates/ui-egui/src/reload.rs`, `crates/engine/src/cmd/reload.rs` | a command split into plan / probe / apply so the app reads the files on a worker thread (`tasks::spawn_tracked`: progress and `Cancel::Flag`); the result is committed through `Session::execute_fn` (undo step, save, XMP auto-write), and dropped for a photo a later-started reload has already updated; `wait: true` runs it in place |
| Find Similar Photos | `crates/ui-egui/src/find_similar.rs`, `crates/engine/src/cmd/similar.rs` | a command split into plan / measure / apply, whose reads are kept when it is stopped; a control request is answered when it ends (`Outcome::Later`) |
| Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps |
## Testing
File diff suppressed because one or more lines are too long
+13
View File
@@ -8,6 +8,19 @@
decomposed Vietnamese letters lost their stacked marks. Text is now composed before it is laid out, so both draw as
they do in the app (issue #3480, #3277).
## 2026-10-31
### Find Similar Photos no longer freezes the window
- The first search reads every photo in the library, and it did that on the thread that draws the window, so in a large
library the app stopped responding and Windows offered to close it (issue #3468). The photos are now read in the
background: a "Find Similar Photos" row in the activity stack counts them, you can keep working meanwhile, and its ✕
stops the search. A stopped search leaves the view as it was, and the next one goes on from what was read. The menu
item is off while a search runs, and opening another library stops it. When a search ends the grid shows the photos it
found, also from the Photo menu, and a message says how many. `library.findSimilar` from the control channel and
`mcp --connect` still gets `{photos}`, now when the search ends; a caller that stops waiting (the control server
gives a request 60 s) can ask again once that search has ended (while it runs, a new request is refused as already
running), and what was read isn't read again.
## 2026-10-30
### Lens distortion correction for the Sony A7 III