Lightroom catalog import: turn masks into the photo's upright frame (part of #1806)

This commit is contained in:
Marco Arnulfo
2026-10-29 23:14:23 +01:00
parent 77c0a6adfa
commit b3f569a824
2 changed files with 93 additions and 0 deletions
+43
View File
@@ -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 {
@@ -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 {