mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
Photos imported before focus points were read got one only through Reload from Disk, which
hashes whole files on the UI thread. Now:
- lightcraft_meta::focus::read_header reads only what the decoder looks at: IFD0 (Make,
Model), the Exif IFD, the maker note's IFD and the focus tags' values (a JPEG: its segments
up to the Exif one), walked round by round and decoded with the whole-file code from a
buffer of the file's length holding just those ranges. Bounded rounds, bytes (2 MB) and
size (1 GB). One 32 KB read per α1 / A7S III ARW; on 109 real files (83 with a point)
it gives the whole-file read's point for every one.
- A background job (crates/engine/src/focus_scan.rs) reads photos with no point from a
supported body (by the stored camera; nothing else is opened), ARW or JPEG, one at a
time in path order, with a "Reading focus points" activity row (Cancel::Flag). The UI's
per-frame focus.pump gives it the face scan's pace (paused while dragging or typing) and
starts it once per library opening. Commits every 200 photos or 3 s as system ops (no
undo step, no sidecar write), only meta.focus, only on photos that still have none at the
same path. "Looked, none" goes to focus-scanned.bin beside the library, headed by the
decoder's tag so a decoder knowing more bodies starts afresh; unreadable files are retried
at the next opening.
- photo.readFocusPoints {ids?, wait?} (Photo > Read Focus Points) runs it on demand.
Measured cold on a 5400 rpm USB drive: 200 α1 / A7S III ARWs in 1.9 s (~100 photos/s,
32 KB each), so 58,796 raws take about 10 minutes instead of re-reading 2.25 TB.
298 lines
14 KiB
Rust
298 lines
14 KiB
Rust
//! Reading focus points for photos already in the library (`photo.readFocusPoints`, `focus.pump`): header-only reads,
|
|
//! batches committed without undo steps, "looked, found nothing" remembered across sessions, and the activity row.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
use crate::focus_scan::{read_focus, record_contains, record_len};
|
|
|
|
fn temp_dir(tag: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("lc-focus-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn sony_arw(mode: u8, samples: usize) -> Vec<u8> {
|
|
lightcraft_meta::focus::synthetic_arw(mode, samples)
|
|
}
|
|
|
|
/// A photo of `camera` at `path` (an import made before focus points were read: none stored).
|
|
fn photo(id: u64, path: &Path, camera: &str) -> Photo {
|
|
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
|
let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into_owned() }, &name, "ARW", 8640, 5760, "2026-10-13T00:00:00");
|
|
p.meta.camera = camera.into();
|
|
p
|
|
}
|
|
|
|
fn add(s: &mut Session, photos: Vec<Photo>) {
|
|
s.execute_fn("test.add", |s| {
|
|
for p in photos {
|
|
s.apply_system(Op::AddPhoto { photo: Box::new(p) })?;
|
|
}
|
|
Ok(Value::Null)
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
fn focus(s: &Session, id: u64) -> Option<lightcraft_meta::FocusPoint> {
|
|
s.catalog.photo(PhotoId(id)).unwrap().meta.focus.clone()
|
|
}
|
|
|
|
fn pump_until_idle(s: &mut Session) -> Value {
|
|
let started = Instant::now();
|
|
loop {
|
|
let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
|
if v["active"] == false {
|
|
return v;
|
|
}
|
|
assert!(started.elapsed() < Duration::from_secs(30), "the job never ended: {v}");
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_header_read_reads_a_small_part_of_the_file() {
|
|
let dir = temp_dir("header");
|
|
let path = dir.join("a.ARW");
|
|
let file = sony_arw(3, 4_000_000);
|
|
std::fs::write(&path, &file).unwrap();
|
|
let r = read_focus(&path.to_string_lossy()).unwrap();
|
|
assert!(r.focus.is_some());
|
|
assert_eq!(r.focus, lightcraft_meta::extract(&file).focus, "the same point as a whole-file read");
|
|
assert!(r.bytes_read <= 32 * 1024 && r.bytes_read * 100 < file.len() as u64, "read {} of {}", r.bytes_read, file.len());
|
|
assert!(read_focus(&dir.join("gone.ARW").to_string_lossy()).is_err());
|
|
}
|
|
|
|
/// Fills the missing points; leaves other cameras, other formats and known points alone without opening them; records
|
|
/// "looked, found nothing" (manual focus, garbage) but not a file it could not read; makes no undo step.
|
|
#[test]
|
|
fn fills_missing_points_and_remembers_photos_without_one() {
|
|
let dir = temp_dir("fill");
|
|
let af = dir.join("af.ARW");
|
|
std::fs::write(&af, sony_arw(3, 100_000)).unwrap();
|
|
let manual = dir.join("manual.ARW");
|
|
std::fs::write(&manual, sony_arw(0, 100_000)).unwrap();
|
|
let garbage = dir.join("garbage.ARW");
|
|
std::fs::write(&garbage, vec![0x5a; 70_000]).unwrap();
|
|
let heif = dir.join("x.HIF");
|
|
std::fs::write(&heif, sony_arw(3, 1000)).unwrap();
|
|
let known = lightcraft_meta::FocusPoint {
|
|
rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
|
|
mode: Some("AF-S".into()),
|
|
area: None,
|
|
tracking: None,
|
|
};
|
|
let mut with_point = photo(4, &af, "SONY ILCE-1");
|
|
with_point.meta.focus = Some(known.clone());
|
|
let mut s = Session::new();
|
|
add(
|
|
&mut s,
|
|
vec![
|
|
photo(1, &af, "SONY ILCE-1"),
|
|
photo(2, &manual, "SONY ILCE-1"),
|
|
// another body's photo is not opened, even when its file would have a point
|
|
photo(3, &af, "Canon EOS R5"),
|
|
with_point,
|
|
photo(5, &dir.join("offline.ARW"), "SONY ILCE-7SM3"),
|
|
photo(6, &garbage, "SONY ILCE-1"),
|
|
photo(7, &heif, "SONY ILCE-1"),
|
|
],
|
|
);
|
|
let undo = s.undo.len();
|
|
let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap();
|
|
assert_eq!(r["queued"], 4, "{r}");
|
|
let last = &r["last"];
|
|
assert_eq!(
|
|
(last["done"].as_u64(), last["found"].as_u64(), last["none"].as_u64(), last["unavailable"].as_u64()),
|
|
(Some(4), Some(1), Some(2), Some(1)),
|
|
"{r}"
|
|
);
|
|
assert!(last["bytesRead"].as_u64().unwrap() <= 3 * 32 * 1024, "{r}");
|
|
assert_eq!(focus(&s, 1), lightcraft_meta::extract(&std::fs::read(&af).unwrap()).focus);
|
|
assert!(focus(&s, 1).is_some());
|
|
assert_eq!(focus(&s, 1).unwrap().words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]);
|
|
assert_eq!(focus(&s, 2), None);
|
|
assert_eq!(focus(&s, 3), None);
|
|
assert_eq!(focus(&s, 4), Some(known), "a stored point is never replaced");
|
|
assert_eq!(focus(&s, 5), None);
|
|
assert_eq!(focus(&s, 7), None);
|
|
assert!(record_contains(&s, PhotoId(2)) && record_contains(&s, PhotoId(6)));
|
|
assert!(!record_contains(&s, PhotoId(5)), "an unreadable file is tried again later");
|
|
assert_eq!(record_len(&s), 2);
|
|
assert_eq!(s.undo.len(), undo, "no undo step");
|
|
// asked again: only the offline photo is left
|
|
assert_eq!(s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap()["queued"], 1);
|
|
// named photos are looked at again, even those recorded as having none
|
|
assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 3, 4], "wait": true})).unwrap()["queued"], 1);
|
|
}
|
|
|
|
/// What was read survives a restart: the next session reads nothing again, and a photo whose file was offline is read
|
|
/// once it is back. The automatic start (once per library opening, from the pump) picks it up.
|
|
#[test]
|
|
fn resumes_after_a_restart_without_reading_again() {
|
|
let dir = temp_dir("resume");
|
|
let lib = dir.join("library");
|
|
let af = dir.join("af.ARW");
|
|
std::fs::write(&af, sony_arw(3, 50_000)).unwrap();
|
|
let manual = dir.join("manual.ARW");
|
|
std::fs::write(&manual, sony_arw(0, 50_000)).unwrap();
|
|
let later = dir.join("later.ARW");
|
|
{
|
|
let mut s = Session::new();
|
|
s.open_library(&lib, false).unwrap();
|
|
add(&mut s, vec![photo(1, &af, "SONY ILCE-1"), photo(2, &manual, "SONY ILCE-1"), photo(3, &later, "SONY ILCE-1")]);
|
|
// the pump starts it by itself
|
|
let first = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
|
assert_eq!(first["active"], true, "{first}");
|
|
let v = pump_until_idle(&mut s);
|
|
assert_eq!(v["last"]["found"], 1, "{v}");
|
|
assert!(lib.join(crate::focus_scan::RECORD_FILE).exists());
|
|
}
|
|
let mut s = Session::new();
|
|
s.open_library(&lib, false).unwrap();
|
|
assert!(focus(&s, 1).is_some(), "the point was saved with the library");
|
|
// the file that was offline is back: only it is read
|
|
std::fs::write(&later, sony_arw(3, 50_000)).unwrap();
|
|
let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
|
assert!(record_contains(&s, PhotoId(2)), "the record was read back");
|
|
assert_eq!(v["running"]["total"], 1, "{v}");
|
|
let v = pump_until_idle(&mut s);
|
|
assert_eq!((v["last"]["total"].as_u64(), v["last"]["found"].as_u64()), (Some(1), Some(1)), "{v}");
|
|
assert!(focus(&s, 3).is_some());
|
|
assert!(record_contains(&s, PhotoId(2)));
|
|
// and it doesn't start again in this opening
|
|
assert_eq!(s.execute("focus.pump", &json!({})).unwrap()["active"], false);
|
|
}
|
|
|
|
/// The job has a row in the activity stack (n of N, cancellable); paused, it reads nothing new; cancelled (the row's ✕),
|
|
/// it commits what it read and the row goes away.
|
|
#[test]
|
|
fn the_activity_row_progresses_pauses_and_cancels() {
|
|
let dir = temp_dir("row");
|
|
let mut photos = Vec::new();
|
|
for i in 0..40u64 {
|
|
let p = dir.join(format!("{i:03}.ARW"));
|
|
std::fs::write(&p, sony_arw(3, 20_000)).unwrap();
|
|
photos.push(photo(i + 1, &p, "SONY ILCE-7SM3"));
|
|
}
|
|
let mut s = Session::new();
|
|
add(&mut s, photos);
|
|
// the pump starts the job by itself, at the pace it is given: paused, while the user drags
|
|
let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
|
assert_eq!(v["active"], true);
|
|
let rows = s.activity.list();
|
|
let row = rows.iter().find(|t| t.kind == "focus").expect("an activity row");
|
|
assert_eq!((row.label.as_str(), row.total, row.cancellable), ("Reading focus points", 40, true));
|
|
// already running: asking again starts nothing new
|
|
let r = s.execute("photo.readFocusPoints", &json!({})).unwrap();
|
|
assert_eq!((r["queued"].as_u64(), r["running"].as_bool()), (Some(0), Some(true)));
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
let still = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["done"].as_u64().unwrap();
|
|
assert_eq!(still, 0, "paused: nothing read");
|
|
// a few more at a light pace, then cancel
|
|
let started = Instant::now();
|
|
let mut done = still;
|
|
while done < still + 3 && started.elapsed() < Duration::from_secs(20) {
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
done = s.execute("focus.pump", &json!({"pace": "light"})).unwrap()["running"]["done"].as_u64().unwrap_or(40);
|
|
}
|
|
let row = s.activity.list().into_iter().find(|t| t.kind == "focus").expect("still running");
|
|
assert!(row.done >= 3, "progress shows: {}", row.done);
|
|
s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
|
s.activity.cancel(row.id).unwrap();
|
|
let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
|
assert_eq!(v["active"], false, "{v}");
|
|
assert_eq!(v["last"]["cancelled"], true, "{v}");
|
|
assert!(s.activity.list().iter().all(|t| t.kind != "focus"), "the row is gone");
|
|
let found = s.catalog.photos().filter(|p| p.meta.focus.is_some()).count() as u64;
|
|
assert_eq!(found, v["last"]["found"].as_u64().unwrap(), "what was read before the cancel is kept");
|
|
assert!(found < 40);
|
|
// the rest are read next time
|
|
let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap();
|
|
assert_eq!(r["queued"].as_u64().unwrap(), 40 - found);
|
|
assert_eq!(s.catalog.photos().filter(|p| p.meta.focus.is_some()).count(), 40);
|
|
}
|
|
|
|
/// Photos deleted, moved or given a point while the job runs are left alone; a library closed mid-run takes the job
|
|
/// with it (nothing lands in the next library), and nothing panics.
|
|
#[test]
|
|
fn changes_under_a_running_job_are_respected() {
|
|
let dir = temp_dir("changes");
|
|
let mut photos = Vec::new();
|
|
for i in 0..6u64 {
|
|
let p = dir.join(format!("{i}.ARW"));
|
|
std::fs::write(&p, sony_arw(3, 10_000)).unwrap();
|
|
photos.push(photo(i + 1, &p, "SONY ILCE-1"));
|
|
}
|
|
let mut s = Session::new();
|
|
s.open_library(dir.join("lib-a"), false).unwrap();
|
|
add(&mut s, photos);
|
|
// started by the pump, paused
|
|
assert_eq!(s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["total"], 6);
|
|
// meanwhile: photo 1 removed, photo 2 relinked elsewhere, photo 3 given a point by the user's Reload, photo 4's file
|
|
// replaced by garbage, photo 5's deleted
|
|
let mine =
|
|
lightcraft_meta::FocusPoint { rect: lightcraft_geom::Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.6 }, mode: None, area: None, tracking: None };
|
|
s.execute_fn("test.change", |s| {
|
|
s.apply_system(Op::RemovePhoto { id: PhotoId(1) })?;
|
|
let moved = dir.join("moved.ARW");
|
|
std::fs::copy(dir.join("1.ARW"), &moved).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
|
s.apply_system(Op::Relink {
|
|
id: PhotoId(2),
|
|
file_name: "moved.ARW".into(),
|
|
source: Source::File { path: moved.to_string_lossy().into() },
|
|
format: None,
|
|
})?;
|
|
let mut meta = s.catalog.photo(PhotoId(3)).unwrap().meta.clone();
|
|
meta.focus = Some(mine.clone());
|
|
s.apply_system(Op::SetMeta { id: PhotoId(3), meta: Box::new(meta) })?;
|
|
Ok(Value::Null)
|
|
})
|
|
.unwrap();
|
|
std::fs::write(dir.join("3.ARW"), b"not a raw any more").unwrap();
|
|
std::fs::remove_file(dir.join("4.ARW")).unwrap();
|
|
let v = pump_until_idle(&mut s);
|
|
assert!(s.catalog.photo(PhotoId(1)).is_none());
|
|
assert_eq!(focus(&s, 2), None, "read at the old path: left for another run");
|
|
assert_eq!(focus(&s, 3), Some(mine));
|
|
assert_eq!(focus(&s, 5), None);
|
|
assert!(focus(&s, 6).is_some());
|
|
assert!(v["last"]["done"].as_u64().unwrap() <= 6, "{v}");
|
|
// a library closed mid-run: the job goes with it
|
|
s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
|
assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 4, 5]})).unwrap()["queued"], 3);
|
|
s.open_library(dir.join("lib-b"), false).unwrap();
|
|
let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
|
assert_eq!(v["active"], false, "{v}");
|
|
assert!(s.activity.list().iter().all(|t| t.kind != "focus"));
|
|
assert_eq!(s.catalog.photos().count(), 0);
|
|
}
|
|
|
|
/// Opt-in measurement on real files: `LIGHTCRAFT_FOCUS_FILES=<a text file listing raw paths, one per line>` reads each
|
|
/// whole and by header, asserts they give the same focus point, and prints the bytes the header read took. Skipped
|
|
/// without it; no user file is part of the repository.
|
|
#[test]
|
|
fn real_files_header_read_matches_whole_file() {
|
|
let Ok(list) = std::env::var("LIGHTCRAFT_FOCUS_FILES") else { return };
|
|
let paths: Vec<String> = std::fs::read_to_string(list).unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
|
|
let (mut n, mut with_point, mut bytes, mut max) = (0u64, 0u64, 0u64, 0u64);
|
|
for p in &paths {
|
|
let whole = lightcraft_meta::extract(&std::fs::read(p).unwrap()).focus;
|
|
let r = read_focus(p).unwrap();
|
|
assert_eq!(r.focus, whole, "{p}");
|
|
n += 1;
|
|
with_point += u64::from(whole.is_some());
|
|
bytes += r.bytes_read;
|
|
max = max.max(r.bytes_read);
|
|
}
|
|
eprintln!(
|
|
"focus header reads: {n} files ({with_point} with a point) agree with whole-file reads; {} bytes each on average, {max} at most",
|
|
bytes / n.max(1)
|
|
);
|
|
}
|