mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 09:20:07 -06:00
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:
@@ -40,6 +40,7 @@ mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
pub mod reload;
|
||||
pub mod similar;
|
||||
mod slideshow;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms, req.reply.clone());
|
||||
ctx.request_repaint();
|
||||
return started;
|
||||
}
|
||||
let r = app.run(id, params);
|
||||
ctx.request_repaint();
|
||||
wrap(r)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user