diff --git a/Cargo.lock b/Cargo.lock index 1952f5c4..aa91c3d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2095,6 +2095,7 @@ version = "0.2.1" dependencies = [ "crc32fast", "lightcraft-develop", + "lightcraft-meta", "log", "proptest", "serde", @@ -2348,6 +2349,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-engine", "lightcraft-geom", + "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 47508359..c70c2831 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lightcraft-develop = { workspace = true } +lightcraft-meta = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index aad5949b..097a7179 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -35,7 +35,7 @@ use lightcraft_develop::DevelopSettings; pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of}; pub use lock::{LibraryLock, LockError, LockOwner}; pub use model::*; -pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey}; +pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey}; pub use rules::{Match, Rule, RuleSet}; use serde::{Deserialize, Serialize}; pub use store::{FsStore, MemStore, Store}; diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index f6658454..9248b220 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -183,6 +183,11 @@ pub struct Meta { pub copyright_url: String, pub creator: String, pub keywords: Vec, + /// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo. + /// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP. + /// 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, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 72c7ffb1..384c2ae5 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -39,6 +39,8 @@ pub struct Filter { pub date: Option, /// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`). pub keyword: Option, + /// A person: photos with a named face region of this name (case-insensitive), as read from XMP. + pub person: Option, pub camera: Option, /// Lens (case-insensitive substring). pub lens: Option, @@ -113,6 +115,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), + "person" | "who" => has_person(p, val), "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), "date" => p.date().starts_with(val), @@ -125,6 +128,12 @@ fn token_matches(p: &Photo, tok: &str) -> bool { hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t)) } +/// Whether `p` has a named face region called `name` (case-insensitive). +fn has_person(p: &Photo, name: &str) -> bool { + let name = name.trim().to_lowercase(); + p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name)) +} + impl Filter { /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { @@ -152,9 +161,14 @@ impl Filter { if let Some(e) = self.edited { v.push(if e { "edited".into() } else { "unedited".into() }); } - for (name, val) in - [("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)] - { + for (name, val) in [ + ("keyword", &self.keyword), + ("person", &self.person), + ("camera", &self.camera), + ("lens", &self.lens), + ("date", &self.date), + ("imported", &self.imported), + ] { if let Some(x) = val { v.push(format!("{name} {x}")); } @@ -267,6 +281,11 @@ impl Filter { { return false; } + if let Some(n) = &self.person + && !has_person(p, n) + { + return false; + } if let Some(c) = &self.camera && !p.meta.camera.eq_ignore_ascii_case(c) { @@ -330,6 +349,54 @@ impl Catalog { } m.into_iter().collect() } + + /// The people named on faces in the library (MWG regions read from XMP): how many photos each + /// appears in and the photo showing their largest face (for a card's picture). Most photos first, + /// then by name. Names that differ only in case are one person, shown as first seen; a person + /// twice in one photo counts once. + pub fn people(&self) -> Vec { + self.people_in(&Filter::default()) + } + + /// [`Self::people`] among the photos `filter` lets through (its `person` is ignored): what the + /// People view offers while other filters (a date, a rating, an album…) are active, so picking + /// a person never ends in an empty grid. + pub fn people_in(&self, filter: &Filter) -> Vec { + let filter = Filter { person: None, ..filter.clone() }; + let mut m: std::collections::HashMap = Default::default(); + for p in self.photos().filter(|p| filter.matches(p, self)) { + let mut seen: Vec = Vec::new(); + for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + // the face's size in pixels of the photo + let area = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) * p.width as f64 * p.height as f64; + let (person, best) = + m.entry(key.clone()).or_insert_with(|| (Person { name: name.to_string(), count: 0, photo: p.id, face: r.rect }, area)); + if !seen.contains(&key) { + person.count += 1; + seen.push(key); + } + if area > *best || (area == *best && p.id < person.photo) { + (person.photo, person.face, *best) = (p.id, r.rect, area); + } + } + } + let mut v: Vec = m.into_values().map(|(p, _)| p).collect(); + v.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + v + } +} + +/// A person named on faces in the library ([`Catalog::people`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Person { + pub name: String, + /// Photos they appear in. + pub count: usize, + /// The photo with their largest face, and that face (normalized, in the photo's upright frame). + pub photo: PhotoId, + pub face: lightcraft_meta::Rect, } #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 86442d2a..6f76b694 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -297,3 +297,86 @@ fn folder_identity_ignores_spelling() { assert!(folder_within("/a/b", "/")); assert!(folder_within("C:\\x", "c:\\")); } + +/// People come from named *face* regions: counted once per photo, case-insensitive, most photos +/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match. +#[test] +fn people_from_named_face_regions() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut c = Catalog::new(); + let mut add = |name: &str, regions: Vec| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.regions = regions; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let a = add( + "a.jpg", + vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)], + ); + let b = + add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]); + let big = Rect { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 }; + let d = add("d.jpg", vec![Region { rect: big, ..region(Some("John Roe"), RegionKind::Face) }]); + let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]); + add("f.jpg", vec![]); + let people = c.people(); + let summary: Vec<(&str, usize)> = people.iter().map(|p| (p.name.as_str(), p.count)).collect(); + assert_eq!(summary, vec![("Jane Doe", 2), ("John Roe", 2), ("Sam", 1)], "once per photo, pets and unnamed faces left out"); + // the picture is the person's largest face; ties go to the lower photo id + assert_eq!((people[0].photo, people[0].face), (a, Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 })); + assert_eq!((people[1].photo, people[1].face), (d, big), "the larger face wins over an earlier, smaller one"); + + let q = |f: Filter| c.query(&f, &Sort::default()); + assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2); + let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() }); + got.sort(); + assert_eq!(got, vec![b, d]); + assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person"); + assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case"); + assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a)); + + // other filters narrow who is offered (so picking a person never ends in an empty grid); the + // `person` filter itself does not + c.apply(Op::SetRating { id: b, rating: 3 }).unwrap(); + let names = |f: Filter| c.people_in(&f).into_iter().map(|p| (p.name, p.count)).collect::>(); + let rated = Filter { rating: 1, ..Default::default() }; + assert_eq!( + names(rated.clone()), + vec![("JANE DOE".to_string(), 1), ("John Roe".to_string(), 1)], + "only the rated photo's people, counted within it, spelled as first seen there" + ); + assert_eq!( + names(Filter { person: Some("Sam".into()), ..rated }), + names(Filter { rating: 1, ..Default::default() }), + "the person filter is ignored" + ); + assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos"); +} + +/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written +/// before regions existed (no `regions` key) reads with none. +#[test] +fn empty_regions_are_not_serialized_and_default_when_missing() { + let m = Meta { keywords: vec!["k".into()], ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("regions").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.regions.is_empty()); + let mut with = m.clone(); + with.regions.push(lightcraft_meta::Region { + rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some("A".into()), + description: None, + }); + let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap(); + assert_eq!(back, with); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index cba1c338..84100638 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -263,7 +263,7 @@ pub fn specs() -> Vec { "Filter", [], None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}", + "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}", always, |s, p| { let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); @@ -446,6 +446,66 @@ pub fn specs() -> Vec { cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)), cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)), cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)), + // ---- face / pet regions + cmd!( + "photo.removeRegion", + "Remove Face Box", + [], + None, + "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", + always, + |s, p| { + let id = + p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?; + let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone(); + if index >= meta.regions.len() { + return Err(bad("photo.removeRegion", "no such region")); + } + let gone = meta.regions.remove(index); + s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"removed": gone.name})) + } + ), + cmd!( + "photo.setRegion", + "Resize Face Box", + [], + None, + "{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar", + always, + |s, p| { + const C: &str = "photo.setRegion"; + // a drag is one undo step however many frames it took: the caller commits once, on release + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad(C, "missing or invalid `index`"))?; + let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?; + let num = |k: &str| { + r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number"))) + }; + let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?); + let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0)); + let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0)); + if x1 - x0 < 0.005 || y1 - y0 < 0.005 { + return Err(bad(C, "the box would be too small")); + } + let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone(); + let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?; + region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 }; + s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}})) + } + ), // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index b268333c..d2d769c5 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -38,6 +38,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { copyright_url: m.copyright_url.clone().unwrap_or_default(), creator: m.artist.clone().unwrap_or_default(), keywords: m.keywords.clone(), + regions: m.regions.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68200a35..da624241 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -125,6 +125,9 @@ pub struct Session { pub undo: Vec, pub redo: Vec, pub interaction: Option, + /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on + /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. + pub(crate) skip_auto_write: bool, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -223,6 +226,7 @@ impl Session { undo: Vec::new(), redo: Vec::new(), interaction: None, + skip_auto_write: false, clipboard: None, meta_clipboard: None, browse: None, @@ -308,8 +312,11 @@ impl Session { self.journal.drain(..1000); } } - if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { - self.auto_write_sidecars(&self.pending_log[log_start..]); + if self.depth == 0 { + let skip = std::mem::take(&mut self.skip_auto_write); + if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { + self.auto_write_sidecars(&self.pending_log[log_start..]); + } } if self.depth == 0 && self.library.is_some() { // Make the command durable before reporting success. When the command's own records diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 616cfb0f..4382160a 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -4,7 +4,7 @@ //! ```text //! LightCraft Library/ //! catalog.snap catalog.log (lightcraft-catalog journal) -//! presets.json view.json (user presets + favourites; last source/filter/sort/selection) +//! presets.json view.json (user presets + favourites; last source/sort/selection) //! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export) //! thumbs/ (rendered thumbnail cache, safe to delete) //! Originals/ (photos imported with "copy into library") @@ -102,7 +102,8 @@ struct PresetsFile { struct ViewFile { source: LibrarySource, browse: Option, - filter: lightcraft_catalog::Filter, + // No filter: a library opens unfiltered. A date, keyword or person left over from the last session + // would silently hide photos, with only a small badge to say so. sort: lightcraft_catalog::Sort, selection: Selection, } @@ -346,7 +347,6 @@ impl Session { if let Some(v) = settings.read::(files.as_mut(), "view.json") { self.source = v.source; self.browse = v.browse; - self.filter = v.filter; self.sort = v.sort; self.selection = v.selection; self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); @@ -494,17 +494,11 @@ impl Session { } fn view_json(&self) -> Vec { - let view = ViewFile { - source: self.source, - browse: self.browse.clone(), - filter: self.filter.clone(), - sort: self.sort, - selection: self.selection.clone(), - }; + let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() }; serde_json::to_vec_pretty(&view).unwrap_or_default() } - /// Save the view state (source, filter, sort, selection) if it changed since it was last + /// Save the view state (source, sort, selection) if it changed since it was last /// written. Native hosts get this from [`Session::close_library`]; the browser host calls it /// periodically, since a tab can be closed without notice. pub fn save_view(&mut self) { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index bab0c5c7..c5860c49 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -705,6 +705,48 @@ impl crate::Session { }) } + /// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the + /// photo's own look with its crop replaced by a square around the face (room for hair and chin), + /// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up + /// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content. + pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the + // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. + let orient = p.develop.orientation; + let face = orient.map_norm_rect(face); + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; + let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; + // finite, within the photo, and never wider than its short side (so the clamps below are valid) + let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; + let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h); + let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 }); + let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0); + let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h }; + let mut settings = (*p.develop).clone(); + settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() }; + let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); + // pixels along the long edge that leave `edge` across the crop + let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64); + let level = SourceLevel::for_size(needed as usize); + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, &settings, edge); + Some(RenderJob { + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + }) + } + /// Size-independent cache key of a photo's view render (loupe) for its current settings. fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish() diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 69bf8f0f..7e924948 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -99,6 +99,7 @@ pub struct SidecarData { pub alt_text: Option, pub extended_description: Option, pub keywords: Option>, + pub regions: Option>, /// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or /// `xmp:CreateDate` (first found). Used only when the file itself has no capture time. pub captured: Option, @@ -133,6 +134,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result bool { if let Some(k) = &sc.keywords { m.keywords = k.clone(); } + if let Some(r) = &sc.regions { + m.regions = r.clone(); + } let develop = match &sc.develop { Some(DevelopPatch::Full(s)) => (**s).clone(), Some(DevelopPatch::Partial(v)) => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 58b7e643..2837d672 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -727,3 +727,59 @@ fn curve_reset_by_channel_and_whole() { assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default()); assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err()); } + +/// A People card's close-up is a square (in pixels) inside the photo, around the face, whatever the +/// face rectangle says: corners, oversized and degenerate rectangles never leave the frame or panic. +#[test] +fn face_job_crops_a_square_inside_the_photo() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let (w, h) = { + let p = s.catalog.photo(id).unwrap(); + (f64::from(p.width), f64::from(p.height)) + }; + let faces = [ + Rect { x0: 0.40, y0: 0.30, x1: 0.50, y1: 0.45 }, + Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 }, + Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 }, + Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 }, + Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 }, + Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY }, + ]; + for face in faces { + let job = s.face_job(id, face, 256).expect("a job for a photo in the library"); + let r = job.settings.crop.geometry.rect; + assert!((0.0..=1.0).contains(&r.x0) && (0.0..=1.0).contains(&r.y0) && r.x1 <= 1.0 + 1e-9 && r.y1 <= 1.0 + 1e-9, "{face:?} → {r:?}"); + assert!(r.x1 > r.x0 && r.y1 > r.y0, "{face:?} → {r:?}"); + let (pw, ph) = ((r.x1 - r.x0) * w, (r.y1 - r.y0) * h); + assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in pixels: {face:?} → {pw} × {ph}"); + assert_eq!(job.settings.crop.geometry.angle, 0.0); + } + // a face around (0.45, 0.375) is centred in its crop (room to spare on every side) + let r = s.face_job(id, faces[0], 256).unwrap().settings.crop.geometry.rect; + assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}"); + assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none()); +} + +/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box +/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels. +#[test] +fn face_job_follows_rotate_right() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let (w, h) = { + let p = s.catalog.photo(id).unwrap(); + (f64::from(p.width), f64::from(p.height)) + }; + // a small face left of centre on the upright photo + let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 }; + s.execute("photo.rotateRight", &json!({})).unwrap(); + let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect; + // a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325) + assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}"); + // the rotated photo is h × w pixels + let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w); + assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}"); +} diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index d7436ff1..f23ae9bd 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -201,6 +201,25 @@ fn album_source_reports_in_library_state_and_persists_the_view() { let _ = std::fs::remove_dir_all(&dir); } +/// A library opens unfiltered: the source and sort come back, a filter from the last session does not +/// (it would hide photos behind a small badge). +#[test] +fn filters_are_not_restored_when_a_library_reopens() { + let dir = temp_dir("filter-not-restored"); + let mut s = open(&dir); + let all = s.visible_cloned().len(); + s.execute("library.sort", &json!({"key": "fileName"})).unwrap(); + s.execute("library.filter", &json!({"rating": 5, "date": "2026-01-16", "person": "Jane Doe"})).unwrap(); + assert!(s.visible_cloned().len() < all); + s.close_library().unwrap(); + drop(s); + let mut s2 = open(&dir); + assert_eq!(s2.filter, lightcraft_catalog::Filter::default()); + assert_eq!(s2.visible_cloned().len(), all); + assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn filter_chips_clear_one_filter_and_total_ignores_the_filter() { let dir = temp_dir("chips"); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 0832e2fb..fede1fec 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -173,6 +173,162 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() { let _ = std::fs::remove_dir_all(&src); } +const MWG_REGIONS: &str = r#" + + + + + + + + + + + + + + + + + + + + +"#; + +/// A face region written by Lightroom (MWG-RS) into a sidecar reaches `Photo.meta.regions` on +/// import — the same path real catalogs use, exercised end to end rather than just at the parser. +#[test] +fn regions_from_sidecar_are_read_on_import() { + let src = temp_dir("regions"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["sidecars"], 1); + let p = s.catalog.photos().next().unwrap().clone(); + assert_eq!(p.meta.regions.len(), 1); + let region = &p.meta.regions[0]; + assert_eq!(region.name.as_deref(), Some("Jane Doe")); + assert_eq!(region.kind, lightcraft_meta::RegionKind::Face); + assert!((region.rect.width() - 0.3).abs() < 1e-9, "{:?}", region.rect); + let _ = std::fs::remove_dir_all(&src); +} + +/// Removing a face box is a catalog-only, undoable edit: it never rewrites the sidecar (Lightroom's +/// regions and everything else in it stay byte for byte), even with auto-write on. +#[test] +fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() { + let src = temp_dir("region-remove"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let before = std::fs::read(src.join("portrait.xmp")).unwrap(); + let n = s.catalog.photo(id).unwrap().meta.regions.len(); + assert!(n >= 1); + assert!(s.execute("photo.removeRegion", &json!({"index": n})).is_err(), "out of range"); + assert!(s.execute("photo.removeRegion", &json!({})).is_err(), "no index"); + let r = s.execute("photo.removeRegion", &json!({"index": 0})).unwrap(); + assert_eq!(r["removed"], "Jane Doe"); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n - 1); + assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched"); + // an ordinary edit still auto-writes (the guard is per command) + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + assert_ne!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "auto-write still works for other edits"); + // undo brings the region back + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n); + let _ = std::fs::remove_dir_all(&src); +} + +/// Resizing a face box is a catalog-only, undoable edit with strict input handling; it leaves the +/// sidecar alone like removal does. +#[test] +fn resizing_a_region_validates_clamps_undoes_and_leaves_the_sidecar_alone() { + let src = temp_dir("region-resize"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let before = std::fs::read(src.join("portrait.xmp")).unwrap(); + let original = s.catalog.photo(id).unwrap().meta.regions[0].rect; + let set = |s: &mut Session, rect: serde_json::Value| s.execute("photo.setRegion", &json!({"index": 0, "rect": rect})); + // hostile input is an error and changes nothing + for bad in [ + json!({"x0": 0.1, "y0": 0.1, "x1": 0.1, "y1": 0.5}), + json!({"x0": 0.1, "y0": 0.1, "x1": 0.1001, "y1": 0.5}), + json!({"x0": 0.1, "y0": 0.1, "x1": 0.5}), + json!({"x0": "a", "y0": 0.1, "x1": 0.5, "y1": 0.5}), + json!({"x0": null, "y0": 0.1, "x1": 0.5, "y1": 0.5}), + ] { + assert!(set(&mut s, bad.clone()).is_err(), "{bad}"); + } + assert!(s.execute("photo.setRegion", &json!({"index": 9, "rect": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.5}})).is_err(), "no such region"); + assert!(s.execute("photo.setRegion", &json!({"index": 0})).is_err(), "no rect"); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original); + // corners given in any order and outside the photo are clamped into it + let r = set(&mut s, json!({"x0": 0.6, "y0": 1.5, "x1": -0.2, "y1": 0.2})).unwrap(); + assert_eq!(r["rect"], json!({"x0": 0.0, "y0": 0.2, "x1": 0.6, "y1": 1.0})); + let now = s.catalog.photo(id).unwrap().meta.regions[0].rect; + assert_eq!((now.x0, now.y0, now.x1, now.y1), (0.0, 0.2, 0.6, 1.0)); + assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original); + let _ = std::fs::remove_dir_all(&src); +} + +/// Re-reading a sidecar: one that has `mwg-rs:Regions` is authoritative, so an emptied region list +/// clears the stale regions; one without `mwg-rs:Regions` (an app that doesn't do regions) leaves the +/// photo's regions alone. +#[test] +fn rereading_a_sidecar_clears_regions_only_when_it_states_them() { + let src = temp_dir("region-reread"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1); + // another app rewrote the sidecar without any notion of regions: they stay + std::fs::write( + src.join("portrait.xmp"), + r#" + +"#, + ) + .unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.rating, 3, "the sidecar was read"); + assert_eq!(p.meta.regions.len(), 1, "a sidecar without mwg-rs:Regions keeps the photo's regions"); + // a region-aware app removed every region: the sidecar says so, and the catalog follows + let emptied = MWG_REGIONS.replace( + r#" + + + + "#, + "", + ); + assert_ne!(emptied, MWG_REGIONS); + std::fs::write(src.join("portrait.xmp"), emptied).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + assert!(s.catalog.photo(id).unwrap().meta.regions.is_empty(), "an emptied region list clears stale regions"); + // undo brings them back + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index b64ff04a..757adfe9 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -163,7 +163,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(e) = f.edited { add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null})); } - for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] { + for (name, key, val) in + [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword), ("Person", "person", &f.person)] + { if let Some(x) = val { add(format!("{name}: {x}"), json!({key: Null})); } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 509285aa..327c60fc 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -332,6 +332,23 @@ impl Orientation { // flipH ∘ rot(t) = rot(-t) ∘ flipH Orientation::from_parts(!f, (4 - t) % 4) } + /// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns + /// are their own inverses; quarter turns swap. + pub fn inverse(self) -> Orientation { + match self { + Orientation::Rotate90 => Orientation::Rotate270, + Orientation::Rotate270 => Orientation::Rotate90, + o => o, + } + } + /// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates + /// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's + /// Rotate Left/Right). + pub fn map_norm_rect(self, r: Rect) -> Rect { + let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0); + let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0); + Rect::from_points(Point::new(ax, ay), Point::new(bx, by)) + } /// Map a point in stored pixel space (w×h stored dims) to oriented space. pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) { let (flip, t) = self.to_parts(); @@ -417,6 +434,19 @@ mod tests { assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90); } + #[test] + fn orientation_inverse_and_norm_rect() { + let r = Rect::new(0.1, 0.2, 0.3, 0.6); + for v in 1..=8 { + let o = Orientation::from_exif(v); + let back = o.inverse().map_norm_rect(o.map_norm_rect(r)); + assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}"); + } + // a quarter turn clockwise: the left of the upright photo becomes its top + let q = Orientation::Rotate90.map_norm_rect(r); + assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}"); + } + proptest::proptest! { #[test] fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) { diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 594129d0..31a35cc6 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -26,7 +26,7 @@ pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; -pub use lightcraft_geom::Orientation; +pub use lightcraft_geom::{Orientation, Rect}; pub use tags::{TagRow, file_tag_rows, tag_rows}; pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc}; pub use xmp_merge::{MergeRules, merge_xmp}; @@ -50,6 +50,31 @@ pub struct Flash { pub raw: u16, } +/// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face, +/// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — LightCraft does not write +/// regions yet (see `docs/xmp-interop.md`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Region { + /// Normalized to the image's full, oriented frame (`Metadata::width`/`height`), y-down, 0..1 on + /// both axes — MWG's own convention, and what `AppliedToDimensions` areas are converted to. + pub rect: Rect, + pub kind: RegionKind, + /// `mwg-rs:Name` (e.g. the person's name). + pub name: Option, + pub description: Option, +} + +/// `mwg-rs:Type`. Unrecognised values are kept verbatim rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RegionKind { + Face, + Pet, + Focus, + BarCode, + /// `mwg-rs:Type` was missing, empty, or a value we don't recognise. + Other(String), +} + /// Everything LightCraft shows or searches about a photo. All fields are optional; unknown = `None`/empty. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Metadata { @@ -115,6 +140,8 @@ pub struct Metadata { pub rating: Option, /// Colour label name (e.g. "Red"). pub label: Option, + /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. + pub regions: Vec, } macro_rules! fill { @@ -175,6 +202,9 @@ impl Metadata { if self.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } + if self.regions.is_empty() { + self.regions = other.regions.clone(); + } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with @@ -187,6 +217,9 @@ impl Metadata { if !other.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } + if !other.regions.is_empty() { + self.regions = other.regions.clone(); + } } /// Exposure time formatted like cameras show it: `1/250`, `0.5`, `2″`. diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 07373e62..b8a54109 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -8,7 +8,8 @@ //! Writing emits the interchange subset in standard namespaces (dc, xmp, photoshop, exif, exifEX, tiff, lr) and //! LightCraft's full develop settings as an opaque JSON string in `lc:settings`. -use crate::{DateTime, Flash, Gps, Metadata, Orientation, parse_number}; +use crate::{DateTime, Flash, Gps, Metadata, Orientation, Region, RegionKind, parse_number}; +use lightcraft_geom::{Point, Rect}; use quick_xml::escape::{escape, partial_escape}; use quick_xml::events::Event; use std::collections::BTreeMap; @@ -32,6 +33,11 @@ const NAMESPACES: &[(&str, &str)] = &[ ("lc", LC_NS), // Read-only: develop settings written by other raw developers (interchange; see docs/xmp-interop.md). ("crs", CRS_NS), + // Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region + // Guidelines v2.0). Canonicalising these three lets a packet use any prefix for them. + ("mwg-rs", "http://www.metadataworkinggroup.com/schemas/regions/"), + ("stArea", "http://ns.adobe.com/xmp/sType/Area#"), + ("stDim", "http://ns.adobe.com/xap/1.0/sType/Dimensions#"), ]; /// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only. @@ -374,6 +380,72 @@ fn parse_gps_coord(s: &str) -> Option { v.is_finite().then_some(v) } +/// `mwg-rs:Regions` → every region in its `RegionList`, converted to the full-image normalized frame. +/// Malformed or out-of-range entries are skipped rather than rejecting the whole list: one bad region +/// (from Lightroom, digiKam, Picasa, or a hand-edited file) shouldn't hide the rest. +fn parse_regions(regions: &XmpValue) -> Vec { + // `AppliedToDimensions` documents the pixel size regions were authored against. It's only consulted + // for the rare region whose own Area is in pixel units instead of MWG's normalized default below. + let px_dims = regions.field("mwg-rs:AppliedToDimensions").and_then(|d| { + let w = d.field("stDim:w").and_then(XmpValue::text).and_then(parse_number)?; + let h = d.field("stDim:h").and_then(XmpValue::text).and_then(parse_number)?; + (w > 0.0 && h > 0.0).then_some((w, h)) + }); + let Some(list) = regions.field("mwg-rs:RegionList") else { return Vec::new() }; + list.items().iter().filter_map(|item| parse_one_region(item, px_dims)).collect() +} + +fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option { + let area = item.field("mwg-rs:Area")?; + let num_attr = |k: &str| area.field(k).and_then(XmpValue::text).and_then(parse_number); + // stArea:x/y are the region's CENTER, not its top-left corner (MWG Region Guidelines §Area). + let (cx, cy, w, h) = (num_attr("stArea:x")?, num_attr("stArea:y")?, num_attr("stArea:w")?, num_attr("stArea:h")?); + let finite_and_sane = [cx, cy, w, h].iter().all(|v| v.is_finite() && v.abs() <= 1.0e6); + if !finite_and_sane || w <= 0.0 || h <= 0.0 { + return None; + } + // `stArea:unit` is "normalized" (a fraction of AppliedToDimensions) in every writer we know of, and + // that needs no cross-referencing at all. Only a region that explicitly says "pixel" gets divided + // down, and only if AppliedToDimensions is there to divide by; otherwise it's dropped rather than + // stored as a nonsense fraction. + let is_pixels = area.field("stArea:unit").and_then(XmpValue::text).is_some_and(|u| u.eq_ignore_ascii_case("pixel")); + let (cx, cy, w, h) = match (is_pixels, px_dims) { + (false, _) => (cx, cy, w, h), + (true, Some((dw, dh))) => (cx / dw, cy / dh, w / dw, h / dh), + (true, None) => return None, + }; + // Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own + // frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for + // 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame. + // MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored + // twins here; boxes on mirrored photos are taken as written (see LR-LIB-PEOPLE in docs/parity.md). + let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite()); + let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI); + let (cx, cy, w, h) = match rotation { + Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w), + Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w), + Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h), + _ => (cx, cy, w, h), + }; + // Keep only the part of the box inside the photo: a region hanging off an edge (common for faces + // cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every + // stored rect is within 0..1 and non-empty. + let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT); + if rect.is_empty() { + return None; + } + let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") { + t if t.eq_ignore_ascii_case("face") => RegionKind::Face, + t if t.eq_ignore_ascii_case("pet") => RegionKind::Pet, + t if t.eq_ignore_ascii_case("focus") => RegionKind::Focus, + t if t.eq_ignore_ascii_case("barcode") => RegionKind::BarCode, + t => RegionKind::Other(t.to_string()), + }; + Some(Region { rect, kind, name, description }) +} + fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { let a = v.abs(); let d = a.trunc(); @@ -470,6 +542,7 @@ pub fn parse_xmp(s: &str) -> Result { let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); } + m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default(); let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned(); Ok(XmpData { metadata: m, lc_settings, properties: props, values }) } @@ -610,6 +683,9 @@ mod tests { fn full() -> Metadata { Metadata { + // 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(), make: Some("Maker & Sons".into()), model: Some("X <1>".into()), serial_number: Some("SN1".into()), @@ -764,4 +840,204 @@ mod tests { assert_eq!(parse_gps_coord("é"), None); assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); } + + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how + /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one + /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas + /// (the default, and the only unit real files use) don't need it. + #[test] + fn mwg_regions_element_form() { + let x = r#" + + + + + + + + Jane Doe + Face + + + + + + + Pet + Rex + Good boy + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 3, "{regions:?}"); + + assert_eq!(regions[0].kind, RegionKind::Face); + assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); + assert_eq!(regions[0].description, None); + let r = regions[0].rect; + assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); + assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); + + // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. + assert_eq!(regions[1].kind, RegionKind::Other(String::new())); + assert_eq!(regions[1].name, None); + + assert_eq!(regions[2].kind, RegionKind::Pet); + assert_eq!(regions[2].name.as_deref(), Some("Rex")); + assert_eq!(regions[2].description.as_deref(), Some("Good boy")); + } + + /// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame + /// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the + /// upright frame. + #[test] + fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { + let x = r#" + + + + + + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); + assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); + assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); + assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); + assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); + assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); + } + + /// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an + /// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an + /// unrecognised `mwg-rs:Type` value kept verbatim. + #[test] + fn mwg_regions_attribute_form_and_unknown_type() { + let x = r#" + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 2, "{regions:?}"); + assert_eq!(regions[0].kind, RegionKind::BarCode); + assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); + } + + /// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of + /// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area + /// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). + #[test] + fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { + let x = r#" + + + + + No Area + + + + + + Face + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); + assert_eq!(regions[0].kind, RegionKind::Face); + } + + /// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a + /// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view + /// would then draw or crop far outside the image. + #[test] + fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { + let x = r#" + + + + + Far right + Far left + Touching edge + Huge + Corner + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); + for r in ®ions { + let q = r.rect; + assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); + } + assert_eq!(regions[0].rect, Rect::UNIT); + let c = regions[1].rect; + assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); + } + + /// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). + #[test] + fn regions_merge_like_keywords() { + let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; + let mut a = Metadata::default(); + let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; + a.fill_missing(&b); + assert_eq!(a.regions, vec![with_region.clone()]); + + let mut a = Metadata::default(); + a.overlay_user_fields(&b); + assert_eq!(a.regions, vec![with_region]); + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b2047b0d..1dd1a063 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lightcraft-geom = { workspace = true } +lightcraft-meta = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 58c90c24..b4afd8c1 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -78,6 +78,8 @@ pub enum Icon { /// Community chat (opens the ArtCraft Discord): a speech bubble with three dots. Our own /// generic drawing, not any service's logo. Chat, + /// Face boxes on/off: four corner brackets round a small face. + FaceBox, } struct Pen<'a> { @@ -404,6 +406,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 18.0, 6.5, 200.0, 340.0); pen.rect(2.5, 2.5, 17.5, 17.5, 2.0); } + FaceBox => { + pen.line(&[(2.5, 7.0), (2.5, 2.5), (7.0, 2.5)]); + pen.line(&[(13.0, 2.5), (17.5, 2.5), (17.5, 7.0)]); + pen.line(&[(2.5, 13.0), (2.5, 17.5), (7.0, 17.5)]); + pen.line(&[(13.0, 17.5), (17.5, 17.5), (17.5, 13.0)]); + pen.circle(10.0, 8.5, 2.4); + pen.arc(10.0, 16.0, 4.5, 215.0, 325.0); + } Picker => { pen.line(&[(4.0, 16.0), (11.5, 8.5)]); pen.closed(&[(11.0, 5.0), (15.0, 9.0), (17.0, 7.0), (13.0, 3.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20e54730..cdf77ac8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -718,6 +718,7 @@ impl LightcraftApp { state::ViewMode::Compare => panels::compare::show_compare(self, ui), state::ViewMode::Survey => panels::compare::show_survey(self, ui), state::ViewMode::Reference => panels::compare::show_reference(self, ui), + state::ViewMode::People => panels::people::show(self, ui), }); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); @@ -794,6 +795,7 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, + people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, @@ -840,6 +842,23 @@ impl Caches { } } } + /// The people named on faces among the photos the filter lets through (its own `person` aside), + /// with photo counts. + pub fn people( + &mut self, + cat: &lightcraft_catalog::Catalog, + filter: &lightcraft_catalog::Filter, + ) -> std::sync::Arc> { + let key = lightcraft_catalog::Filter { person: None, ..filter.clone() }; + match &self.people { + Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), + _ => { + let t = std::sync::Arc::new(cat.people_in(&key)); + self.people = Some((cat.revision, key, t.clone())); + t + } + } + } /// All Photos / Picks / Recently Deleted counts. pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { if let Some((r, c)) = self.counts diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 23bd39fe..cd20c923 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -119,9 +119,11 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.detail", "view.compare", "view.survey", + "view.people", "---", "view.leftPanel", "view.photoCounts", + "view.faceBoxes", "view.filmstrip", "view.histogram", "view.navigator", @@ -292,8 +294,10 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.detail" => Some(u.view == ViewMode::Detail), "view.compare" => Some(u.view == ViewMode::Compare), "view.survey" => Some(u.view == ViewMode::Survey), + "view.people" => Some(u.view == ViewMode::People), "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), + "view.faceBoxes" => Some(u.face_boxes), "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 5d838b75..7f38e1af 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -21,6 +21,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.detail", "Detail", Some("D"), "View"), ("view.compare", "Compare", Some("Shift+C"), "View"), ("view.survey", "Survey", Some("N"), "View"), + ("view.people", "People", None, "View"), + ("view.faceBoxes", "Face Boxes", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -263,6 +265,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "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})) + } + "view.people" => { + app.ui.view = ViewMode::People; + Ok(json!({"people": app.session.catalog.people().len()})) + } "view.survey" => { app.ui.view = ViewMode::Survey; Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 4e0f9176..e36cba6a 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -26,6 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), + ("people", Icon::Subject, ViewMode::People, "People"), ] { if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { let _ = app.run(&format!("view.{id}"), json!({})); @@ -165,6 +166,11 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { { let _ = app.run("view.beforeAfter", json!({})); } + if app.ui.view == ViewMode::Detail + && icon_button(&mut child, "faceBoxes", Icon::FaceBox, vec2(30.0, 30.0), app.ui.face_boxes, true, "Face boxes").clicked() + { + let _ = app.run("view.faceBoxes", json!({})); + } if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() { let _ = app.run("view.filmstrip", json!({})); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 597a5cb9..cde3efdb 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -334,6 +334,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let map = CanvasMap::new(&frame, img_rect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); info_overlay(app, &p, canvas, &photo); + if !fullscreen { + filter_pill(app, ui, canvas); + } + if app.ui.face_boxes { + match region_overlay(ui, &p, &map, &photo, d.orientation) { + Some(RegionEdit::Remove(index)) => { + let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + } + Some(RegionEdit::Resize(index, r)) => { + let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); + } + None => {} + } + } // 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; @@ -421,6 +435,163 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } +/// What the user did to a face box this frame. +enum RegionEdit { + /// The × was clicked. + Remove(usize), + /// A handle drag ended: the region's new box (normalized, upright frame). + Resize(usize, lightcraft_geom::Rect), +} + +/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show. +const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ + (0.0, 0.0, egui::CursorIcon::ResizeNwSe), + (0.5, 0.0, egui::CursorIcon::ResizeVertical), + (1.0, 0.0, egui::CursorIcon::ResizeNeSw), + (1.0, 0.5, egui::CursorIcon::ResizeHorizontal), + (1.0, 1.0, egui::CursorIcon::ResizeNwSe), + (0.5, 1.0, egui::CursorIcon::ResizeVertical), + (0.0, 1.0, egui::CursorIcon::ResizeNeSw), + (0.0, 0.5, egui::CursorIcon::ResizeHorizontal), +]; + +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows +/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, +/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: +/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; +/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +fn region_overlay( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + orient: lightcraft_geom::Orientation, +) -> Option { + let t = Tokens::get(p.ctx()); + let clip = p.clip_rect(); + let pointer = ui.input(|i| i.pointer.hover_pos()); + // the box being dragged: (region index, its box so far) + let drag_key = egui::Id::new("region-drag"); + let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key)); + let mut edit = None; + for (index, r) in photo.meta.regions.iter().enumerate() { + let dragging = live.filter(|(i, _)| *i == index); + let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); + let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); + // white with a black keyline just outside it, so the box shows on any background + p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside); + if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) { + for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { + let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); + let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag()); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(*cursor); + } + let sq = Rect::from_center_size(c, vec2(7.0, 7.0)); + p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE }); + p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside); + if resp.dragged() + && let Some(pp) = resp.interact_pointer_pos() + { + // move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one + let mut n = rect; + if *fx == 0.0 { + n.min.x = pp.x.min(n.max.x - 12.0); + } else if *fx == 1.0 { + n.max.x = pp.x.max(n.min.x + 12.0); + } + if *fy == 0.0 { + n.min.y = pp.y.min(n.max.y - 12.0); + } else if *fy == 1.0 { + n.max.y = pp.y.max(n.min.y + 12.0); + } + let (a, b) = (map.norm(n.min), map.norm(n.max)); + let shown = lightcraft_geom::Rect { + x0: a.x.min(b.x).clamp(0.0, 1.0), + y0: a.y.min(b.y).clamp(0.0, 1.0), + x1: a.x.max(b.x).clamp(0.0, 1.0), + y1: a.y.max(b.y).clamp(0.0, 1.0), + }; + // back to the upright frame the region is stored in + let new = orient.inverse().map_norm_rect(shown); + ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); + ui.ctx().request_repaint(); + } + if resp.drag_stopped() { + if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) { + edit = Some(RegionEdit::Resize(i, new)); + } + ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key)); + } + } + } + if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { + let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); + let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click()); + register(ui.ctx(), format!("regionRemove:{index}"), xr); + let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); + p.rect_filled(xr, 2.0, fill); + p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); + let (c, m) = (xr.center(), 3.5); + let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text("Remove this face box (undo with Edit ▸ Undo)").clicked() { + edit = Some(RegionEdit::Remove(index)); + } + } + let Some(name) = &r.name else { continue }; + // the name in a dark label with a caret, centred above the box (below it when there is no room) + let g = p.layout_no_wrap(name.clone(), t.font(13.0), Color32::from_gray(225)); + let (pad, caret) = (vec2(14.0, 7.0), 5.0); + let size = g.size() + pad * 2.0; + let above = rect.top() - caret - size.y >= clip.top(); + let (top, tip, base) = if above { + (rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0) + } else { + (rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0) + }; + let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left())); + let label = Rect::from_min_size(pos2(left, top), size); + let fill = Color32::from_rgba_unmultiplied(56, 56, 56, 235); + p.rect_filled(label, 3.0, fill); + p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside); + let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0); + p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE)); + p.galley(label.min + pad, g, Color32::from_gray(225)); + } + edit +} + +/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous +/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the +/// canvas margin, so the photo does not move. +fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); + if chips.is_empty() { + return; + } + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let label = chips.iter().map(|c| c.label.as_str()).collect::>().join(" · "); + let g = p.layout_no_wrap(format!("Filtered: {label}"), t.font(12.0), Color32::from_gray(225)); + let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0)); + let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0)); + let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click()); + register(ui.ctx(), "loupe:filters", r); + p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); + let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); + p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0); + let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text("Click to clear these filters").clicked() { + let _ = app.run("library.clearFilter", json!({})); + } +} + /// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; /// hovering it explains (registered as `notice:previewOnly:loupe`). fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index cfb40d68..8317dda7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,7 @@ pub mod left; pub mod library_problem; pub mod masking; pub mod notices; +pub mod people; pub mod presets; pub mod profiles; pub mod right; diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs new file mode 100644 index 00000000..118fbd1c --- /dev/null +++ b/crates/ui-egui/src/panels/people.rs @@ -0,0 +1,87 @@ +//! People: a card per person named on faces (read from XMP): a close-up of their largest face, the +//! name and how many photos they are in. A click shows that person's photos in the grid. +//! +//! Only the rows on screen ask for a face render (the engine caches them, memory and disk). + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use lightcraft_catalog::Person; +use serde_json::json; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A card's face picture (points); the name and count sit below it. +const CARD: f32 = 150.0; +const LABEL_H: f32 = 44.0; +const GAP: f32 = 16.0; +const PAD: f32 = 20.0; +const HEADER_H: f32 = 44.0; + +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let people = app.caches.people(&app.session.catalog, &app.session.filter); + let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover()); + ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, "Named People", t.semibold(15.0), t.text); + ui.painter().text(pos2(head.right() - PAD, head.center().y), Align2::RIGHT_CENTER, people.len().to_string(), t.font(13.0), t.text_dim); + // the filters narrowing the list (a date, a keyword…), removable here + let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); + super::chips::show(app, ui, &chips); + if people.is_empty() { + let (title, body) = if chips.is_empty() { + ("No named people yet", "Face names written to XMP by Lightroom and other apps show up here.") + } else { + ("No named people in these photos", "Remove a filter above, or choose Clear all") + }; + super::empty_message(ui, ui.available_rect_before_wrap(), title, body); + return; + } + let ppp = ui.ctx().pixels_per_point(); + let active = app.session.filter.person.clone(); + egui::ScrollArea::vertical().auto_shrink(false).show_viewport(ui, |ui, viewport| { + let width = ui.available_width(); + let cols = (((width - PAD * 2.0 + GAP) / (CARD + GAP)).floor() as usize).max(1); + let row_h = CARD + LABEL_H + GAP; + let rows = people.len().div_ceil(cols); + let (area, _) = ui.allocate_exact_size(vec2(width, PAD * 2.0 + rows as f32 * row_h), Sense::hover()); + let first = ((viewport.top() - PAD) / row_h).floor().max(0.0) as usize; + let last = (((viewport.bottom() - PAD) / row_h).ceil().max(0.0) as usize).min(rows); + for row in first..last { + for col in 0..cols { + let Some(person) = people.get(row * cols + col) else { break }; + let min = area.min + vec2(PAD + col as f32 * (CARD + GAP), PAD + row as f32 * row_h); + let selected = active.as_deref().is_some_and(|a| a.eq_ignore_ascii_case(&person.name)); + card(app, ui, person, Rect::from_min_size(min, vec2(CARD, CARD + LABEL_H)), ppp, selected); + } + } + }); +} + +fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ppp: f32, selected: bool) { + let t = Tokens::get(ui.ctx()); + let face = Rect::from_min_size(r.min, vec2(CARD, CARD)); + let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click()); + register(ui.ctx(), format!("person:{}", person.name), r); + let p = ui.painter(); + p.rect_filled(face, 3.0, t.canvas); + if let Some(job) = app.session.face_job(person.photo, person.face, (CARD * ppp).ceil() as usize) + && let Some(tex) = app.renderer.variant(job) + { + p.image(tex.tex.id(), face, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + if selected { + p.rect_stroke(face, 3.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + } else if resp.hovered() { + p.rect_stroke(face, 3.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside); + } + // a long name must not run past the card + let name = + if person.name.chars().count() > 19 { format!("{}…", person.name.chars().take(18).collect::()) } else { person.name.clone() }; + p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text); + let photos = if person.count == 1 { "1 photo".to_string() } else { format!("{} photos", person.count) }; + p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim); + if resp.on_hover_text(format!("{} — show their photos", person.name)).clicked() { + let _ = app.run("library.filter", json!({"person": person.name})); + let _ = app.run("view.photoGrid", json!({})); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1a4446e3..04567c41 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -16,6 +16,8 @@ pub enum ViewMode { Survey, /// A reference photo (left, fixed) beside the active photo (right, being edited). Reference, + /// A card per person named on faces (close-up, name, photo count). + People, } /// The right-hand tool/panel shown next to the tool strip. @@ -262,6 +264,8 @@ pub struct UiState { pub grid_info: String, /// Photo counts next to sources and albums in the left panel. pub show_counts: bool, + /// Face / pet boxes (read from XMP) over the photo in the loupe. + pub face_boxes: bool, /// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched). pub hidden_locations: Vec, /// Copies opened in an external editor this session (reloaded when the window is focused @@ -548,6 +552,7 @@ impl Default for UiState { show_filenames: true, grid_info: "filename".into(), show_counts: true, + face_boxes: true, hidden_locations: Vec::new(), dragging_control: None, external_edits: Vec::new(), diff --git a/docs/parity.md b/docs/parity.md index 2f86116a..dd5ceaf8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,9 +22,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 11 | 3 | 1 | 1 | 3/5 (60%) | 5/5 (100%) | -| B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) | +| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | -| D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 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) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) | @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 15 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) | -| **Total** | 387 | 30 | 90 | 37 | 194/200 (97%) | 139/149 (93%) | +| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | +| **Total** | 387 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.6% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.6%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.5% of 158. ## Top gaps @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" | @@ -195,7 +195,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | | LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | | LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | -| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | +| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people | | LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | | LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library | @@ -724,7 +724,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ✅ | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops) | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `crates/ui-egui/src/panels/people.rs` | Named People cards (face close-up, name, photos) for the photos the active filters let through, with the filter chips shown above them; no Unnamed People, no per-person page, no confirm / reject | | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 017f3569..003b8774 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -70,6 +70,26 @@ library share a stem, the stem sidecar belongs to one of them — a raw first, o the others write and read `.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`). +## Face regions (MWG) + +LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema +(`mwg-rs:Regions`, `http://www.metadataworkinggroup.com/schemas/regions/`), as Lightroom, digiKam, Picasa and others +write them, from sidecars and from XMP embedded in the file. Named faces become people in the People view +(LR-LIB-PEOPLE in [`parity.md`](parity.md)), and every region is drawn as a box in the loupe. + +- **Read only.** LightCraft never writes `mwg-rs:Regions`; saving a sidecar keeps another application's regions byte + for byte. Removing or resizing a box in the loupe changes the library only. +- **Areas**: `stArea:x`/`y` are the box's centre, `w`/`h` its size, normalized to the photo (`stArea:unit="pixel"` + areas are divided by `mwg-rs:AppliedToDimensions`, and dropped without it). Boxes are clipped to the photo; one + that misses it entirely, or has a non-finite, zero or negative size, is skipped (the rest of the list is kept). +- **Orientation**: regions are stored on the upright (EXIF-oriented) photo. A `mwg-rs:Rotation` of −π/2, +π/2 or ±π + (how Lightroom marks a box given in the sensor's frame for Exif orientation 6, 8 or 3) is turned back into the + upright frame; MWG can't say "mirrored", so boxes on mirrored photos (orientations 2, 4, 5, 7) are taken as written. + Boxes follow LightCraft's own Rotate Left/Right and flips. +- **Re-reading**: a sidecar that has `mwg-rs:Regions` — even with an empty list — replaces the photo's regions, so + regions removed in another application go away here too. A sidecar without `mwg-rs:Regions` (an application that + doesn't do regions) leaves the photo's regions as they are. + ## Reading `crs:` develop fields Many raw developers store edits as `crs:` properties (`http://ns.adobe.com/camera-raw-settings/1.0/`) in sidecars, in