diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs index 6fc618039..eb02ff8bf 100644 --- a/crates/engine/src/activity.rs +++ b/crates/engine/src/activity.rs @@ -73,7 +73,7 @@ impl Unit { pub struct TaskInfo { pub id: u64, /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, - /// `faces`, `denoise`, `findMissing`, `applePhotos`. + /// `faces`, `denoise`, `findMissing`, `applePhotos`, `focus`. pub kind: &'static str, /// English; the frontend translates it. pub label: String, diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs new file mode 100644 index 000000000..efe1d1b7f --- /dev/null +++ b/crates/engine/src/cmd/focus.rs @@ -0,0 +1,50 @@ +//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the +//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`. + +use serde_json::Value; + +use super::{CommandSpec, cmd}; +use crate::{Result, Session}; + +#[cfg(not(target_arch = "wasm32"))] +fn read_points(s: &mut Session, p: &Value) -> Result { + crate::focus_scan::read_points(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn read_points(_: &mut Session, _: &Value) -> Result { + Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn pump(s: &mut Session, p: &Value) -> Result { + crate::focus_scan::pump(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn pump(_: &mut Session, _: &Value) -> Result { + Ok(serde_json::json!({"active": false})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "photo.readFocusPoints", + "Read Focus Points", + ["Photo"], + None, + "{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)", + super::always, + read_points + ), + cmd!( + query "focus.pump", + "Read Focus Points in the Background", + [], + None, + "{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at", + super::always, + pump + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..47477d3c7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; +mod focus; pub mod geocode; pub mod keywords; pub mod library; @@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(focus::specs()); v.extend(activity::specs()); v }) diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index fd4d9f2e7..1b5920999 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -222,15 +222,29 @@ const SCAN_MAX_FILE: u64 = 64 << 20; /// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft /// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to). -#[derive(Default)] +/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag). pub(crate) struct Scanned { set: std::collections::HashSet, unsaved: Vec, path: Option, file_matches: bool, + magic: [u8; 4], + /// What made the record: a file made by anything else is started afresh. + tag: String, +} + +impl Default for Scanned { + fn default() -> Self { + Scanned::new(*SCAN_MAGIC, SCAN_TAG) + } } impl Scanned { + /// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`. + pub fn new(magic: [u8; 4], tag: &str) -> Scanned { + Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, magic, tag: tag.to_string() } + } + pub fn path(&self) -> Option<&Path> { self.path.as_deref() } @@ -265,12 +279,13 @@ impl Scanned { } } - fn header() -> Vec { + fn header(&self) -> Vec { let mut h = Vec::new(); - h.extend_from_slice(SCAN_MAGIC); + h.extend_from_slice(&self.magic); h.push(SCAN_VERSION); - h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes()); - h.extend_from_slice(SCAN_TAG.as_bytes()); + let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes()); + h.extend_from_slice(&(tag.len() as u16).to_le_bytes()); + h.extend_from_slice(tag); h } @@ -280,7 +295,7 @@ impl Scanned { return; } let Ok(bytes) = std::fs::read(path) else { return }; - let head = Self::header(); + let head = self.header(); let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return }; self.file_matches = true; // a half-written last id (a crash while appending) is ignored @@ -302,7 +317,7 @@ impl Scanned { let buf: Vec = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); f.write_all(&buf)?; } else { - let mut buf = Self::header(); + let mut buf = self.header(); buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes())); let part = path.with_extension("part"); std::fs::write(&part, &buf)?; diff --git a/crates/engine/src/focus_scan.rs b/crates/engine/src/focus_scan.rs new file mode 100644 index 000000000..6be63e7eb --- /dev/null +++ b/crates/engine/src/focus_scan.rs @@ -0,0 +1,432 @@ +//! Reading the camera's focus point for photos already in the library (issue #1480). +//! +//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job +//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body +//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few +//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1 +//! ARW), one file at a time on one worker thread. +//! +//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping +//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only +//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path. +//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in +//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the +//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are +//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded: +//! it is tried again the next time the library opens. +//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's +//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between +//! files while they are around. +//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand +//! with `photo.readFocusPoints`. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use lightcraft_meta::focus::HeaderRead; +use serde_json::{Value, json}; + +use crate::activity::{Cancel, TaskGuard}; +use crate::faces_index::Scanned; +use crate::{Result, Session}; + +/// The record of photos looked at with nothing found, beside the library. +pub(crate) const RECORD_FILE: &str = "focus-scanned.bin"; +const RECORD_MAGIC: [u8; 4] = *b"LCFP"; +/// Photos committed at once. +pub(crate) const COMMIT_EVERY: usize = 200; +/// What has been read is committed at least this often, so points appear while a long run goes on. +const COMMIT_AFTER: Duration = Duration::from_secs(3); +/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first. +const LIGHT_REST: Duration = Duration::from_millis(40); +/// How often a paused worker looks at its pace again. +const PAUSE_POLL: Duration = Duration::from_millis(50); +/// Results taken in per pump (the rest wait for the next frame). +const MAX_DRAIN: usize = 5000; + +/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads). +const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"]; + +/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum Pace { + Pause = 0, + Light = 1, + Normal = 2, +} + +impl Pace { + pub fn parse(name: Option<&str>) -> Pace { + match name { + Some("pause") => Pace::Pause, + Some("light") => Pace::Light, + _ => Pace::Normal, + } + } +} + +/// The focus point in the file at `path`, read from its header only. +pub fn read_focus(path: &str) -> std::result::Result { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); + lightcraft_meta::focus::read_header(len, &mut |off, buf| { + file.seek(SeekFrom::Start(off))?; + file.read_exact(buf) + }) + .map_err(|e| format!("{path}: {e}")) +} + +/// What the worker says about one photo. +enum Outcome { + /// The header was read: the point it holds, or none. + Read { id: PhotoId, focus: Option, bytes: u64 }, + /// The file couldn't be read (offline, gone, changed under the read): tried again another time. + Unavailable, +} + +/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Summary { + pub total: u64, + pub done: u64, + pub found: u64, + pub none: u64, + pub unavailable: u64, + pub bytes: u64, + pub seconds: f64, + pub cancelled: bool, +} + +impl Summary { + fn json(&self) -> Value { + json!({ + "total": self.total, + "done": self.done, + "found": self.found, + "none": self.none, + "unavailable": self.unavailable, + "bytesRead": self.bytes, + "seconds": self.seconds, + "cancelled": self.cancelled, + }) + } +} + +struct Job { + identity: Arc<()>, + cancel: Arc, + pace: Arc, + rx: mpsc::Receiver<(Outcome, String)>, + /// Its row in the activity stack (dropping the job removes it). + guard: TaskGuard, + /// The path each photo was read at: a photo moved or relinked since is left for another run. + paths: HashMap, + pending: Vec<(PhotoId, Option)>, + committed_at: Instant, + started: Instant, + summary: Summary, +} + +impl Drop for Job { + fn drop(&mut self) { + // the worker stops before its next file (and when it can't hand a result over) + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The backfill's state in the session. +pub(crate) struct FocusScan { + record: Scanned, + /// The library opening the record and the automatic start belong to. + library: Option>, + auto_checked: bool, + job: Option, + last: Option, + /// The pace the UI last gave (a job started between two pumps starts at it). + pace: Pace, +} + +impl Default for FocusScan { + fn default() -> Self { + FocusScan { + record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()), + library: None, + auto_checked: false, + job: None, + last: None, + pace: Pace::Normal, + } + } +} + +/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and +/// are read again there next time) and loads that library's record. +fn follow_library(s: &mut Session) { + if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) { + return; + } + s.focus_scan.job = None; + s.focus_scan.auto_checked = false; + let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE)); + s.focus_scan.record.reset(path); + s.focus_scan.library = Some(Arc::clone(&s.library_identity)); +} + +/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file; +/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together). +fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> { + let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> { + if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) { + return None; + } + let Source::File { path } = &ph.source else { return None }; + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone())) + }; + let mut out: Vec<(PhotoId, String)> = match ids { + Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(), + None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(), + }; + out.sort_by(|a, b| a.1.cmp(&b.1)); + out.dedup_by_key(|c| c.0); + out +} + +/// Start reading `list` on a worker thread. +fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> { + let cancel = Arc::new(AtomicBool::new(false)); + let pace = Arc::new(AtomicU8::new(pace as u8)); + let (tx, rx) = mpsc::channel(); + let paths: HashMap = list.iter().cloned().collect(); + let total = list.len() as u64; + let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone()); + std::thread::Builder::new() + .name("focus-points".into()) + .spawn(move || { + for (id, path) in list { + loop { + if worker_cancel.load(Ordering::Relaxed) { + return; + } + if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 { + break; + } + std::thread::sleep(PAUSE_POLL); + } + if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 { + std::thread::sleep(LIGHT_REST); + } + // a decoder bug in one file is that file's problem, not the job's end + let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) { + Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read }, + Ok(Err(e)) => { + log::debug!("focus point: {e}"); + Outcome::Unavailable + } + Err(_) => Outcome::Unavailable, + }; + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if tx.send((outcome, name)).is_err() { + return; + } + } + }) + .map_err(|e| format!("could not start reading focus points: {e}"))?; + let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone())); + guard.progress(0, total); + let now = Instant::now(); + s.focus_scan.job = Some(Job { + identity: Arc::clone(&s.library_identity), + cancel, + pace, + rx, + guard, + paths, + pending: Vec::new(), + committed_at: now, + started: now, + summary: Summary { total, ..Default::default() }, + }); + Ok(()) +} + +/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none +/// (to the record). +fn commit(s: &mut Session) { + let Some(job) = s.focus_scan.job.as_mut() else { return }; + let pending = std::mem::take(&mut job.pending); + job.committed_at = Instant::now(); + if !Arc::ptr_eq(&job.identity, &s.library_identity) { + return; + } + let mut ops = Vec::new(); + let mut none = Vec::new(); + for (id, focus) in pending { + let Some(ph) = s.catalog.photo(id) else { continue }; + let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read); + // deleted, moved, relinked or given a point meanwhile: leave it + if !same_file || !ph.in_library() || ph.meta.focus.is_some() { + continue; + } + match focus { + Some(f) => { + let mut meta = ph.meta.clone(); + meta.focus = Some(f); + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + None => none.push(id.0), + } + } + for id in none { + s.focus_scan.record.insert(id); + } + if let Err(e) = s.focus_scan.record.save() { + log::warn!("could not save the list of photos read for focus points: {e}"); + } + if ops.is_empty() { + return; + } + match s.apply_system(Op::Batch { ops }) { + // the focus point is never written to XMP: no sidecar is rewritten for it + Ok(()) => s.skip_auto_write = true, + Err(e) => log::warn!("could not store focus points: {e}"), + } +} + +/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled. +/// Returns whether a job is still running. +fn step(s: &mut Session, pace: Pace) -> bool { + let Some(job) = s.focus_scan.job.as_mut() else { return false }; + job.pace.store(pace as u8, Ordering::Relaxed); + let mut finished = job.cancel.load(Ordering::Relaxed); + let mut last_name = None; + for _ in 0..MAX_DRAIN { + match job.rx.try_recv() { + Ok((outcome, name)) => { + job.summary.done += 1; + last_name = Some(name); + match outcome { + Outcome::Read { id, focus, bytes } => { + job.summary.bytes = job.summary.bytes.saturating_add(bytes); + if focus.is_some() { + job.summary.found += 1; + } else { + job.summary.none += 1; + } + job.pending.push((id, focus)); + } + Outcome::Unavailable => job.summary.unavailable += 1, + } + } + Err(mpsc::TryRecvError::Empty) => break, + // the worker ended (all read, cancelled, or it could not go on) + Err(mpsc::TryRecvError::Disconnected) => { + finished = true; + break; + } + } + } + finished |= job.summary.done >= job.summary.total; + job.guard.progress(job.summary.done, job.summary.total); + if let Some(name) = last_name { + job.guard.detail(&name); + } + if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) { + commit(s); + } + if finished && let Some(job) = s.focus_scan.job.take() { + let mut summary = job.summary.clone(); + summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total; + summary.seconds = job.started.elapsed().as_secs_f64(); + log::info!( + "focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s", + summary.done, + summary.total, + summary.found, + summary.none, + summary.unavailable, + summary.bytes / 1024, + summary.seconds + ); + s.focus_scan.last = Some(summary); + return false; + } + true +} + +fn status(s: &Session) -> Value { + let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found})); + json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()}) +} + +/// `focus.pump {pace?}`: cheap to call every frame. +pub(crate) fn pump(s: &mut Session, p: &Value) -> Result { + follow_library(s); + let pace = Pace::parse(p.get("pace").and_then(Value::as_str)); + s.focus_scan.pace = pace; + if !step(s, pace) && !s.focus_scan.auto_checked { + // once per library opening: start by itself when photos are left to look at (a few KB each) + s.focus_scan.auto_checked = true; + let list = candidates(s, None); + if !list.is_empty() + && let Err(e) = start(s, list, pace) + { + log::warn!("{e}"); + } + } + Ok(status(s)) +} + +/// `photo.readFocusPoints {ids?, wait?}`. +pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "photo.readFocusPoints"; + follow_library(s); + if s.focus_scan.job.is_some() { + return Ok(json!({"queued": 0, "running": true, "status": status(s)})); + } + let ids: Option> = match p.get("ids") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_array() + .ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))? + .iter() + .map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))) + .collect::>>()?, + ), + }; + let list = candidates(s, ids.as_deref()); + let queued = list.len(); + if queued == 0 { + return Ok(json!({"queued": 0, "running": false})); + } + let wait = p.get("wait").and_then(Value::as_bool) == Some(true); + start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?; + // a run asked for counts as this library opening's automatic one + s.focus_scan.auto_checked = true; + if !wait { + return Ok(json!({"queued": queued, "running": true})); + } + // a script's call: read them all now (the batches still commit as they come) + while step(s, Pace::Normal) { + std::thread::sleep(Duration::from_millis(5)); + } + let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null); + Ok(json!({"queued": queued, "running": false, "last": last})) +} + +#[cfg(test)] +pub(crate) fn record_len(s: &Session) -> usize { + s.focus_scan.record.len() +} + +#[cfg(test)] +pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool { + s.focus_scan.record.contains(id.0) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9631b7d84..46d99073a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,8 @@ mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files; +#[cfg(not(target_arch = "wasm32"))] +mod focus_scan; pub mod fonts; pub mod guard; pub mod import; @@ -209,6 +211,9 @@ pub struct Session { /// The loaded recognition model and the face embeddings made with it. #[cfg(not(target_arch = "wasm32"))] pub(crate) faces: faces_index::FacesState, + /// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) focus_scan: focus_scan::FocusScan, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -350,6 +355,8 @@ impl Session { face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + focus_scan: Default::default(), clipboard: None, meta_clipboard: None, browse: None, @@ -1002,6 +1009,8 @@ mod tests_face_models; mod tests_face_recognize; #[cfg(test)] mod tests_filesystem_history; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_focus_scan; #[cfg(test)] mod tests_folders; #[cfg(test)] diff --git a/crates/engine/src/tests_focus_scan.rs b/crates/engine/src/tests_focus_scan.rs new file mode 100644 index 000000000..eadfbdbc2 --- /dev/null +++ b/crates/engine/src/tests_focus_scan.rs @@ -0,0 +1,297 @@ +//! 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 { + 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) { + 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 { + 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=` 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 = 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) + ); +} diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs index 92801f8a7..e1c84f296 100644 --- a/crates/meta/src/focus.rs +++ b/crates/meta/src/focus.rs @@ -29,6 +29,7 @@ //! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or //! Flexible Spot frame is a real choice and is kept. +pub use crate::focus_read::{HEADER_PREFIX, HeaderRead, MAX_HEADER_BYTES, read_header}; use crate::{Orientation, Rect}; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; use serde::{Deserialize, Serialize}; @@ -80,6 +81,19 @@ impl FocusPoint { /// tags differently (or not mean the same by them): it shows no focus point until its files are checked. pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; +/// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is +/// one whose files can carry a focus point, so a photo from any other camera is never opened to look. +pub fn camera_supported(camera: &str) -> bool { + let camera = camera.trim(); + camera.get(..4).is_some_and(|m| m.eq_ignore_ascii_case("SONY")) && camera.rsplit(' ').next().is_some_and(|model| SONY_MODELS.contains(&model)) +} + +/// Names what [`read`] decodes (its bodies), so a record of "looked, found nothing" made by this +/// decoder is not trusted by one that knows more bodies. +pub fn decoder_tag() -> String { + format!("sony-v1:{}", SONY_MODELS.join(",")) +} + const FOCUS_MODE: u16 = 0x201b; const AF_AREA_SETTING: u16 = 0x201c; const AF_TRACKING: u16 = 0x2021; @@ -160,6 +174,54 @@ pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Opt FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() } +/// A minimal little-endian TIFF laid out as an α1 ARW's metadata is (IFD0 with Make `SONY` and Model `ILCE-1` → +/// Exif IFD → a header-less maker note with the focus tags: AF-C or the given FocusMode byte, Wide, Face tracking, +/// Animal Eye Tracking at (3226, 1980) of 8640 × 5760), followed by `samples` bytes standing in for sensor data. For +/// tests of the readers and the backfill in other crates; not a real raw (it has no image). +#[doc(hidden)] +pub fn synthetic_arw(mode: u8, samples: usize) -> Vec { + fn entry(f: &mut Vec, tag: u16, typ: u16, count: u32, value: [u8; 4]) { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&typ.to_le_bytes()); + f.extend_from_slice(&count.to_le_bytes()); + f.extend_from_slice(&value); + } + let encipher = |p: u8| if p >= 249 { p } else { (u32::from(p).pow(3) % 249) as u8 }; + let mut f = b"II*\0".to_vec(); + f.extend_from_slice(&8u32.to_le_bytes()); + // IFD0 at 8: Make (at 62), Model (at 68), Orientation, the Exif IFD (at 76) + f.extend_from_slice(&4u16.to_le_bytes()); + entry(&mut f, t::MAKE, 2, 5, 62u32.to_le_bytes()); + entry(&mut f, t::MODEL, 2, 7, 68u32.to_le_bytes()); + entry(&mut f, t::ORIENTATION, 3, 1, [1, 0, 0, 0]); + entry(&mut f, t::EXIF_IFD, 4, 1, 76u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + f.extend_from_slice(b"SONY\0\0ILCE-1\0\0"); + // the Exif IFD: the maker note at 94, 156 bytes + f.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut f, t::MAKER_NOTE, 7, 156, 94u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + // the maker note's IFD, its values from 172 + f.extend_from_slice(&6u16.to_le_bytes()); + entry(&mut f, FOCUS_MODE, 1, 1, [mode, 0, 0, 0]); + entry(&mut f, AF_AREA_SETTING, 1, 1, [0, 0, 0, 0]); + entry(&mut f, AF_TRACKING, 1, 1, [1, 0, 0, 0]); + entry(&mut f, FOCUS_LOCATION, 3, 4, 172u32.to_le_bytes()); + entry(&mut f, FOCUS_FRAME_SIZE, 7, 6, 180u32.to_le_bytes()); + entry(&mut f, TAG_9402, 7, 64, 186u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + for v in [8640u16, 5760, 3226, 1980] { + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0xaf, 0, 0xb4, 0, 1, 1]); + let mut block = [0u8; 64]; + block[0x16] = encipher(0x80 | mode); + block[0x17] = encipher(20); + f.extend_from_slice(&block); + f.extend((0..samples).map(|i| (i % 251) as u8)); + f +} + /// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. fn decipher(b: u8) -> u8 { if b >= 249 { @@ -415,13 +477,20 @@ mod tests { /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { + sony_file_with(make, model, orientation, note_entries, None) + } + + /// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF + /// (the gap filled with "sample data") when `far` is given. + fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option) -> Vec { let order = ByteOrder::Little; // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, // which we only know after writing; write once to learn where the note lands, then rebuild - let build = |note_at: u64| -> Vec { + let build = |note_at: u64, end: u64| -> (Vec, Vec) { let mut note = b"SONY DSC \0\0\0".to_vec(); let ifd_len = 2 + note_entries.len() * 12 + 4; let mut tail: Vec = Vec::new(); + let mut outside: Vec = Vec::new(); order.put_u16(&mut note, note_entries.len() as u16); for (tag, v) in note_entries { let raw = lightcraft_tiff::writer::encode(order, v); @@ -432,6 +501,9 @@ mod tests { let mut r = raw.clone(); r.resize(4, 0); note.extend(r); + } else if let (Some(gap), TAG_9402) = (far, *tag) { + order.put_u32(&mut note, (end + gap as u64) as u32); + outside = raw; } else { order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); tail.extend(raw); @@ -449,12 +521,17 @@ mod tests { ifd0.set(t::MODEL, Value::Ascii(model.into())); ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); ifd0.set_child(t::EXIF_IFD, exif); - TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap() + (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) }; - let first = build(0); + let (first, _) = build(0, 0); let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; - let file = build(at); + let (mut file, outside) = build(at, first.len() as u64); + assert_eq!(file.len(), first.len()); assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + if let Some(gap) = far { + file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); + file.extend(outside); + } file } @@ -499,6 +576,119 @@ mod tests { } } + /// A whole-file read and a header-only read of the same file agree, and the header read reads a + /// small part of it (the sample data after the metadata is never touched). + #[test] + fn header_read_matches_the_whole_file_and_reads_little() { + let read = |file: &[u8]| { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let s = off as usize; + buf.copy_from_slice(&file[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.bytes_read, asked); + r + }; + // a raw: the metadata, then 8 MB of "samples" + let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let meta_len = file.len(); + file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); + let whole = crate::extract(&file).focus; + assert!(whole.is_some()); + let r = read(&file); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); + assert!(meta_len < HEADER_PREFIX as usize); + // the area-mode block placed 3 MB further on: found in a further round, still a small part + let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); + far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); + let whole = crate::extract(&far).focus; + assert!(whole.is_some()); + let r = read(&far); + assert_eq!(r.focus, whole); + assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); + // a JPEG: the Exif segment after another segment + let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); + jpeg.extend_from_slice(&[0xff, 0xe1]); + jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpeg.extend_from_slice(b"Exif\0\0"); + jpeg.extend_from_slice(&exif); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); + let whole = crate::extract(&jpeg).focus; + assert!(whole.is_some()); + let r = read(&jpeg); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX); + // unsupported bodies agree too (no point either way) + let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + assert_eq!(read(&other).focus, None); + } + + /// Every truncation and corruption of a file gives the header read the whole-file read's answer + /// (or an error when the file is shorter than it said), never a panic. + #[test] + fn header_read_of_damaged_files_agrees_or_fails_cleanly() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + for cut in (0..file.len()).step_by(5) { + let part = &file[..cut]; + let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); + } + let mut seed = 0x9e37_79b9_u32; + for _ in 0..400 { + let mut f = file.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % f.len(); + f[i] = (seed >> 8) as u8; + } + let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(&f[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(&f).focus); + } + // a reader told the file is longer than it is (replaced since it was measured): an error + let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }); + assert!(r.is_err()); + } + + #[test] + fn the_synthetic_arw_is_laid_out_as_described() { + let f = synthetic_arw(3, 10); + assert_eq!(f.len(), 260); + let p = crate::extract(&f).focus.expect("a point"); + assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); + } + + #[test] + fn cameras_that_can_have_a_point() { + assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); + for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + assert!(!camera_supported(c), "{c}"); + } + } + /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative /// to; skipped without it. No user file is part of the repository. diff --git a/crates/meta/src/focus_read.rs b/crates/meta/src/focus_read.rs new file mode 100644 index 000000000..5a5d349d1 --- /dev/null +++ b/crates/meta/src/focus_read.rs @@ -0,0 +1,465 @@ +//! Reading a file's focus point without reading the file: [`read_header`] asks for the few byte +//! ranges [`crate::focus::read`] looks at (a TIFF raw's IFD0, its Exif IFD, the maker-note IFD and +//! the values of the focus tags; a JPEG's Exif segment) and decodes them exactly as a whole-file +//! read would. On the α1 and the A7S III all of it sits in the first 16 KB of a 50–120 MB ARW, so a +//! library of them can be backfilled from a slow disk by reading one small block per photo. +//! +//! The ranges are found by walking the structure as the readers do (the IFD tables and the value +//! offsets they hold), round by round: each round asks only for what the bytes loaded so far point +//! at. A TIFF is decoded from a buffer of the file's length in which only the loaded ranges hold the +//! file's bytes (the rest stays zero and its pages are never touched), so every offset and bound the +//! reader checks is the one it checks on the whole file. Rounds, bytes and the buffer are bounded: +//! a file that wants more is reported as having no focus point. + +use std::io; + +use crate::focus::FocusPoint; +use lightcraft_tiff::{ByteOrder, FieldType, Tiff, tags as t}; + +/// Bytes read first: the α1's and A7S III's focus data ends before 16 KB, so one read covers it. +pub const HEADER_PREFIX: u64 = 32 * 1024; +/// Later reads are widened to this granularity (fewer, larger reads on a spinning disk). +const ALIGN: u64 = 4 * 1024; +/// The most rounds of reads one file gets (a real file needs one; the walk takes at most nine). +const MAX_ROUNDS: usize = 12; +/// The most bytes read from one file: past this the layout is not one the decoder knows. +pub const MAX_HEADER_BYTES: u64 = 2 << 20; +/// The largest TIFF given a buffer of its length (its untouched pages are never written, but the +/// allocation must succeed: the engine's planned preview reads use the same bound). Raws are far below it. +const MAX_SPARSE_FILE: u64 = 1 << 30; +/// JPEG segments walked before giving up. +const MAX_SEGMENTS: usize = 1024; +/// Entries read from one IFD (the TIFF reader's limit). +const MAX_ENTRIES: u64 = 8192; + +/// The focus tags whose values [`crate::focus`] reads from a Sony maker note. +const FOCUS_TAGS: [u16; 6] = [0x201b, 0x201c, 0x2021, 0x2027, 0x2037, 0x9402]; + +/// What [`read_header`] found, and what it cost. +#[derive(Clone, Debug, PartialEq)] +pub struct HeaderRead { + /// The focus point a whole-file read gives ([`crate::extract`]`(file).focus`). + pub focus: Option, + /// Bytes read from the file. + pub bytes_read: u64, +} + +/// The focus point of a file of `len` bytes, reading only what it needs through `read_at(offset, +/// buf)`, which fills `buf` with the file's bytes at `offset` (an error — a file that shrank or +/// went away — is returned). A malformed or unknown file is `Ok` with no point. +pub fn read_header(len: u64, read_at: &mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>) -> io::Result { + let mut reader = Reader { len, read_at, bytes: 0 }; + let head = reader.fetch(0, HEADER_PREFIX.min(len))?; + let focus = if head.starts_with(&[0xff, 0xd8]) { + jpeg(&mut reader, &head)? + } else if matches!(Tiff::sniff(&head), Some((_, false))) { + tiff(&mut reader, head)? + } else { + None + }; + Ok(HeaderRead { focus, bytes_read: reader.bytes }) +} + +struct Reader<'a> { + len: u64, + read_at: &'a mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>, + bytes: u64, +} + +impl Reader<'_> { + /// `[start, end)` of the file (clipped to it), counted against [`MAX_HEADER_BYTES`]. + fn fetch(&mut self, start: u64, end: u64) -> io::Result> { + let end = end.min(self.len); + let n = end.saturating_sub(start); + if self.bytes.saturating_add(n) > MAX_HEADER_BYTES { + return Err(io::Error::other(OverBudget)); + } + let mut buf = vec![0u8; usize::try_from(n).map_err(|_| io::Error::other("range too large"))?]; + if n > 0 { + (self.read_at)(start, &mut buf)?; + } + self.bytes = self.bytes.saturating_add(n); + Ok(buf) + } +} + +/// A file whose focus data would take more than [`MAX_HEADER_BYTES`] to read. +#[derive(Debug)] +struct OverBudget; + +impl std::fmt::Display for OverBudget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("focus data larger than a header") + } +} + +impl std::error::Error for OverBudget {} + +fn over_budget(e: &io::Error) -> bool { + e.get_ref().is_some_and(|i| i.is::()) +} + +/// An over-budget read is "no focus point here", not a failure to report. +fn budget_is_none(r: io::Result>) -> io::Result> { + match r { + Err(e) if over_budget(&e) => Ok(None), + r => r, + } +} + +/// A JPEG's first `Exif` APP1 segment, found as [`crate::jpeg_segments`] finds it. +fn jpeg(r: &mut Reader, head: &[u8]) -> io::Result> { + budget_is_none(jpeg_inner(r, head)) +} + +fn jpeg_inner(r: &mut Reader, head: &[u8]) -> io::Result> { + let len = r.len; + let at = |r: &mut Reader, start: u64, n: u64| -> io::Result> { + let local = usize::try_from(start).ok().zip(usize::try_from(start.saturating_add(n)).ok()).and_then(|(a, b)| head.get(a..b)); + match local { + Some(s) => Ok(s.to_vec()), + None => r.fetch(start, start.saturating_add(n)), + } + }; + let mut i = 2u64; + for _ in 0..MAX_SEGMENTS { + if i.saturating_add(4) > len { + break; + } + let h = at(r, i, 4)?; + let (Some(&ff), Some(&marker), Some(&a), Some(&b)) = (h.first(), h.get(1), h.get(2), h.get(3)) else { break }; + if ff != 0xff { + break; + } + if marker == 0xff { + i += 1; + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let seg = u64::from(u16::from_be_bytes([a, b])); + if seg < 2 { + break; + } + let end = i.saturating_add(2).saturating_add(seg).min(len); + if marker == 0xe1 { + let payload = at(r, i + 4, end.saturating_sub(i + 4))?; + if payload.starts_with(b"Exif\0") { + return Ok(crate::read_exif(payload.get(6..).unwrap_or(&[])).focus); + } + } + i = end; + } + Ok(None) +} + +/// A TIFF raw, decoded from a buffer of its length holding only the ranges [`plan`] asked for. +fn tiff(r: &mut Reader, head: Vec) -> io::Result> { + if r.len > MAX_SPARSE_FILE { + return Ok(None); + } + let Ok(len) = usize::try_from(r.len) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let Some(dst) = buf.get_mut(..head.len()) else { return Ok(None) }; + dst.copy_from_slice(&head); + let mut loaded = Loaded::default(); + loaded.insert(0, head.len() as u64); + for _ in 0..MAX_ROUNDS { + match plan(&buf, &loaded) { + Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)), + Step::Nothing => return Ok(None), + Step::Need(ranges) => { + for (a, b) in ranges { + // widen to the read granularity, but never re-read what is loaded + let (wa, wb) = ((a / ALIGN) * ALIGN, b.div_ceil(ALIGN).saturating_mul(ALIGN).min(r.len)); + for (s, e) in loaded.gaps(wa, wb) { + let bytes = match r.fetch(s, e) { + Err(e) if over_budget(&e) => return Ok(None), + other => other?, + }; + let (Ok(su), Ok(eu)) = (usize::try_from(s), usize::try_from(e)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(su..eu) else { return Ok(None) }; + dst.copy_from_slice(&bytes); + loaded.insert(s, e); + } + } + } + } + } + Ok(None) +} + +/// Sorted, disjoint, non-touching byte ranges. +#[derive(Default)] +struct Loaded(Vec<(u64, u64)>); + +impl Loaded { + fn insert(&mut self, start: u64, end: u64) { + if start >= end { + return; + } + let (mut s, mut e) = (start, end); + let mut out = Vec::with_capacity(self.0.len() + 1); + for &(a, b) in &self.0 { + if b < s || a > e { + out.push((a, b)); + } else { + s = s.min(a); + e = e.max(b); + } + } + out.push((s, e)); + out.sort_unstable(); + self.0 = out; + } + + fn contains(&self, start: u64, end: u64) -> bool { + start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) + } + + /// The parts of `[start, end)` not loaded. + fn gaps(&self, start: u64, end: u64) -> Vec<(u64, u64)> { + let mut out = Vec::new(); + let mut at = start; + for &(a, b) in &self.0 { + if b <= at || a >= end { + continue; + } + if a > at { + out.push((at, a.min(end))); + } + at = at.max(b); + if at >= end { + break; + } + } + if at < end { + out.push((at, end)); + } + out + } +} + +enum Step { + /// Load these and ask again. + Need(Vec<(u64, u64)>), + /// Everything the decoder reads is loaded. + Ready, + /// The file has nothing the decoder could read (no Exif, no maker note, a structure it rejects). + Nothing, +} + +/// One IFD entry as the TIFF reader sees it: where its value is (absolute), if it fits the file. +struct RawEntry { + tag: u16, + typ: FieldType, + start: u64, + end: u64, +} + +struct Want<'a> { + buf: &'a [u8], + order: ByteOrder, + loaded: &'a Loaded, + need: Vec<(u64, u64)>, +} + +impl Want<'_> { + fn len(&self) -> u64 { + self.buf.len() as u64 + } + + /// Whether `[start, end)` (clipped to the file) is loaded; asks for it if not. + fn want(&mut self, start: u64, end: u64) -> bool { + let end = end.min(self.len()); + if self.loaded.contains(start, end) { + return true; + } + self.need.push((start, end)); + false + } + + /// The entries of the IFD at `off` (values relative to 0), once its table is loaded; `Some(empty)` + /// for one the reader would reject. + fn table(&mut self, off: u64) -> Option> { + let len = self.len(); + if off == 0 || off >= len { + return Some(Vec::new()); + } + if !self.want(off, off.saturating_add(2)) { + return None; + } + let pos = usize::try_from(off).ok()?; + let n = u64::from(self.order.read_u16(self.buf, pos)?); + let table = off + 2; + let n_read = n.min(len.saturating_sub(table) / 12).min(MAX_ENTRIES); + let end = table + n_read * 12 + if n_read == n { 4 } else { 0 }; + if !self.want(off, end) { + return None; + } + let mut out = Vec::new(); + for i in 0..n_read { + let Ok(p) = usize::try_from(table + i * 12) else { continue }; + let (Some(tag), Some(typ), Some(count)) = ( + self.order.read_u16(self.buf, p), + self.order.read_u16(self.buf, p + 2).and_then(FieldType::from_u16), + self.order.read_u32(self.buf, p + 4), + ) else { + continue; + }; + let Some(total) = u64::from(count).checked_mul(typ.size() as u64) else { continue }; + let field = p as u64 + 8; + let start = if total <= 4 { + field + } else { + match self.order.read_u32(self.buf, p + 8) { + Some(v) => u64::from(v), + None => continue, + } + }; + let Some(end) = start.checked_add(total).filter(|&e| e <= len) else { continue }; + out.push(RawEntry { tag, typ, start, end }); + } + Some(out) + } + + /// The first unsigned value of an entry (loaded). + fn first_value(&self, e: &RawEntry) -> Option { + let pos = usize::try_from(e.start).ok()?; + match e.typ.size() { + 1 => self.buf.get(pos).map(|&b| u64::from(b)), + 2 => self.order.read_u16(self.buf, pos).map(u64::from), + 4 => self.order.read_u32(self.buf, pos).map(u64::from), + _ => self.order.read_u64(self.buf, pos), + } + } + + fn step(self) -> Step { + if self.need.is_empty() { Step::Ready } else { Step::Need(self.need) } + } +} + +/// What of a classic TIFF the focus decoder still needs, given what is `loaded`: IFD0 (Make, Model, +/// the Exif pointer), the Exif IFD (the maker-note entry), the maker note's header and IFD as +/// `lightcraft_tiff::makernote` locates it, and the focus tags' values. +fn plan(buf: &[u8], loaded: &Loaded) -> Step { + let Some((order, false)) = Tiff::sniff(buf) else { return Step::Nothing }; + let mut w = Want { buf, order, loaded, need: Vec::new() }; + let Some(ifd0) = order.read_u32(buf, 4).map(u64::from) else { return Step::Nothing }; + let Some(e0) = w.table(ifd0) else { return w.step() }; + let mut exif = None; + for e in &e0 { + if matches!(e.tag, t::MAKE | t::MODEL | t::UNIQUE_CAMERA_MODEL | t::ORIENTATION) { + w.want(e.start, e.end); + } + if e.tag == t::EXIF_IFD && exif.is_none() && w.want(e.start, e.end) { + exif = w.first_value(e); + } + } + if !w.need.is_empty() { + return w.step(); + } + let Some(exif) = exif.filter(|&o| o != 0) else { return Step::Nothing }; + let Some(ee) = w.table(exif) else { return w.step() }; + let Some(note) = ee.iter().find(|e| e.tag == t::MAKER_NOTE) else { return Step::Nothing }; + let head_end = note.start.saturating_add(12).min(note.end); + if !w.want(note.start, head_end) { + return w.step(); + } + let head = usize::try_from(note.start).ok().zip(usize::try_from(head_end).ok()).and_then(|(a, b)| buf.get(a..b)).unwrap_or(&[]); + let sony = head.starts_with(b"SONY") || head.starts_with(b"VHAB \0"); + // another maker's header: `focus::read` only decodes Sony and header-less notes + let foreign: [&[u8]; 13] = [ + b"Nikon\0", + b"FUJIFILM", + b"GENERALE", + b"OLYMPUS\0", + b"OM SYSTEM\0", + b"OLYMP\0", + b"EPSON\0", + b"MINOL\0", + b"CAMER\0", + b"SANYO\0", + b"Panasonic\0", + b"AOC\0", + b"PENTAX \0", + ]; + if !sony && foreign.iter().any(|p| head.starts_with(p)) { + return Step::Nothing; + } + let mut entries = None; + if sony { + match w.table(note.start.saturating_add(12)) { + None => return w.step(), + Some(e) if !e.is_empty() => entries = Some(e), + Some(_) => {} + } + } + let entries = match entries { + Some(e) => e, + None => match w.table(note.start) { + Some(e) => e, + None => return w.step(), + }, + }; + for e in entries.iter().filter(|e| FOCUS_TAGS.contains(&e.tag)) { + w.want(e.start, e.end); + } + w.step() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader over `file` that counts what it is asked for. + fn read(file: &[u8]) -> (io::Result, u64) { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let start = usize::try_from(off).map_err(io::Error::other)?; + let src = file.get(start..start + buf.len()).ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; + buf.copy_from_slice(src); + Ok(()) + }); + (r, asked) + } + + #[test] + fn loaded_ranges() { + let mut l = Loaded::default(); + l.insert(10, 20); + l.insert(30, 40); + assert_eq!(l.gaps(0, 50), vec![(0, 10), (20, 30), (40, 50)]); + l.insert(20, 30); + assert_eq!(l.0, vec![(10, 40)]); + assert!(l.contains(12, 38) && !l.contains(5, 12)); + assert!(l.gaps(15, 35).is_empty()); + } + + #[test] + fn garbage_and_empty_files_are_no_point() { + for file in [vec![], vec![0xff, 0xd8], b"II*\0\x08\0\0\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), vec![0x42; 100_000]] { + let (r, _) = read(&file); + assert_eq!(r.unwrap().focus, None); + } + // a JPEG of nothing but fill bytes and tiny segments stops at its bounds + let mut j = vec![0xff, 0xd8]; + for _ in 0..5000 { + j.extend_from_slice(&[0xff, 0xe2, 0, 2]); + } + assert_eq!(read(&j).0.unwrap().focus, None); + } + + #[test] + fn a_file_that_shrinks_mid_read_is_an_error() { + // the reader is told the file is longer than it is (replaced between the stat and the read) + let r = read_header(1 << 20, &mut |_, _| Err(io::Error::from(io::ErrorKind::UnexpectedEof))); + assert!(r.is_err()); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index abf6fd868..ad8ffbafd 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -16,6 +16,7 @@ pub mod cr3; mod datetime; mod exif; pub mod focus; +mod focus_read; mod gpx; mod iptc; pub mod tags; diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index db5349830..0256c33f3 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2136,5 +2136,7 @@ "Tracking": "Tracking", "Face Tracking": "Gesichtsverfolgung", "Animal Eye Tracking": "Tieraugen-Tracking", - "Human Eye Tracking": "Augen-Tracking (Mensch)" + "Human Eye Tracking": "Augen-Tracking (Mensch)", + "Read Focus Points": "Fokuspunkte lesen", + "Reading focus points": "Fokuspunkte werden gelesen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e94a5b377..5167800df 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2013,5 +2013,7 @@ "Tracking": "Seguimiento", "Face Tracking": "Seguimiento de caras", "Animal Eye Tracking": "Seguimiento de ojos de animales", - "Human Eye Tracking": "Seguimiento de ojos humanos" + "Human Eye Tracking": "Seguimiento de ojos humanos", + "Read Focus Points": "Leer puntos de enfoque", + "Reading focus points": "Leyendo puntos de enfoque" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 89b1d309b..0660fe67f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1975,5 +1975,7 @@ "Tracking": "Suivi", "Face Tracking": "Suivi des visages", "Animal Eye Tracking": "Suivi des yeux d’animaux", - "Human Eye Tracking": "Suivi des yeux humains" + "Human Eye Tracking": "Suivi des yeux humains", + "Read Focus Points": "Lire les points de mise au point", + "Reading focus points": "Lecture des points de mise au point" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index bec052af7..10314b8a9 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1979,5 +1979,7 @@ "Tracking": "トラッキング", "Face Tracking": "顔トラッキング", "Animal Eye Tracking": "動物瞳トラッキング", - "Human Eye Tracking": "人物瞳トラッキング" + "Human Eye Tracking": "人物瞳トラッキング", + "Read Focus Points": "フォーカスポイントを読み込む", + "Reading focus points": "フォーカスポイントを読み込み中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index e64080fa3..e92b5d0be 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2016,5 +2016,7 @@ "Tracking": "Rastreamento", "Face Tracking": "Rastreamento de rosto", "Animal Eye Tracking": "Rastreamento de olhos de animais", - "Human Eye Tracking": "Rastreamento de olhos humanos" + "Human Eye Tracking": "Rastreamento de olhos humanos", + "Read Focus Points": "Ler pontos de foco", + "Reading focus points": "Lendo pontos de foco" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 7e1de22df..92753fd94 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2169,5 +2169,7 @@ "Tracking": "Слежение", "Face Tracking": "Отслеживание лиц", "Animal Eye Tracking": "Слежение за глазами животных", - "Human Eye Tracking": "Слежение за глазами людей" + "Human Eye Tracking": "Слежение за глазами людей", + "Read Focus Points": "Прочитать точки фокусировки", + "Reading focus points": "Чтение точек фокусировки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 5f884dfb2..7e07ec82c 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2169,5 +2169,7 @@ "Tracking": "Стеження", "Face Tracking": "Відстеження облич", "Animal Eye Tracking": "Стеження за очима тварин", - "Human Eye Tracking": "Стеження за очима людей" + "Human Eye Tracking": "Стеження за очима людей", + "Read Focus Points": "Прочитати точки фокусування", + "Reading focus points": "Читання точок фокусування" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b5e340a5c..6652d249e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2015,5 +2015,7 @@ "Tracking": "追踪", "Face Tracking": "人脸追踪", "Animal Eye Tracking": "动物眼部追踪", - "Human Eye Tracking": "人眼追踪" + "Human Eye Tracking": "人眼追踪", + "Read Focus Points": "读取对焦点", + "Reading focus points": "正在读取对焦点" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index af33e1f94..103164ad8 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1977,5 +1977,7 @@ "Tracking": "追蹤", "Face Tracking": "臉部追蹤", "Animal Eye Tracking": "動物眼部追蹤", - "Human Eye Tracking": "人眼追蹤" + "Human Eye Tracking": "人眼追蹤", + "Read Focus Points": "讀取對焦點", + "Reading focus points": "正在讀取對焦點" } diff --git a/crates/ui-egui/src/focus_scan.rs b/crates/ui-egui/src/focus_scan.rs new file mode 100644 index 000000000..de6e292dc --- /dev/null +++ b/crates/ui-egui/src/focus_scan.rs @@ -0,0 +1,24 @@ +//! Keeps the engine's focus-point reader going (`focus.pump`): it reads the camera's focus point for photos imported +//! before it was read, a few KB of each file, at the pace the face scan uses (nothing new while the user drags, types or +//! scrolls). Its progress is its row in the activity stack. + +use serde_json::json; + +use crate::LightcraftApp; + +/// How often a running reader is asked for what it has read (its batches commit from here). +const BUSY_SECS: f64 = 0.25; + +/// Called every frame. Idle, it costs a comparison and wakes nothing; reading, it asks again every [`BUSY_SECS`]. +pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + if now < app.caches.focus_next_pump { + return; + } + let pace = crate::panels::faces::background_pace(app, ctx, now); + let Ok(v) = app.session.execute("focus.pump", &json!({"pace": pace})) else { return }; + if v["active"] == true { + app.caches.focus_next_pump = now + BUSY_SECS; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(BUSY_SECS)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bfd72a9c6..090f29137 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod credits; pub mod date_picker; pub mod docking; pub mod export_task; +mod focus_scan; pub mod headless; pub mod i18n; pub mod icons; @@ -1079,6 +1080,7 @@ impl LightcraftApp { panels::denoise::pump(self, ctx); model_setup::pump(self, ctx); panels::faces::pump(self, ctx); + focus_scan::pump(self, ctx); self.collect_screenshots(ctx); self.issue_screenshots(ctx); if self.fonts_ready { @@ -1512,6 +1514,8 @@ pub struct Caches { pub faces_indexed: u64, pub faces_pending: u64, pub faces_next_pump: f64, + /// When to ask the focus-point reader again (`focus_scan::pump`). + pub focus_next_pump: f64, /// When the user last dragged, typed or scrolled, and when they last moved the pointer (egui time). pub last_input: f64, /// The pace the face scan was last given, and whether the window was in front then (for `ui.inspect`). diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 43703ed9c..b753d1ef2 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -204,6 +204,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.saveMetadataToFile", "photo.readMetadataFromFile", "photo.reload", + "photo.readFocusPoints", "app.showInFinder", "dialog.rename", "dialog.captureTime", diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..902dabc06 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -27,6 +27,13 @@ fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static st (pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching), focused && !minimized) } +/// The pace for other background work that reads the library's files (the focus-point reader): the face scan's, without +/// the "watching" speed-up. +pub(crate) fn background_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> &'static str { + let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false))); + pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, false) +} + /// The pace for a window that has the keyboard (or not) and is minimized (or not), `worked` seconds after the user last /// dragged, typed or scrolled and `moved` seconds after they last moved the pointer, while they are (or are not) looking /// at the scan's progress. diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs index fa9ae60a0..f93dd4f7b 100644 --- a/crates/ui-egui/src/tests_focus_point.rs +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -156,3 +156,34 @@ fn compare_and_survey_draw_each_photos_focus_point() { assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one"); } } + +/// A photo imported before focus points were read gets its point from the background reader the frame loop drives +/// (`focus.pump`, started by itself once the library is open), and Photo ▸ Read Focus Points runs it on demand. +#[test] +fn the_frame_loop_reads_focus_points_for_photos_imported_earlier() { + let dir = std::env::temp_dir().join(format!("lc-ui-focus-scan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let add = |app: &mut LightcraftApp, id: u64, name: &str| { + let path = dir.join(name); + std::fs::write(&path, lightcraft_meta::focus::synthetic_arw(3, 200_000)).expect("write"); + let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into() }, name, "ARW", 8640, 5760, "2026-10-13T00:00:00"); + p.meta.camera = "SONY ILCE-1".into(); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).expect("add"); + }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + add(&mut app, 9001, "old.ARW"); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let has_point = |h: &Headless, id: u64| h.app.session.catalog.photo(PhotoId(id)).is_some_and(|p| p.meta.focus.is_some()); + assert!(h.step_until(SETTLE, |h| has_point(h, 9001)), "read by itself"); + assert!(h.step_until(T, |h| h.app.session.activity.list().iter().all(|t| t.kind != "focus")), "its row goes when it is done"); + // a photo added later is read when asked (the menu item runs the command) + add(&mut h.app, 9002, "later.ARW"); + let spec = h.app.session.commands().into_iter().find(|c| c.id == "photo.readFocusPoints").expect("the command"); + assert_eq!(spec.menu, vec!["Photo"]); + let r = run(&mut h, "photo.readFocusPoints", json!({})); + assert_eq!(r["queued"], 1, "{r}"); + assert!(h.step_until(SETTLE, |h| has_point(h, 9002))); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 02336c97c..5a09a9a48 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -33,7 +33,7 @@ guard.detail(&file_name); dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`, `Clone + Send`). - **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`, - `download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it + `download`, `faces`, `focus`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it translated, so add it to every catalog in `crates/ui-egui/locales/`. - **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps. `set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count` @@ -59,6 +59,7 @@ guard.detail(&file_name); | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | 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(..)`) | +| 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 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 185ad2678..a5ea5ad4a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,6 +107,9 @@ The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none). `view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}` (on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set). +`photo.readFocusPoints {ids?, wait?}` reads the point for photos imported before it was read, from a few KB of each +file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls +`focus.pump {pace}` every frame to drive it. The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: diff --git a/docs/focus-points.md b/docs/focus-points.md index c6993948f..61c562b03 100644 --- a/docs/focus-points.md +++ b/docs/focus-points.md @@ -21,11 +21,46 @@ zooms to it, so checking whether the subject is sharp takes one key while cullin Photos without one have no `focus` key. `view.zoomToggle` replies `{focus: [x, y]}` (the pan it centred on) when it zoomed to one. -The point is read when a photo is imported. Photos imported earlier get it with **Photo ▸ Reload from Disk** -(`photo.reload`), which fills camera data the catalog lacks; it never replaces a point already stored. It is +The point is read when a photo is imported. Photos imported earlier get it in the background (below). It is read-only camera data: never written to XMP, and absent from the catalog JSON when unknown, so older catalogs read unchanged (no catalog format change). +## Photos imported earlier + +A library imported before focus points were read gets them by itself: when a library opens and some of its photos +from a supported body have no point and haven't been looked at, LightCraft reads them in the background, with a +**Reading focus points** row in the activity stack (n of N; ✕ stops it). **Photo ▸ Read Focus Points** +(`photo.readFocusPoints`) starts the same job by hand. Photo ▸ Reload from Disk still fills a missing point too, but it +re-reads and hashes whole files and runs while you wait, so it is for a few photos, not a library. + +- **What is opened**: only photos whose stored camera is a supported body (`SONY ILCE-1`, `SONY ILCE-7SM3`), whose + file is an ARW or JPEG, that are in the library and have no point yet. Nothing else is opened. +- **What is read**: the few kilobytes that hold the focus data, not the file + (`lightcraft_meta::focus::read_header`). It walks the file's structure as the decoder does — the TIFF header and + IFD0 (Make, Model), the Exif IFD (the MakerNote entry), the maker note's IFD and the values of the focus tags; in a + JPEG, the segments up to the Exif one — asking only for the ranges the bytes read so far point at, and decodes them + with the same code as a whole-file read. On the α1 and the A7S III everything sits in the first 16 KB, so one 32 KB + read per photo does it (instead of 50–120 MB). Rounds (12), bytes (2 MB) and the file size are bounded; a file that + wants more is treated as having no point. On 109 real α1 / A7S III ARWs (83 with a point) the header read gave the + same point as the whole-file read for every file. +- **Pace**: one file at a time on one worker thread (the drive may be a spinning disk), in path order so neighbouring + files are read together. Nothing new is read while you drag, type or scroll, and it rests briefly between files + while you are around, so your own previews come first. Measured on a 5400 rpm USB drive, cold cache, 200 α1 / A7S III + ARWs: 1.9 s (about 100 photos a second, 32 KB each), so a library of 58,796 raws takes about ten minutes at full + pace instead of re-reading 2.25 TB. +- **Saving**: what was read is committed every 200 photos (or every 3 seconds) as LightCraft's own bookkeeping: no + undo step, no sidecar written, and only `meta.focus` changes, on a photo that still has none and still points at the + file that was read (a photo deleted, moved or relinked meanwhile is left for another run). Photos that turned out to + have no point (manual focus, a centre-only Wide report) are listed in `focus-scanned.bin` beside the library, so they + are not read again at every launch. The list is headed by the decoder's tag (the bodies it knows): when a body is + added, the list starts afresh and those photos are looked at again. A file that can't be read (an offline drive, a + file gone or replaced during the read) is not listed, and is tried again the next time the library opens. +- **Resuming**: quitting mid-run keeps what was committed; the next launch reads the rest. +- **Agents**: `photo.readFocusPoints {ids?, wait?}` — without `ids`, every photo not looked at yet; with `ids`, those + photos, looked at again even if listed. `wait: true` returns when done with `{last: {total, done, found, none, + unavailable, bytesRead, seconds, cancelled}}` (`lightcraft-cli run --library DIR photo.readFocusPoints wait=true`). + The app's frame loop calls `focus.pump {pace}`, which also starts the job once per library opening. + ## Cameras Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)** @@ -75,5 +110,10 @@ DMF (AF, then manual touch-up) keeps its point. - `crates/meta/src/focus.rs`: the decoder (bounded; hostile values give no point, never a panic) and its tests, including an opt-in test over real files (`LIGHTCRAFT_FOCUS_SAMPLES=`). +- `crates/meta/src/focus_read.rs`: the header-only read; its tests check it against the whole-file read on intact, + truncated and corrupted files and count what it reads. +- `crates/engine/src/focus_scan.rs`: the background job (`photo.readFocusPoints`, `focus.pump`) and + `crates/engine/src/tests_focus_scan.rs`, with an opt-in comparison over real files + (`LIGHTCRAFT_FOCUS_FILES=`); `crates/ui-egui/src/focus_scan.rs` drives it from the frame loop. - `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. diff --git a/docs/mcp.md b/docs/mcp.md index d3d23e325..51fb7968a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -208,7 +208,7 @@ still reaches every command. |---|---| | `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | | `lightcraft://photos` | Photos in the current view (`catalog.query`) | -| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md)) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md); `run_command photo.readFocusPoints {wait: true}` reads it for photos imported before it was read, a few KB per file) | | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 45bdaac01..578ea28c5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -130,7 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | -| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `crates/meta/src/focus.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import (Reload from Disk fills older photos) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | @@ -790,7 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it (#1480) | +| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it; photos already in the library read in the background, header only (#1480) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7bf854d0e..c30248ee5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -9,8 +9,10 @@ - **Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key (View ▸ Zoom to Focus Point turns this off). Photos without a point zoom as before. - Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame - centre show nothing. Photos imported earlier get their point with Photo ▸ Reload from Disk. More cameras will - follow. Details: [focus-points.md](focus-points.md). + centre show nothing. More cameras will follow. Details: [focus-points.md](focus-points.md). +- **Photos already in your library get theirs in the background**: LightCraft reads just the few kilobytes of each + file that hold the focus point (about 100 photos a second from a spinning disk), with a *Reading focus points* row + in the activity stack you can stop. Photo ▸ Read Focus Points runs it by hand. ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you