Catalog/UI: show XMP face/pet regions in the loupe view

Threads the Vec<Region> that lightcraft-meta already reads from XMP
(MWG-RS) through to the screen, so it's no longer inert:

- catalog::Meta gains a `regions` field (catalog now depends on
  lightcraft-meta, which doesn't depend back on catalog, so no cycle).
- crates/engine/src/files.rs (meta_of) and src/sidecar.rs (parse_sidecar
  / merge_into) populate it for both embedded-XMP and external-sidecar
  photos, mirroring how `keywords` is already handled.
- ui-egui's loupe view (panels/detail.rs) draws each region as a
  rectangle over the photo via the existing CanvasMap, color-coded by
  kind (Face/Pet/other) with its name label if present, right alongside
  the existing info overlay. Read-only, no new interaction.

New end-to-end test (regions_from_sidecar_are_read_on_import) covers a
region written to a sidecar reaching Photo.meta.regions through real
import, not just the parser. cargo xtask ci green (fmt, clippy -D warnings, test, parity, layers, assets, wasm).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Voshart 2026-10-05 14:31:12 -04:00
parent f29436a1f6
commit f1b2d9af11
8 changed files with 81 additions and 0 deletions

2
Cargo.lock generated
View File

@ -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",

View File

@ -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 }

View File

@ -183,6 +183,9 @@ pub struct Meta {
pub copyright_url: String,
pub creator: String,
pub keywords: Vec<String>,
/// Face/pet/focus regions read from XMP (MWG-RS). Read-only today; nothing in
/// LightCraft writes regions yet.
pub regions: Vec<lightcraft_meta::Region>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

View File

@ -38,6 +38,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
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()))
}

View File

@ -99,6 +99,7 @@ pub struct SidecarData {
pub alt_text: Option<String>,
pub extended_description: Option<String>,
pub keywords: Option<Vec<String>>,
pub regions: Option<Vec<lightcraft_meta::Region>>,
/// 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<String>,
@ -133,6 +134,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
alt_text: m.alt_text.clone(),
extended_description: m.extended_description.clone(),
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
regions: (!m.regions.is_empty()).then(|| m.regions.clone()),
captured: m.capture_time.map(|d| d.to_iso()),
..Default::default()
};
@ -202,6 +204,9 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> 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)) => {

View File

@ -173,6 +173,51 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
let _ = std::fs::remove_dir_all(&src);
}
const MWG_REGIONS: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
<mwg-rs:Regions>
<rdf:Description>
<mwg-rs:AppliedToDimensions stDim:w="100" stDim:h="100" stDim:unit="pixel"/>
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li>
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</rdf:Description>
</mwg-rs:Regions>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
/// 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);
}
fn synthetic_dng(xmp: &str) -> Vec<u8> {
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
}

View File

@ -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 }

View File

@ -333,6 +333,7 @@ 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);
region_overlay(&p, &map, &photo);
// 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;
@ -420,6 +421,28 @@ 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)));
}
/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Read-only —
/// LightCraft doesn't write regions yet.
fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) {
use lightcraft_meta::RegionKind;
let t = Tokens::get(p.ctx());
for r in &photo.meta.regions {
let color = match r.kind {
RegionKind::Face => Color32::from_rgb(255, 214, 10),
RegionKind::Pet => Color32::from_rgb(120, 220, 120),
RegionKind::Focus | RegionKind::BarCode | RegionKind::Other(_) => Color32::from_white_alpha(140),
};
let rect = Rect::from_two_pos(map.screen(Point::new(r.rect.x0, r.rect.y0)), map.screen(Point::new(r.rect.x1, r.rect.y1)));
p.rect_stroke(rect, 2.0, Stroke::new(1.5, color), StrokeKind::Outside);
if let Some(name) = &r.name {
let g = p.layout_no_wrap(name.clone(), t.font(12.0), color);
let at = pos2(rect.left(), rect.top() - g.size().y - 2.0);
p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200));
p.galley(at, g, color);
}
}
}
/// 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) {