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:
parent
f29436a1f6
commit
f1b2d9af11
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue