mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-VIEW-FOCUSPOINT: show the camera's focus point (Sony) and zoom to it (#1480)
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.
This commit is contained in:
@@ -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<lightcraft_meta::Region>,
|
||||
/// 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<lightcraft_meta::FocusPoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -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<PhotoId> {
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
// 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()))
|
||||
}
|
||||
|
||||
@@ -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<Metadata, TiffError> {
|
||||
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<String> {
|
||||
|
||||
@@ -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<String>,
|
||||
/// The AF area chosen on the camera (`Wide`, `Zone`, `Flexible Spot`…).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub area: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<FocusPoint> {
|
||||
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<Orientation>) -> Option<FocusPoint> {
|
||||
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<u8> {
|
||||
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<FocusPoint> {
|
||||
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<u8>, 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<u8> {
|
||||
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<u8> {
|
||||
let mut note = b"SONY DSC \0\0\0".to_vec();
|
||||
let ifd_len = 2 + note_entries.len() * 12 + 4;
|
||||
let mut tail: Vec<u8> = 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=<dir>` 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only.
|
||||
pub regions: Vec<Region>,
|
||||
/// 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<FocusPoint>,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "人物瞳トラッキング"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Слежение за глазами людей"
|
||||
}
|
||||
|
||||
@@ -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": "Стеження за очима людей"
|
||||
}
|
||||
|
||||
@@ -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": "人眼追踪"
|
||||
}
|
||||
|
||||
@@ -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": "人眼追蹤"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<bool> {
|
||||
"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),
|
||||
|
||||
@@ -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<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.compare" => 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<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.zoomToggle" => {
|
||||
// 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" => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<lightcraft_geom::Rect> {
|
||||
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:<id>`).
|
||||
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;
|
||||
|
||||
@@ -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<String> {
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FocusPoint>) -> (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<egui::Rect> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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=<photo folder>`).
|
||||
- `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`.
|
||||
+1
-1
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) 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 (🟡) |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user