From ac84cd31ee9475f75107c3aa5f30700e0332e198 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:14:24 +0200 Subject: [PATCH 1/2] LR-VIEW-FOCUSPOINT: show the camera's focus point (Sony) and zoom to it (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the autofocus point from Sony maker notes (FocusLocation 0x2027, FocusFrameSize 0x2037, FocusMode 0x201b, AFAreaModeSetting 0x201c, AFTracking 0x2021 and the area mode used from the enciphered 0x9402 block), for the ILCE-1 and ILCE-7SM3, whose layout was checked against ExifTool's output on 333 real ARWs. Manual focus, centre-only Multi reports and unchecked bodies give no point. The box is stored on the upright photo as Meta.focus (omitted when unknown; no catalog format change), filled at import and by Reload from Disk. View > Show Focus Point (Alt+F) draws it in Detail, Compare and Survey through rotation and crop; View > Zoom to Focus Point (on by default) makes Toggle Zoom centre on it; the Info panel shows "AF-C · Wide · Animal Eye Tracking". photo.inspect returns meta.focus. --- crates/catalog/src/model.rs | 5 + crates/catalog/src/tests.rs | 22 + crates/engine/src/cmd/convert.rs | 25 ++ crates/engine/src/demo.rs | 10 + crates/engine/src/files.rs | 1 + crates/meta/src/exif.rs | 8 +- crates/meta/src/focus.rs | 559 ++++++++++++++++++++++++ crates/meta/src/lib.rs | 8 + crates/meta/src/xmp.rs | 1 + crates/ui-egui/locales/de.json | 20 +- crates/ui-egui/locales/es.json | 20 +- crates/ui-egui/locales/fr.json | 20 +- crates/ui-egui/locales/ja.json | 20 +- crates/ui-egui/locales/pt-br.json | 20 +- crates/ui-egui/locales/ru.json | 20 +- crates/ui-egui/locales/uk.json | 20 +- crates/ui-egui/locales/zh-hans.json | 20 +- crates/ui-egui/locales/zh-hant.json | 20 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 4 + crates/ui-egui/src/menus.rs | 25 +- crates/ui-egui/src/panels/compare.rs | 3 + crates/ui-egui/src/panels/detail.rs | 60 +++ crates/ui-egui/src/panels/right.rs | 19 + crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_focus_point.rs | 158 +++++++ docs/control-protocol.md | 5 + docs/focus-points.md | 79 ++++ docs/mcp.md | 2 +- docs/parity-checklist.md | 12 +- docs/whats-new.md | 10 + 31 files changed, 1186 insertions(+), 19 deletions(-) create mode 100644 crates/meta/src/focus.rs create mode 100644 crates/ui-egui/src/tests_focus_point.rs create mode 100644 docs/focus-points.md diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e..f7b832e3a 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -199,6 +199,11 @@ pub struct Meta { /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub regions: Vec, + /// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony + /// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never + /// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..42dc515f1 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -391,6 +391,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() { assert_eq!(back, with); } +/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a +/// catalog from before it existed reads with none, and writes the same), kept exactly when known. +#[test] +fn focus_point_is_not_serialized_when_unknown_and_round_trips() { + let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("focus").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.focus.is_none()); + let mut with = m.clone(); + with.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: None, + }); + let json = serde_json::to_string(&with).unwrap(); + assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}"); + let back: Meta = serde_json::from_str(&json).unwrap(); + assert_eq!(back, with); +} + // ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos fn many(c: &mut Catalog, n: usize) -> Vec { diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..53c6a10f9 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -234,6 +234,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P m.aperture = m.aperture.or(src.aperture); m.iso = m.iso.or(src.iso); m.gps = m.gps.or(src.gps); + // the camera's focus point: photos imported before it was read get it here + if m.focus.is_none() { + m.focus = src.focus.clone(); + } let mut ops = Vec::new(); if m != before { ops.push(Op::SetMeta { id, meta: Box::new(m) }); @@ -421,6 +425,27 @@ mod tests { assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + /// Reload from Disk gives a photo imported before focus points were read its camera focus point, + /// and never replaces one it has. + #[test] + fn reload_fills_a_missing_focus_point() { + let fp = |x: f64| lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 }, + mode: Some("AF-C".into()), + area: None, + tracking: None, + }; + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00"); + ph.captured = Some("2026-10-04T08:16:11".into()); + let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() }; + info.meta.focus = Some(fp(0.3)); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!(meta.focus, Some(fp(0.3))); + ph.meta.focus = Some(fp(0.1)); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept"); + } + fn lens() -> lightcraft_develop::EmbeddedLens { lightcraft_develop::EmbeddedLens { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 6a89dda88..ab5976115 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -30,6 +30,16 @@ pub fn load(s: &mut Session) { creator: "LightCraft Demo".into(), ..Default::default() }; + // one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so + // View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file + if sc.name == "Cosmos bloom" { + p.meta.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 }, + mode: Some("AF-S".into()), + area: Some("Flexible Spot".into()), + tracking: None, + }); + } p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8]; p.flag = match sc.id % 6 { 1 => Flag::Pick, diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..2ec364b5a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -40,6 +40,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), regions: m.regions.clone(), + focus: m.focus.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index ca8d7b7aa..6e175b379 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -16,8 +16,12 @@ pub fn read_exif(bytes: &[u8]) -> Metadata { /// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. pub fn try_read_exif(bytes: &[u8]) -> Result { - let tiff = Tiff::parse(strip_exif_header(bytes))?; - Ok(from_tiff(&tiff)) + let data = strip_exif_header(bytes); + let tiff = Tiff::parse(data)?; + let mut m = from_tiff(&tiff); + // the maker note's values are offsets into the stream, which `from_tiff` doesn't have + m.focus = crate::focus::read(data, &tiff, m.make.as_deref(), m.model.as_deref(), m.orientation); + Ok(m) } fn text(ifd: Option<&Ifd>, tag: u16) -> Option { diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs new file mode 100644 index 000000000..92801f8a7 --- /dev/null +++ b/crates/meta/src/focus.rs @@ -0,0 +1,559 @@ +//! Where the camera focused: the autofocus point a camera records in its maker notes, as a box on the +//! upright photo ([`FocusPoint`]). Sony only for now. +//! +//! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool +//! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and +//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read. +//! +//! | Tag | Layout (confirmed) | Use | +//! |---|---|---| +//! | `0x2027` FocusLocation | 4 × int16u: the width and height of the frame it is measured in, then the focus centre x, y in pixels of that frame (the sensor's landscape frame: a portrait shot still says 8640 × 5760) | the point | +//! | `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height of the frame the camera showed, then 2 bytes that are zero when there is no frame (manual focus) | the box size | +//! | `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | manual = no point | +//! | `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | shown | +//! | `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | shown | +//! | `0x9402` (enciphered block) byte 0x16, 0x17 | FocusMode (low 7 bits) and the AF area mode actually used: 0 Multi, 1 Center, 2 Spot, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual | shown; read only when its focus mode agrees with `0x201b` | +//! +//! The `0x94xx` blocks are enciphered byte by byte (the documentation calls it "a simple substitution +//! cipher"). On the sample files each plain byte `p < 249` is stored as `p³ mod 249` and bytes 249–255 +//! as themselves (e.g. a stored `0x20` reads as AFAreaMode 20, stored `0x77` as `0x83`, FocusMode AF-C +//! with its top bit set); the map is a bijection on 0..249 (cubing is invertible mod 3 and mod 83), so +//! it is undone by a table of its inverse. +//! +//! **No real point** (nothing is stored, so nothing is drawn and Z zooms as before): +//! - a body the layout hasn't been confirmed on ([`SONY_MODELS`]), a missing or malformed tag, a zero +//! frame size, or a centre outside its frame; +//! - manual focus (`0x201b` = 0, or the block's area mode says Manual): the camera still records where +//! the AF frame was parked, not where anything was in focus (on the samples these are off-centre); +//! - the exact frame centre while the area used was Multi (Wide with nothing tracked): on the samples +//! 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. + +use crate::{Orientation, Rect}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; +use serde::{Deserialize, Serialize}; + +/// Where the camera focused, on the upright photo. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FocusPoint { + /// The focus frame, normalized to the photo's full, EXIF-oriented frame (y down, 0..1 on both + /// axes, clipped to it) — the frame face regions use, so it follows Rotate, flips and crops. + pub rect: Rect, + /// Focus mode as the camera names it (`AF-C`, `AF-S`, `DMF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// The AF area chosen on the camera (`Wide`, `Zone`, `Flexible Spot`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub area: Option, + /// What the camera followed, when it says (`Animal Eye Tracking`, `Face tracking`, `Lock On AF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracking: Option, +} + +impl FocusPoint { + /// The box's centre (normalized, upright). + pub fn center(&self) -> (f64, f64) { + ((self.rect.x0 + self.rect.x1) / 2.0, (self.rect.y0 + self.rect.y1) / 2.0) + } + /// The words that describe it, in order (mode, area, tracking), for "AF-C · Wide · Animal Eye Tracking". + pub fn words(&self) -> Vec<&str> { + [&self.mode, &self.area, &self.tracking].into_iter().filter_map(|s| s.as_deref()).filter(|s| !s.is_empty()).collect() + } + /// The box clipped to the frame and checked: `None` when it is not a usable box (non-finite, + /// empty, or entirely outside the photo). + pub fn sanitized(mut self) -> Option { + let r = self.rect; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return None; + } + let (x0, x1) = (r.x0.min(r.x1).clamp(0.0, 1.0), r.x0.max(r.x1).clamp(0.0, 1.0)); + let (y0, y1) = (r.y0.min(r.y1).clamp(0.0, 1.0), r.y0.max(r.y1).clamp(0.0, 1.0)); + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + return None; + } + self.rect = Rect { x0, y0, x1, y1 }; + Some(self) + } +} + +/// Sony bodies whose focus tags have been checked against real files. Another body may store the same +/// 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"]; + +const FOCUS_MODE: u16 = 0x201b; +const AF_AREA_SETTING: u16 = 0x201c; +const AF_TRACKING: u16 = 0x2021; +const FOCUS_LOCATION: u16 = 0x2027; +const FOCUS_FRAME_SIZE: u16 = 0x2037; +const TAG_9402: u16 = 0x9402; + +/// Side of the box drawn when the camera gave a point but no frame size, as a fraction of the frame's +/// short side (about the size of the α1's smallest frames). +const DEFAULT_SIDE: f64 = 0.03; + +/// The focus point a file's maker note records, or `None` (see the module notes for when). +/// `data` is the TIFF stream the maker note's offsets refer to (a whole raw, or an Exif block). +pub fn read(data: &[u8], tiff: &Tiff, make: Option<&str>, model: Option<&str>, orientation: Option) -> Option { + let make = make?; + if !make.to_ascii_uppercase().starts_with("SONY") || !SONY_MODELS.contains(&model?.trim()) { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, make)?; + // ARWs from these bodies carry a header-less IFD; their JPEGs the `SONY DSC` header + if !matches!(mn.kind, makernote::MakerNoteKind::Sony | makernote::MakerNoteKind::Plain) { + return None; + } + sony(&mn.ifd, mn.order, orientation.unwrap_or_default()) +} + +fn byte(ifd: &Ifd, tag: u16) -> Option { + ifd.value(tag)?.get_u64(0).and_then(|v| u8::try_from(v).ok()) +} + +/// Decode the Sony tags of a parsed maker-note IFD. +pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Option { + let loc = ifd.value(FOCUS_LOCATION)?; + let [w, h, x, y] = [0, 1, 2, 3].map(|i| loc.get_u64(i)); + let (w, h, x, y) = (w? as f64, h? as f64, x? as f64, y? as f64); + if w < 1.0 || h < 1.0 || x > w || y > h { + return None; + } + let mode = byte(ifd, FOCUS_MODE); + if mode == Some(0) { + return None; + } + // the enciphered block: its focus mode must agree with the plain one, else its layout is not the one we know + let block = ifd.bytes(TAG_9402).and_then(|b| { + let (m, area) = (decipher(*b.get(0x16)?) & 0x7f, decipher(*b.get(0x17)?)); + (Some(m) == mode).then_some(area) + }); + if block == Some(255) { + return None; + } + let setting = byte(ifd, AF_AREA_SETTING); + let tracking = byte(ifd, AF_TRACKING); + let wide_untracked = match block { + Some(a) => a == 0, + None => setting == Some(0) && tracking.is_none_or(|t| t == 0), + }; + if wide_untracked && x * 2.0 == w && y * 2.0 == h { + return None; + } + // frame size: two int16u in the note's byte order, then a non-zero marker when there is a frame + let size = ifd.bytes(FOCUS_FRAME_SIZE).and_then(|b| { + let (fw, fh) = (order.read_u16(b, 0)? as f64, order.read_u16(b, 2)? as f64); + let shown = b.get(4..6).is_none_or(|m| m.iter().any(|&v| v != 0)); + (shown && fw >= 1.0 && fh >= 1.0).then_some((fw, fh)) + }); + let (fw, fh) = size.unwrap_or_else(|| { + let side = w.min(h) * DEFAULT_SIDE; + (side, side) + }); + let sensor = Rect { x0: (x - fw / 2.0) / w, y0: (y - fh / 2.0) / h, x1: (x + fw / 2.0) / w, y1: (y + fh / 2.0) / h }; + let name = |v: Option, f: fn(u8) -> Option<&'static str>| v.and_then(f).map(str::to_string); + let area = name(setting, area_setting_name); + let used = name(block, area_used_name); + // what it followed: the area mode used when it says more than the setting (Animal Eye Tracking on + // Wide), else the tracking setting when it is on + let tracking = used.filter(|u| Some(u) != area.as_ref() && u != "Multi").or_else(|| name(tracking, tracking_name).filter(|t| t != "Off")); + FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() +} + +/// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. +fn decipher(b: u8) -> u8 { + if b >= 249 { + return b; + } + (0..249u32).find(|p| (p * p * p) % 249 == u32::from(b)).and_then(|p| u8::try_from(p).ok()).unwrap_or(b) +} + +fn focus_mode_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Manual", + 2 => "AF-S", + 3 => "AF-C", + 4 => "AF-A", + 6 => "DMF", + 7 => "AF-D", + _ => return None, + }) +} + +fn area_setting_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Wide", + 1 | 9 => "Center", + 3 | 4 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + _ => return None, + }) +} + +fn tracking_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Off", + 1 => "Face tracking", + 2 => "Lock On AF", + _ => return None, + }) +} + +fn area_used_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Multi", + 1 => "Center", + 2 => "Spot", + 3 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + 14 => "Tracking", + 15 => "Face Tracking", + 20 => "Animal Eye Tracking", + 21 => "Human Eye Tracking", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + + fn encipher(p: u8) -> u8 { + if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } + } + + fn entry(tag: u16, value: Value) -> Entry { + Entry { tag, value, offset: 0 } + } + + /// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. + fn alpha1() -> Ifd { + let mut block = vec![0u8; 0x40]; + block[0x16] = encipher(0x83); + block[0x17] = encipher(20); + Ifd { + entries: vec![ + entry(FOCUS_MODE, Value::Byte(vec![3])), + entry(AF_AREA_SETTING, Value::Byte(vec![0])), + entry(AF_TRACKING, Value::Byte(vec![1])), + entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), + entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), + entry(TAG_9402, Value::Undefined(block)), + ], + ..Default::default() + } + } + + fn set(ifd: &mut Ifd, tag: u16, value: Value) { + ifd.entries.retain(|e| e.tag != tag); + ifd.entries.push(entry(tag, value)); + // IFD entries are kept sorted by tag (lookups rely on it) + ifd.entries.sort_by_key(|e| e.tag); + } + + #[test] + fn the_cipher_matches_the_bytes_seen_in_files() { + // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values + assert_eq!(decipher(0x20), 20); + assert_eq!(decipher(0x77), 0x83); + assert_eq!(decipher(0xff), 0xff); + for p in 0..=255u8 { + assert_eq!(decipher(encipher(p)), p); + } + } + + #[test] + fn reads_the_alpha1_sample() { + let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); + assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); + assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + let (cx, cy) = f.center(); + assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); + } + + #[test] + fn big_endian_frame_size() { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); + let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); + assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); + } + + #[test] + fn follows_the_exif_orientation() { + // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); + let (cx, cy) = f.center(); + // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left + assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); + let (cx, cy) = f.center(); + assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); + } + + #[test] + fn no_real_point() { + // manual focus + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // the block says Manual (its focus mode agreeing) + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // Multi at the exact centre + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(0); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // …and without the block: Wide, tracking off + ifd.entries.retain(|e| e.tag != TAG_9402); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // missing location, zero frame, centre outside it + let mut ifd = alpha1(); + ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); + } + } + + #[test] + fn a_centred_spot_is_a_real_point() { + let mut ifd = alpha1(); + set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(2); + block[0x17] = encipher(3); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); + let (cx, cy) = f.center(); + assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); + } + + #[test] + fn a_block_that_disagrees_is_ignored() { + // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(7); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); + // a block too short to hold the bytes is ignored the same way + set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); + } + + #[test] + fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { + for fs in [ + Value::Undefined(vec![0, 0, 0, 0, 0, 0]), + Value::Undefined(vec![1]), + Value::Short(vec![5]), + Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), + ] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, fs); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); + } + // a frame reaching past the corner is clipped to the photo + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); + } + + #[test] + fn wrong_types_and_extreme_values_never_panic() { + let values = [ + Value::Ascii("x".into()), + Value::Byte(vec![]), + Value::SShort(vec![-1, -1, -1, -1]), + Value::Long(vec![u32::MAX; 4]), + Value::Double(vec![f64::NAN; 4]), + Value::Undefined(vec![0xff; 300]), + ]; + for v in &values { + for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { + let mut ifd = alpha1(); + set(&mut ifd, tag, v.clone()); + if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { + assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); + } + } + } + assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + } + + /// 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 { + 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 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(); + order.put_u16(&mut note, note_entries.len() as u16); + for (tag, v) in note_entries { + let raw = lightcraft_tiff::writer::encode(order, v); + order.put_u16(&mut note, *tag); + order.put_u16(&mut note, v.field_type() as u16); + order.put_u32(&mut note, v.count() as u32); + if raw.len() <= 4 { + let mut r = raw.clone(); + r.resize(4, 0); + note.extend(r); + } else { + order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); + tail.extend(raw); + if tail.len() % 2 == 1 { + tail.push(0); + } + } + } + order.put_u32(&mut note, 0); + note.extend(tail); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + 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() + }; + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; + let file = build(at); + assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + file + } + + fn alpha1_entries() -> Vec<(u16, Value)> { + alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() + } + + #[test] + fn read_exif_finds_it_through_the_maker_note_and_orientation() { + let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let m = crate::read_exif(&file); + let f = m.focus.expect("focus point"); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + // EXIF 8: sensor x becomes 1 - upright y + let (cx, cy) = f.center(); + assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); + // an unconfirmed body or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); + } + + #[test] + fn truncated_and_garbage_maker_notes_never_panic() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for cut in (0..file.len()).step_by(7) { + let _ = crate::read_exif(&file[..cut]); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..300 { + let mut f = file.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = note_at + (seed as usize % (file.len() - note_at)); + f[i] = (seed >> 8) as u8; + } + if let Some(fp) = crate::read_exif(&f).focus { + assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); + } + } + } + + /// 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. + #[test] + fn real_sony_files_match_exiftool() { + let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; + let dir = std::path::Path::new(&dir); + type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; + // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) + let cases: [(&str, u16, Want); 8] = [ + // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking + ( + "2023/11-november/17th/_DSC6954.ARW", + 1, + Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + ( + "2023/11-november/17th/_DSC6894.ARW", + 1, + Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + // α1, manual focus (the location 4441 3060 is where the frame was parked) + ("2023/11-november/15th/_DSC6613.ARW", 1, None), + // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 + ("2022/01-january/9th/_DSC6079.ARW", 8, None), + // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking + ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), + // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking + ( + "2020/10-october/31st/DSC00065.ARW", + 8, + Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), + ), + // A7S III upside down, AF-C, a centred Flexible Spot: a real point + ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), + // A7S III, manual focus + ("2021/06-june/12th/DSC06806.ARW", 1, None), + ]; + for (name, exif_orientation, want) in cases { + let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = crate::extract(&bytes).focus; + match (want, got) { + (None, None) => {} + (Some((loc, size, words)), Some(f)) => { + let [w, h, x, y] = loc; + let sensor = + Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; + let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); + for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { + assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); + } + assert_eq!(f.words(), words, "{name}"); + } + (w, g) => panic!("{name}: want {w:?}, got {g:?}"), + } + } + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 31a35cc65..abf6fd868 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -15,6 +15,7 @@ mod container; pub mod cr3; mod datetime; mod exif; +pub mod focus; mod gpx; mod iptc; pub mod tags; @@ -24,6 +25,7 @@ mod xmp_merge; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; +pub use focus::FocusPoint; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; pub use lightcraft_geom::{Orientation, Rect}; @@ -142,6 +144,9 @@ pub struct Metadata { pub label: Option, /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. pub regions: Vec, + /// Where the camera focused, from its maker notes ([`focus`]; Sony only for now), on the upright photo. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, } macro_rules! fill { @@ -205,6 +210,9 @@ impl Metadata { if self.regions.is_empty() { self.regions = other.regions.clone(); } + if self.focus.is_none() { + self.focus = other.focus.clone(); + } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2a4d753dc..8c3b75263 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -686,6 +686,7 @@ mod tests { // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. regions: Vec::new(), + focus: None, make: Some("Maker & Sons".into()), model: Some("X <1>".into()), serial_number: Some("SN1".into()), diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..db5349830 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,23 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Show Focus Point": "Fokuspunkt anzeigen", + "Zoom to Focus Point": "Auf Fokuspunkt zoomen", + "Focus": "Fokus", + "Focus point": "Fokuspunkt", + "Wide": "Breit", + "Center": "Mitte", + "Flexible Spot": "Flexibler Spot", + "Zone": "Feld", + "Expanded Flexible Spot": "Erweiterter flexibler Spot", + "Custom AF Area": "Benutzerdefinierter AF-Bereich", + "Face tracking": "Gesichtsverfolgung", + "Lock On AF": "Lock-on-AF", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Tracking", + "Face Tracking": "Gesichtsverfolgung", + "Animal Eye Tracking": "Tieraugen-Tracking", + "Human Eye Tracking": "Augen-Tracking (Mensch)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..e94a5b377 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,23 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Show Focus Point": "Mostrar punto de enfoque", + "Zoom to Focus Point": "Zoom al punto de enfoque", + "Focus": "Enfoque", + "Focus point": "Punto de enfoque", + "Wide": "Amplio", + "Center": "Centro", + "Flexible Spot": "Punto flexible", + "Zone": "Zona", + "Expanded Flexible Spot": "Punto flexible ampliado", + "Custom AF Area": "Área AF personalizada", + "Face tracking": "Seguimiento de caras", + "Lock On AF": "AF de bloqueo", + "Multi": "Multi", + "Spot": "Punto", + "Tracking": "Seguimiento", + "Face Tracking": "Seguimiento de caras", + "Animal Eye Tracking": "Seguimiento de ojos de animales", + "Human Eye Tracking": "Seguimiento de ojos humanos" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..89b1d309b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,23 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Show Focus Point": "Afficher le point de mise au point", + "Zoom to Focus Point": "Zoomer sur le point de mise au point", + "Focus": "Mise au point", + "Focus point": "Point de mise au point", + "Wide": "Large", + "Center": "Centre", + "Flexible Spot": "Spot flexible", + "Zone": "Zone", + "Expanded Flexible Spot": "Spot flexible étendu", + "Custom AF Area": "Zone AF personnalisée", + "Face tracking": "Suivi des visages", + "Lock On AF": "AF verrouillé", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Suivi", + "Face Tracking": "Suivi des visages", + "Animal Eye Tracking": "Suivi des yeux d’animaux", + "Human Eye Tracking": "Suivi des yeux humains" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..bec052af7 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,23 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Show Focus Point": "フォーカスポイントを表示", + "Zoom to Focus Point": "フォーカスポイントにズーム", + "Focus": "フォーカス", + "Focus point": "フォーカスポイント", + "Wide": "ワイド", + "Center": "中央", + "Flexible Spot": "フレキシブルスポット", + "Zone": "ゾーン", + "Expanded Flexible Spot": "拡張フレキシブルスポット", + "Custom AF Area": "カスタムAFエリア", + "Face tracking": "顔追尾", + "Lock On AF": "ロックオンAF", + "Multi": "マルチ", + "Spot": "スポット", + "Tracking": "トラッキング", + "Face Tracking": "顔トラッキング", + "Animal Eye Tracking": "動物瞳トラッキング", + "Human Eye Tracking": "人物瞳トラッキング" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..e64080fa3 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,23 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Show Focus Point": "Mostrar ponto de foco", + "Zoom to Focus Point": "Zoom no ponto de foco", + "Focus": "Foco", + "Focus point": "Ponto de foco", + "Wide": "Amplo", + "Center": "Centro", + "Flexible Spot": "Ponto flexível", + "Zone": "Zona", + "Expanded Flexible Spot": "Ponto flexível expandido", + "Custom AF Area": "Área de AF personalizada", + "Face tracking": "Rastreamento de rosto", + "Lock On AF": "AF com travamento", + "Multi": "Multi", + "Spot": "Ponto", + "Tracking": "Rastreamento", + "Face Tracking": "Rastreamento de rosto", + "Animal Eye Tracking": "Rastreamento de olhos de animais", + "Human Eye Tracking": "Rastreamento de olhos humanos" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..7e1de22df 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,23 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Show Focus Point": "Показать точку фокусировки", + "Zoom to Focus Point": "Увеличить по точке фокусировки", + "Focus": "Фокус", + "Focus point": "Точка фокусировки", + "Wide": "Широкая", + "Center": "Центр", + "Flexible Spot": "Гибкая точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Расширенная гибкая точка", + "Custom AF Area": "Своя зона АФ", + "Face tracking": "Отслеживание лиц", + "Lock On AF": "АФ с захватом", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Слежение", + "Face Tracking": "Отслеживание лиц", + "Animal Eye Tracking": "Слежение за глазами животных", + "Human Eye Tracking": "Слежение за глазами людей" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5f884dfb2 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,23 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Show Focus Point": "Показати точку фокусування", + "Zoom to Focus Point": "Збільшити до точки фокусування", + "Focus": "Фокус", + "Focus point": "Точка фокусування", + "Wide": "Широка", + "Center": "Центр", + "Flexible Spot": "Гнучка точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Розширена гнучка точка", + "Custom AF Area": "Власна зона АФ", + "Face tracking": "Відстеження облич", + "Lock On AF": "АФ із захопленням", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Стеження", + "Face Tracking": "Відстеження облич", + "Animal Eye Tracking": "Стеження за очима тварин", + "Human Eye Tracking": "Стеження за очима людей" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..b5e340a5c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,23 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Show Focus Point": "显示对焦点", + "Zoom to Focus Point": "缩放至对焦点", + "Focus": "对焦", + "Focus point": "对焦点", + "Wide": "广域", + "Center": "中间", + "Flexible Spot": "自由点", + "Zone": "区", + "Expanded Flexible Spot": "扩展自由点", + "Custom AF Area": "自定义AF区域", + "Face tracking": "人脸追踪", + "Lock On AF": "锁定AF", + "Multi": "多重", + "Spot": "点", + "Tracking": "追踪", + "Face Tracking": "人脸追踪", + "Animal Eye Tracking": "动物眼部追踪", + "Human Eye Tracking": "人眼追踪" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..af33e1f94 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,23 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Show Focus Point": "顯示對焦點", + "Zoom to Focus Point": "縮放至對焦點", + "Focus": "對焦", + "Focus point": "對焦點", + "Wide": "廣域", + "Center": "中間", + "Flexible Spot": "自由點", + "Zone": "區", + "Expanded Flexible Spot": "擴充自由點", + "Custom AF Area": "自訂AF區域", + "Face tracking": "臉部追蹤", + "Lock On AF": "鎖定AF", + "Multi": "多重", + "Spot": "點", + "Tracking": "追蹤", + "Face Tracking": "臉部追蹤", + "Animal Eye Tracking": "動物眼部追蹤", + "Human Eye Tracking": "人眼追蹤" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..bfd72a9c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,8 @@ mod tests_dialogs; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_focus_point; +#[cfg(test)] mod tests_grading; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..43703ed9c 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -132,6 +132,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Sidebar Sections", "view.photoCounts", "view.faceBoxes", + "view.focusPoint", + "view.zoomToFocus", "view.filmstrip", "view.histogram", "view.navigator", @@ -315,6 +317,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), "view.faceBoxes" => Some(u.face_boxes), + "view.focusPoint" => Some(u.settings.focus_point), + "view.zoomToFocus" => Some(u.settings.zoom_to_focus), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..3e9b71c1f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -65,6 +65,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.people", "People", None, "View"), ("view.person", "Show Person", None, ""), ("view.faceBoxes", "Face Boxes", None, "View"), + ("view.focusPoint", "Show Focus Point", Some("Alt+F"), "View"), + ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -486,6 +488,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "view.focusPoint" => { + // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. + app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); + Ok(json!({"on": app.ui.settings.focus_point})) + } + "view.zoomToFocus" => { + // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. + app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); + Ok(json!({"on": app.ui.settings.zoom_to_focus})) + } "view.faceBoxes" => { app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); Ok(json!({"show": app.ui.face_boxes})) @@ -707,9 +719,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // the same ratio a click on the photo zooms to - app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; + // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus + // point when the photo has one (View ▸ Zoom to Focus Point) + let zoom_in = app.ui.zoom == Zoom::Fit; + app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; app.ui.zoom_anim = true; + if zoom_in + && app.ui.settings.zoom_to_focus + && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) + { + app.ui.pan = pan; + return Some(Ok(json!({"focus": [pan.0, pan.1]}))); + } Ok(Value::Null) } "view.clickZoom" => { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4c2855718..048852c69 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -254,6 +254,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if photo.flag == Flag::Reject { p.rect_filled(display, 0.0, Color32::from_black_alpha(110)); } + if app.ui.settings.focus_point { + super::detail::draw_focus_point(&p, display, &photo, &format!("compare:focusPoint:{}", id.0)); + } let active = app.session.selection.active == Some(id); if active { p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 66f9c7997..cb2a31dea 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -236,6 +236,63 @@ pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { [px(w), px(h)] } +/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the +/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. +pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option { + let f = photo.meta.focus.as_ref()?; + let d = &photo.develop; + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); + let [w, h] = output_px(&frame); + let to_out = frame.out_to_norm(w, h).inverse()?; + // stored on the upright photo: the user's Rotate / Flip on top, then the crop + let r = d.orientation.map_norm_rect(f.rect); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { + let q = to_out.apply(c); + (q.x / w as f64, q.y / h as f64) + }); + let xs = corners.map(|c| c.0); + let ys = corners.map(|c| c.1); + let out = lightcraft_geom::Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { + return None; + } + // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) + let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); + ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) +} + +/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. +pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { + let r = focus_box(app.session.catalog.photo(id)?)?; + Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Show Focus Point): green corner +/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. +/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:`). +pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { + let Some(r) = focus_box(photo) else { return }; + let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); + let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); + let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); + let arm = (b.width().min(b.height()) * 0.3).max(5.0); + let green = Color32::from_rgb(90, 230, 110); + for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { + let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; + p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); + p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); + } + let c = b.center(); + p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); + p.circle_filled(c, 1.5, green); + register(p.ctx(), key, b); +} + /// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { let t = if *anim { 0.22 } else { 0.0 }; @@ -722,6 +779,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { None => {} } } + if app.ui.settings.focus_point && !crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); + } // a fine grid while a transform (geometry) slider is dragged, to judge verticals if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { let n = 12; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..e0b032385 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -760,9 +760,28 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { } }); } + // where the camera focused, and how (View ▸ Show Focus Point draws it): only when known; the + // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits + if let Some(text) = focus_text(m.focus.as_ref()) { + ui.add_space(3.0); + let r = ui.horizontal_wrapped(|ui| { + ui.label(dim(crate::i18n::tr("Focus"))); + ui.add_space(12.0); + ui.label(egui::RichText::new(text).size(12.0).color(t.text)); + }); + register(ui.ctx(), "info:focus", r.response.rect); + } }); } +/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye +/// Tracking"), or `None` when the photo has no focus point. +pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option { + let f = f?; + let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); + Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) +} + fn human_size(bytes: u64) -> String { match bytes { b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..46349e866 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -275,6 +275,11 @@ pub struct AppSettings { /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, + /// View ▸ Show Focus Point: draw the box the camera focused on (`view.focusPoint`). + pub focus_point: bool, + /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point + /// when the photo has one (`view.zoomToFocus`). + pub zoom_to_focus: bool, } impl Default for AppSettings { @@ -300,6 +305,8 @@ impl Default for AppSettings { light_theme: LightTheme::Silver, favorite_themes: Vec::new(), display_profile: String::new(), + focus_point: false, + zoom_to_focus: true, } } } diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs new file mode 100644 index 000000000..fa9ae60a0 --- /dev/null +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -0,0 +1,158 @@ +//! Headless tests of the camera's focus point (#1480): View ▸ Show Focus Point draws the box where the +//! camera focused, through the user's rotation and crop; Toggle Zoom (Z) centres on it; the Info panel +//! names the focus mode. + +use std::sync::Arc; +use std::time::Duration; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Zoom; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn focus(x0: f64, y0: f64, x1: f64, y1: f64) -> FocusPoint { + FocusPoint { + rect: lightcraft_geom::Rect { x0, y0, x1, y1 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: Some("Animal Eye Tracking".into()), + } +} + +/// The demo library in Detail with the Info panel, its first photo active and given `f` as its focus point. +fn detail_with_focus(f: Option) -> (Headless, PhotoId) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = *h.app.session.visible_cloned().first().expect("a demo photo"); + let mut meta = h.app.session.catalog.photo(id).expect("photo").meta.clone(); + meta.focus = f; + h.app.session.catalog.apply(Op::SetMeta { id, meta: Box::new(meta) }).expect("set meta"); + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail", "right": "info"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + (h, id) +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn run(h: &mut Headless, cmd: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": cmd, "params": params}), T); + assert_eq!(r["ok"], true, "{cmd}: {r}"); + h.settle(SETTLE); + r["result"].clone() +} + +/// Where `(x, y)` (fractions of the drawn photo) is on screen. +fn on_image(h: &Headless, x: f32, y: f32) -> egui::Pos2 { + let img = h.app.image_rect.expect("the photo is drawn"); + egui::pos2(img.left() + x * img.width(), img.top() + y * img.height()) +} + +#[test] +fn the_overlay_draws_on_the_focus_point_and_follows_rotation() { + let (mut h, _) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + assert!(widget(&h, "canvas:focusPoint").is_none(), "off by default"); + // the Info panel names how the camera focused + assert!(widget(&h, "info:focus").is_some(), "no Focus row in the Info panel"); + assert_eq!(run(&mut h, "view.focusPoint", json!({}))["on"], true); + let b = widget(&h, "canvas:focusPoint").expect("the focus box is drawn"); + let want = on_image(&h, 0.23, 0.33); + assert!((b.center() - want).length() < 1.5, "box at {:?}, want {want:?}", b.center()); + let img = h.app.image_rect.unwrap(); + assert!((b.width() - 0.06 * img.width()).abs() < 1.5, "{b:?} in {img:?}"); + // Rotate Right turns the photo 90° clockwise: the upright point (x, y) is drawn at (1 − y, x) + run(&mut h, "photo.rotateRight", json!({})); + let b = widget(&h, "canvas:focusPoint").expect("still drawn after rotating"); + let want = on_image(&h, 0.67, 0.23); + assert!((b.center() - want).length() < 1.5, "rotated box at {:?}, want {want:?}", b.center()); + // and the command turns it off again + assert_eq!(run(&mut h, "view.focusPoint", json!({"on": false}))["on"], false); + assert!(widget(&h, "canvas:focusPoint").is_none()); +} + +#[test] +fn toggle_zoom_centres_on_the_focus_point() { + let (mut h, _) = detail_with_focus(Some(focus(0.70, 0.20, 0.74, 0.26))); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + let r = h.request("ui.key", json!({"key": "z"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{:?}", h.app.ui.zoom); + let (u, v) = h.app.ui.pan; + assert!((u - 0.72).abs() < 1e-3 && (v - 0.23).abs() < 1e-3, "pan {u} {v}"); + // the zoomed view is centred on it (the canvas centre shows the focus point) + let canvas = h.app.canvas_rect.expect("canvas"); + let at = on_image(&h, 0.72, 0.23); + let img = h.app.image_rect.unwrap(); + // (unless the photo's edge stops the pan: here the point is well inside, so it is centred) + assert!(img.width() > canvas.width(), "zoomed in"); + assert!((at.x - canvas.center().x).abs() < 3.0, "{at:?} vs {:?}", canvas.center()); + // Z again: back to Fit; with Zoom to Focus Point off, Z keeps the current pan + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(run(&mut h, "view.zoomToFocus", json!({}))["on"], false); + h.app.ui.pan = (0.5, 0.5); + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); +} + +#[test] +fn without_a_focus_point_zoom_and_info_are_unchanged() { + let (mut h, _) = detail_with_focus(None); + run(&mut h, "view.focusPoint", json!({"on": true})); + assert!(widget(&h, "canvas:focusPoint").is_none()); + assert!(widget(&h, "info:focus").is_none()); + h.app.ui.pan = (0.4, 0.6); + run(&mut h, "view.zoomToggle", json!({})); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_))); + assert_eq!(h.app.ui.pan, (0.4, 0.6), "no focus point: Z zooms where it did before"); +} + +/// The box maps through the crop (and a point cropped away gives no box, so Z zooms as before). +#[test] +fn focus_box_follows_the_crop_and_drops_a_point_cropped_away() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 6000, 4000, "2026-10-13T00:00:00"); + p.meta.focus = Some(focus(0.70, 0.45, 0.80, 0.55)); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.70).abs() < 1e-9 && (b.y1 - 0.55).abs() < 1e-9, "{b:?}"); + // keep the right half: x 0.5..1 becomes 0..1 + let mut d = (*p.develop).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.5, y0: 0.0, x1: 1.0, y1: 1.0 }; + p.develop = Arc::new(d.clone()); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.40).abs() < 1e-6 && (b.x1 - 0.60).abs() < 1e-6, "{b:?}"); + // keep the left half: the point is gone + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 }; + p.develop = Arc::new(d); + assert!(crate::panels::detail::focus_box(&p).is_none()); + // a zero-size photo or a garbage box never panics + let mut z = Photo::new(PhotoId(2), Source::Demo { scene: 1 }, "z.jpg", "JPEG", 0, 0, "2026-10-13T00:00:00"); + z.meta.focus = Some(focus(f64::NAN, 0.0, f64::INFINITY, 1.0)); + assert!(crate::panels::detail::focus_box(&z).is_none()); + z.meta.focus = Some(focus(0.4, 0.4, 0.6, 0.6)); + assert!(crate::panels::detail::focus_box(&z).is_some()); +} + +#[test] +fn compare_and_survey_draw_each_photos_focus_point() { + let (mut h, id) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo"); + run(&mut h, "view.focusPoint", json!({"on": true})); + run(&mut h, "library.select", json!({"ids": [id.0, other.0], "active": id.0})); + for view in ["view.compare", "view.survey"] { + run(&mut h, view, json!({})); + assert!(widget(&h, &format!("compare:focusPoint:{}", id.0)).is_some(), "{view}: no focus box"); + assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5ecf41b0d..185ad2678 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -103,6 +103,11 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in `photo.inspect`: `{rect: {x0, y0, +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). + 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: "charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. diff --git a/docs/focus-points.md b/docs/focus-points.md new file mode 100644 index 000000000..c6993948f --- /dev/null +++ b/docs/focus-points.md @@ -0,0 +1,79 @@ +# Focus points + +Most cameras record where they focused. LightCraft reads that point from the file, draws it over the photo and +zooms to it, so checking whether the subject is sharp takes one key while culling. Lightroom shows no focus points +(photographers add plug-ins for it); this goes beyond parity (tracker row `LR-VIEW-FOCUSPOINT`). + +## Using it + +- **View ▸ Show Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in + Detail, Compare and Survey. It follows Rotate, flips and the crop; a point cropped away is not drawn. Off by + default; the choice is saved with the app's settings. +- **View ▸ Zoom to Focus Point** (`view.zoomToFocus {on?}`, on by default): Toggle Zoom (Z or Space) zooms in + centred on the focus point instead of the last position. A click on the photo still zooms where you click, and a + photo without a focus point zooms as before. On by default because it changes nothing for photos without a point, + and for those with one the focus point is what a 1:1 check is for. In Compare the two panes share one pan, so Z + centres on the active photo's point; per-pane focus zoom is a follow-up. +- **Info panel**: a *Focus* row says how the camera focused, e.g. `AF-C · Wide · Animal Eye Tracking` (focus mode, + the AF area set on the camera, and what it tracked when the camera says). +- **Agents**: `photo.inspect` (and the MCP `lightcraft://photo/active` resource) returns it as `meta.focus`: + `{rect: {x0, y0, x1, y1}, mode?, area?, tracking?}`, the box normalized to the upright (EXIF-oriented) photo, y down. + 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 +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). + +## Cameras + +Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)** +(`lightcraft_meta::focus::SONY_MODELS`). Other Sony bodies store the same tags, but a body is added only after its +files are checked, so an unchecked one shows nothing rather than a misleading box. Fujifilm, Canon, Nikon, OM System +and Panasonic record similar data and are later steps. + +### Sony layout and evidence + +Tag identities and value names come from the public ExifTool tag-name documentation (Sony MakerNotes table). No +third-party code was read or ported. Every field was confirmed on real files by comparing LightCraft's decoder with +ExifTool's output, run as a black box: 333 ARWs sampled across four years of a photographer's library (180 α1, +153 A7S III; 12 portrait or upside-down, 54 in APS-C crop or reduced-size modes), with no mismatch in the point, frame or names. + +| MakerNote tag | Layout | Notes | +|---|---|---| +| `0x2027` FocusLocation | 4 × int16u: frame width, frame height, focus x, focus y | in the sensor's landscape frame (a portrait shot still says 8640 × 5760); the frame is the image size (5616 × 3744 in α1 APS-C mode) | +| `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height, then 2 bytes that are zero when no frame was shown | zero in manual focus; one AF-C α1 file had no frame (a small box is drawn: 3 % of the short side) | +| `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | | +| `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | | +| `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | | +| `0x9402` bytes 0x16 / 0x17 | enciphered: focus mode (low 7 bits) and the AF area mode used (0 Multi, 2 Spot, 3 Flexible Spot, 11 Zone, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual…) | read only when its focus mode equals `0x201b`'s | + +The documentation says the `0x94xx` blocks use "a simple substitution cipher". On the files each byte `p < 249` is +stored as `p³ mod 249` (bytes 249–255 unchanged): ExifTool's decoded AFAreaMode 20 is stored as `0x20`, a decoded +`0x83` as `0x77`. Cubing is a bijection modulo 249 (= 3 × 83), so the inverse is a table lookup. + +On the α1 files the box sits on the subject: drawn on the embedded previews, it lands on the bird's eye in eye-AF +shots and follows spot, zone, wide and tracking shots, in portrait and landscape. + +### When there is no real point + +Nothing is stored (so nothing is drawn and Z zooms as before) when: + +- the body is not one of the checked ones, the tags are missing or malformed, the frame size is zero, or the centre + lies outside its frame; +- **manual focus** (`0x201b` = 0, or the block's area mode is Manual). The camera still records where the AF frame was + parked: 83 of the 91 sampled manual-focus files carry an off-centre location that says nothing about focus; +- the location is the **exact frame centre while the area used was Multi** (Wide with nothing tracked; without the + block, Wide with tracking off). The wide area reports the centre when it picked no particular spot; 7 of the 34 + sampled autofocus Multi files did. A centred Spot, Zone or Flexible Spot frame is a choice the photographer made, and + is kept (49 of the 111 sampled spot and zone files were centred, the camera's default position). + +DMF (AF, then manual touch-up) keeps its point. + +## Code + +- `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/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 e09138cfb..d3d23e325 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`) | +| `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://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 76d91ba3e..45bdaac01 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-VIEW-FOCUSPOINT: camera focus point overlay and zoom (#1480); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -28,7 +28,7 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 174. ## Top gaps and shortcut notes @@ -130,6 +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-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 | @@ -789,6 +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-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 b5c2ac68c..7bf854d0e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,16 @@ ## 2026-10-13 +### Where the camera focused +- **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare + and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`). + Lightroom has no such view. +- **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). + ### 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 let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From fdad5a4ad437583723d1a3c4bf22641a473ecfbc Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:06:06 +0200 Subject: [PATCH 2/2] LR-VIEW-FOCUSPOINT: read focus points for photos already in the library, header-only in the background (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos imported before focus points were read got one only through Reload from Disk, which hashes whole files on the UI thread. Now: - lightcraft_meta::focus::read_header reads only what the decoder looks at: IFD0 (Make, Model), the Exif IFD, the maker note's IFD and the focus tags' values (a JPEG: its segments up to the Exif one), walked round by round and decoded with the whole-file code from a buffer of the file's length holding just those ranges. Bounded rounds, bytes (2 MB) and size (1 GB). One 32 KB read per α1 / A7S III ARW; on 109 real files (83 with a point) it gives the whole-file read's point for every one. - A background job (crates/engine/src/focus_scan.rs) reads photos with no point from a supported body (by the stored camera; nothing else is opened), ARW or JPEG, one at a time in path order, with a "Reading focus points" activity row (Cancel::Flag). The UI's per-frame focus.pump gives it the face scan's pace (paused while dragging or typing) and starts it once per library opening. Commits every 200 photos or 3 s as system ops (no undo step, no sidecar write), only meta.focus, only on photos that still have none at the same path. "Looked, none" goes to focus-scanned.bin beside the library, headed by the decoder's tag so a decoder knowing more bodies starts afresh; unreadable files are retried at the next opening. - photo.readFocusPoints {ids?, wait?} (Photo > Read Focus Points) runs it on demand. Measured cold on a 5400 rpm USB drive: 200 α1 / A7S III ARWs in 1.9 s (~100 photos/s, 32 KB each), so 58,796 raws take about 10 minutes instead of re-reading 2.25 TB. --- crates/engine/src/activity.rs | 2 +- crates/engine/src/cmd/focus.rs | 50 +++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/faces_index.rs | 29 +- crates/engine/src/focus_scan.rs | 432 ++++++++++++++++++++++ crates/engine/src/lib.rs | 9 + crates/engine/src/tests_focus_scan.rs | 297 +++++++++++++++ crates/meta/src/focus.rs | 198 +++++++++- crates/meta/src/focus_read.rs | 465 ++++++++++++++++++++++++ crates/meta/src/lib.rs | 1 + crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/focus_scan.rs | 24 ++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/panels/faces.rs | 7 + crates/ui-egui/src/tests_focus_point.rs | 31 ++ docs/background-tasks.md | 3 +- docs/control-protocol.md | 3 + docs/focus-points.md | 44 ++- docs/mcp.md | 2 +- docs/parity-checklist.md | 4 +- docs/whats-new.md | 6 +- 30 files changed, 1621 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/focus.rs create mode 100644 crates/engine/src/focus_scan.rs create mode 100644 crates/engine/src/tests_focus_scan.rs create mode 100644 crates/meta/src/focus_read.rs create mode 100644 crates/ui-egui/src/focus_scan.rs 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