Merge PR #495
This commit is contained in:
commit
5bd4302037
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue