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); }