From b3f569a824f43435195bf48981ecf75aa82bef15 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 17:29:14 +0200 Subject: [PATCH 1/5] Lightroom catalog import: turn masks into the photo's upright frame (part of #1806) --- crates/engine/src/lightroom_catalog.rs | 43 +++++++++++++++++ crates/engine/src/lightroom_catalog/tests.rs | 50 ++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 3066187ce..4cc6c6c1e 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -705,6 +705,7 @@ fn mapped_settings( } let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect); catalog_crop(&mut partial, orientation, stored_aspect); + catalog_masks(&mut partial, orientation); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); } @@ -764,6 +765,48 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6 geometry.insert("angle".into(), json!(angle)); } +/// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the +/// stored image (before orientation), normalized to its size; LightCraft's are on the oriented +/// image. So the points go through the orientation, a radial's radii (in long-edge units) swap when +/// the axes do, and a mirror turns its angle the other way. A quarter turn keeps the angle: the +/// ellipse turned 90° is the same ellipse with its radii swapped. +fn catalog_masks(partial: &mut Value, orientation: Orientation) { + if orientation == Orientation::Normal { + return; + } + let point = |p: Option<&mut Value>| { + let Some(p) = p.and_then(Value::as_object_mut) else { return }; + let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else { return }; + let (u, v) = orientation.map(x, y, 1.0, 1.0); + p.insert("x".into(), json!(u)); + p.insert("y".into(), json!(v)); + }; + let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return }; + let components = masks.iter_mut().filter_map(|m| m.get_mut("components").and_then(Value::as_array_mut)).flatten(); + for shape in components.filter_map(|c| c.get_mut("shape").and_then(Value::as_object_mut)) { + point(shape.get_mut("start")); + point(shape.get_mut("end")); + point(shape.get_mut("center")); + let strokes = shape.get_mut("strokes").and_then(Value::as_array_mut).into_iter().flatten(); + for points in strokes.filter_map(|s| s.get_mut("points").and_then(Value::as_array_mut)) { + points.iter_mut().for_each(|p| point(Some(p))); + } + if shape.get("kind").and_then(Value::as_str) == Some("radial") { + if orientation.swaps_axes() + && let (Some(rx), Some(ry)) = (shape.get("rx").cloned(), shape.get("ry").cloned()) + { + shape.insert("rx".into(), ry); + shape.insert("ry".into(), rx); + } + if orientation.to_parts().0 + && let Some(angle) = shape.get("angle").and_then(Value::as_f64) + { + shape.insert("angle".into(), json!(-angle)); + } + } + } +} + fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { use crate::preset_import::Lua; match value { diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index 654b40458..e420a995e 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -284,6 +284,56 @@ fn catalog_crops_follow_the_photo_orientation() { assert!(partial.get("crop").is_none()); } +#[test] +fn catalog_masks_follow_the_photo_orientation() { + // #1806, EMY_9996 (EXIF 8, shown a quarter turn counter-clockwise): a horizon gradient along + // the stored image's x axis runs along the shown photo's y axis + let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.1, CorrectionMasks = { + { What = "Mask/Gradient", MaskValue = 1, ZeroX = 0.362544, ZeroY = 0.481093, FullX = 0.451639, FullY = 0.480645 } } } } }"#; + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5).unwrap(); + let g = &partial["masks"][0]["components"][0]["shape"]; + let close = |v: &Value, want: f64| (v.as_f64().unwrap() - want).abs() < 1e-9; + assert!(close(&g["start"]["x"], 0.480645) && close(&g["start"]["y"], 1.0 - 0.451639), "{g}"); + assert!(close(&g["end"]["x"], 0.481093) && close(&g["end"]["y"], 1.0 - 0.362544), "{g}"); + + let masks = |o: Orientation| { + let s = r#"s = { + MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { + { What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 }, + { What = "Mask/CircularGradient", Left = 0, Right = 0.2, Top = 0.1, Bottom = 0.3, Angle = 10, Feather = 50, Version = 2, Flipped = true }, + { What = "Mask/Paint", Radius = 0.05, MaskValue = 1, Dabs = { "d 0.1 0.2", "d 0.3 0.6" } } } } } }"#; + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let c = &partial["masks"][0]["components"]; + let (linear, radial, brush) = (&c[0]["shape"], &c[1]["shape"], &c[2]["shape"]["strokes"][0]["points"]); + [ + &linear["start"]["x"], + &linear["start"]["y"], + &linear["end"]["x"], + &linear["end"]["y"], + &radial["center"]["x"], + &radial["center"]["y"], + &radial["rx"], + &radial["ry"], + &radial["angle"], + &brush[0]["x"], + &brush[0]["y"], + &brush[1]["x"], + &brush[1]["y"], + ] + .map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9) + }; + // (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise; + // a radial's radii (in long-edge units) swap with the axes, and a mirror turns its angle the + // other way + let r = (0.1, (0.1_f64 / 1.5 * 1e9).round() / 1e9); + assert_eq!(masks(Orientation::Normal), [0.1, 0.2, 0.3, 0.6, 0.1, 0.2, r.0, r.1, 10.0, 0.1, 0.2, 0.3, 0.6]); + assert_eq!(masks(Orientation::Rotate90), [0.8, 0.1, 0.4, 0.3, 0.8, 0.1, r.1, r.0, 10.0, 0.8, 0.1, 0.4, 0.3]); + assert_eq!(masks(Orientation::Rotate270), [0.2, 0.9, 0.6, 0.7, 0.2, 0.9, r.1, r.0, 10.0, 0.2, 0.9, 0.6, 0.7]); + assert_eq!(masks(Orientation::Rotate180), [0.9, 0.8, 0.7, 0.4, 0.9, 0.8, r.0, r.1, 10.0, 0.9, 0.8, 0.7, 0.4]); + assert_eq!(masks(Orientation::FlipH), [0.9, 0.2, 0.7, 0.6, 0.9, 0.2, r.0, r.1, -10.0, 0.9, 0.2, 0.7, 0.6]); + assert_eq!(masks(Orientation::Transpose), [0.2, 0.1, 0.6, 0.3, 0.2, 0.1, r.1, r.0, -10.0, 0.2, 0.1, 0.6, 0.3]); +} + /// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image /// of aspect `w` (height 1) shown with orientation `o`. fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { From 307ec1a053dea397537d41e110d66c2ddfea539c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 17:41:38 +0200 Subject: [PATCH 2/5] Lightroom catalog import: size radial masks with the stored image's aspect (part of #1806) --- crates/engine/src/lightroom_catalog.rs | 12 ++++++++---- crates/engine/src/lightroom_catalog/tests.rs | 7 +++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 4cc6c6c1e..12934c0e0 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -703,7 +703,11 @@ fn mapped_settings( if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) { props.insert("crs:HasCrop".into(), vec!["True".into()]); } - let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect); + // Mask edges are fractions of the stored image, like the crop's, so radii go to long-edge units + // with its aspect: `aspect` is the photo's in LightCraft, which is the shown one for a photo + // already in the library and only stands in when the catalog has no file size. + let mask_aspect = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { aspect }; + let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, mask_aspect); catalog_crop(&mut partial, orientation, stored_aspect); catalog_masks(&mut partial, orientation); if deferred > 0 { @@ -767,9 +771,9 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6 /// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the /// stored image (before orientation), normalized to its size; LightCraft's are on the oriented -/// image. So the points go through the orientation, a radial's radii (in long-edge units) swap when -/// the axes do, and a mirror turns its angle the other way. A quarter turn keeps the angle: the -/// ellipse turned 90° is the same ellipse with its radii swapped. +/// image. So the points go through the orientation, a radial's radii (in long-edge units, from the +/// stored image's aspect) swap when the axes do, and a mirror turns its angle the other way. A +/// quarter turn keeps the angle: the ellipse turned 90° is the same ellipse with its radii swapped. fn catalog_masks(partial: &mut Value, orientation: Orientation) { if orientation == Orientation::Normal { return; diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index e420a995e..857de2f5c 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -332,6 +332,13 @@ fn catalog_masks_follow_the_photo_orientation() { assert_eq!(masks(Orientation::Rotate180), [0.9, 0.8, 0.7, 0.4, 0.9, 0.8, r.0, r.1, 10.0, 0.9, 0.8, 0.7, 0.4]); assert_eq!(masks(Orientation::FlipH), [0.9, 0.2, 0.7, 0.6, 0.9, 0.2, r.0, r.1, -10.0, 0.9, 0.2, 0.7, 0.6]); assert_eq!(masks(Orientation::Transpose), [0.2, 0.1, 0.6, 0.3, 0.2, 0.1, r.1, r.0, -10.0, 0.2, 0.1, 0.6, 0.3]); + // a photo already in the library has its shown size (portrait here), but Lightroom's edges are + // fractions of the stored image: a circle on a 3:2 stored image stays a circle + let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { + { What = "Mask/CircularGradient", Left = 0.3, Right = 0.5, Top = 0.3, Bottom = 0.6, Version = 2, Flipped = true } } } } }"#; + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5).unwrap(); + let radial = &partial["masks"][0]["components"][0]["shape"]; + assert!(close(&radial["rx"], 0.1) && close(&radial["ry"], 0.1), "{radial}"); } /// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image From e5667d4e6febacfc2bd1e4a1a00693cb00eb386d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 29 Oct 2026 23:30:45 +0100 Subject: [PATCH 3/5] Lightroom catalog import: turn masks with the rotation a photo already in the library keeps (part of #1806) Updating a photo already in the library keeps its Rotate Left / Right and Flip in LightCraft, which LightCraft draws masks after, but the masks went through the catalog's orientation only: an upright photo turned right in LightCraft got an unturned gradient. They now go through the catalog's orientation and then the photo's own. --- crates/engine/src/lightroom_catalog.rs | 13 ++- crates/engine/src/lightroom_catalog/tests.rs | 90 ++++++++++++++++---- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 12934c0e0..94fe34a95 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -693,6 +693,7 @@ fn mapped_settings( aspect: f64, orientation: Orientation, stored_aspect: f64, + user: Orientation, ) -> Result<(Value, Vec), String> { let mut root = crate::preset_import::parse_lua(text)?; let deferred = strip_lua_sentinels(&mut root); @@ -709,7 +710,7 @@ fn mapped_settings( let mask_aspect = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { aspect }; let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, mask_aspect); catalog_crop(&mut partial, orientation, stored_aspect); - catalog_masks(&mut partial, orientation); + catalog_masks(&mut partial, in_photo_frame(orientation, user)); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); } @@ -769,6 +770,15 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6 geometry.insert("angle".into(), json!(angle)); } +/// The catalog's orientation (the stored image to the photo as Lightroom shows it), then the +/// photo's own Rotate Left / Right and Flip in LightCraft (`user`), which an update of a photo +/// already in the library keeps: the frame LightCraft draws masks in. +fn in_photo_frame(catalog: Orientation, user: Orientation) -> Orientation { + let (flip, turns) = user.to_parts(); + let o = if flip { catalog.flipped_h() } else { catalog }; + (0..turns).fold(o, |o, _| o.rotated(true)) +} + /// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the /// stored image (before orientation), normalized to its size; LightCraft's are on the oriented /// image. So the points go through the orientation, a radial's radii (in long-edge units, from the @@ -1103,6 +1113,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co p.width.max(1) as f64 / p.height.max(1) as f64, orientation, stored_aspect, + p.develop.orientation, ) { Ok((partial, unknown)) => { p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0)); diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index 857de2f5c..17ec44cfb 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -173,8 +173,15 @@ fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { assert!(apply(&mut s, cyclic, false).is_err()); assert_eq!(s.catalog.to_snapshot(), before); assert!( - mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) - .is_err() + mapped_settings( + &format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), + crate::crs::Target::RawAbsolute, + 1.0, + Orientation::Normal, + 1.5, + Orientation::Normal + ) + .is_err() ); } #[test] @@ -191,6 +198,7 @@ fn compressed_xmp_is_bounded_and_settings_are_data_only() { 1.5, Orientation::Normal, 1.5, + Orientation::Normal, ) .unwrap(); assert_eq!(partial["light"]["exposure"], 0.75); @@ -202,11 +210,11 @@ fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin let settings = "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); assert_eq!(partial["light"]["exposure"], 0.5); assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert_eq!( (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), (Some("custom"), Some(3578.0), Some(-5.0)) @@ -223,6 +231,7 @@ fn catalog_crops_without_has_crop_are_imported() { 1.5, Orientation::Normal, 1.5, + Orientation::Normal, ) .unwrap(); let crop = &partial["crop"]["geometry"]; @@ -233,11 +242,13 @@ fn catalog_crops_without_has_crop_are_imported() { assert_eq!(crop["angle"], 0.0); assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) - let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = + mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); // no crop fields: the crop stays untouched - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = + mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert!(partial.get("crop").is_none()); } @@ -261,7 +272,7 @@ fn lightroom_orientation_codes() { fn catalog_crops_follow_the_photo_orientation() { let crop = |o: Orientation| { let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); let g = &partial["crop"]["geometry"]; let r = &g["rect"]; [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) @@ -274,13 +285,14 @@ fn catalog_crops_follow_the_photo_orientation() { assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); // a mirror turns the straighten the other way let angle = |o: Orientation| { - let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); partial["crop"]["geometry"]["angle"].as_f64().unwrap() }; assert_eq!(angle(Orientation::Rotate90), -2.0); assert_eq!(angle(Orientation::FlipH), 2.0); // no crop: nothing to orient - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); + let (partial, _) = + mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap(); assert!(partial.get("crop").is_none()); } @@ -290,7 +302,7 @@ fn catalog_masks_follow_the_photo_orientation() { // the stored image's x axis runs along the shown photo's y axis let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.1, CorrectionMasks = { { What = "Mask/Gradient", MaskValue = 1, ZeroX = 0.362544, ZeroY = 0.481093, FullX = 0.451639, FullY = 0.480645 } } } } }"#; - let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5).unwrap(); + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5, Orientation::Normal).unwrap(); let g = &partial["masks"][0]["components"][0]["shape"]; let close = |v: &Value, want: f64| (v.as_f64().unwrap() - want).abs() < 1e-9; assert!(close(&g["start"]["x"], 0.480645) && close(&g["start"]["y"], 1.0 - 0.451639), "{g}"); @@ -302,7 +314,7 @@ fn catalog_masks_follow_the_photo_orientation() { { What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 }, { What = "Mask/CircularGradient", Left = 0, Right = 0.2, Top = 0.1, Bottom = 0.3, Angle = 10, Feather = 50, Version = 2, Flipped = true }, { What = "Mask/Paint", Radius = 0.05, MaskValue = 1, Dabs = { "d 0.1 0.2", "d 0.3 0.6" } } } } } }"#; - let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); let c = &partial["masks"][0]["components"]; let (linear, radial, brush) = (&c[0]["shape"], &c[1]["shape"], &c[2]["shape"]["strokes"][0]["points"]); [ @@ -336,7 +348,7 @@ fn catalog_masks_follow_the_photo_orientation() { // fractions of the stored image: a circle on a 3:2 stored image stays a circle let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { { What = "Mask/CircularGradient", Left = 0.3, Right = 0.5, Top = 0.3, Bottom = 0.6, Version = 2, Flipped = true } } } } }"#; - let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5).unwrap(); + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap(); let radial = &partial["masks"][0]["components"][0]["shape"]; assert!(close(&radial["rx"], 0.1) && close(&radial["ry"], 0.1), "{radial}"); } @@ -363,7 +375,8 @@ fn straightened_catalog_crops_round_trip() { for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { for angle in [6.5, -6.5] { let g = CropGeometry { angle, ..g }; - let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = + mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); let close = |a: f64, b: f64| (a - b).abs() < 1e-9; assert!( @@ -393,7 +406,7 @@ fn straightened_catalog_crops_match_lightroom_exports() { (6100.0, 9150.0), ), ] { - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0, Orientation::Normal).unwrap(); let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; let (w, h) = (r.width() * ow, r.height() * oh); @@ -416,6 +429,44 @@ fn imported_crops_use_the_catalog_orientation() { assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); } +/// 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] +fn updated_masks_follow_the_rotation_kept_from_the_library() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].image.insert("orientation".into(), json!("AB")); + data.photos[0].settings = + r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { + { What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 } } } } }"# + .into(); + data.photos[0].xmp = String::new(); + let mut s = crate::Session::new(); + apply(&mut s, data.clone(), false).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let turned = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; + s.set_develop(id, turned, "Rotate Right").unwrap(); + apply(&mut s, data, true).unwrap(); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.develop.orientation, Orientation::Rotate90, "the update keeps the photo's rotation"); + let g = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"][0]["shape"]; + let r = [&g["start"]["x"], &g["start"]["y"], &g["end"]["x"], &g["end"]["y"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9); + // (x, y) → (1 − y, x), a quarter turn clockwise + assert_eq!(r, [0.8, 0.1, 0.4, 0.3], "{g}"); + // the catalog's orientation first, then the photo's own, for every pair + for a in 1..=8 { + for b in 1..=8 { + let (a, b) = (Orientation::from_exif(a), Orientation::from_exif(b)); + for (x, y) in [(0.1, 0.2), (0.7, 0.4)] { + let (u, v) = a.map(x, y, 1.0, 1.0); + let ((p, q), (want_p, want_q)) = (in_photo_frame(a, b).map(x, y, 1.0, 1.0), b.map(u, v, 1.0, 1.0)); + assert!((p - want_p).abs() < 1e-9 && (q - want_q).abs() < 1e-9, "{a:?} then {b:?}: ({p}, {q}) for ({want_p}, {want_q})"); + } + } + } +} + #[test] fn deferred_auto_tone_sentinels_never_become_real_slider_values() { let mut data = sample(); @@ -431,8 +482,15 @@ fn deferred_auto_tone_sentinels_never_become_real_slider_values() { assert_eq!(p.develop.light.shadows, 65.0); assert_eq!(p.develop.light.blacks, -22.0); assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); - let (partial, _) = - mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = mapped_settings( + "s = { Exposure2012 = -5, Contrast2012 = -100 }", + crate::crs::Target::RawAbsolute, + 1.0, + Orientation::Normal, + 1.5, + Orientation::Normal, + ) + .unwrap(); assert_eq!(partial["light"]["exposure"], -5.0); assert_eq!(partial["light"]["contrast"], -100.0); } From 491fe2869a6b4aeb88d76e15b2cb7c856e12f787 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 29 Oct 2026 23:36:35 +0100 Subject: [PATCH 4/5] Lightroom catalog import: turn the catalog XMP's masks into the photo's frame too (part of #1806) The catalog's XMP is merged before the develop row, and its masks were taken as they are, on the stored image: when the row has no masks of its own to replace them, they stayed a quarter turn off on a vertical shot. They now go through the same frame as the row's. A radial is fitted to the stored image before it is turned, so its radii swap with the stored aspect and not the sidecar default. --- crates/engine/src/lightroom_catalog.rs | 19 +++++++++- crates/engine/src/lightroom_catalog/tests.rs | 39 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) 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] From cf35cd16944e8ea2608f24598579915ab8ee7355 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 29 Oct 2026 23:56:13 +0100 Subject: [PATCH 5/5] Lightroom catalog import: check the catalog XMP's radials on a photo already in the library (part of #1806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test's stored image and LightCraft's size of the photo were both 20 × 10, so fitting radials to the photo's size instead of the stored image went unnoticed. The photo is now already in the library at its shown 10 × 20, as a vertical shot is. --- crates/engine/src/lightroom_catalog/tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index a48ed9451..16ea9e4eb 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -431,7 +431,7 @@ fn imported_crops_use_the_catalog_orientation() { /// 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. +/// the 2:1 stored image, also for a photo already in the library at its shown 10 × 20. #[test] fn catalog_xmp_masks_follow_the_photo_orientation() { let mut data = sample(); @@ -454,8 +454,12 @@ fn catalog_xmp_masks_follow_the_photo_orientation() { "# .into(); let mut s = crate::Session::new(); - apply(&mut s, data, false).unwrap(); - let p = s.catalog.photos().next().unwrap(); + let id = s.catalog.alloc_photo_id(); + let shown = Photo::new(id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 10, 20, "before"); + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(shown) }).unwrap(); + apply(&mut s, data, true).unwrap(); + assert_eq!(s.catalog.len(), 1, "the catalog's photo is the one already in the library"); + let p = s.catalog.photo(id).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"]);