This commit is contained in:
Brandon Thomas 2026-10-09 09:04:03 -04:00
commit 5bd4302037
1 changed files with 117 additions and 35 deletions

View File

@ -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>), String> {
fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec<String>), 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<Orientation> {
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);
}