diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 94fe34a95..9a1359c4d 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -821,6 +821,20 @@ fn catalog_masks(partial: &mut Value, orientation: Orientation) { } } +/// The catalog's XMP masks in LightCraft's frame, as [`catalog_masks`] puts the develop row's: they +/// are on the same stored image, and they stay when the row has no masks of its own. The packet's +/// radials come fitted to the sidecar default aspect and `sidecar::merge_into` re-fits them to the +/// photo's size, so they are fitted to the stored image to be turned (a quarter turn swaps the +/// radii) and handed back in the default aspect, for that re-fit to undo. +fn catalog_xmp_masks(partial: &mut Value, orientation: Orientation, stored_aspect: f64, photo: &Photo) { + use crate::crs_masks::{DEFAULT_ASPECT, refit_radials}; + let merged = if photo.width > 0 && photo.height > 0 { photo.width as f64 / photo.height as f64 } else { DEFAULT_ASPECT }; + let fit = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { merged }; + refit_radials(partial, DEFAULT_ASPECT, fit); + catalog_masks(partial, orientation); + refit_radials(partial, merged, DEFAULT_ASPECT); +} + fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { use crate::preset_import::Lua; match value { @@ -1080,11 +1094,14 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co if keep_develop { preserved += 1; } + let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default(); + let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64; if !src.xmp.is_empty() { match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) { Ok(mut sc) => { if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { strip_xmp_sentinels(partial); + catalog_xmp_masks(partial, in_photo_frame(orientation, p.develop.orientation), stored_aspect, &p); } crate::sidecar::merge_into(&mut p, &sc, &now); } @@ -1105,8 +1122,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co p.meta.keywords = src.keywords.clone(); } if !src.settings.is_empty() && !keep_develop { - let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default(); - let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64; match mapped_settings( &src.settings, crate::crs::Target::for_photo(&p), diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index 17ec44cfb..a48ed9451 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -429,6 +429,45 @@ fn imported_crops_use_the_catalog_orientation() { assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); } +/// Masks only the catalog's XMP has (the develop row has none to replace them) are on the stored +/// image too: on a vertical shot (`DA`) they turn like the row's, and a radial keeps its shape on +/// the 2:1 stored image. +#[test] +fn catalog_xmp_masks_follow_the_photo_orientation() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].image.insert("orientation".into(), json!("DA")); + data.photos[0].image.insert("fileWidth".into(), json!(20)); + data.photos[0].image.insert("fileHeight".into(), json!(10)); + data.photos[0].settings = "s = { Exposure2012 = 0.5 }".into(); + data.photos[0].xmp = r#" + + + + + + + + + +"# + .into(); + let mut s = crate::Session::new(); + apply(&mut s, data, false).unwrap(); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.develop.light.exposure, 0.5, "the develop row is applied too"); + let c = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"]; + let (linear, radial) = (&c[0]["shape"], &c[1]["shape"]); + let r = [&linear["start"]["x"], &linear["start"]["y"], &linear["end"]["x"], &linear["end"]["y"], &radial["center"]["x"], &radial["center"]["y"]] + .map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9); + // (x, y) → (y, 1 − x), a quarter turn counter-clockwise + assert_eq!(r, [0.2, 0.9, 0.6, 0.7, 0.45, 0.6], "{c}"); + // 4 × 3 px of a 20 × 10 stored image, in long-edge units, with the axes swapped + let radii = [&radial["rx"], &radial["ry"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9); + assert_eq!(radii, [0.075, 0.1], "{radial}"); +} + /// A photo already in the library keeps its Rotate Left / Right on update, so the catalog's masks /// go through it too: an upright (`AB`) photo turned right in LightCraft gets its gradient turned. #[test]