From 369c56a6ed4657a06c50b23815b722807e46f638 Mon Sep 17 00:00:00 2001 From: rock-meister <303256+rock-meister@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:26:47 -0500 Subject: [PATCH] LRC-LIB-CATALOG-IMPORT: import straightened catalog crops as Lightroom shows them With a CropAngle, Lightroom's CropLeft/CropTop and CropRight/CropBottom are the top-left and bottom-right corners of the tilted crop on the stored image, and its angle turns the other way to LightCraft's. The import treated the edges as an axis-aligned rectangle in the rotated frame and kept the angle's sign, so straightened crops came out the wrong shape and size. Carry the two corners through the orientation and then LightCraft's straighten rotation (angle = -CropAngle, mirrored orientations flip it back), using the stored image's aspect from fileWidth/fileHeight. On a real catalog every one of 223 straightened crops now matches the frame of Lightroom's export, and the content difference falls to that of unstraightened crops. Refs #483 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lightroom_catalog.rs | 152 +++++++++++++++++++------ 1 file changed, 117 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 9deedc3b..6983c2f8 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -10,7 +10,7 @@ use std::sync::{ use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue}; use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source}; -use lightcraft_geom::{Orientation, Point, Rect}; +use lightcraft_geom::{Affine, Orientation, Point, Rect}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -478,7 +478,7 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } } -fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation) -> Result<(Value, Vec), String> { +fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec), String> { let mut root = crate::preset_import::parse_lua(text)?; let deferred = strip_lua_sentinels(&mut root); let (mut props, values) = crate::preset_import::lua_settings_props(&root)?; @@ -489,7 +489,7 @@ fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation) props.insert("crs:HasCrop".into(), vec!["True".into()]); } let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect); - orient_crop(&mut partial, orientation); + catalog_crop(&mut partial, orientation, stored_aspect); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); } @@ -519,23 +519,34 @@ fn lightroom_orientation(code: &str) -> Option { ALL.into_iter().find(|&o| code.chars().eq([corner(o, 0.0, 0.0), corner(o, 1.0, 0.0)])) } -/// `crs` crop edges are normalized to the stored (unrotated) image, but LightCraft's crop is in the -/// oriented frame: carry the rectangle through `orientation`, and mirror the straighten angle when -/// the orientation mirrors. -fn orient_crop(partial: &mut Value, orientation: Orientation) { - if orientation == Orientation::Normal { - return; - } +/// Lightroom's crop in LightCraft's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`) +/// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and +/// straightening), normalized to its size, plus a straighten angle that turns the other way to +/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its +/// centre, normalized to the oriented image's size, so the corners go through the orientation and +/// then the rotation; `stored_aspect` (width / height of the stored image) matters once the +/// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops +/// in Lightroom's exports within 1%. +fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f64) { let Some(geometry) = partial.pointer_mut("/crop/geometry").and_then(Value::as_object_mut) else { return }; let edge = |k: &str| geometry.get("rect").and_then(|r| r.get(k)).and_then(Value::as_f64); let (Some(x0), Some(y0), Some(x1), Some(y1)) = (edge("x0"), edge("y0"), edge("x1"), edge("y1")) else { return }; - let r = orientation.map_norm_rect(Rect::from_points(Point::new(x0, y0), Point::new(x1, y1))); - geometry.insert("rect".into(), json!({"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1})); - if orientation.to_parts().0 - && let Some(angle) = geometry.get("angle").and_then(Value::as_f64) - { - geometry.insert("angle".into(), json!(-angle)); + let lightroom_angle = geometry.get("angle").and_then(Value::as_f64).unwrap_or(0.0); + if orientation == Orientation::Normal && lightroom_angle == 0.0 { + return; } + let w = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { 1.0 }; + let (ow, oh) = if orientation.swaps_axes() { (1.0, w) } else { (w, 1.0) }; + // a mirror turns the straighten the other way too + let angle = if orientation.to_parts().0 { lightroom_angle } else { -lightroom_angle }; + let to_rotated = Affine::rotate_about(angle.to_radians(), Point::new(ow / 2.0, oh / 2.0)); + let corner = |x: f64, y: f64| { + let (u, v) = orientation.map(x * w, y, w, 1.0); + to_rotated.apply(Point::new(u, v)) + }; + let r = Rect::from_points(corner(x0, y0), corner(x1, y1)); + geometry.insert("rect".into(), json!({"x0": r.x0 / ow, "y0": r.y0 / oh, "x1": r.x1 / ow, "y1": r.y1 / oh})); + geometry.insert("angle".into(), json!(angle)); } fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { @@ -731,11 +742,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } 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, p.kind == lightcraft_catalog::MediaKind::Raw, p.width.max(1) as f64 / p.height.max(1) as f64, orientation, + stored_aspect, ) { Ok((partial, unknown)) => { p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0)); @@ -844,6 +857,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex #[cfg(test)] mod tests { use super::*; + use lightcraft_geom::CropGeometry; fn row(v: Value) -> Row { serde_json::from_value(v).unwrap() } @@ -922,7 +936,7 @@ mod tests { cyclic.collections[0].insert("parent".into(), json!(10)); assert!(apply(&mut s, cyclic, false).is_err()); assert_eq!(s.catalog.to_snapshot(), before); - assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal).is_err()); + assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err()); } #[test] fn compressed_xmp_is_bounded_and_settings_are_data_only() { @@ -933,7 +947,7 @@ mod tests { encoded[0] = 0xff; assert!(xmp(Some(&json!(encoded))).is_err()); let (partial, _) = - mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal).unwrap(); + mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap(); assert_eq!(partial["light"]["exposure"], 0.75); } @@ -941,10 +955,11 @@ mod tests { fn catalog_crops_without_has_crop_are_imported() { // Lightroom Classic catalogs store crop edges and angle but never `HasCrop` let (partial, unknown) = mapped_settings( - "s = { CropAngle = 2.39897, CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", + "s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", true, 1.5, Orientation::Normal, + 1.5, ) .unwrap(); let crop = &partial["crop"]["geometry"]; @@ -952,13 +967,14 @@ mod tests { assert_eq!(crop["rect"]["y0"], 0.1); assert_eq!(crop["rect"]["x1"], 0.957298); assert_eq!(crop["rect"]["y1"], 1.0); - assert_eq!(crop["angle"], 2.39897); - assert!(!unknown.iter().any(|k| k.starts_with("CropLeft") || k.starts_with("CropAngle")), "{unknown:?}"); - // an angle alone is a straightened, uncropped frame - let (partial, _) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal).unwrap(); - assert_eq!(partial["crop"]["geometry"]["angle"], -1.5); + 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 }", true, 1.5, Orientation::Normal, 1.5).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 }", true, 1.5, Orientation::Normal).unwrap(); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap(); assert!(partial.get("crop").is_none()); } @@ -980,25 +996,91 @@ mod tests { #[test] fn catalog_crops_follow_the_photo_orientation() { - let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9, CropAngle = 2 }"; let crop = |o: Orientation| { - let (partial, _) = mapped_settings(settings, true, 1.5, o).unwrap(); + let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; + let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).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) }; // (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise - assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 2.0]); - assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 2.0]); - assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 2.0]); - assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 2.0]); - // a mirror also mirrors the straighten angle - assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, -2.0]); + assert_eq!(crop(Orientation::Normal), [0.1, 0.2, 0.6, 0.9, 0.0]); + assert_eq!(crop(Orientation::Rotate90), [0.1, 0.1, 0.8, 0.6, 0.0]); + assert_eq!(crop(Orientation::Rotate270), [0.2, 0.4, 0.9, 0.9, 0.0]); + assert_eq!(crop(Orientation::Rotate180), [0.4, 0.1, 0.9, 0.8, 0.0]); + 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 }", true, 1.5, o, 1.5).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 }", true, 1.5, Orientation::Rotate90).unwrap(); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap(); assert!(partial.get("crop").is_none()); } + /// 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 { + let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) }; + let to_source = Affine::rotate_about(-g.angle.to_radians(), Point::new(ow / 2.0, oh / 2.0)); + let stored = |x: f64, y: f64| { + let p = to_source.apply(Point::new(x * ow, y * oh)); + o.inverse().map(p.x, p.y, ow, oh) + }; + // the rectangle's corners that land top-left and bottom-right on the stored image + let corners = [stored(g.rect.x0, g.rect.y0), stored(g.rect.x1, g.rect.y0), stored(g.rect.x1, g.rect.y1), stored(g.rect.x0, g.rect.y1)]; + let (tl, br) = (0..4).map(|i| (corners[i], corners[(i + 2) % 4])).find(|(a, b)| a.0 < b.0 && a.1 < b.1).unwrap(); + let angle = if o.to_parts().0 { g.angle } else { -g.angle }; + format!("s = {{ CropLeft = {}, CropTop = {}, CropRight = {}, CropBottom = {}, CropAngle = {angle} }}", tl.0 / w, tl.1, br.0 / w, br.1) + } + + #[test] + fn straightened_catalog_crops_round_trip() { + let g = CropGeometry { rect: Rect::new(0.2, 0.15, 0.7, 0.8), angle: 6.5 }; + 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), true, 1.5, o, 1.5).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!( + close(got.angle, g.angle) + && [(got.rect.x0, g.rect.x0), (got.rect.y0, g.rect.y0), (got.rect.x1, g.rect.x1), (got.rect.y1, g.rect.y1)] + .iter() + .all(|&(a, b)| close(a, b)), + "{o:?} {angle}: {got:?}" + ); + } + } + } + + #[test] + fn straightened_catalog_crops_match_lightroom_exports() { + // Settings and pixel sizes from a real Lightroom Classic catalog (9504 × 6336 stored) and the + // JPEGs Lightroom exported from it at full size. + for (settings, o, export) in [ + ( + "s = { CropAngle = -9.34444, CropBottom = 0.802034, CropLeft = 0.05499, CropRight = 0.94501, CropTop = 0.197966 }", + Orientation::Normal, + (7725.0, 5150.0), + ), + ( + "s = { CropAngle = -1.48964, CropBottom = 0.962457, CropLeft = 0.010429, CropRight = 0.989571, CropTop = 0.037543 }", + Orientation::Rotate90, + (6100.0, 9150.0), + ), + ] { + let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).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); + assert!((w - export.0).abs() <= 3.0 && (h - export.1).abs() <= 3.0, "{o:?}: {w:.1} × {h:.1}, Lightroom exported {export:?}"); + } + } + #[test] fn imported_crops_use_the_catalog_orientation() { let mut data = sample(); @@ -1030,7 +1112,7 @@ mod tests { 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 }", true, 1.0, Orientation::Normal).unwrap(); + let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap(); assert_eq!(partial["light"]["exposure"], -5.0); assert_eq!(partial["light"]["contrast"], -100.0); }