From aaf3094c8d7a81ec8e1603a6c64fe7336244614f Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 12:59:42 +0200 Subject: [PATCH 1/6] LR-META-XMP: keep Lightroom's saved Upright geometry from XMP and catalogs (#205) Lightroom saves one Upright matrix per mode (crs:UprightTransform_0 to _5). LightCraft read only the mode and solved the photo again, so on public references its framing was 23 to 78 px away from Lightroom's export on average. The selected mode's matrix is now kept where the photo's frame is known (photo import, Read Metadata from File, Lightroom catalog import) and turned from Lightroom's stored-image frame into LightCraft's oriented, centred one. The convention was measured on Lightroom Classic 12 exports of public NEFs (one shown in portrait, one cropped with Constrain Crop): a forward homography on column vectors, normalised to the stored image (y down, before the EXIF orientation), applied before the crop, with the fill scale in it. LightCraft's render mapping with the kept matrix matches those exports to 0.11 to 0.54 px on average and under 1 px at worst; end-to-end renders match to 0.08 to 0.50 px. PerspectiveUpright 2, 3 and 4 now read as Full, Level and Vertical (they were read as Level, Vertical and Full), and a stated mode no longer keeps a transform solved for another one. Guided, Upright versions other than 151388160 and unusable matrices are solved as before, with the import warning. Upright Off no longer warns, and Lightroom's Upright bookkeeping fields are no longer reported as unimported adjustments. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/crs.rs | 168 ++++++++- crates/engine/src/files.rs | 2 + crates/engine/src/import.rs | 6 +- crates/engine/src/lib.rs | 2 + crates/engine/src/lightroom_catalog.rs | 37 ++ crates/engine/src/media.rs | 14 + crates/engine/src/sidecar.rs | 84 +++-- crates/engine/src/tests_upright.rs | 497 +++++++++++++++++++++++++ crates/engine/src/tests_xmp.rs | 18 +- docs/lightroom-catalog-import.md | 3 +- docs/parity-checklist.md | 7 +- docs/target-app-parity.md | 5 +- docs/whats-new.md | 12 + docs/xmp-interop.md | 35 +- 14 files changed, 828 insertions(+), 62 deletions(-) create mode 100644 crates/engine/src/tests_upright.rs diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index b7488a02..9dedb697 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -12,7 +12,8 @@ use std::collections::BTreeMap; -use lightcraft_develop::{MIXER_BANDS, Preset}; +use lightcraft_develop::{MIXER_BANDS, Preset, Upright}; +use lightcraft_geom::{Homography, Orientation, Point}; use serde_json::{Map, Value, json}; /// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]). @@ -421,15 +422,11 @@ pub fn to_partial(props: &Props, target: Target) -> Value { n(o, "PerspectiveX", "geometry.offset_x"); n(o, "PerspectiveY", "geometry.offset_y"); if let Some(u) = num(props, "crs:PerspectiveUpright") { - let mode = match u as i64 { - 1 => "auto", - 2 => "level", - 3 => "vertical", - 4 => "full", - 5 => "guided", - _ => "off", - }; - put(o, "geometry.upright", json!(mode)); + put(o, "geometry.upright", json!(upright_mode(u).unwrap_or(Upright::Off))); + // a stated mode never keeps a transform solved for another one: it is solved again for this + // photo, unless the photo's frame is known and Lightroom's own saved matrix is placed + // ([`saved_upright`], [`crate::sidecar::SidecarData::place_upright`]) + put(o, "geometry.upright_transform", Value::Null); } // ---- Crop (normalized edges of the unrotated image + straighten angle) @@ -485,8 +482,11 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values // Point Color slots that are all empty (-1) let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v)); let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::().is_ok_and(|x| x == -1.0) || n.trim().is_empty())); + // Upright's analysis record (version, saved matrices, centre, focal length, guides): placed or + // reported by the photo import's `uprightGeometry` diagnostic, never an adjustment of its own let quiet = |k: &str| { k.starts_with("Enable") + || k.starts_with("Upright") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix" @@ -511,8 +511,8 @@ pub fn camera_profile_name(props: &Props) -> Option<&str> { first(props, "crs:CameraProfile") } -/// Whether an XMP packet contains a saved Lightroom Upright matrix. Lightroom's matrix uses a -/// coordinate convention that LightCraft does not currently import. +/// Whether an XMP packet contains a saved Lightroom Upright matrix (usable or not: see +/// [`saved_upright`]). pub fn has_saved_upright_transform(props: &Props) -> bool { props.keys().any(|key| { key.strip_prefix("crs:").is_some_and(|name| { @@ -521,6 +521,147 @@ pub fn has_saved_upright_transform(props: &Props) -> bool { }) } +/// The Upright mode a `crs:PerspectiveUpright` value names: 0 Off, 1 Auto, 2 Full, 3 Level, +/// 4 Vertical, 5 Guided (issue #205: the matrix saved for each value has that mode's shape, and +/// ExifTool names the values the same way). `None` for anything else. +pub fn upright_mode(value: f64) -> Option { + if value.fract() != 0.0 { + return None; + } + Some(match value as i64 { + 0 => Upright::Off, + 1 => Upright::Auto, + 2 => Upright::Full, + 3 => Upright::Level, + 4 => Upright::Vertical, + 5 => Upright::Guided, + _ => return None, + }) +} + +/// The Upright mode a packet selects (`crs:PerspectiveUpright`), if it names one. +pub fn selected_upright(props: &Props) -> Option { + num(props, "crs:PerspectiveUpright").and_then(upright_mode) +} + +/// The `crs:UprightVersion` whose saved matrices were measured against Lightroom's exports (the +/// only one seen, in packets written by Camera Raw 15.0 to 17.2: Lightroom Classic 12 to 14 and +/// the Lightroom desktop app). Matrices of any other version are not read: their convention is +/// unknown. +pub const UPRIGHT_VERSION: u64 = 151_388_160; + +/// Lightroom's saved Upright correction for the selected mode (`crs:UprightTransform_`, +/// where value is `crs:PerspectiveUpright`). +/// +/// The convention was measured, not assumed (issue #205; three Upright Auto exports of public raws, +/// one of them portrait and one cropped, matched to 0.5 px): a forward homography (uncorrected → +/// corrected, column vectors, `p' = M·p`) in coordinates normalized to the *stored* image (x / width, +/// y / height, origin top left, y down, before the EXIF orientation), applied before the crop, with +/// Lightroom's scale to fill the frame already in it. Guided Upright's slot holds no result (its +/// correction is solved from the guides when drawn), so Guided is never read from here. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SavedUpright { + pub mode: Upright, + /// Row-major, scaled so the last element is 1. + pub matrix: [f64; 9], +} + +/// Largest magnitude accepted for an element of a saved matrix (normalized coordinates: a real +/// correction is of order 1). +const UPRIGHT_MAX_ELEMENT: f64 = 100.0; + +/// The packet's saved Upright correction for its selected mode, when LightCraft can apply it: a +/// mode with an automatic result (Auto, Full, Level, Vertical), [`UPRIGHT_VERSION`], a +/// `crs:UprightTransformCount` that includes the mode's slot, and nine finite numbers that make a +/// sensible correction (no point of the photo at or beyond the horizon, no mirror, no collapse). +pub fn saved_upright(props: &Props) -> Option { + // the slot is the mode's `crs:PerspectiveUpright` value; Off and Guided have no automatic result + let mode = selected_upright(props)?; + let slot = match mode { + Upright::Auto => 1, + Upright::Full => 2, + Upright::Level => 3, + Upright::Vertical => 4, + Upright::Off | Upright::Guided => return None, + }; + if first(props, "crs:UprightVersion")?.parse::().ok()? != UPRIGHT_VERSION { + return None; + } + let count = first(props, "crs:UprightTransformCount")?.parse::().ok()?; + if count <= slot { + return None; + } + let matrix = parse_upright_matrix(first(props, &format!("crs:UprightTransform_{slot}"))?)?; + Some(SavedUpright { mode, matrix }) +} + +/// Nine comma-separated numbers as a usable saved Upright matrix (see [`saved_upright`]). +fn parse_upright_matrix(text: &str) -> Option<[f64; 9]> { + let mut m = [0.0; 9]; + let mut parts = text.split(','); + for v in &mut m { + *v = parts.next()?.trim().parse::().ok().filter(|x| x.is_finite())?; + } + if parts.next().is_some() || m[8].abs() < 1e-9 { + return None; + } + let last = m[8]; + for v in &mut m { + *v /= last; + } + if m.iter().any(|v| !v.is_finite() || v.abs() > UPRIGHT_MAX_ELEMENT) { + return None; + } + // every point of the photo stays in front of the horizon (w is affine: its corners bound it) + let w = |x: f64, y: f64| m[6] * x + m[7] * y + m[8]; + if [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)].iter().any(|&(x, y)| w(x, y) < 1e-3) { + return None; + } + // with w > 0 there, the sign of the determinant is the sign of the local area: no mirror, no collapse + let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]); + (det.is_finite() && det > 1e-6).then_some(m) +} + +/// Normalized stored coordinates → normalized oriented coordinates, as a homography. +fn orientation_homography(orientation: Orientation) -> Homography { + let at = |x: f64, y: f64| { + let (u, v) = orientation.map(x, y, 1.0, 1.0); + Point::new(u, v) + }; + let (o, ex, ey) = (at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0)); + Homography([ex.x - o.x, ey.x - o.x, o.x, ex.y - o.y, ey.y - o.y, o.y, 0.0, 0.0, 1.0]) +} + +/// Centred oriented coordinates (`(p − centre) / (long edge / 2)`) → normalized oriented +/// coordinates, for an oriented image of `ow × oh`. +fn centred_to_normalized(ow: f64, oh: f64) -> Homography { + let l = ow.max(oh) / 2.0; + Homography([l / ow, 0.0, 0.5, 0.0, l / oh, 0.5, 0.0, 0.0, 1.0]) +} + +/// A saved Upright correction in LightCraft's frame: the homography of +/// `Geometry::upright_transform` (lens-corrected → transformed, centred coordinates of the oriented +/// image). `orientation` turns the stored image, `stored_w × stored_h` (any unit; only the aspect +/// matters), into LightCraft's oriented image. `None` for a frame without a finite, positive size. +pub fn upright_in_frame(saved: &SavedUpright, orientation: Orientation, stored_w: f64, stored_h: f64) -> Option { + if !(stored_w.is_finite() && stored_h.is_finite() && stored_w > 0.0 && stored_h > 0.0) { + return None; + } + let (ow, oh) = if orientation.swaps_axes() { (stored_h, stored_w) } else { (stored_w, stored_h) }; + let turn = orientation_homography(orientation); + let oriented = turn.mul(&Homography(saved.matrix)).mul(&turn.inverse()?); + let c = centred_to_normalized(ow, oh); + let h = c.inverse()?.mul(&oriented).mul(&c); + let last = h.0[8]; + let h = Homography(h.0.map(|v| v / last)); + h.0.iter().all(|v| v.is_finite()).then_some(h) +} + +/// Put a placed Upright correction ([`upright_in_frame`]) into a partial settings object. +pub fn put_upright_transform(partial: &mut Value, h: Homography) { + put(partial, "geometry.upright_transform", json!(h)); +} + /// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets. /// Returns `None` when the packet has no adjustments we understand. pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option { @@ -604,7 +745,8 @@ mod tests { assert_eq!(s.vignette.amount, -21.0); assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); - assert_eq!(s.geometry.upright, Upright::Level); + // Lightroom's 2 is Full (issue #205), not Level + assert_eq!(s.geometry.upright, Upright::Full); assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff7..9fb2fd98 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -181,6 +181,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { content_hash, xmp: lightcraft_meta::embedded(bytes).xmp, preview_only: None, + orientation: Some(raw.orientation), }); } let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; @@ -213,6 +214,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { embedded_lens: None, xmp: None, preview_only: None, + orientation: Some(o), }) } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 20d1983e..20172809 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -965,7 +965,11 @@ impl ImportJob { .or_else(|| info.xmp.clone().filter(|_| raw)); let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { - Ok(sc) => Some(sc), + Ok(mut sc) => { + // Lightroom's saved Upright, in the frame the file's own orientation gives + sc.place_upright(info.stored_frame()); + Some(sc) + } Err(e) => { log::warn!("import {path}: XMP: {e}"); None diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431..1df86d9f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1029,4 +1029,6 @@ mod tests_spots; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_upright; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 2519057a..619823a4 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -534,6 +534,16 @@ 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); + // Lightroom's saved Upright correction, in the stored image's frame like the crop + match crate::crs::saved_upright(&props).and_then(|saved| crate::crs::upright_in_frame(&saved, orientation, stored_aspect, 1.0)) { + Some(transform) => crate::crs::put_upright_transform(&mut partial, transform), + None if crate::crs::has_saved_upright_transform(&props) + && crate::crs::selected_upright(&props).is_some_and(|m| m != lightcraft_develop::Upright::Off) => + { + unknown.push("Upright (Lightroom's saved geometry not kept)".into()); + } + None => {} + } if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); } @@ -766,6 +776,9 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { strip_xmp_sentinels(partial); } + // the stored image's frame, as for the crop (`catalog_crop`) + let stored = (number(&src.image, "fileWidth") as f64, number(&src.image, "fileHeight") as f64); + sc.place_upright(lightroom_orientation(text(&src.image, "orientation")).map(|o| (o, stored.0, stored.1))); crate::sidecar::merge_into(&mut p, &sc, &now); } Err(e) => warnings.push(format!("image {} XMP: {e}", src.source_id)), @@ -1167,6 +1180,30 @@ mod tests { assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); } + /// Lightroom's saved Upright is in the stored image's frame, like the crop: it turns with the + /// catalog orientation; one that can't be kept is reported. + #[test] + fn catalog_upright_is_kept_in_the_stored_frame() { + let auto = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000"; + let settings = |version: &str| { + format!(r#"s = {{ PerspectiveUpright = 1, UprightVersion = {version}, UprightTransformCount = 6, UprightTransform_1 = "{auto}" }}"#) + }; + let saved = crate::crs::SavedUpright { + mode: lightcraft_develop::Upright::Auto, + matrix: auto.split(',').map(|v| v.parse::().unwrap()).collect::>().try_into().unwrap(), + }; + for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270] { + let (partial, unknown) = mapped_settings(&settings("151388160"), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let want = crate::crs::upright_in_frame(&saved, o, 1.5, 1.0).unwrap(); + assert_eq!(partial["geometry"]["upright"], "auto"); + assert_eq!(partial["geometry"]["upright_transform"], json!(want), "{o:?}"); + assert!(unknown.iter().all(|u| !u.starts_with("Upright")), "{unknown:?}"); + } + let (partial, unknown) = mapped_settings(&settings("1"), crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert!(partial["geometry"]["upright_transform"].is_null(), "{partial}"); + assert!(unknown.iter().any(|u| u.starts_with("Upright")), "{unknown:?}"); + } + #[test] fn deferred_auto_tone_sentinels_never_become_real_slider_values() { let mut data = sample(); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359..60f2913c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1492,6 +1492,20 @@ pub struct ProbeInfo { /// A raw variant that can't be decoded yet: why. The file is described (and will be shown and /// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`]. pub preview_only: Option, + /// How the stored image is turned into the oriented one `width × height` describes (the + /// file's EXIF orientation, as the decoder applies it); `None` when not known. Lightroom's + /// saved Upright matrices are in the stored frame ([`crate::crs::SavedUpright`]). + pub orientation: Option, +} + +impl ProbeInfo { + /// The stored image's frame: its orientation and its size before that orientation, when both + /// are known (see [`crate::sidecar::SidecarData::place_upright`]). + pub fn stored_frame(&self) -> Option<(lightcraft_geom::Orientation, f64, f64)> { + let o = self.orientation?; + let (w, h) = (f64::from(self.width), f64::from(self.height)); + (w > 0.0 && h > 0.0).then_some(if o.swaps_axes() { (o, h, w) } else { (o, w, h) }) + } } pub type FileProbe = Arc Result + Send + Sync>; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 4f44780e..470720b5 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -107,6 +107,9 @@ pub struct SidecarData { /// Actionable diagnostics for foreign develop settings that were not fully carried over. /// These are surfaced in the photo import report and never persisted in the catalog. pub warnings: Vec, + /// Lightroom's saved Upright correction for the selected mode, waiting for the photo's frame + /// ([`SidecarData::place_upright`]); `None` when the packet has none LightCraft can apply. + pub upright: Option, } #[derive(Clone, Debug, PartialEq)] @@ -183,40 +186,33 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu fields: Vec::new(), }); } - let upright = crate::crs::has_saved_upright_transform(&d.properties); - if upright { - let mode = d - .properties - .get("crs:PerspectiveUpright") - .and_then(|values| values.first()) - .map(String::as_str) - .map(|mode| match mode { - "0" => crate::import::ImportUprightMode::Off, - "1" => crate::import::ImportUprightMode::Auto, - "2" => crate::import::ImportUprightMode::Level, - "3" => crate::import::ImportUprightMode::Vertical, - "4" => crate::import::ImportUprightMode::Full, - "5" => crate::import::ImportUprightMode::Guided, - _ => crate::import::ImportUprightMode::Unknown, - }) - .unwrap_or(crate::import::ImportUprightMode::Unknown); - out.warnings.push(crate::import::ImportWarning { - path: String::new(), - code: crate::import::ImportWarningCode::UprightGeometry, - profile: None, - mode: Some(mode), - fields: Vec::new(), - }); + // Saved Upright matrices: Lightroom's geometry is kept when the photo's frame is known + // ([`SidecarData::place_upright`]); until then, and whenever it can't be, the selected + // mode will be solved again and the warning says so. With Upright Off nothing is lost. + if crate::crs::has_saved_upright_transform(&d.properties) { + use crate::import::ImportUprightMode as M; + let mode = match crate::crs::selected_upright(&d.properties) { + Some(lightcraft_develop::Upright::Off) => M::Off, + Some(lightcraft_develop::Upright::Auto) => M::Auto, + Some(lightcraft_develop::Upright::Full) => M::Full, + Some(lightcraft_develop::Upright::Level) => M::Level, + Some(lightcraft_develop::Upright::Vertical) => M::Vertical, + Some(lightcraft_develop::Upright::Guided) => M::Guided, + None => M::Unknown, + }; + if mode != M::Off { + out.warnings.push(crate::import::ImportWarning { + path: String::new(), + code: crate::import::ImportWarningCode::UprightGeometry, + profile: None, + mode: Some(mode), + fields: Vec::new(), + }); + } + out.upright = crate::crs::saved_upright(&d.properties); } - let other: Vec<&str> = unmapped - .iter() - .map(String::as_str) - .filter(|name| { - *name != "CameraProfile" - && !name.starts_with("UprightTransform_") - && !matches!(*name, "UprightTransformCount" | "UprightCenterNormX" | "UprightCenterNormY" | "UprightVersion") - }) - .collect(); + let other: Vec<&str> = + unmapped.iter().map(String::as_str).filter(|name| *name != "CameraProfile" && !name.starts_with("Upright")).collect(); if !other.is_empty() { out.warnings.push(crate::import::ImportWarning { path: String::new(), @@ -296,6 +292,21 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool { } impl SidecarData { + /// Keep Lightroom's saved Upright correction ([`SidecarData::upright`]) for a photo whose stored + /// image (`stored_w × stored_h`, before its orientation) is turned onto LightCraft's frame by + /// `orientation`: the correction goes into the develop patch and the `uprightGeometry` warning + /// is dropped. Without a frame (or with a degenerate one) nothing changes: the mode is solved + /// again and the warning stays. Returns whether the correction was placed. + pub fn place_upright(&mut self, frame: Option<(lightcraft_geom::Orientation, f64, f64)>) -> bool { + let (Some(saved), Some((orientation, w, h)), Some(DevelopPatch::Partial(partial))) = (self.upright, frame, self.develop.as_mut()) else { + return false; + }; + let Some(transform) = crate::crs::upright_in_frame(&saved, orientation, w, h) else { return false }; + crate::crs::put_upright_transform(partial, transform); + self.warnings.retain(|w| w.code != crate::import::ImportWarningCode::UprightGeometry); + true + } + /// Read the label through the catalog's label names: other editors write the label's name /// (a custom set's "To Do" as well as "Red"); unknown names clear the label. pub fn resolve_label(mut self, cat: &lightcraft_catalog::Catalog) -> Self { @@ -586,7 +597,12 @@ impl Session { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; let Some(orig) = file_path(p) else { return Ok(None) }; let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) }; - let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?; + let mut sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?; + if sc.upright.is_some() { + // Lightroom's matrix is in the stored image's frame: the file says how it is oriented + let probed = crate::import::probe_paths(self, &[orig.to_string()]).into_iter().next().and_then(std::result::Result::ok); + sc.place_upright(probed.and_then(|info| info.stored_frame())); + } let mut warnings = sc.warnings.clone(); for warning in &mut warnings { warning.path = orig.to_string(); diff --git a/crates/engine/src/tests_upright.rs b/crates/engine/src/tests_upright.rs new file mode 100644 index 00000000..ff714703 --- /dev/null +++ b/crates/engine/src/tests_upright.rs @@ -0,0 +1,497 @@ +//! Lightroom's saved Upright matrices (issue #205): which slot each mode reads, what is refused, +//! the conversion into LightCraft's frame, the geometry against measurements of Lightroom's own +//! exports, and the import, re-read and save paths. +//! +//! The matrices below come from public Lightroom Classic 12 sidecars of public raws +//! (github.com/zjqc/photographing-art, MIT): numbers only, no image data. + +use std::path::Path; + +use lightcraft_develop::{DevelopSettings, Upright}; +use lightcraft_geom::{Homography, Orientation, Point}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::crs::{Props, SavedUpright, Target, saved_upright, to_partial, upright_in_frame, upright_mode}; +use crate::tests_xmp::temp_dir; + +const IDENTITY: &str = "1.000000000,0.000000000,0.000000000,0.000000000,1.000000000,0.000000000,0.000000000,0.000000000,1.000000000"; + +/// The six matrices Lightroom saved for DSC_1457.NEF (slots 0 Off, 1 Auto, 2 Full, 3 Level, +/// 4 Vertical, 5 Guided). +const SLOTS: [&str; 6] = [ + IDENTITY, + "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000", + "1.000410884,-0.014998297,0.000328989,0.012818795,1.012962929,-0.022290590,-0.000090999,-0.018590990,1.000000000", + "1.015108937,-0.006862808,-0.004123065,0.015441320,1.015108937,-0.015275128,0.000000000,0.000000000,1.000000000", + "1.000505332,-0.014999682,0.000327389,0.012654172,1.013013074,-0.022230588,-0.000000003,-0.018592354,1.000000000", + IDENTITY, +]; + +const ORIENTATIONS: [Orientation; 8] = [ + Orientation::Normal, + Orientation::FlipH, + Orientation::Rotate180, + Orientation::FlipV, + Orientation::Transpose, + Orientation::Rotate90, + Orientation::Transverse, + Orientation::Rotate270, +]; + +/// An XMP packet in Lightroom's form: `fields` (attribute text) plus the six saved slots. +fn packet(fields: &str) -> String { + let slots: String = SLOTS.iter().enumerate().map(|(i, m)| format!(r#" crs:UprightTransform_{i}="{m}""#)).collect(); + format!( + r#" + + "# + ) +} + +fn lightroom(mode: u8) -> String { + packet(&format!(r#"crs:PerspectiveUpright="{mode}" crs:UprightVersion="151388160" crs:UprightTransformCount="6""#)) +} + +fn props(x: &str) -> Props { + lightcraft_meta::parse_xmp(x).unwrap().properties +} + +fn matrix(text: &str) -> [f64; 9] { + let v: Vec = text.split(',').map(|t| t.trim().parse().unwrap()).collect(); + v.try_into().unwrap() +} + +fn apply(m: &[f64; 9], p: Point) -> Point { + Homography(*m).apply(p) +} + +#[test] +fn perspective_upright_values_name_lightroom_modes() { + let expect = [Upright::Off, Upright::Auto, Upright::Full, Upright::Level, Upright::Vertical, Upright::Guided]; + for (value, mode) in expect.into_iter().enumerate() { + assert_eq!(upright_mode(value as f64), Some(mode)); + } + for odd in [6.0, -1.0, 1.5, f64::NAN, f64::INFINITY] { + assert_eq!(upright_mode(odd), None, "{odd}"); + } + // a stated mode never keeps a transform solved for another one + for (value, name) in [(2, "full"), (3, "level"), (4, "vertical")] { + let partial = to_partial(&props(&lightroom(value)), Target::Any); + assert_eq!(partial["geometry"]["upright"], name, "{value}"); + assert!(partial["geometry"]["upright_transform"].is_null(), "{partial}"); + let mut before = DevelopSettings::default(); + before.geometry.upright_transform = Some(Homography([1.1, 0.0, 0.0, 0.0, 1.1, 0.0, 0.0, 0.0, 1.0])); + assert_eq!(lightcraft_develop::apply_partial(&before, &partial, 1.0).geometry.upright_transform, None); + } +} + +#[test] +fn each_mode_reads_its_own_slot() { + for (value, mode) in [(1, Upright::Auto), (2, Upright::Full), (3, Upright::Level), (4, Upright::Vertical)] { + let saved = saved_upright(&props(&lightroom(value))).unwrap(); + assert_eq!(saved, SavedUpright { mode, matrix: matrix(SLOTS[value as usize]) }); + } + // Off has nothing to apply; Guided's slot holds no result (Lightroom solves it from the guides) + assert_eq!(saved_upright(&props(&lightroom(0))), None); + assert_eq!(saved_upright(&props(&lightroom(5))), None); + // the shapes that identify the slots: Level is a rotation about the centre, Vertical has no + // horizontal keystone, Full has both + let level = matrix(SLOTS[3]); + assert_eq!((level[6], level[7]), (0.0, 0.0)); + assert!(apply(&level, Point::new(0.5, 0.5)).dist(Point::new(0.5, 0.5)) < 1e-8); + assert!(matrix(SLOTS[4])[6].abs() < 1e-6 && matrix(SLOTS[4])[7].abs() > 1e-3); + assert!(matrix(SLOTS[2])[6].abs() > 1e-5 && matrix(SLOTS[2])[7].abs() > 1e-3); +} + +#[test] +fn saved_matrices_lightcraft_cannot_apply_are_refused() { + let base = lightroom(1); + let auto = SLOTS[1]; + let refused = |what: &str, xmp: String| assert_eq!(saved_upright(&props(&xmp)), None, "{what}"); + refused("no version", base.replace(r#" crs:UprightVersion="151388160""#, "")); + refused("another version", base.replace("151388160", "151388161")); + refused("a version that isn't a number", base.replace("151388160", "9.6")); + refused("no count", base.replace(r#" crs:UprightTransformCount="6""#, "")); + refused("a count without the slot", base.replace(r#"crs:UprightTransformCount="6""#, r#"crs:UprightTransformCount="1""#)); + refused("a count that isn't a number", base.replace(r#"crs:UprightTransformCount="6""#, r#"crs:UprightTransformCount="six""#)); + refused("a negative count", base.replace(r#"crs:UprightTransformCount="6""#, r#"crs:UprightTransformCount="-6""#)); + refused("no matrix for the mode", base.replace(&format!(r#" crs:UprightTransform_1="{auto}""#), "")); + refused("an empty matrix", base.replace(auto, "")); + refused("an unknown mode", base.replace(r#"crs:PerspectiveUpright="1""#, r#"crs:PerspectiveUpright="9""#)); + refused("a fractional mode", base.replace(r#"crs:PerspectiveUpright="1""#, r#"crs:PerspectiveUpright="1.5""#)); + for (what, m) in [ + ("eight numbers", "1,0,0,0,1,0,0,0"), + ("ten numbers", "1,0,0,0,1,0,0,0,1,0"), + ("text", "1,0,0,0,one,0,0,0,1"), + ("NaN", "1,0,0,0,NaN,0,0,0,1"), + ("infinity", "1,0,0,0,inf,0,0,0,1"), + ("an overflow", "1,0,0,0,1e400,0,0,0,1"), + ("a zero last element", "1,0,0,0,1,0,0,0,0"), + ("a huge element", "1,0,250,0,1,0,0,0,1"), + ("the horizon inside the photo", "1,0,0,0,1,0,-2,0,1"), + ("a mirror", "-1,0,1,0,1,0,0,0,1"), + ("a collapse", "1,1,0,1,1,0,0,0,1"), + ("separators only", ",,,,,,,,"), + ] { + refused(what, base.replace(auto, m)); + } + // a matrix scaled by its last element is the same correction + let scaled: Vec = matrix(auto).iter().map(|v| format!("{}", v * 2.0)).collect(); + let saved = saved_upright(&props(&base.replace(auto, &scaled.join(", ")))).unwrap(); + assert!(saved.matrix.iter().zip(matrix(auto)).all(|(a, b)| (a - b).abs() < 1e-12), "{saved:?}"); +} + +/// A deterministic stream of hostile field values: none may panic, and whatever is accepted maps +/// to finite coordinates in every orientation. +#[test] +fn hostile_upright_fields_never_panic() { + let pieces = ["1", "0", "-0", "1e308", "-1e308", "1e-320", "NaN", "inf", "", " ", ",", "0.5", "2", "-3", "x", "1e400", "١"]; + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for _ in 0..4000 { + let n = (next() % 12) as usize; + let m: Vec<&str> = (0..n).map(|_| pieces[(next() % pieces.len() as u64) as usize]).collect(); + let mode = ["1", "2", "3", "4", "0", "5", "-1", "1e9", "x"][(next() % 9) as usize]; + let count = ["6", "0", "5", "-1", "18446744073709551616", "x"][(next() % 6) as usize]; + let version = ["151388160", "0", "x", "-151388160"][(next() % 4) as usize]; + let xmp = packet(&format!(r#"crs:PerspectiveUpright="{mode}" crs:UprightVersion="{version}" crs:UprightTransformCount="{count}""#)) + .replace(SLOTS[1], &m.join(",")) + .replace(SLOTS[3], &m.join(",")); + let Ok(d) = lightcraft_meta::parse_xmp(&xmp) else { continue }; + if let Some(saved) = saved_upright(&d.properties) { + for o in ORIENTATIONS { + for (w, h) in [(3.0, 2.0), (1.0, 1e-300), (1e300, 1.0), (f64::NAN, 1.0), (0.0, 1.0)] { + if let Some(t) = upright_in_frame(&saved, o, w, h) { + assert!(t.0.iter().all(|v| v.is_finite()), "{saved:?} {o:?}"); + } + } + } + } + } +} + +/// Lightroom's own reading of a matrix (stored, normalized coordinates) and LightCraft's reading of +/// the converted one (oriented, centred coordinates) move every point of the photo to the same +/// place, in every orientation; converting back gives Lightroom's matrix. +#[test] +fn frame_conversion_agrees_with_lightroom_point_for_point() { + let (sw, sh) = (6000.0, 4000.0); + for slot in [1, 2, 3, 4] { + let saved = SavedUpright { mode: Upright::Auto, matrix: matrix(SLOTS[slot]) }; + for o in ORIENTATIONS { + let h = upright_in_frame(&saved, o, sw, sh).unwrap(); + let (ow, oh) = if o.swaps_axes() { (sh, sw) } else { (sw, sh) }; + let l = ow.max(oh) / 2.0; + let oriented = |p: Point| { + let (x, y) = o.map(p.x * sw, p.y * sh, sw, sh); + Point::new(x, y) + }; + for i in 0..=10 { + for j in 0..=10 { + let p = Point::new(i as f64 / 10.0, j as f64 / 10.0); + let want = oriented(apply(&saved.matrix, p)); + let a = oriented(p); + let c = h.apply(Point::new((a.x - ow / 2.0) / l, (a.y - oh / 2.0) / l)); + let got = Point::new(c.x * l + ow / 2.0, c.y * l + oh / 2.0); + assert!(got.dist(want) < 1e-6, "slot {slot} {o:?} {p:?}: {got:?} vs {want:?}"); + } + } + // and back: LightCraft's homography read in Lightroom's frame is the saved matrix + let corners = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)]; + let moved = corners.map(|p| { + let a = oriented(p); + let c = h.apply(Point::new((a.x - ow / 2.0) / l, (a.y - oh / 2.0) / l)); + stored_from_oriented(o, Point::new(c.x * l + ow / 2.0, c.y * l + oh / 2.0), sw, sh) + }); + let back = Homography::from_quads(&corners, &moved).unwrap(); + assert!(back.0.iter().zip(saved.matrix).all(|(a, b)| (a - b).abs() < 1e-9), "slot {slot} {o:?}: {back:?}"); + } + } + // identity stays identity, in every orientation and frame + let identity = SavedUpright { mode: Upright::Auto, matrix: matrix(IDENTITY) }; + for o in ORIENTATIONS { + for (w, h) in [(6000.0, 4000.0), (40.0, 24.0), (1.0, 1.0), (24.0, 40.0)] { + let t = upright_in_frame(&identity, o, w, h).unwrap(); + assert!(t.0.iter().zip(Homography::IDENTITY.0).all(|(a, b)| (a - b).abs() < 1e-12), "{o:?} {t:?}"); + } + } + for (w, h) in [(0.0, 1.0), (1.0, -1.0), (f64::NAN, 1.0), (1.0, f64::INFINITY)] { + assert_eq!(upright_in_frame(&identity, Orientation::Normal, w, h), None); + } +} + +/// Oriented px (an oriented image of the stored `sw × sh`) → normalized stored coordinates. +fn stored_from_oriented(o: Orientation, p: Point, sw: f64, sh: f64) -> Point { + // invert `Orientation::map` from three of its points (it is affine) + let at = |x: f64, y: f64| { + let (u, v) = o.map(x, y, 1.0, 1.0); + Point::new(u, v) + }; + let (z, ex, ey) = (at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0)); + let fwd = Homography([ex.x - z.x, ey.x - z.x, z.x, ex.y - z.y, ey.y - z.y, z.y, 0.0, 0.0, 1.0]); + let (ow, oh) = if o.swaps_axes() { (sh, sw) } else { (sw, sh) }; + fwd.inverse().unwrap().apply(Point::new(p.x / ow, p.y / oh)) +} + +/// Lightroom's Level matrix is a rotation about the centre of the stored image: in LightCraft's +/// isotropic coordinates it is a rotation about the origin, of the same angle. +#[test] +fn level_matrix_is_a_rotation_in_lightcraft_coordinates() { + let saved = SavedUpright { mode: Upright::Level, matrix: matrix(SLOTS[3]) }; + let h = upright_in_frame(&saved, Orientation::Normal, 4800.0, 3200.0).unwrap().0; + // (the saved matrix has nine decimals: its centre stays put to about 1e-9) + assert!(h[2].abs() < 1e-8 && h[5].abs() < 1e-8 && h[6].abs() < 1e-12 && h[7].abs() < 1e-12, "{h:?}"); + assert!((h[0] - h[4]).abs() < 1e-9 && (h[1] + h[3]).abs() < 2e-6, "{h:?}"); + let degrees = h[3].atan2(h[0]).to_degrees(); + assert!((degrees - 0.581).abs() < 0.001, "{degrees}"); +} + +/// One Lightroom export of a public raw with Upright Auto, measured (SIFT matches between +/// LightCraft's render without geometry and Lightroom's export, RANSAC, then least squares on +/// the inliers): the fitted map from LightCraft's base pixels to export pixels. +struct Reference { + name: &'static str, + /// `crs:UprightTransform_1` of the export's own packet. + auto: &'static str, + orientation: Orientation, + /// Lightroom's stored image (its default crop), before orientation. + stored: (f64, f64), + /// Where Lightroom's oriented image starts inside LightCraft's (LightCraft decodes these NEFs + /// a little larger), in oriented pixels; measured on Upright Off exports to 0.5 px. + inset: (f64, f64), + crop: [f64; 4], + export: (usize, usize), + /// Base px → export px (OpenCV pixel coordinates: centres at whole numbers). + fit: [f64; 9], +} + +const REFERENCES: [Reference; 3] = [ + Reference { + name: "DSC_1457 (landscape)", + auto: SLOTS[1], + orientation: Orientation::Normal, + stored: (4800.0, 3200.0), + inset: (6.0, 6.0), + crop: [0.0, 0.0, 1.0, 1.0], + export: (4800, 3200), + fit: [ + 1.0103827566515864, + -0.012910769838480735, + -35.18705406883105, + 0.009940071369263049, + 1.0117804655183038, + -55.14972108764364, + -1.5048134911065293e-06, + -1.0989184648032297e-06, + 1.0, + ], + }, + Reference { + name: "DSC_1316 (portrait, EXIF orientation 8)", + auto: "1.014491182,0.006583939,-0.010537101,-0.014802884,1.014513584,0.000149262,0.000001442,0.000009357,0.999998558", + orientation: Orientation::Rotate270, + stored: (6000.0, 4000.0), + inset: (10.0, 18.0), + crop: [0.0, 0.0, 1.0, 1.0], + export: (4000, 6000), + fit: [ + 1.0145773365814015, + 0.00989331650336279, + -69.00371740408151, + -0.009872550000906167, + 1.0145972704719037, + -42.124240481845156, + -1.2235571140649089e-09, + 1.328135644733238e-08, + 1.0, + ], + }, + Reference { + name: "DSC_2051 (cropped, Constrain Crop on)", + auto: "1.015255953,0.006622517,-0.010961914,-0.016162078,1.014735275,0.000170720,0.000065470,-0.001142520,1.000000000", + orientation: Orientation::Normal, + stored: (4800.0, 3200.0), + inset: (6.0, 6.0), + crop: [0.127635, 0.195079, 0.85347, 0.920914], + export: (3484, 2323), + fit: [ + 1.0154458088182998, + 0.010299904328396522, + -672.1052981190101, + -0.01076867585799946, + 1.0153630727100376, + -629.6318219157675, + 1.638771482679291e-08, + -2.8365852308384464e-07, + 1.0, + ], + }, +]; + +/// LightCraft's render mapping (`Frame`: crop, perspective) with the imported matrix agrees with +/// Lightroom's exports to about a pixel at full resolution; without it, by tens of pixels. +#[test] +fn imported_upright_renders_like_lightroom_exports() { + for r in &REFERENCES { + let (ow, oh) = if r.orientation.swaps_axes() { (r.stored.1, r.stored.0) } else { r.stored }; + let saved = SavedUpright { mode: Upright::Auto, matrix: matrix(r.auto) }; + let error = |transform: Option| { + let mut s = DevelopSettings::default(); + s.geometry.upright = Upright::Auto; + s.geometry.upright_transform = transform; + let [x0, y0, x1, y1] = r.crop; + s.crop.geometry.rect = lightcraft_geom::Rect { x0, y0, x1, y1 }; + let f = lightcraft_pipeline::geometry::Frame::new(ow as usize, oh as usize, &s, true); + let to_oriented = f.out_to_oriented(r.export.0, r.export.1); + let fit = Homography(r.fit).inverse().unwrap(); + let (mut worst, mut sum, mut n) = (0.0f64, 0.0, 0.0); + for i in 0..9 { + for j in 0..9 { + let e = Point::new((0.05 + 0.1125 * i as f64) * r.export.0 as f64, (0.05 + 0.1125 * j as f64) * r.export.1 as f64); + // continuous coordinates (pixel centres at +0.5) through LightCraft's frame + let t = to_oriented.apply(Point::new(e.x + 0.5, e.y + 0.5)); + let src = f.warp.as_ref().map_or(t, |w| w.to_corrected(t)); + let predicted = Point::new(src.x - 0.5 + r.inset.0, src.y - 0.5 + r.inset.1); + let d = predicted.dist(fit.apply(e)); + worst = worst.max(d); + sum += d; + n += 1.0; + } + } + (sum / n, worst) + }; + let (mean, worst) = error(upright_in_frame(&saved, r.orientation, r.stored.0, r.stored.1)); + assert!(mean < 0.75 && worst < 1.25, "{}: mean {mean:.3} px, worst {worst:.3} px", r.name); + let (mean_without, _) = error(None); + assert!(mean_without > 15.0, "{}: {mean_without:.1} px without the saved matrix", r.name); + } +} + +fn write_png(path: &Path) { + let (w, h) = (40usize, 24usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 6) as u8, (i / w * 9) as u8, 90, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// A 40 × 24 JPEG stored on its side: EXIF orientation 6 shows it 24 × 40. +fn write_turned_jpeg(path: &Path) { + let (w, h) = (40usize, 24usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 6) as u8, (i / w * 9) as u8, 30, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let exif = lightcraft_codecs::exif::minimal_exif(6); + let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() }; + let bytes = + lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, lightcraft_codecs::ChromaSubsampling::S444, &meta).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn upright_warnings(report: &Value) -> Vec { + report["warnings"].as_array().map(|w| w.iter().filter(|w| w["code"] == "uprightGeometry").cloned().collect()).unwrap_or_default() +} + +fn geometry(s: &Session) -> lightcraft_develop::Geometry { + s.catalog.photos().next().unwrap().develop.geometry.clone() +} + +fn placed(slot: usize, o: Orientation, w: f64, h: f64) -> Homography { + upright_in_frame(&SavedUpright { mode: Upright::Auto, matrix: matrix(SLOTS[slot]) }, o, w, h).unwrap() +} + +#[test] +fn import_keeps_lightroom_upright_and_rereads_follow_the_sidecar() { + let src = temp_dir("upright-import"); + let photo = src.join("tower.png"); + write_png(&photo); + let sidecar = src.join("tower.xmp"); + std::fs::write(&sidecar, lightroom(1)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + assert!(upright_warnings(&r).is_empty(), "{r}"); + // the analysis record is not an adjustment that was lost + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + let mut unmapped = warnings.iter().filter(|w| w["code"] == "unmappedXmp").flat_map(|w| w["fields"].as_array().cloned().unwrap_or_default()); + assert!(unmapped.all(|f| !f.as_str().unwrap_or_default().starts_with("Upright")), "{r}"); + let g = geometry(&s); + assert_eq!(g.upright, Upright::Auto); + assert_eq!(g.upright_transform, Some(placed(1, Orientation::Normal, 40.0, 24.0))); + let id = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + + // re-reading follows a changed mode to its own slot + std::fs::write(&sidecar, lightroom(4)).unwrap(); + let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert!(upright_warnings(&reread).is_empty(), "{reread}"); + let g = geometry(&s); + assert_eq!((g.upright, g.upright_transform), (Upright::Vertical, Some(placed(4, Orientation::Normal, 40.0, 24.0)))); + + // Guided: Lightroom keeps no result to import, so it is solved here and says so + std::fs::write(&sidecar, lightroom(5)).unwrap(); + let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(upright_warnings(&reread).first().map(|w| w["mode"].clone()), Some(json!("guided")), "{reread}"); + assert_eq!((geometry(&s).upright, geometry(&s).upright_transform), (Upright::Guided, None)); + + // an Upright version whose convention is unknown: solved again, with the warning + std::fs::write(&sidecar, lightroom(3).replace("151388160", "151388161")).unwrap(); + let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(upright_warnings(&reread).first().map(|w| w["mode"].clone()), Some(json!("level")), "{reread}"); + assert_eq!((geometry(&s).upright, geometry(&s).upright_transform), (Upright::Level, None)); + + // Upright Off: Lightroom applies none of its saved matrices, so nothing is lost + std::fs::write(&sidecar, lightroom(0)).unwrap(); + let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert!(upright_warnings(&reread).is_empty(), "{reread}"); + assert_eq!((geometry(&s).upright, geometry(&s).upright_transform), (Upright::Off, None)); + let _ = std::fs::remove_dir_all(&src); +} + +/// The matrix is in the stored image's frame: a photo shown on its side gets it turned with it. +#[test] +fn import_turns_lightroom_upright_with_the_file_orientation() { + let src = temp_dir("upright-turned"); + let photo = src.join("turned.jpg"); + write_turned_jpeg(&photo); + std::fs::write(src.join("turned.xmp"), lightroom(1)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + assert!(upright_warnings(&r).is_empty(), "{r}"); + let p = s.catalog.photos().next().unwrap().clone(); + assert_eq!((p.width, p.height), (24, 40)); + let turned = placed(1, Orientation::Rotate90, 40.0, 24.0); + assert_eq!(p.develop.geometry.upright_transform, Some(turned)); + assert_ne!(Some(turned), Some(placed(1, Orientation::Normal, 24.0, 40.0))); + let _ = std::fs::remove_dir_all(&src); +} + +/// LightCraft's own sidecar keeps the imported correction exactly (round trip through +/// `lc:settings`), and another library importing it gets the same geometry. +#[test] +fn imported_upright_round_trips_through_lightcraft_sidecars() { + let src = temp_dir("upright-roundtrip"); + let photo = src.join("hall.png"); + write_png(&photo); + std::fs::write(src.join("hall.xmp"), lightroom(2)).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + let first = geometry(&s); + assert_eq!((first.upright, first.upright_transform), (Upright::Full, Some(placed(2, Orientation::Normal, 40.0, 24.0)))); + let id = s.catalog.photos().next().unwrap().id; + s.execute("photo.saveMetadataToFile", &json!({"ids": [id.0]})).unwrap(); + let saved = std::fs::read_to_string(src.join("hall.xmp")).unwrap(); + assert!(saved.contains("crs:UprightTransform_2"), "Lightroom's fields are kept byte for byte"); + let mut again = Session::new().with_fs(); + let r = again.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + assert!(upright_warnings(&r).is_empty(), "{r}"); + assert_eq!(geometry(&again), first); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078c..ce593a5e 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -200,9 +200,11 @@ fn default_lightroom_profiles_are_not_reported() { let _ = std::fs::remove_dir_all(&src); } -/// Foreign profile names and saved Upright matrices stay visible without pretending their -/// proprietary colour or coordinate data was imported. Diagnostics are per-photo report data; -/// importing a file without XMP remains quiet, and the source packet is never rewritten. +/// Foreign profile names and saved Upright matrices that can't be applied stay visible without +/// pretending their proprietary colour or coordinate data was imported (this packet's Upright +/// version is not the measured one: `tests_upright` covers the matrices that are imported). +/// Diagnostics are per-photo report data; importing a file without XMP remains quiet, and the +/// source packet is never rewritten. #[test] fn foreign_profile_and_upright_import_diagnostics_are_actionable() { let src = temp_dir("xmp-diagnostics"); @@ -211,7 +213,7 @@ fn foreign_profile_and_upright_import_diagnostics_are_actionable() { let xmp = r#" "#; @@ -263,12 +265,18 @@ fn foreign_profile_and_upright_import_diagnostics_are_actionable() { "{reread}" ); assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == "auto"), "{reread}"); - for (source, mode) in [("0", "off"), ("2", "level"), ("3", "vertical"), ("4", "full"), ("5", "guided"), ("9", "unknown")] { + // Lightroom's values: 2 Full, 3 Level, 4 Vertical (issue #205) + for (source, mode) in [("2", "full"), ("3", "level"), ("4", "vertical"), ("5", "guided"), ("9", "unknown")] { let mode_xmp = xmp.replace("crs:PerspectiveUpright=\"1\"", &format!("crs:PerspectiveUpright=\"{source}\"")); std::fs::write(&sidecar, mode_xmp).unwrap(); let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap(); assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == mode), "{source}: {reread}"); } + // with Upright Off Lightroom applies none of its saved matrices: nothing to warn about + std::fs::write(&sidecar, xmp.replace("crs:PerspectiveUpright=\"1\"", "crs:PerspectiveUpright=\"0\"")).unwrap(); + let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap(); + assert!(reread["warnings"].as_array().into_iter().flatten().all(|w| w["code"] != "uprightGeometry"), "{reread}"); + std::fs::write(&sidecar, xmp).unwrap(); // A foreign name colliding with a built-in profile label remains warning metadata only. let collision = src.join("collision.png"); diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 878603e0..44a43b4e 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -42,7 +42,8 @@ WITHOUT ROWID or virtual table produces an explicit error, handled as a warning Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped -fields are reported. Lightroom's `ProcessVersion` is not mapped: newly imported photos are on +fields are reported. Lightroom's saved Upright correction is kept in the stored image's frame, like the crop (see +[xmp-interop.md](xmp-interop.md), *Saved Upright geometry*). Lightroom's `ProcessVersion` is not mapped: newly imported photos are on LightCraft's latest rendering process, and photos already in the library keep theirs ([process-versions.md](process-versions.md)). When a recovery archive is saved, it retains source settings/history/snapshots and original smart-collection rules. Smart collections become regular albums with current diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..52214683 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-META-XMP: Lightroom's saved Upright is kept, issue #205; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -167,7 +167,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Lightroom's saved Upright correction for the selected mode (`crs:UprightTransform_*`) is kept instead of being solved again, matching three Lightroom Upright Auto exports to under 1 px (issue #205, `crates/engine/src/tests_upright.rs`, see `docs/xmp-interop.md` → Saved Upright geometry), and `crs:PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -215,7 +215,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | -| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | +| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP and catalogs is kept as it is (LR-META-XMP) | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | | LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-META-XMP: Lightroom's saved Upright correction for the selected mode is kept from XMP and catalogs instead of being solved again (issue #205); `PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead..b62dd273 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** patch (Lightroom's saved Upright is kept from XMP, #205; numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -157,7 +157,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). |---|---:|---:|---| | Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) | | Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) | -| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | +| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile ignored (#204), Guided Upright from XMP not carried over (#205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | | **Product** | **×0.804** | **×0.830** | | ### Mainstream practitioner: ~49% @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | patch | File-exchange evidence: Lightroom's saved Upright correction is kept from XMP and catalogs (#205); only Guided Upright isn't carried over; the ×0.92 / ×0.95 discount and all numbers are unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a..3e1f7c34 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,17 @@ # What's new in LightCraft +## 2026-10-12 + +### Lightroom Upright comes across +- Photos edited in Lightroom with Upright Auto, Full, Level or Vertical now open framed as they are in Lightroom + (issue #205). LightCraft keeps the correction Lightroom saved with the edit instead of analysing the photo again. + This works for XMP sidecars, DNG files with the edit inside and Lightroom Classic catalogs. Photos imported + earlier get it with Photo ▸ Read Metadata from File. +- Lightroom's Upright values 2, 3 and 4 now read as Full, Level and Vertical. They used to come in as Level, + Vertical and Full. +- Guided Upright doesn't come across yet (Lightroom's guides aren't read), and the import report says so. The + report no longer lists Lightroom's Upright analysis fields as adjustments that weren't imported. + ## 2026-10-11 ### JPEG XL export diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d119..fc3e7d92 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -38,8 +38,8 @@ used — on import (it then also files a copied photo in its date folder) and by `lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate). When foreign `crs:` settings are only partly transferable, `library.import` includes per-photo `warnings` in its -report. Warnings identify unsupported profile names, saved Upright geometry that will be recalculated, and other -unmapped adjustments; they do not change the source packet or become catalog edits. +report. Warnings identify unsupported profile names, saved Upright geometry that can't be kept and will be recalculated, +and other unmapped adjustments; they do not change the source packet or become catalog edits. ## Saving into an existing sidecar @@ -140,13 +140,42 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | | | `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | | `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | | -| `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided; saved `UprightTransform_*` matrices are not imported, so photo import reports that Lightroom geometry was not preserved and the selected mode will be recalculated | +| `PerspectiveUpright` | `geometry.upright`, `geometry.upright_transform` | 0 off, 1 auto, 2 full, 3 level, 4 vertical, 5 guided; Lightroom's saved correction for the selected mode is kept as it is when it can be (see *Saved Upright geometry*), otherwise LightCraft works the mode out itself (Guided needs guides drawn in LightCraft: Lightroom's aren't read) and photo import says so | | `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop | Values pass through our control specs, so anything outside our slider ranges gets clamped. `crs:CameraProfile` is kept as the selected name in the photo's import warning and the active profile is left as it is: the name carries no rendering data and never selects an Adobe profile. Import reports such a profile as not applied (colours may differ from Lightroom), except Lightroom's default profiles (`Adobe Standard`, `Adobe Color`), which every sidecar names and which LightCraft's own starting look stands in for. +### Saved Upright geometry + +Lightroom saves its Upright analysis with the edit: `UprightVersion`, `UprightTransformCount` (6) and one 3 × 3 +matrix per mode, `UprightTransform_0` to `_5`, numbered like `PerspectiveUpright` (0 Off and 5 Guided hold the +identity: Guided is solved from the guides when they are drawn). LightCraft keeps the selected mode's matrix, so the +photo is framed as in Lightroom instead of being analysed again, when + +- the mode is Auto, Full, Level or Vertical; +- `UprightVersion` is `151388160` (the only version seen so far; any other is solved again); +- `UprightTransformCount` includes the mode's matrix, and the matrix is nine finite numbers that make a sensible + correction (no part of the photo at or beyond the horizon, no mirror, no collapse); +- the file's orientation is known: on import, Read Metadata from File and Lightroom catalog import (Synchronize + Folder solves the mode again). + +The matrix's convention was measured on Lightroom's exports, not assumed (issue #205): a forward homography (photo +before correction to photo after it, column vectors) in coordinates normalised to the stored image (x / width, +y / height, origin top left, y down, before the EXIF orientation), applied before the crop, with Lightroom's scale +to fill the frame already in it. LightCraft turns it with the file's orientation into its own frame +(`geometry.upright_transform`, centred coordinates of the oriented image) and applies the crop on top, as +Lightroom does. On three public NEF raws exported by Lightroom Classic 12 with Upright Auto (one shown in portrait, +one cropped with Constrain Crop on), LightCraft's geometry with the kept matrix matches the exports to about half a +pixel on average and under a pixel at worst, at full size; without it, the frames differ by 22 to 36 px on average. + +Not measured yet: the Transform sliders (Vertical, Horizontal, Rotate, Aspect, Scale, X / Y Offset) together with a +saved Upright (LightCraft applies its own slider mapping on top, as with its own Upright), Level, Vertical and Full +exports (their matrices have those modes' shapes, but no export of them with its raw was available), photos with +Lightroom lens profile corrections, and an angled crop on top of Upright. Guided Upright's guides +(`UprightFourSegments_*`) aren't read. + **Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur, process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below. From 7bb11f460429f44259932988663e07022cc789ce Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 14:12:15 +0200 Subject: [PATCH 2/6] LR-META-XMP: keep saved Upright in the photo's frame, put sidecar crops there too (#205) Review fixes for the saved Upright import. - The saved matrix is placed when the sidecar is merged, in the frame the file's orientation and then the photo's own Rotate Left / Right give (Orientation::then). Read Metadata after a rotation no longer keeps it in the old frame (up to 21 px off on DSC_1457). Rotate Left / Right turns a stored Upright correction with the photo (upright::turned), LightCraft's own as well. - Sidecar crops go through the Lightroom catalog import's conversion (catalog_crop, moved to crs::crop_in_frame unchanged) whenever the file's orientation is known. A portrait raw's crop used to cut the wrong side (DSC_1486 rendered 2548 x 4812 for Lightroom's 3200 x 3808; now 0.12 px mean from Lightroom's export after the NEF frame inset), and straightened crops from a real catalog's settings came out 8459 x 3827 for Lightroom's 7725 x 5150. A straightened crop on top of a saved Upright isn't measured: the mode is solved again, with the warning. - Lightroom catalogs don't read the saved Upright until a real catalog verifies its encoding and frame: the mode is solved again and the import reports it. - Guided Upright guides are reported whether or not automatic matrices are present. The new regressions fail on the previous commit and pass here. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 3 + crates/engine/src/crs.rs | 63 ++++++++- crates/engine/src/import.rs | 4 +- crates/engine/src/lightroom_catalog.rs | 89 ++++--------- crates/engine/src/media.rs | 9 +- crates/engine/src/sidecar.rs | 97 ++++++++------ crates/engine/src/tests_upright.rs | 176 ++++++++++++++++++++++++- crates/geom/src/lib.rs | 27 ++++ crates/pipeline/src/tests_geometry.rs | 33 +++++ crates/pipeline/src/upright.rs | 17 ++- docs/lightroom-catalog-import.md | 6 +- docs/parity-checklist.md | 6 +- docs/target-app-parity.md | 4 +- docs/whats-new.md | 8 +- docs/xmp-interop.md | 16 ++- 15 files changed, 428 insertions(+), 130 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 2d06d73f..da549097 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -226,6 +226,9 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { if let Some(d) = s.develop_of(id) { let mut d = (*d).clone(); d.orientation = d.orientation.rotated(cw); + // a stored Upright correction (LightCraft's or Lightroom's) turns with the photo + let turn = if cw { lightcraft_geom::Orientation::Rotate90 } else { lightcraft_geom::Orientation::Rotate270 }; + d.geometry.upright_transform = d.geometry.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn)); ops.extend(s.develop_op(id, d, "Rotate")); } } diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 9dedb697..d1f5707b 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use lightcraft_develop::{MIXER_BANDS, Preset, Upright}; -use lightcraft_geom::{Homography, Orientation, Point}; +use lightcraft_geom::{Affine, Homography, Orientation, Point, Rect}; use serde_json::{Map, Value, json}; /// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]). @@ -425,7 +425,7 @@ pub fn to_partial(props: &Props, target: Target) -> Value { put(o, "geometry.upright", json!(upright_mode(u).unwrap_or(Upright::Off))); // a stated mode never keeps a transform solved for another one: it is solved again for this // photo, unless the photo's frame is known and Lightroom's own saved matrix is placed - // ([`saved_upright`], [`crate::sidecar::SidecarData::place_upright`]) + // ([`saved_upright`], [`crate::sidecar::SidecarData::set_frame`]) put(o, "geometry.upright_transform", Value::Null); } @@ -544,6 +544,34 @@ pub fn selected_upright(props: &Props) -> Option { num(props, "crs:PerspectiveUpright").and_then(upright_mode) } +/// Whether a packet has Guided Upright guides (`crs:UprightFourSegments_`, or a positive +/// `crs:UprightFourSegmentsCount`). LightCraft doesn't read them. +pub fn has_upright_guides(props: &Props) -> bool { + let stated = |values: &Vec| values.iter().any(|v| !v.trim().is_empty()); + props.iter().any(|(key, values)| { + key.strip_prefix("crs:UprightFourSegments_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) && stated(values) + }) || num(props, "crs:UprightFourSegmentsCount").is_some_and(|n| n > 0.0) +} + +/// The Upright geometry an import loses unless Lightroom's saved matrix is kept, by the mode the +/// packet selects: saved matrices for any mode but Off (an unreadable mode value is `Unknown`), and +/// Guided Upright's guides. `None`: nothing would be lost (Upright Off, or no Upright record). +pub fn upright_at_risk(props: &Props) -> Option { + use crate::import::ImportUprightMode as M; + let (matrices, guides) = (has_saved_upright_transform(props), has_upright_guides(props)); + let stated = props.get("crs:PerspectiveUpright").is_some_and(|v| v.iter().any(|s| !s.trim().is_empty())); + match selected_upright(props) { + Some(Upright::Off) => None, + None if !stated => None, + None => (matrices || guides).then_some(M::Unknown), + Some(Upright::Guided) => (matrices || guides).then_some(M::Guided), + Some(Upright::Auto) => matrices.then_some(M::Auto), + Some(Upright::Full) => matrices.then_some(M::Full), + Some(Upright::Level) => matrices.then_some(M::Level), + Some(Upright::Vertical) => matrices.then_some(M::Vertical), + } +} + /// The `crs:UprightVersion` whose saved matrices were measured against Lightroom's exports (the /// only one seen, in packets written by Camera Raw 15.0 to 17.2: Lightroom Classic 12 to 14 and /// the Lightroom desktop app). Matrices of any other version are not read: their convention is @@ -662,6 +690,37 @@ pub fn put_upright_transform(partial: &mut Value, h: Homography) { put(partial, "geometry.upright_transform", json!(h)); } +/// Lightroom's crop in LightCraft's frame (Lightroom catalogs and, given the photo's frame, XMP +/// sidecars: [`crate::sidecar::SidecarData::set_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%. +pub fn crop_in_frame(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 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)); +} + /// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets. /// Returns `None` when the packet has no adjustments we understand. pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option { diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 20172809..fd69a80a 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -966,8 +966,8 @@ impl ImportJob { let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(mut sc) => { - // Lightroom's saved Upright, in the frame the file's own orientation gives - sc.place_upright(info.stored_frame()); + // Lightroom's crop and saved Upright refer to the file's stored image + sc.set_frame(info.stored_frame()); Some(sc) } Err(e) => { diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 619823a4..d8ad93db 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::{Affine, Orientation, Point, Rect}; +use lightcraft_geom::Orientation; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -533,16 +533,12 @@ fn mapped_settings( props.insert("crs:HasCrop".into(), vec!["True".into()]); } let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect); - catalog_crop(&mut partial, orientation, stored_aspect); - // Lightroom's saved Upright correction, in the stored image's frame like the crop - match crate::crs::saved_upright(&props).and_then(|saved| crate::crs::upright_in_frame(&saved, orientation, stored_aspect, 1.0)) { - Some(transform) => crate::crs::put_upright_transform(&mut partial, transform), - None if crate::crs::has_saved_upright_transform(&props) - && crate::crs::selected_upright(&props).is_some_and(|m| m != lightcraft_develop::Upright::Off) => - { - unknown.push("Upright (Lightroom's saved geometry not kept)".into()); - } - None => {} + crate::crs::crop_in_frame(&mut partial, orientation, stored_aspect); + // Lightroom's saved Upright isn't kept from catalogs yet: how a catalog encodes it, and whether + // its orientation agrees with the decoded file, haven't been checked on a real catalog (issue + // #205). The mode is solved again, and the report says what was lost. + if crate::crs::upright_at_risk(&props).is_some() { + unknown.push("Upright (Lightroom's saved geometry, solved again: not read from catalogs yet)".into()); } if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); @@ -573,36 +569,6 @@ 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)])) } -/// 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 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 { use crate::preset_import::Lua; match value { @@ -776,9 +742,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { strip_xmp_sentinels(partial); } - // the stored image's frame, as for the crop (`catalog_crop`) - let stored = (number(&src.image, "fileWidth") as f64, number(&src.image, "fileHeight") as f64); - sc.place_upright(lightroom_orientation(text(&src.image, "orientation")).map(|o| (o, stored.0, stored.1))); crate::sidecar::merge_into(&mut p, &sc, &now); } Err(e) => warnings.push(format!("image {} XMP: {e}", src.source_id)), @@ -914,7 +877,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex #[cfg(test)] mod tests { use super::*; - use lightcraft_geom::CropGeometry; + use lightcraft_geom::{Affine, CropGeometry, Point, Rect}; fn row(v: Value) -> Row { serde_json::from_value(v).unwrap() } @@ -1180,28 +1143,26 @@ mod tests { assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); } - /// Lightroom's saved Upright is in the stored image's frame, like the crop: it turns with the - /// catalog orientation; one that can't be kept is reported. + /// Lightroom's saved Upright isn't read from catalogs until a real catalog verifies how it is + /// stored there (issue #205): whatever form the fields take, the mode is solved again (no + /// transform is kept) and the report names what was lost. Upright Off loses nothing. #[test] - fn catalog_upright_is_kept_in_the_stored_frame() { + fn catalog_upright_falls_back_to_solving_and_is_reported() { let auto = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000"; - let settings = |version: &str| { - format!(r#"s = {{ PerspectiveUpright = 1, UprightVersion = {version}, UprightTransformCount = 6, UprightTransform_1 = "{auto}" }}"#) - }; - let saved = crate::crs::SavedUpright { - mode: lightcraft_develop::Upright::Auto, - matrix: auto.split(',').map(|v| v.parse::().unwrap()).collect::>().try_into().unwrap(), - }; - for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270] { - let (partial, unknown) = mapped_settings(&settings("151388160"), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); - let want = crate::crs::upright_in_frame(&saved, o, 1.5, 1.0).unwrap(); - assert_eq!(partial["geometry"]["upright"], "auto"); - assert_eq!(partial["geometry"]["upright_transform"], json!(want), "{o:?}"); - assert!(unknown.iter().all(|u| !u.starts_with("Upright")), "{unknown:?}"); + for settings in [ + format!(r#"s = {{ PerspectiveUpright = 1, UprightVersion = 151388160, UprightTransformCount = 6, UprightTransform_1 = "{auto}" }}"#), + "s = { PerspectiveUpright = 3, UprightVersion = 151388160, UprightTransform_3 = { 1, 0, 0, 0, 1, 0, 0, 0, 1 } }".to_string(), + r#"s = { PerspectiveUpright = 5, UprightFourSegmentsCount = 2, UprightFourSegments_0 = "0.2, 0.1, 0.25, 0.9" }"#.to_string(), + ] { + for o in [Orientation::Normal, Orientation::Rotate90] { + let (partial, unknown) = mapped_settings(&settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + assert!(partial["geometry"]["upright_transform"].is_null(), "{settings}: {partial}"); + assert_eq!(unknown.iter().filter(|u| u.starts_with("Upright")).count(), 1, "{settings}: {unknown:?}"); + } } - let (partial, unknown) = mapped_settings(&settings("1"), crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); - assert!(partial["geometry"]["upright_transform"].is_null(), "{partial}"); - assert!(unknown.iter().any(|u| u.starts_with("Upright")), "{unknown:?}"); + let off = format!(r#"s = {{ PerspectiveUpright = 0, UprightVersion = 151388160, UprightTransform_1 = "{auto}" }}"#); + let (_, unknown) = mapped_settings(&off, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert!(unknown.iter().all(|u| !u.starts_with("Upright")), "{unknown:?}"); } #[test] diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 60f2913c..877f9aba 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1500,11 +1500,12 @@ pub struct ProbeInfo { impl ProbeInfo { /// The stored image's frame: its orientation and its size before that orientation, when both - /// are known (see [`crate::sidecar::SidecarData::place_upright`]). - pub fn stored_frame(&self) -> Option<(lightcraft_geom::Orientation, f64, f64)> { - let o = self.orientation?; + /// are known (see [`crate::sidecar::SidecarData::set_frame`]). + pub fn stored_frame(&self) -> Option { + let orientation = self.orientation?; let (w, h) = (f64::from(self.width), f64::from(self.height)); - (w > 0.0 && h > 0.0).then_some(if o.swaps_axes() { (o, h, w) } else { (o, w, h) }) + let (width, height) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + (w > 0.0 && h > 0.0).then_some(crate::sidecar::StoredFrame { orientation, width, height }) } } diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 470720b5..c2ff9fd4 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -108,8 +108,23 @@ pub struct SidecarData { /// These are surfaced in the photo import report and never persisted in the catalog. pub warnings: Vec, /// Lightroom's saved Upright correction for the selected mode, waiting for the photo's frame - /// ([`SidecarData::place_upright`]); `None` when the packet has none LightCraft can apply. + /// ([`SidecarData::set_frame`]); `None` when the packet has none LightCraft can apply. pub upright: Option, + /// The stored image the packet's crop and saved Upright refer to, when the caller knows it. + pub frame: Option, + /// [`SidecarData::upright`] in the frame the file's orientation gives (before the photo's own + /// Rotate Left / Right, which [`merge_into`] adds): set by [`SidecarData::set_frame`]. + pub placed_upright: Option, +} + +/// A file's stored image: its size before orientation, and the orientation that turns it into the +/// image LightCraft shows before the photo's own Rotate Left / Right (the file's EXIF orientation, +/// as the decoder applies it). Lightroom's crop edges and saved Upright matrices refer to it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StoredFrame { + pub orientation: lightcraft_geom::Orientation, + pub width: f64, + pub height: f64, } #[derive(Clone, Debug, PartialEq)] @@ -186,31 +201,19 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu fields: Vec::new(), }); } - // Saved Upright matrices: Lightroom's geometry is kept when the photo's frame is known - // ([`SidecarData::place_upright`]); until then, and whenever it can't be, the selected - // mode will be solved again and the warning says so. With Upright Off nothing is lost. - if crate::crs::has_saved_upright_transform(&d.properties) { - use crate::import::ImportUprightMode as M; - let mode = match crate::crs::selected_upright(&d.properties) { - Some(lightcraft_develop::Upright::Off) => M::Off, - Some(lightcraft_develop::Upright::Auto) => M::Auto, - Some(lightcraft_develop::Upright::Full) => M::Full, - Some(lightcraft_develop::Upright::Level) => M::Level, - Some(lightcraft_develop::Upright::Vertical) => M::Vertical, - Some(lightcraft_develop::Upright::Guided) => M::Guided, - None => M::Unknown, - }; - if mode != M::Off { - out.warnings.push(crate::import::ImportWarning { - path: String::new(), - code: crate::import::ImportWarningCode::UprightGeometry, - profile: None, - mode: Some(mode), - fields: Vec::new(), - }); - } - out.upright = crate::crs::saved_upright(&d.properties); + // Lightroom's Upright: its saved matrix is kept when the photo's frame is known + // ([`SidecarData::set_frame`]); until then, and whenever it can't be (Guided's guides + // aren't read), the mode is solved again and the warning says so. Off loses nothing. + if let Some(mode) = crate::crs::upright_at_risk(&d.properties) { + out.warnings.push(crate::import::ImportWarning { + path: String::new(), + code: crate::import::ImportWarningCode::UprightGeometry, + profile: None, + mode: Some(mode), + fields: Vec::new(), + }); } + out.upright = crate::crs::saved_upright(&d.properties); let other: Vec<&str> = unmapped.iter().map(String::as_str).filter(|name| *name != "CameraProfile" && !name.starts_with("Upright")).collect(); if !other.is_empty() { @@ -276,6 +279,15 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool { Some(DevelopPatch::Full(s)) => (**s).clone(), Some(DevelopPatch::Partial(v)) => { let mut v = v.clone(); + if let Some(f) = sc.frame { + // the crop and Lightroom's saved Upright are on the stored image: into the frame the + // file's orientation and then the photo's own Rotate Left / Right give + let user = p.develop.orientation; + crate::crs::crop_in_frame(&mut v, f.orientation.then(user), f.width / f.height); + if let Some(h) = sc.placed_upright { + crate::crs::put_upright_transform(&mut v, lightcraft_pipeline::upright::turned(&h, user)); + } + } if p.width > 0 && p.height > 0 { crate::crs_masks::refit_radials(&mut v, crate::crs_masks::DEFAULT_ASPECT, p.width as f64 / p.height as f64); } @@ -292,19 +304,25 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool { } impl SidecarData { - /// Keep Lightroom's saved Upright correction ([`SidecarData::upright`]) for a photo whose stored - /// image (`stored_w × stored_h`, before its orientation) is turned onto LightCraft's frame by - /// `orientation`: the correction goes into the develop patch and the `uprightGeometry` warning - /// is dropped. Without a frame (or with a degenerate one) nothing changes: the mode is solved - /// again and the warning stays. Returns whether the correction was placed. - pub fn place_upright(&mut self, frame: Option<(lightcraft_geom::Orientation, f64, f64)>) -> bool { - let (Some(saved), Some((orientation, w, h)), Some(DevelopPatch::Partial(partial))) = (self.upright, frame, self.develop.as_mut()) else { + /// The photo's stored image (`None`: not known). With it, [`merge_into`] puts the packet's crop + /// into LightCraft's frame (as the Lightroom catalog import does) and keeps Lightroom's saved + /// Upright correction, so the `uprightGeometry` warning is dropped. Without it, or for a + /// straightened crop on top of a saved Upright (not measured yet), the mode is solved again and + /// the warning stays. Returns whether the saved Upright will be kept. + pub fn set_frame(&mut self, frame: Option) -> bool { + self.frame = frame.filter(|f| f.width.is_finite() && f.height.is_finite() && f.width > 0.0 && f.height > 0.0); + self.placed_upright = None; + let (Some(saved), Some(f), Some(DevelopPatch::Partial(partial))) = (self.upright, self.frame, self.develop.as_ref()) else { return false; }; - let Some(transform) = crate::crs::upright_in_frame(&saved, orientation, w, h) else { return false }; - crate::crs::put_upright_transform(partial, transform); - self.warnings.retain(|w| w.code != crate::import::ImportWarningCode::UprightGeometry); - true + if partial.pointer("/crop/geometry/angle").and_then(Value::as_f64).is_some_and(|a| a != 0.0) { + return false; + } + self.placed_upright = crate::crs::upright_in_frame(&saved, f.orientation, f.width, f.height); + if self.placed_upright.is_some() { + self.warnings.retain(|w| w.code != crate::import::ImportWarningCode::UprightGeometry); + } + self.placed_upright.is_some() } /// Read the label through the catalog's label names: other editors write the label's name @@ -598,10 +616,11 @@ impl Session { let Some(orig) = file_path(p) else { return Ok(None) }; let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) }; let mut sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?; - if sc.upright.is_some() { - // Lightroom's matrix is in the stored image's frame: the file says how it is oriented + let cropped = matches!(&sc.develop, Some(DevelopPatch::Partial(v)) if v.pointer("/crop/geometry").is_some()); + if sc.upright.is_some() || cropped { + // Lightroom's crop and saved Upright are on the stored image: the file says how it is oriented let probed = crate::import::probe_paths(self, &[orig.to_string()]).into_iter().next().and_then(std::result::Result::ok); - sc.place_upright(probed.and_then(|info| info.stored_frame())); + sc.set_frame(probed.and_then(|info| info.stored_frame())); } let mut warnings = sc.warnings.clone(); for warning in &mut warnings { diff --git a/crates/engine/src/tests_upright.rs b/crates/engine/src/tests_upright.rs index ff714703..3be04369 100644 --- a/crates/engine/src/tests_upright.rs +++ b/crates/engine/src/tests_upright.rs @@ -386,10 +386,14 @@ fn write_png(path: &Path) { /// A 40 × 24 JPEG stored on its side: EXIF orientation 6 shows it 24 × 40. fn write_turned_jpeg(path: &Path) { - let (w, h) = (40usize, 24usize); + write_jpeg(path, 40, 24, 6); +} + +/// A `w × h` JPEG (stored size) with EXIF orientation `exif`. +fn write_jpeg(path: &Path, w: usize, h: usize, exif: u16) { let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 6) as u8, (i / w * 9) as u8, 30, 255]).collect(); let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; - let exif = lightcraft_codecs::exif::minimal_exif(6); + let exif = lightcraft_codecs::exif::minimal_exif(exif); let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() }; let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, lightcraft_codecs::ChromaSubsampling::S444, &meta).unwrap(); @@ -495,3 +499,171 @@ fn imported_upright_round_trips_through_lightcraft_sidecars() { assert_eq!(geometry(&again), first); let _ = std::fs::remove_dir_all(&src); } + +/// Lightroom's matrix placed in the frame the file's orientation gives, then turned with the +/// photo's own Rotate Left / Right, is the matrix placed in the composed frame: for every pair. +/// Not turning it would leave DSC_1457's correction tens of pixels off after a Rotate Right. +#[test] +fn a_kept_upright_turns_with_the_photo_orientation() { + let saved = SavedUpright { mode: Upright::Auto, matrix: matrix(SLOTS[1]) }; + let (sw, sh) = (4800.0, 3200.0); + for file in ORIENTATIONS { + for user in ORIENTATIONS { + let turned = lightcraft_pipeline::upright::turned(&upright_in_frame(&saved, file, sw, sh).unwrap(), user); + let direct = upright_in_frame(&saved, file.then(user), sw, sh).unwrap(); + assert!(turned.0.iter().zip(direct.0).all(|(a, b)| (a - b).abs() < 1e-12), "{file:?} then {user:?}"); + } + } + // the unturned matrix after Rotate Right, in pixels of the 3200 × 4800 frame + let (right, stale) = + (upright_in_frame(&saved, Orientation::Rotate90, sw, sh).unwrap(), upright_in_frame(&saved, Orientation::Normal, sw, sh).unwrap()); + let worst = (0..=8) + .flat_map(|i| (0..=8).map(move |j| Point::new(i as f64 / 8.0 * 2.0 / 3.0 * 2.0 - 2.0 / 3.0, j as f64 / 8.0 * 2.0 - 1.0))) + .map(|c| right.apply(c).dist(stale.apply(c)) * 2400.0) + .fold(0.0, f64::max); + assert!(worst > 15.0, "{worst:.1} px"); +} + +/// Rotate Right, then Read Metadata from File; and Read Metadata, then Rotate Right: the same +/// correction, in the turned frame. Two more turns bring it back. +#[test] +fn rotating_and_rereading_keep_lightroom_upright_in_the_photo_frame() { + let src = temp_dir("upright-rotate"); + let photo = src.join("arch.png"); + write_png(&photo); + std::fs::write(src.join("arch.xmp"), lightroom(1)).unwrap(); + let want = placed(1, Orientation::Rotate90, 40.0, 24.0); + let close = |a: Option, b: Homography| a.is_some_and(|a| a.0.iter().zip(b.0).all(|(x, y)| (x - y).abs() < 1e-12)); + for read_first in [false, true] { + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + if read_first { + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + } else { + s.execute("photo.rotateRight", &json!({})).unwrap(); + let r = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert!(upright_warnings(&r).is_empty(), "{r}"); + } + let g = geometry(&s); + assert!(close(g.upright_transform, want), "read first: {read_first}: {g:?}"); + s.execute("photo.rotateLeft", &json!({})).unwrap(); + assert!(close(geometry(&s).upright_transform, placed(1, Orientation::Normal, 40.0, 24.0)), "back"); + s.execute("photo.flipHorizontal", &json!({})).unwrap(); + assert!(close(geometry(&s).upright_transform, placed(1, Orientation::Normal, 40.0, 24.0)), "a flip of the output leaves it"); + } + let _ = std::fs::remove_dir_all(&src); +} + +fn crop_packet(fields: &str) -> String { + format!( + r#" + + "# + ) +} + +fn crop_of(s: &Session) -> lightcraft_geom::CropGeometry { + s.catalog.photos().next().unwrap().develop.crop.geometry +} + +/// DSC_1486.NEF (zjqc/photographing-art) is stored 3:2 and shown in portrait (EXIF 8); its +/// Lightroom crop cuts the stored image's left 20.7 %, which is the bottom of the portrait, and +/// Lightroom exports 3200 × 3808. The sidecar crop used to cut the portrait's left instead. +#[test] +fn a_sidecar_crop_on_a_turned_photo_lands_where_lightroom_puts_it() { + let src = temp_dir("crop-portrait"); + let photo = src.join("portrait.jpg"); + write_jpeg(&photo, 60, 40, 8); + let sidecar = src.join("portrait.xmp"); + std::fs::write(&sidecar, crop_packet(r#"crs:CropLeft="0.206655" crs:CropTop="0" crs:CropRight="1" crs:CropBottom="1" crs:CropAngle="0""#)) + .unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + let check = |s: &Session, what: &str| { + let g = crop_of(s); + let r = [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1].map(|v| (v * 1e6).round() / 1e6); + assert_eq!(r, [0.0, 0.0, 1.0, 0.793345], "{what}"); + let (w, h) = s.catalog.photos().next().unwrap().shown_size(); + assert!((w / h - 3200.0 / 3808.0).abs() < 1e-3, "{what}: {w} × {h}"); + }; + check(&s, "import"); + let id = s.catalog.photos().next().unwrap().id; + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + check(&s, "read metadata"); + let _ = std::fs::remove_dir_all(&src); +} + +/// Straightened crops from a real Lightroom Classic catalog (stored 9504 × 6336, the sizes of the +/// JPEGs Lightroom exported from it, the same numbers as the catalog import's test): the same +/// fields in a sidecar give the same frame, upright and on its side. +#[test] +fn a_straightened_sidecar_crop_matches_lightroom_exports() { + for (fields, exif, export) in [ + ( + r#"crs:CropAngle="-9.34444" crs:CropBottom="0.802034" crs:CropLeft="0.05499" crs:CropRight="0.94501" crs:CropTop="0.197966""#, + 1, + (7725.0, 5150.0), + ), + ( + r#"crs:CropAngle="-1.48964" crs:CropBottom="0.962457" crs:CropLeft="0.010429" crs:CropRight="0.989571" crs:CropTop="0.037543""#, + 6, + (6100.0, 9150.0), + ), + ] { + let src = temp_dir(&format!("crop-straightened-{exif}")); + let photo = src.join("hall.jpg"); + write_jpeg(&photo, 48, 32, exif); + std::fs::write(src.join("hall.xmp"), crop_packet(fields)).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + let r = crop_of(&s).rect; + let (ow, oh) = if exif == 6 { (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, "EXIF {exif}: {w:.1} × {h:.1}, Lightroom exported {export:?}"); + let _ = std::fs::remove_dir_all(&src); + } +} + +/// A straightened crop on top of a saved Upright isn't measured yet: the mode is solved again +/// and the warning says so. +#[test] +fn a_straightened_crop_on_a_saved_upright_is_solved_again_with_the_warning() { + let src = temp_dir("upright-straightened"); + let photo = src.join("bridge.png"); + write_png(&photo); + let xmp = lightroom(1).replace(r#"crs:UprightFourSegmentsCount="0""#, r#"crs:UprightFourSegmentsCount="0" crs:HasCrop="True" crs:CropLeft="0.1" crs:CropTop="0.1" crs:CropRight="0.9" crs:CropBottom="0.9" crs:CropAngle="2.5""#); + std::fs::write(src.join("bridge.xmp"), xmp).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + assert_eq!(upright_warnings(&r).first().map(|w| w["mode"].clone()), Some(json!("auto")), "{r}"); + assert_eq!((geometry(&s).upright, geometry(&s).upright_transform), (Upright::Auto, None)); + let _ = std::fs::remove_dir_all(&src); +} + +/// Guided Upright's guides aren't read: a packet with guides and no automatic matrices still says +/// so; without guides or matrices nothing is lost. +#[test] +fn guided_upright_guides_that_are_not_read_are_reported() { + let src = temp_dir("upright-guides"); + let photo = src.join("tower.png"); + write_png(&photo); + let guides = r#" + + "#; + std::fs::write(src.join("tower.xmp"), guides).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + assert_eq!(upright_warnings(&r).first().map(|w| w["mode"].clone()), Some(json!("guided")), "{r}"); + let none = props( + &guides + .replace(r#"crs:UprightFourSegmentsCount="2""#, r#"crs:UprightFourSegmentsCount="0""#) + .replace(r#"crs:UprightFourSegments_0="0.21, 0.12, 0.23, 0.88" crs:UprightFourSegments_1="0.78, 0.10, 0.76, 0.90""#, ""), + ); + assert_eq!(crate::crs::upright_at_risk(&none), None); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1d81279d..3b115b30 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -341,6 +341,15 @@ impl Orientation { // flipH ∘ rot(t) = rot(-t) ∘ flipH Orientation::from_parts(!f, (4 - t) % 4) } + /// This orientation, then `next` applied to its result (`a.then(b).map(p) == b.map(a.map(p))`, + /// each with its own image size): e.g. a file's EXIF orientation, then the user's Rotate Right. + pub fn then(self, next: Orientation) -> Orientation { + let (f1, t1) = self.to_parts(); + let (f2, t2) = next.to_parts(); + // a flip reverses the turns before it: F·R^t = R^-t·F + let turns = if f2 { (4 + t2 - t1 % 4) % 4 } else { (t1 + t2) % 4 }; + Orientation::from_parts(f1 != f2, turns) + } /// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns /// are their own inverses; quarter turns swap. pub fn inverse(self) -> Orientation { @@ -443,6 +452,24 @@ mod tests { assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90); } + #[test] + fn orientations_compose() { + let (w, h) = (4.0, 2.0); + for a in 1..=8 { + for b in 1..=8 { + let (a, b) = (Orientation::from_exif(a), Orientation::from_exif(b)); + let ab = a.then(b); + let (aw, ah) = if a.swaps_axes() { (h, w) } else { (w, h) }; + for (x, y) in [(0.0, 0.0), (1.0, 0.5), (3.5, 1.5), (4.0, 2.0)] { + let (u, v) = a.map(x, y, w, h); + assert_eq!(b.map(u, v, aw, ah), ab.map(x, y, w, h), "{a:?} then {b:?}"); + } + } + } + assert_eq!(Orientation::Rotate270.then(Orientation::Rotate90), Orientation::Normal); + assert_eq!(Orientation::Rotate90.then(Orientation::Normal.flipped_h()), Orientation::Rotate90.flipped_h()); + } + #[test] fn orientation_inverse_and_norm_rect() { let r = Rect::new(0.1, 0.2, 0.3, 0.6); diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index d7105acf..9a7e2deb 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -488,3 +488,36 @@ fn level_degrees_sign_matches_crop_angle() { let deg = crate::upright::level_degrees(&src, &SourceInfo::default(), &DevelopSettings::default()).expect("lines found"); assert!((deg + 6.0).abs() < 0.5, "{deg}"); } + +/// Rotate Right on a photo with a stored Upright correction: the corrected scene turns with the +/// photo (every source point lands where the turned frame puts it), from every orientation. +#[test] +fn a_turned_upright_correction_moves_with_the_photo() { + use lightcraft_develop::Upright; + use lightcraft_geom::{Homography, Orientation}; + let (sw, sh) = (600usize, 400usize); + let h = Homography([1.02, -0.013, -0.006, 0.026, 1.021, -0.025, -0.004, -0.03, 1.0]); + for e in 1..=8 { + let o1 = Orientation::from_exif(e); + let o2 = o1.rotated(true); + let settings = |o: Orientation, t: Homography| { + let mut s = DevelopSettings { orientation: o, ..Default::default() }; + s.geometry.upright = Upright::Auto; + s.geometry.upright_transform = Some(t); + s + }; + let f1 = crate::geometry::Frame::new(sw, sh, &settings(o1, h), false); + let f2 = crate::geometry::Frame::new(sw, sh, &settings(o2, crate::upright::turned(&h, Orientation::Rotate90)), false); + let (w1, w2) = (f1.warp.as_ref().unwrap(), f2.warp.as_ref().unwrap()); + for (x, y) in [(10.0, 20.0), (300.0, 200.0), (590.0, 15.0), (45.0, 390.0)] { + let at = |o: Orientation| { + let (u, v) = o.map(x, y, sw as f64, sh as f64); + Point::new(u, v) + }; + let t1 = w1.from_corrected(at(o1)); + let (u, v) = Orientation::Rotate90.map(t1.x, t1.y, f1.ow, f1.oh); + let t2 = w2.from_corrected(at(o2)); + assert!(t2.dist(Point::new(u, v)) < 1e-6, "{o1:?}: {t2:?} vs ({u}, {v})"); + } + } +} diff --git a/crates/pipeline/src/upright.rs b/crates/pipeline/src/upright.rs index 93dffad0..17a92100 100644 --- a/crates/pipeline/src/upright.rs +++ b/crates/pipeline/src/upright.rs @@ -19,7 +19,7 @@ use std::borrow::Cow; use std::sync::Mutex; use lightcraft_develop::{Crop, DevelopSettings, Geometry, Upright}; -use lightcraft_geom::{Homography, Point}; +use lightcraft_geom::{Homography, Orientation, Point}; use lightcraft_raster::{Plane, Rgb32f}; use crate::SourceInfo; @@ -595,6 +595,21 @@ pub fn resolve<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings) -> C Cow::Owned(e) } +/// A stored Upright correction (`Geometry::upright_transform`) for the photo turned a further +/// `turn` (Rotate Left / Right): the same correction of the same scene, in the turned frame. In +/// centred coordinates, which are isotropic and keep their scale (the long edge stays the long +/// edge), a flip is x → −x and a clockwise quarter turn (x, y) → (−y, x). +pub fn turned(h: &Homography, turn: Orientation) -> Homography { + let (flip, quarter_turns) = turn.to_parts(); + let mut m = if flip { Homography([-1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]) } else { Homography::IDENTITY }; + for _ in 0..quarter_turns { + m = Homography([0.0, -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]).mul(&m); + } + // m is a signed permutation: its inverse is its transpose + let back = Homography([m.0[0], m.0[3], 0.0, m.0[1], m.0[4], 0.0, 0.0, 0.0, 1.0]); + m.mul(h).mul(&back) +} + /// The Upright homography in effect for settings (stored automatic transform, or solved from guides). /// `ow × oh` is the oriented image size (guides are normalized to it). pub fn homography(g: &Geometry, ow: f64, oh: f64) -> Homography { diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 44a43b4e..26332fe1 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -42,8 +42,10 @@ WITHOUT ROWID or virtual table produces an explicit error, handled as a warning Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped -fields are reported. Lightroom's saved Upright correction is kept in the stored image's frame, like the crop (see -[xmp-interop.md](xmp-interop.md), *Saved Upright geometry*). Lightroom's `ProcessVersion` is not mapped: newly imported photos are on +fields are reported. Lightroom's saved Upright correction isn't read from catalogs yet: how a catalog stores it, and +whether the catalog's orientation agrees with the decoded file, haven't been checked on a real catalog, so the mode +is solved again and the import reports `Upright` (XMP sidecars keep it: [xmp-interop.md](xmp-interop.md), *Saved +Upright geometry*). Lightroom's `ProcessVersion` is not mapped: newly imported photos are on LightCraft's latest rendering process, and photos already in the library keep theirs ([process-versions.md](process-versions.md)). When a recovery archive is saved, it retains source settings/history/snapshots and original smart-collection rules. Smart collections become regular albums with current diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 52214683..365414fb 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -167,7 +167,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Lightroom's saved Upright correction for the selected mode (`crs:UprightTransform_*`) is kept instead of being solved again, matching three Lightroom Upright Auto exports to under 1 px (issue #205, `crates/engine/src/tests_upright.rs`, see `docs/xmp-interop.md` → Saved Upright geometry), and `crs:PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Lightroom's saved Upright correction for the selected mode (`crs:UprightTransform_*`) is kept from XMP instead of being solved again, matching three Lightroom Upright Auto exports to under 1 px (issue #205, `crates/engine/src/tests_upright.rs`, see `docs/xmp-interop.md` → Saved Upright geometry; not yet from Lightroom catalogs), `crs:PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical, and sidecar crops go through the file's orientation like catalog crops (portrait raws were cut on the wrong side) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -215,7 +215,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | -| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP and catalogs is kept as it is (LR-META-XMP) | +| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP is kept as it is (LR-META-XMP); Rotate Left / Right turns a stored correction with the photo | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | | LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | @@ -787,7 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | LR-META-XMP: Lightroom's saved Upright correction for the selected mode is kept from XMP and catalogs instead of being solved again (issue #205); `PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical; statuses unchanged | +| 2026-10-12 | minor | LR-META-XMP: Lightroom's saved Upright correction for the selected mode is kept from XMP instead of being solved again (issue #205; catalogs fall back and report it); `PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical; sidecar crops follow the file's orientation; LR-EDIT-GEOM-UPRIGHT: Rotate turns a stored correction; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b62dd273..413ed333 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -157,7 +157,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). |---|---:|---:|---| | Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) | | Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) | -| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile ignored (#204), Guided Upright from XMP not carried over (#205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | +| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile ignored (#204), Guided Upright and catalog Upright not carried over (#205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | | **Product** | **×0.804** | **×0.830** | | ### Mainstream practitioner: ~49% @@ -297,7 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| -| 2026-10-12 | patch | File-exchange evidence: Lightroom's saved Upright correction is kept from XMP and catalogs (#205); only Guided Upright isn't carried over; the ×0.92 / ×0.95 discount and all numbers are unchanged | +| 2026-10-12 | patch | File-exchange evidence: Lightroom's saved Upright correction is kept from XMP sidecars (#205); Guided Upright and catalogs aren't carried over yet; the ×0.92 / ×0.95 discount and all numbers are unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3e1f7c34..fd63f141 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -5,8 +5,12 @@ ### Lightroom Upright comes across - Photos edited in Lightroom with Upright Auto, Full, Level or Vertical now open framed as they are in Lightroom (issue #205). LightCraft keeps the correction Lightroom saved with the edit instead of analysing the photo again. - This works for XMP sidecars, DNG files with the edit inside and Lightroom Classic catalogs. Photos imported - earlier get it with Photo ▸ Read Metadata from File. + This works for XMP sidecars and DNG files with the edit inside; photos imported earlier get it with Photo ▸ Read + Metadata from File. Lightroom Classic catalogs still have it worked out again for now, and the import says so. +- Rotate Left and Rotate Right turn an Upright correction with the photo. It used to stay as it was, so the photo + came out skewed after a turn. +- A crop read from an XMP sidecar now lands where Lightroom put it on photos shown on their side (portrait raws), + and straightened crops keep Lightroom's frame. A portrait crop used to cut the wrong side of the photo. - Lightroom's Upright values 2, 3 and 4 now read as Full, Level and Vertical. They used to come in as Level, Vertical and Full. - Guided Upright doesn't come across yet (Lightroom's guides aren't read), and the import report says so. The diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index fc3e7d92..d3dffc6d 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -141,7 +141,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | | `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | | | `PerspectiveUpright` | `geometry.upright`, `geometry.upright_transform` | 0 off, 1 auto, 2 full, 3 level, 4 vertical, 5 guided; Lightroom's saved correction for the selected mode is kept as it is when it can be (see *Saved Upright geometry*), otherwise LightCraft works the mode out itself (Guided needs guides drawn in LightCraft: Lightroom's aren't read) and photo import says so | -| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop | +| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | Lightroom's edges are on the stored image (before its orientation) and its straighten angle turns the other way: both go into the frame the photo is shown in (through the file's orientation and the photo's Rotate Left / Right), as the Lightroom catalog import does; when the file's orientation isn't known (Synchronize Folder, a raw shown from its preview) the edges are taken as written; `HasCrop="False"` → no crop | Values pass through our control specs, so anything outside our slider ranges gets clamped. @@ -158,23 +158,25 @@ photo is framed as in Lightroom instead of being analysed again, when - `UprightVersion` is `151388160` (the only version seen so far; any other is solved again); - `UprightTransformCount` includes the mode's matrix, and the matrix is nine finite numbers that make a sensible correction (no part of the photo at or beyond the horizon, no mirror, no collapse); -- the file's orientation is known: on import, Read Metadata from File and Lightroom catalog import (Synchronize - Folder solves the mode again). +- the file's orientation is known: on import and Read Metadata from File (Synchronize Folder solves the mode again, + and Lightroom catalog import doesn't read saved Upright yet: see [lightroom-catalog-import.md](lightroom-catalog-import.md)); +- the crop has no straighten angle (a straightened crop on top of a saved Upright isn't measured yet). The matrix's convention was measured on Lightroom's exports, not assumed (issue #205): a forward homography (photo before correction to photo after it, column vectors) in coordinates normalised to the stored image (x / width, y / height, origin top left, y down, before the EXIF orientation), applied before the crop, with Lightroom's scale to fill the frame already in it. LightCraft turns it with the file's orientation into its own frame -(`geometry.upright_transform`, centred coordinates of the oriented image) and applies the crop on top, as -Lightroom does. On three public NEF raws exported by Lightroom Classic 12 with Upright Auto (one shown in portrait, +(`geometry.upright_transform`, centred coordinates of the oriented image, the photo's own Rotate Left / Right +included) and applies the crop on top, as Lightroom does. Rotate Left / Right later turns the kept correction with +the photo, as it does LightCraft's own Upright. On three public NEF raws exported by Lightroom Classic 12 with Upright Auto (one shown in portrait, one cropped with Constrain Crop on), LightCraft's geometry with the kept matrix matches the exports to about half a pixel on average and under a pixel at worst, at full size; without it, the frames differ by 22 to 36 px on average. Not measured yet: the Transform sliders (Vertical, Horizontal, Rotate, Aspect, Scale, X / Y Offset) together with a saved Upright (LightCraft applies its own slider mapping on top, as with its own Upright), Level, Vertical and Full exports (their matrices have those modes' shapes, but no export of them with its raw was available), photos with -Lightroom lens profile corrections, and an angled crop on top of Upright. Guided Upright's guides -(`UprightFourSegments_*`) aren't read. +Lightroom lens profile corrections, and a straightened crop on top of Upright (solved again, with the warning). Guided +Upright's guides (`UprightFourSegments_*`) aren't read: import says so whenever a packet has them. **Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur, process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below. From 1c37bda98f8840cb4841a2969ed5516571bb1f51 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 17:09:50 +0200 Subject: [PATCH 3/6] LR-META-XMP: Upright corrections move between photos in each photo's frame; Synchronize Folder reads sidecars in it (#205) Second review round. - Paste, Paste from Previous, Sync and Auto Sync copied a stored Upright correction as it was: a virtual copy turned right got the original's matrix (15.4 px mean, 23.2 px max at 3200 x 4800), and so did a file with another EXIF orientation. They now re-express it from the source photo's frame into each target's through the stored image, as Lightroom keeps it (upright_frames). A patch that turns a photo without bringing a correction (a crop paste, a preset) turns the correction it keeps. Import records the file's orientation in the catalog (Photo::file_orientation, optional); photos imported earlier are taken as stored the way they are shown. - Synchronize Folder read sidecars without the photo's frame: a sidecar whose only change was its time undid import's crop conversion and cleared the kept Upright, silently. Its scan and its read now use the recorded frame (else the file's), and its report carries the sidecars' warnings. Without any frame an existing photo keeps its crop, and the report says so. - The Lightroom catalog import's XMP column dropped its diagnostics (Guided guides vanished); they now go into the per-image report, once alongside the develop settings' ones. - The release note says what isn't covered yet. The new regressions fail on the previous commit and pass here. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/Cargo.toml | 2 +- crates/catalog/src/model.rs | 8 ++ crates/engine/src/cmd/develop.rs | 29 +++-- crates/engine/src/cmd/sync.rs | 8 +- crates/engine/src/crs.rs | 22 +++- crates/engine/src/import.rs | 1 + crates/engine/src/lib.rs | 12 +- crates/engine/src/lightroom_catalog.rs | 84 ++++++++++++- crates/engine/src/media.rs | 5 +- crates/engine/src/sidecar.rs | 57 ++++++++- crates/engine/src/sync.rs | 45 +++++-- crates/engine/src/tests_upright.rs | 163 +++++++++++++++++++++++++ crates/engine/src/upright_frames.rs | 61 +++++++++ docs/lightroom-catalog-import.md | 3 +- docs/parity-checklist.md | 6 +- docs/whats-new.md | 16 ++- docs/xmp-interop.md | 13 +- 17 files changed, 485 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/upright_frames.rs diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 97d8868d..7b8bec9f 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lightcraft-develop = { workspace = true } +lightcraft-geom = { workspace = true } lightcraft-meta = { workspace = true } lightcraft-raw = { workspace = true } serde = { workspace = true } @@ -20,5 +21,4 @@ log = { workspace = true } web-time = { workspace = true } [dev-dependencies] -lightcraft-geom = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index abeaadad..2ed188a0 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -292,6 +292,13 @@ pub struct Photo { /// whether the user changed anything since (see [`crate::local`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub local_baseline: Option, + /// How the file's stored image is turned to be shown (its EXIF orientation, as the decoder + /// applies it; `width × height` is the turned size), recorded on import. Lightroom's crop + /// edges and saved Upright refer to the stored image, and Upright corrections move between + /// photos through it. `None` for photos imported before it was recorded: they are taken as + /// stored the way they are shown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_orientation: Option, } /// What assisted culling measured on a photo. @@ -359,6 +366,7 @@ impl Photo { analysis: None, preview_only: None, local_baseline: None, + file_orientation: None, } } /// A raw file developed from its sensor data: not a rendered image, and not a raw shown from diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 42f59fe7..34d3d467 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -788,6 +788,7 @@ pub fn specs() -> Vec { } let d = s.develop_of(id).unwrap_or_default(); s.clipboard = Some(lightcraft_develop::extract_groups(&d, &s.copy_groups)); + s.clipboard_frame = s.catalog.photo(id).and_then(|p| crate::upright_frames::frame_of(p, &d)); Ok(json!({"groups": s.copy_groups})) } ), @@ -805,10 +806,13 @@ pub fn specs() -> Vec { let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone()); let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups); let ids: Vec = s.targets(p).into_iter().filter(|id| *id != prev).collect(); + let source = s.catalog.photo(prev).and_then(|ph| crate::upright_frames::frame_of(ph, &ph.develop)); let ops = ids .iter() - .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) - .filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &partial, 1.0), "Paste from Previous")) + .filter_map(|id| { + s.catalog.photo(*id).map(|ph| (*id, crate::upright_frames::transferred(ph, &ph.develop, &partial, source.as_ref()))) + }) + .filter_map(|(id, d)| s.develop_op(id, d, "Paste from Previous")) .collect::>(); let n = ops.len(); s.commit("Paste from Previous", Op::Batch { ops })?; @@ -831,10 +835,13 @@ pub fn specs() -> Vec { clip = lightcraft_develop::extract_groups(&full, &only); } let ids = s.targets(p); + let source = s.clipboard_frame; let ops = ids .iter() - .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) - .filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &clip, 1.0), "Paste Settings")) + .filter_map(|id| { + s.catalog.photo(*id).map(|ph| (*id, crate::upright_frames::transferred(ph, &ph.develop, &clip, source.as_ref()))) + }) + .filter_map(|(id, d)| s.develop_op(id, d, "Paste Settings")) .collect::>(); let n = ops.len(); s.commit("Paste Settings", Op::Batch { ops })?; @@ -853,14 +860,15 @@ pub fn specs() -> Vec { let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy); let d = s.develop_of(id).unwrap_or_default(); let partial = lightcraft_develop::extract_groups(&d, &groups); + let source = s.catalog.photo(id).and_then(|ph| crate::upright_frames::frame_of(ph, &d)); let ops = s .selection .ids .clone() .into_iter() .filter(|x| *x != id) - .filter_map(|x| s.develop_of(x).map(|d| (x, d))) - .filter_map(|(x, dd)| s.develop_op(x, lightcraft_develop::apply_partial(&dd, &partial, 1.0), "Sync Settings")) + .filter_map(|x| s.catalog.photo(x).map(|ph| (x, crate::upright_frames::transferred(ph, &ph.develop, &partial, source.as_ref())))) + .filter_map(|(x, dd)| s.develop_op(x, dd, "Sync Settings")) .collect::>(); let n = ops.len(); s.commit("Sync Settings", Op::Batch { ops })?; @@ -973,7 +981,14 @@ pub fn specs() -> Vec { let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) - .filter_map(|(id, d)| s.develop_op(id, preset.apply(&d, amount), &format!("Preset: {}", preset.name))) + .filter_map(|(id, d)| { + // a preset that turns the photo turns the Upright correction it keeps + let mut after = preset.apply(&d, amount); + if let Some(ph) = s.catalog.photo(id) { + crate::upright_frames::settle(ph, &d, &mut after, &preset.settings, None); + } + s.develop_op(id, after, &format!("Preset: {}", preset.name)) + }) .collect::>(); s.commit(&format!("Preset: {}", preset.name), Op::Batch { ops })?; ok() diff --git a/crates/engine/src/cmd/sync.rs b/crates/engine/src/cmd/sync.rs index 81693e5a..c3032871 100644 --- a/crates/engine/src/cmd/sync.rs +++ b/crates/engine/src/cmd/sync.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Synchronize Folder", [], None, - "{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}", + "{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]], warnings?: [{path, code, mode?, fields?}] (what sidecars said that couldn't be carried over, as for library.import)}", always, sync ), @@ -57,5 +57,9 @@ fn sync(s: &mut Session, p: &Value) -> Result { None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?, }; let r = synchronize(s, changes, choice)?; - Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed})) + let mut out = json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed}); + if !r.warnings.is_empty() { + out["warnings"] = json!(r.warnings); + } + Ok(out) } diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index d1f5707b..e89e2a92 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -672,14 +672,28 @@ fn centred_to_normalized(ow: f64, oh: f64) -> Homography { /// image). `orientation` turns the stored image, `stored_w × stored_h` (any unit; only the aspect /// matters), into LightCraft's oriented image. `None` for a frame without a finite, positive size. pub fn upright_in_frame(saved: &SavedUpright, orientation: Orientation, stored_w: f64, stored_h: f64) -> Option { + let a = centred_to_stored(orientation, stored_w, stored_h)?; + normalized(a.inverse()?.mul(&Homography(saved.matrix)).mul(&a)) +} + +/// The inverse of [`upright_in_frame`]: a correction in LightCraft's frame, as a matrix on the +/// stored image (Lightroom's convention), so it can be put in another photo's frame. +pub fn upright_on_stored(h: &Homography, orientation: Orientation, stored_w: f64, stored_h: f64) -> Option<[f64; 9]> { + let a = centred_to_stored(orientation, stored_w, stored_h)?; + normalized(a.mul(h).mul(&a.inverse()?)).map(|n| n.0) +} + +/// Centred coordinates of the oriented image → normalized coordinates of the stored image. +fn centred_to_stored(orientation: Orientation, stored_w: f64, stored_h: f64) -> Option { if !(stored_w.is_finite() && stored_h.is_finite() && stored_w > 0.0 && stored_h > 0.0) { return None; } let (ow, oh) = if orientation.swaps_axes() { (stored_h, stored_w) } else { (stored_w, stored_h) }; - let turn = orientation_homography(orientation); - let oriented = turn.mul(&Homography(saved.matrix)).mul(&turn.inverse()?); - let c = centred_to_normalized(ow, oh); - let h = c.inverse()?.mul(&oriented).mul(&c); + Some(orientation_homography(orientation).inverse()?.mul(¢red_to_normalized(ow, oh))) +} + +/// `h` scaled so its last element is 1, when that leaves it finite. +fn normalized(h: Homography) -> Option { let last = h.0[8]; let h = Homography(h.0.map(|v| v / last)); h.0.iter().all(|v| v.is_finite()).then_some(h) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index fd69a80a..9325f597 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1316,6 +1316,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; p.preview_only = info.preview_only.clone(); + p.file_orientation = info.orientation; apply_import_defaults(s, &mut p); if let Some(sc) = &sidecar { crate::sidecar::merge_into(&mut p, sc, &now); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1df86d9f..37543bc0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,6 +62,7 @@ pub mod segment; pub mod sidecar; pub mod smart; pub mod sync; +pub mod upright_frames; mod view; pub mod walk; @@ -210,6 +211,9 @@ pub struct Session { pub(crate) faces: faces_index::FacesState, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, + /// The frame of the photo the clipboard was copied from (its Upright correction is put into + /// each target's frame on Paste: [`crate::upright_frames`]). + pub clipboard_frame: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. pub browse: Option, /// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported @@ -349,6 +353,7 @@ impl Session { #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), clipboard: None, + clipboard_frame: None, meta_clipboard: None, browse: None, library_folder: None, @@ -784,13 +789,18 @@ impl Session { return Vec::new(); } } + // an Upright correction goes into each photo's own frame + let source = self.catalog.photo(id).and_then(|p| crate::upright_frames::frame_of(p, new)); self.selection .ids .iter() .filter(|x| **x != id) .filter_map(|x| self.develop_of(*x).map(|d| (*x, d))) .filter_map(|(x, d)| { - let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0); + let synced = match self.catalog.photo(x) { + Some(p) => crate::upright_frames::transferred(p, &d, &delta, source.as_ref()), + None => lightcraft_develop::apply_partial(&d, &delta, 1.0), + }; (synced != *d).then(|| self.develop_op(x, synced, label)).flatten() }) .collect() diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index d8ad93db..a5e7df4e 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -537,8 +537,8 @@ fn mapped_settings( // Lightroom's saved Upright isn't kept from catalogs yet: how a catalog encodes it, and whether // its orientation agrees with the decoded file, haven't been checked on a real catalog (issue // #205). The mode is solved again, and the report says what was lost. - if crate::crs::upright_at_risk(&props).is_some() { - unknown.push("Upright (Lightroom's saved geometry, solved again: not read from catalogs yet)".into()); + if let Some(mode) = crate::crs::upright_at_risk(&props) { + unknown.push(catalog_upright_note(mode)); } if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); @@ -569,6 +569,27 @@ 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)])) } +/// How a catalog import reports Lightroom Upright geometry it doesn't carry over. +fn catalog_upright_note(mode: crate::import::ImportUprightMode) -> String { + match mode { + crate::import::ImportUprightMode::Guided => "Upright (Guided: Lightroom's guides aren't read)".into(), + _ => "Upright (Lightroom's saved geometry, solved again: not read from catalogs yet)".into(), + } +} + +/// The catalog report's names for what an XMP packet's import diagnostics say wasn't carried over. +fn catalog_notes(warnings: &[crate::import::ImportWarning]) -> Vec { + use crate::import::ImportWarningCode as C; + warnings + .iter() + .flat_map(|w| match w.code { + C::CameraProfile => vec!["CameraProfile".to_string()], + C::UprightGeometry => vec![catalog_upright_note(w.mode.unwrap_or(crate::import::ImportUprightMode::Unknown))], + C::UnmappedXmp => w.fields.clone(), + }) + .collect() +} + fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { use crate::preset_import::Lua; match value { @@ -736,12 +757,17 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex if keep_develop { preserved += 1; } + // what the XMP and the develop settings couldn't carry over, each named once + let mut notes: Vec = Vec::new(); if !src.xmp.is_empty() { match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) { Ok(mut sc) => { if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { strip_xmp_sentinels(partial); } + if !keep_develop { + notes.extend(catalog_notes(&sc.warnings)); + } crate::sidecar::merge_into(&mut p, &sc, &now); } Err(e) => warnings.push(format!("image {} XMP: {e}", src.source_id)), @@ -773,13 +799,16 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex Ok((partial, unknown)) => { p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0)); p.edited = Some(now.clone()); - if !unknown.is_empty() { - unmapped.insert(src.source_id, unknown); - } + notes.extend(unknown); } Err(e) => warnings.push(format!("image {} settings: {e}", src.source_id)), } } + let mut seen = std::collections::HashSet::new(); + notes.retain(|n| seen.insert(n.clone())); + if !notes.is_empty() { + unmapped.insert(src.source_id, notes); + } if keep_develop { (p.develop, p.edited) = previous_develop; } @@ -1165,6 +1194,51 @@ mod tests { assert!(unknown.iter().all(|u| !u.starts_with("Upright")), "{unknown:?}"); } + /// The catalog's XMP column reports what it can't carry over too: Guided guides there used + /// to import as Guided without guides and no word; a saved Upright in both the XMP and the + /// develop settings is reported once. + #[test] + fn catalog_xmp_upright_and_guides_are_reported() { + let xmp = |fields: &str| { + format!( + r#""# + ) + }; + let guides = xmp( + r#"crs:PerspectiveUpright="5" crs:UprightFourSegmentsCount="2" crs:UprightFourSegments_0="0.2, 0.1, 0.25, 0.9" crs:UprightFourSegments_1="0.8, 0.1, 0.75, 0.9""#, + ); + let auto = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000"; + let matrix = xmp(&format!( + r#"crs:PerspectiveUpright="1" crs:UprightVersion="151388160" crs:UprightTransformCount="6" crs:UprightTransform_1="{auto}""# + )); + let lua = + format!(r#"s = {{ PerspectiveUpright = 1, UprightVersion = 151388160, UprightTransformCount = 6, UprightTransform_1 = "{auto}" }}"#); + for (packet, settings, mode) in + [(guides, String::new(), lightcraft_develop::Upright::Guided), (matrix, lua, lightcraft_develop::Upright::Auto)] + { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].xmp = packet; + data.photos[0].settings = settings; + let mut s = crate::Session::new(); + let r = apply(&mut s, data, false).unwrap(); + let p = s.catalog.photos().next().unwrap(); + assert_eq!((p.develop.geometry.upright, p.develop.geometry.upright_transform), (mode, None)); + let notes: Vec = r["unmapped"] + .as_object() + .into_iter() + .flat_map(|m| m.values()) + .filter_map(Value::as_array) + .flatten() + .filter_map(|v| v.as_str()) + .filter(|v| v.starts_with("Upright")) + .map(String::from) + .collect(); + assert_eq!(notes.len(), 1, "{mode:?}: {r}"); + } + } + #[test] fn deferred_auto_tone_sentinels_never_become_real_slider_values() { let mut data = sample(); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 877f9aba..4ca9ac2b 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1502,10 +1502,7 @@ impl ProbeInfo { /// The stored image's frame: its orientation and its size before that orientation, when both /// are known (see [`crate::sidecar::SidecarData::set_frame`]). pub fn stored_frame(&self) -> Option { - let orientation = self.orientation?; - let (w, h) = (f64::from(self.width), f64::from(self.height)); - let (width, height) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; - (w > 0.0 && h > 0.0).then_some(crate::sidecar::StoredFrame { orientation, width, height }) + crate::sidecar::StoredFrame::of_shown(self.orientation?, f64::from(self.width), f64::from(self.height)) } } diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c2ff9fd4..86737070 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -127,6 +127,27 @@ pub struct StoredFrame { pub height: f64, } +impl StoredFrame { + /// The stored image of a file shown `shown_w × shown_h` after `orientation`. + pub fn of_shown(orientation: lightcraft_geom::Orientation, shown_w: f64, shown_h: f64) -> Option { + let ok = shown_w.is_finite() && shown_h.is_finite() && shown_w > 0.0 && shown_h > 0.0; + let (width, height) = if orientation.swaps_axes() { (shown_h, shown_w) } else { (shown_w, shown_h) }; + ok.then_some(StoredFrame { orientation, width, height }) + } +} + +/// Photo `p`'s stored image, from its catalog record (import records the file's orientation). +pub fn recorded_frame(p: &Photo) -> Option { + StoredFrame::of_shown(p.file_orientation?, f64::from(p.width), f64::from(p.height)) +} + +/// The stored image of the file at `path`, read from the file (photos imported before the +/// orientation was recorded): `None` when it can't be read. +pub fn probe_frame(path: &str) -> Option { + let bytes = std::fs::read(path).ok()?; + crate::files::probe_bytes(path, &bytes).ok()?.stored_frame() +} + #[derive(Clone, Debug, PartialEq)] pub enum DevelopPatch { /// Our own complete settings (`lc:settings`). @@ -325,6 +346,29 @@ impl SidecarData { self.placed_upright.is_some() } + /// Whether the packet's crop or saved Upright needs the photo's stored frame. + pub fn needs_frame(&self) -> bool { + self.upright.is_some() || matches!(&self.develop, Some(DevelopPatch::Partial(v)) if v.pointer("/crop/geometry").is_some()) + } + + /// For a photo already in the library whose file's orientation can't be learnt: leave its crop + /// as it is rather than take Lightroom's crop edges in an unknown frame, and say so. + pub fn hold_unframed_crop(&mut self) { + if self.frame.is_some() { + return; + } + let Some(DevelopPatch::Partial(Value::Object(partial))) = self.develop.as_mut() else { return }; + if partial.remove("crop").is_some() { + self.warnings.push(crate::import::ImportWarning { + path: String::new(), + code: crate::import::ImportWarningCode::UnmappedXmp, + profile: None, + mode: None, + fields: vec!["Crop (the file's orientation couldn't be read)".into()], + }); + } + } + /// Read the label through the catalog's label names: other editors write the label's name /// (a custom set's "To Do" as well as "Red"); unknown names clear the label. pub fn resolve_label(mut self, cat: &lightcraft_catalog::Catalog) -> Self { @@ -616,11 +660,14 @@ impl Session { let Some(orig) = file_path(p) else { return Ok(None) }; let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) }; let mut sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?; - let cropped = matches!(&sc.develop, Some(DevelopPatch::Partial(v)) if v.pointer("/crop/geometry").is_some()); - if sc.upright.is_some() || cropped { - // Lightroom's crop and saved Upright are on the stored image: the file says how it is oriented - let probed = crate::import::probe_paths(self, &[orig.to_string()]).into_iter().next().and_then(std::result::Result::ok); - sc.set_frame(probed.and_then(|info| info.stored_frame())); + if sc.needs_frame() { + // Lightroom's crop and saved Upright are on the stored image: the catalog or the file says + // how it is oriented + let frame = recorded_frame(p).or_else(|| { + crate::import::probe_paths(self, &[orig.to_string()]).into_iter().next().and_then(std::result::Result::ok)?.stored_frame() + }); + sc.set_frame(frame); + sc.hold_unframed_crop(); } let mut warnings = sc.warnings.clone(); for warning in &mut warnings { diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 0a00a59a..f6f1a2fc 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -150,6 +150,8 @@ pub struct SyncReport { pub read: usize, /// (path or photo, why) for what couldn't be done. pub failed: Vec<(String, String)>, + /// What sidecars said that couldn't be carried over in full (as for import). + pub warnings: Vec, } fn invalid(msg: impl Into) -> EngineError { @@ -427,7 +429,9 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool return false; } let Ok(text) = std::fs::read_to_string(&file) else { return false }; - let Ok(sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false }; + let Ok(mut sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false }; + // compared in the photo's frame, as it would be read + framed(&mut sc, crate::sidecar::recorded_frame(p), path); let sc = sc.resolve_label(labels); let mut q = p.clone(); crate::sidecar::merge_into(&mut q, &sc, ""); @@ -464,7 +468,8 @@ pub struct SyncWork { moved: Vec, missing: Vec, /// (photo, its file, how its sidecar is named) - sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>, + /// (photo, its file, sidecar naming, white balance target, its stored frame when recorded) + sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target, Option)>, } /// The catalog half of a synchronize (see [`SyncJob`]). @@ -518,7 +523,7 @@ impl SyncJob { .zip(namings) .filter_map(|(m, naming)| { let p = s.catalog.photo(PhotoId(m.id))?; - Some((p.id, m.path, naming, crate::crs::Target::for_photo(p))) + Some((p.id, m.path, naming, crate::crs::Target::for_photo(p), crate::sidecar::recorded_frame(p))) }) .collect() } else { @@ -590,11 +595,11 @@ impl SyncWork { return; } let mut read = Vec::with_capacity(sidecars.len()); - for (id, path, naming, target) in sidecars { + for (id, path, naming, target, frame) in sidecars { if cancel.load(Relaxed) { break; } - read.push((id, path.clone(), read_sidecar(&path, naming, target))); + read.push((id, path.clone(), read_sidecar(&path, naming, target, frame))); step(1); } if !read.is_empty() { @@ -603,15 +608,33 @@ impl SyncWork { } } -/// Read and parse photo file `path`'s XMP sidecar file. -fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) -> std::result::Result { +/// Read and parse photo file `path`'s XMP sidecar file, its crop and saved Upright in the photo's +/// stored frame (`frame`, else read from the file), as import reads them. +fn read_sidecar( + path: &str, + naming: SidecarNaming, + target: crate::crs::Target, + frame: Option, +) -> std::result::Result { let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?; let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len(); if len > MAX_SIDECAR_BYTES { return Err(format!("{}: too large for an XMP sidecar", file.display())); } let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; - crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display())) + let mut sc = crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display()))?; + framed(&mut sc, frame, path); + Ok(sc) +} + +/// Give a parsed sidecar of the photo whose file is `path` its stored frame: `frame` when the +/// catalog recorded it, else read from the file (only when the packet's crop or saved Upright +/// needs it). Without one, the photo's crop is left as it is and the report says so. +fn framed(sc: &mut crate::sidecar::SidecarData, frame: Option, path: &str) { + if sc.needs_frame() { + sc.set_frame(frame.or_else(|| crate::sidecar::probe_frame(path))); + sc.hold_unframed_crop(); + } } impl SyncCommit { @@ -671,6 +694,12 @@ impl SyncCommit { SyncStep::Sidecars(read) => { let mut ops = Vec::new(); for (id, path, sc) in read { + if let Ok(sc) = &sc { + report.warnings.extend(sc.warnings.iter().cloned().map(|mut w| { + w.path = path.clone(); + w + })); + } match sc.map_err(EngineError::Other).and_then(|sc| s.sidecar_op(id, sc)) { Ok(op) => ops.push(op), Err(e) => report.failed.push((path, e.to_string())), diff --git a/crates/engine/src/tests_upright.rs b/crates/engine/src/tests_upright.rs index 3be04369..da91898f 100644 --- a/crates/engine/src/tests_upright.rs +++ b/crates/engine/src/tests_upright.rs @@ -667,3 +667,166 @@ fn guided_upright_guides_that_are_not_read_are_reported() { assert_eq!(crate::crs::upright_at_risk(&none), None); let _ = std::fs::remove_dir_all(&src); } + +// ------------------------------------------------------------------ settings moved between photos + +fn dev(s: &Session, id: lightcraft_catalog::PhotoId) -> DevelopSettings { + (*s.catalog.photo(id).unwrap().develop).clone() +} + +fn same(a: Option, b: Homography) -> bool { + a.is_some_and(|a| a.0.iter().zip(b.0).all(|(x, y)| (x - y).abs() < 1e-12)) +} + +/// A 60 × 40 JPEG `name` with EXIF `exif` (and, with `sidecar`, that XMP next to it), imported. +fn import_jpeg(s: &mut Session, dir: &Path, name: &str, exif: u16, sidecar: Option<&str>) -> lightcraft_catalog::PhotoId { + let photo = dir.join(format!("{name}.jpg")); + write_jpeg(&photo, 60, 40, exif); + if let Some(x) = sidecar { + std::fs::write(dir.join(format!("{name}.xmp")), x).unwrap(); + } + s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap(); + s.catalog.photos().find(|p| p.file_name == format!("{name}.jpg")).unwrap().id +} + +/// Paste, Sync and Paste from Previous carry an Upright correction into the target photo's frame: +/// a virtual copy turned a quarter right gets the original's correction turned with it (the +/// transferred matrix used to keep the original's frame: about 15 px off at 3200 × 4800). +#[test] +fn transferred_upright_lands_in_the_target_photo_frame() { + for command in ["develop.paste", "develop.sync", "develop.pastePrevious"] { + let src = temp_dir(&format!("upright-transfer-{}", command.replace('.', "-"))); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "hall", 1, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let copy = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap()); + s.execute("library.select", &json!({"ids": [copy.0]})).unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + let turned = placed(1, Orientation::Rotate90, 60.0, 40.0); + assert!(same(dev(&s, copy).geometry.upright_transform, turned)); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("develop.copy", &json!({"groups": ["geometry"]})).unwrap(); + if command == "develop.sync" { + s.selection = crate::Selection { ids: vec![id, copy], active: Some(id) }; + } else { + s.execute("library.select", &json!({"ids": [copy.0]})).unwrap(); + } + s.execute(command, &json!({"groups": ["geometry"]})).unwrap(); + assert!(same(dev(&s, copy).geometry.upright_transform, turned), "{command}: {:?}", dev(&s, copy).geometry.upright_transform); + assert!(same(dev(&s, id).geometry.upright_transform, placed(1, Orientation::Normal, 60.0, 40.0)), "{command}: the source is untouched"); + let _ = std::fs::remove_dir_all(&src); + } +} + +/// Between files with different orientations the correction goes through the stored image, as +/// Lightroom keeps it: into an EXIF-8 photo, and into an EXIF-6 photo the user turned right. +#[test] +fn transferred_upright_follows_each_files_orientation() { + let src = temp_dir("upright-transfer-exif"); + let mut s = Session::new().with_fs(); + let a = import_jpeg(&mut s, &src, "a", 1, Some(&lightroom(1))); + let b = import_jpeg(&mut s, &src, "b", 8, None); + let c = import_jpeg(&mut s, &src, "c", 6, None); + s.execute("library.select", &json!({"ids": [c.0]})).unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("develop.copy", &json!({"groups": ["geometry"]})).unwrap(); + s.execute("library.select", &json!({"ids": [b.0, c.0]})).unwrap(); + s.execute("develop.paste", &json!({})).unwrap(); + assert!(same(dev(&s, b).geometry.upright_transform, placed(1, Orientation::Rotate270, 60.0, 40.0)), "EXIF 8: {:?}", dev(&s, b).geometry); + let c_frame = Orientation::Rotate90.then(Orientation::Rotate90); + assert!(same(dev(&s, c).geometry.upright_transform, placed(1, c_frame, 60.0, 40.0)), "EXIF 6 turned right: {:?}", dev(&s, c).geometry); + let _ = std::fs::remove_dir_all(&src); +} + +/// A crop paste carries the source's rotation: the target's own Upright correction turns with it. +#[test] +fn a_crop_paste_that_turns_the_photo_turns_its_upright() { + let src = temp_dir("upright-crop-paste"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "tower", 1, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let copy = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap()); + s.execute("library.select", &json!({"ids": [copy.0]})).unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("develop.copy", &json!({"groups": ["crop"]})).unwrap(); + s.execute("library.select", &json!({"ids": [copy.0]})).unwrap(); + s.execute("develop.paste", &json!({})).unwrap(); + let d = dev(&s, copy); + assert_eq!(d.orientation, Orientation::Normal); + assert!(same(d.geometry.upright_transform, placed(1, Orientation::Normal, 60.0, 40.0)), "{:?}", d.geometry); + let _ = std::fs::remove_dir_all(&src); +} + +/// Auto Sync carries an edit of the correction into each selected photo's own frame. +#[test] +fn auto_sync_carries_upright_into_each_photos_frame() { + let src = temp_dir("upright-auto-sync"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "nave", 1, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let copy = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap()); + s.execute("library.select", &json!({"ids": [copy.0]})).unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + s.selection = crate::Selection { ids: vec![id, copy], active: Some(id) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + let full = placed(2, Orientation::Normal, 60.0, 40.0); + s.execute("develop.merge", &json!({"settings": {"geometry": {"upright": "full", "upright_transform": full}}})).unwrap(); + assert!(same(dev(&s, id).geometry.upright_transform, full)); + assert!(same(dev(&s, copy).geometry.upright_transform, placed(2, Orientation::Rotate90, 60.0, 40.0)), "{:?}", dev(&s, copy).geometry); + let _ = std::fs::remove_dir_all(&src); +} + +// ------------------------------------------------------------------ Synchronize Folder + +fn bump(path: &Path, seconds: u64) { + let f = std::fs::File::options().write(true).open(path).unwrap(); + f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(seconds)).unwrap(); +} + +/// Synchronize Folder reads a sidecar in the photo's frame, as import does: an unchanged packet +/// (only its time moved) changes nothing; a real change keeps the crop and the saved Upright where +/// import put them; what can't be kept is reported (it used to undo import's conversion silently). +#[test] +fn synchronize_folder_reads_sidecars_in_the_photo_frame() { + let src = temp_dir("upright-folder-sync"); + let mut s = Session::new().with_fs(); + let crop = r#"crs:HasCrop="True" crs:CropLeft="0.206655" crs:CropTop="0" crs:CropRight="1" crs:CropBottom="1" crs:CropAngle="0""#; + let packet = |exposure: &str| lightroom(1).replace(r#"crs:Exposure2012="+0.30""#, &format!(r#"crs:Exposure2012="{exposure}" {crop}"#)); + let id = import_jpeg(&mut s, &src, "portrait", 8, Some(&packet("+0.30"))); + let before = dev(&s, id); + let r = [before.crop.geometry.rect.x0, before.crop.geometry.rect.y0, before.crop.geometry.rect.x1, before.crop.geometry.rect.y1]; + assert_eq!(r.map(|v| (v * 1e6).round() / 1e6), [0.0, 0.0, 1.0, 0.793345]); + assert!(same(before.geometry.upright_transform, placed(1, Orientation::Rotate270, 60.0, 40.0))); + let dir = src.to_string_lossy().to_string(); + let sidecar = src.join("portrait.xmp"); + let sync = |s: &mut Session| s.execute("folder.synchronize", &json!({"path": dir, "readMetadata": true, "importNew": false})).unwrap(); + + bump(&sidecar, 5); + let r = sync(&mut s); + assert_eq!(dev(&s, id), before, "an unchanged packet changed the photo: {r}"); + + std::fs::write(&sidecar, packet("+0.80")).unwrap(); + bump(&sidecar, 10); + let r = sync(&mut s); + let after = dev(&s, id); + assert_eq!(after.light.exposure, 0.8, "{r}"); + assert_eq!(after.crop.geometry, before.crop.geometry, "{r}"); + assert_eq!(after.geometry.upright_transform, before.geometry.upright_transform, "{r}"); + + // Guided guides can't be read: the synchronize report says so + std::fs::write( + &sidecar, + packet("+0.80").replace(r#"crs:PerspectiveUpright="1""#, r#"crs:PerspectiveUpright="5""#).replace( + r#"crs:UprightFourSegmentsCount="0""#, + r#"crs:UprightFourSegmentsCount="2" crs:UprightFourSegments_0="0.2, 0.1, 0.25, 0.9" crs:UprightFourSegments_1="0.8, 0.1, 0.75, 0.9""#, + ), + ) + .unwrap(); + bump(&sidecar, 15); + let r = sync(&mut s); + let warned = r["warnings"].as_array().into_iter().flatten().any(|w| w["code"] == "uprightGeometry" && w["mode"] == "guided"); + assert!(warned, "{r}"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/upright_frames.rs b/crates/engine/src/upright_frames.rs new file mode 100644 index 00000000..46065caa --- /dev/null +++ b/crates/engine/src/upright_frames.rs @@ -0,0 +1,61 @@ +//! Upright corrections belong to the frame of the photo they are stored with +//! (`Geometry::upright_transform`: centred coordinates of the photo as shown, after its file's +//! orientation and its own Rotate Left / Right). Settings moved between photos (Paste, Paste from +//! Previous, Sync, Auto Sync) re-express a correction in the target's frame through the stored +//! image, which is how Lightroom keeps it, and a settings patch that turns a photo turns the +//! correction it keeps (issue #205). + +use lightcraft_catalog::Photo; +use lightcraft_develop::DevelopSettings; +use lightcraft_geom::{Homography, Orientation}; +use serde_json::Value; + +use crate::sidecar::StoredFrame; + +/// A photo's frame for its Upright correction: its file's stored image, then its own rotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhotoFrame { + pub stored: StoredFrame, + pub user: Orientation, +} + +/// The frame of photo `p` with settings `s`. A photo imported before the file's orientation was +/// recorded is taken as stored the way it is shown. +pub fn frame_of(p: &Photo, s: &DevelopSettings) -> Option { + let stored = crate::sidecar::recorded_frame(p).or_else(|| StoredFrame::of_shown(Orientation::Normal, f64::from(p.width), f64::from(p.height)))?; + Some(PhotoFrame { stored, user: s.orientation }) +} + +/// Correction `h`, made in frame `from`, as the same correction of the stored image in frame `to`. +pub fn reexpress(h: &Homography, from: &PhotoFrame, to: &PhotoFrame) -> Option { + let on_stored = crate::crs::upright_on_stored(h, from.stored.orientation.then(from.user), from.stored.width, from.stored.height)?; + let saved = crate::crs::SavedUpright { mode: lightcraft_develop::Upright::Auto, matrix: on_stored }; + crate::crs::upright_in_frame(&saved, to.stored.orientation.then(to.user), to.stored.width, to.stored.height) +} + +/// Settings `after` of photo `p` (which had `before`) once `patch` is applied: a correction the +/// patch brought from photo frame `source` is put into `p`'s frame; a correction `p` keeps is +/// turned when the patch turned the photo. A patch from nowhere in particular (a preset) leaves a +/// correction it brings as it is. +pub fn settle(p: &Photo, before: &DevelopSettings, after: &mut DevelopSettings, patch: &Value, source: Option<&PhotoFrame>) { + let Some(target) = frame_of(p, after) else { return }; + let (from, h) = if patch.pointer("/geometry/upright_transform").is_some() { + (source.copied(), after.geometry.upright_transform) + } else if after.orientation != before.orientation { + (frame_of(p, before), after.geometry.upright_transform) + } else { + return; + }; + if let (Some(from), Some(h)) = (from, h) { + // a frame it can't be put in (no size) leaves it to be solved again + after.geometry.upright_transform = reexpress(&h, &from, &target); + } +} + +/// `patch` applied to settings `before` of photo `p` for a transfer from photo frame `source`, +/// with the Upright correction in `p`'s frame ([`settle`]). +pub fn transferred(p: &Photo, before: &DevelopSettings, patch: &Value, source: Option<&PhotoFrame>) -> DevelopSettings { + let mut after = lightcraft_develop::apply_partial(before, patch, 1.0); + settle(p, before, &mut after, patch, source); + after +} diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 26332fe1..df4d988d 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -44,7 +44,8 @@ mask structures remain editable, but this is approximate rendering: camera profi models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped fields are reported. Lightroom's saved Upright correction isn't read from catalogs yet: how a catalog stores it, and whether the catalog's orientation agrees with the decoded file, haven't been checked on a real catalog, so the mode -is solved again and the import reports `Upright` (XMP sidecars keep it: [xmp-interop.md](xmp-interop.md), *Saved +is solved again and the import reports `Upright`, whether it is in the catalog's develop settings or its XMP (XMP +sidecars keep it: [xmp-interop.md](xmp-interop.md), *Saved Upright geometry*). Lightroom's `ProcessVersion` is not mapped: newly imported photos are on LightCraft's latest rendering process, and photos already in the library keep theirs ([process-versions.md](process-versions.md)). When a recovery archive is saved, it retains source settings/history/snapshots diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 365414fb..edae5e44 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -167,7 +167,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Lightroom's saved Upright correction for the selected mode (`crs:UprightTransform_*`) is kept from XMP instead of being solved again, matching three Lightroom Upright Auto exports to under 1 px (issue #205, `crates/engine/src/tests_upright.rs`, see `docs/xmp-interop.md` → Saved Upright geometry; not yet from Lightroom catalogs), `crs:PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical, and sidecar crops go through the file's orientation like catalog crops (portrait raws were cut on the wrong side) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Lightroom's saved Upright correction for the selected mode (`crs:UprightTransform_*`) is kept from XMP instead of being solved again, matching three Lightroom Upright Auto exports to under 1 px (issue #205, `crates/engine/src/tests_upright.rs`, see `docs/xmp-interop.md` → Saved Upright geometry; not yet from Lightroom catalogs), `crs:PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical, and sidecar crops go through the file's orientation like catalog crops (portrait raws were cut on the wrong side), on import, Read Metadata from File and Synchronize Folder | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -215,7 +215,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | -| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP is kept as it is (LR-META-XMP); Rotate Left / Right turns a stored correction with the photo | +| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP is kept as it is (LR-META-XMP); Rotate Left / Right turns a stored correction with the photo, and Paste / Sync / Auto Sync put it into each photo's frame | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | | LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | @@ -787,7 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | LR-META-XMP: Lightroom's saved Upright correction for the selected mode is kept from XMP instead of being solved again (issue #205; catalogs fall back and report it); `PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical; sidecar crops follow the file's orientation; LR-EDIT-GEOM-UPRIGHT: Rotate turns a stored correction; statuses unchanged | +| 2026-10-12 | minor | LR-META-XMP: Lightroom's saved Upright correction for the selected mode is kept from XMP instead of being solved again (issue #205; catalogs fall back and report it); `PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical; sidecar crops follow the file's orientation (Synchronize Folder too); LR-EDIT-GEOM-UPRIGHT: Rotate turns a stored correction, Paste / Sync carry it into each photo's frame; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index fd63f141..6524bdfd 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -3,14 +3,20 @@ ## 2026-10-12 ### Lightroom Upright comes across -- Photos edited in Lightroom with Upright Auto, Full, Level or Vertical now open framed as they are in Lightroom - (issue #205). LightCraft keeps the correction Lightroom saved with the edit instead of analysing the photo again. - This works for XMP sidecars and DNG files with the edit inside; photos imported earlier get it with Photo ▸ Read - Metadata from File. Lightroom Classic catalogs still have it worked out again for now, and the import says so. +- Photos edited in Lightroom with Upright Auto, Full, Level or Vertical now keep the correction Lightroom saved + with the edit instead of being analysed again (issue #205), so they open framed as they are in Lightroom. Upright + Auto matches Lightroom's exports to under a pixel; Full, Level and Vertical use the same saved correction but + haven't been compared with exports yet. Not covered yet: Transform sliders or a straightened crop on top of + Upright, and Lightroom's lens profile corrections (details in docs/xmp-interop.md, *Saved Upright geometry*). + This works for XMP sidecars and DNG files with the edit inside, on import, with Photo ▸ Read Metadata from File + and with Synchronize Folder. Lightroom Classic catalogs still have it worked out again for now, and the import + says so. - Rotate Left and Rotate Right turn an Upright correction with the photo. It used to stay as it was, so the photo - came out skewed after a turn. + came out skewed after a turn. Paste, Paste from Previous, Sync and Auto Sync now put a copied Upright correction + the right way round for each photo, for example on a turned virtual copy or a photo shot in portrait. - A crop read from an XMP sidecar now lands where Lightroom put it on photos shown on their side (portrait raws), and straightened crops keep Lightroom's frame. A portrait crop used to cut the wrong side of the photo. + Synchronize Folder reads sidecars the same way, and its report lists what a sidecar couldn't carry over. - Lightroom's Upright values 2, 3 and 4 now read as Full, Level and Vertical. They used to come in as Level, Vertical and Full. - Guided Upright doesn't come across yet (Lightroom's guides aren't read), and the import report says so. The diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index d3dffc6d..716ce39f 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -141,7 +141,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | | `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | | | `PerspectiveUpright` | `geometry.upright`, `geometry.upright_transform` | 0 off, 1 auto, 2 full, 3 level, 4 vertical, 5 guided; Lightroom's saved correction for the selected mode is kept as it is when it can be (see *Saved Upright geometry*), otherwise LightCraft works the mode out itself (Guided needs guides drawn in LightCraft: Lightroom's aren't read) and photo import says so | -| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | Lightroom's edges are on the stored image (before its orientation) and its straighten angle turns the other way: both go into the frame the photo is shown in (through the file's orientation and the photo's Rotate Left / Right), as the Lightroom catalog import does; when the file's orientation isn't known (Synchronize Folder, a raw shown from its preview) the edges are taken as written; `HasCrop="False"` → no crop | +| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | Lightroom's edges are on the stored image (before its orientation) and its straighten angle turns the other way: both go into the frame the photo is shown in (through the file's orientation and the photo's Rotate Left / Right), as the Lightroom catalog import does; the file's orientation is recorded on import (photos imported before that read it from the file); when it can't be known (a raw shown from its preview) the edges are taken as written on import, and an existing photo keeps its crop and the report says so; `HasCrop="False"` → no crop | Values pass through our control specs, so anything outside our slider ranges gets clamped. @@ -158,8 +158,8 @@ photo is framed as in Lightroom instead of being analysed again, when - `UprightVersion` is `151388160` (the only version seen so far; any other is solved again); - `UprightTransformCount` includes the mode's matrix, and the matrix is nine finite numbers that make a sensible correction (no part of the photo at or beyond the horizon, no mirror, no collapse); -- the file's orientation is known: on import and Read Metadata from File (Synchronize Folder solves the mode again, - and Lightroom catalog import doesn't read saved Upright yet: see [lightroom-catalog-import.md](lightroom-catalog-import.md)); +- the file's orientation is known: on import, Read Metadata from File and Synchronize Folder (Lightroom catalog + import doesn't read saved Upright yet: see [lightroom-catalog-import.md](lightroom-catalog-import.md)); - the crop has no straighten angle (a straightened crop on top of a saved Upright isn't measured yet). The matrix's convention was measured on Lightroom's exports, not assumed (issue #205): a forward homography (photo @@ -168,7 +168,12 @@ y / height, origin top left, y down, before the EXIF orientation), applied befor to fill the frame already in it. LightCraft turns it with the file's orientation into its own frame (`geometry.upright_transform`, centred coordinates of the oriented image, the photo's own Rotate Left / Right included) and applies the crop on top, as Lightroom does. Rotate Left / Right later turns the kept correction with -the photo, as it does LightCraft's own Upright. On three public NEF raws exported by Lightroom Classic 12 with Upright Auto (one shown in portrait, +the photo, as it does LightCraft's own Upright. + +An Upright correction belongs to its photo's frame. Paste, Paste from Previous, Sync and Auto Sync put a copied +correction into each target's frame through the stored image (as Lightroom keeps it), so a turned virtual copy or a +photo shot in portrait gets it the right way round, and a pasted crop that turns a photo turns the correction it +keeps. A photo imported before the file's orientation was recorded is taken as stored the way it is shown. On three public NEF raws exported by Lightroom Classic 12 with Upright Auto (one shown in portrait, one cropped with Constrain Crop on), LightCraft's geometry with the kept matrix matches the exports to about half a pixel on average and under a pixel at worst, at full size; without it, the frames differ by 22 to 36 px on average. From 6471d93b109b9ca976952569fcd94441526747a4 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 20:12:14 +0200 Subject: [PATCH 4/6] LR-META-XMP: an Upright correction is the photo's own; sidecars are read in the file's current frame (#205) Third review round. A saved Upright matrix (Lightroom's, or LightCraft's own) is the analysis of one image, so it no longer travels to other files: - Paste, Paste from Previous, Sync and Auto Sync move it only between a photo and its virtual copies, turned by the difference in their Rotate Left / Right. Between different files only the Upright mode and the Transform sliders move: the target keeps its own correction while its mode stays, else solves its own, as Lightroom does when it syncs the Upright mode. Main copied LightCraft's own correction verbatim to other files, one scene's correction applied to another. - Presets keep the mode and sliders, never a correction (preset.create and preset.update strip it; an older preset's, on apply or on import, is ignored the same way). - develop.merge (MCP set_develop, the CLI): a change of rotation alone turns the kept correction, as Rotate does, before the commit and Auto Sync's delta; a change of mode drops it; a correction stated in the JSON is taken as given. - Nothing about the file's frame is recorded in the catalog any more (Photo::file_orientation is gone, so the catalog format is unchanged): Read Metadata from File and Synchronize Folder read the file's orientation from the file each time, so a file whose EXIF changed is read as it is now. - A crop-only sidecar change that can't be placed is still listed by the scan, so Synchronize Folder reports why the crop was kept. The new regressions fail on the previous commit and pass here. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/Cargo.toml | 2 +- crates/catalog/src/model.rs | 8 - crates/engine/src/cmd/develop.rs | 36 ++- crates/engine/src/crs.rs | 7 - crates/engine/src/import.rs | 11 +- crates/engine/src/lib.rs | 16 +- crates/engine/src/sidecar.rs | 48 ++-- crates/engine/src/sync.rs | 45 ++-- crates/engine/src/tests_upright.rs | 322 ++++++++++++++++++++++++-- crates/engine/src/upright_frames.rs | 61 ----- crates/engine/src/upright_transfer.rs | 89 +++++++ docs/parity-checklist.md | 4 +- docs/whats-new.md | 9 +- docs/xmp-interop.md | 42 +++- 14 files changed, 519 insertions(+), 181 deletions(-) delete mode 100644 crates/engine/src/upright_frames.rs create mode 100644 crates/engine/src/upright_transfer.rs diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 7b8bec9f..97d8868d 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -10,7 +10,6 @@ workspace = true [dependencies] lightcraft-develop = { workspace = true } -lightcraft-geom = { workspace = true } lightcraft-meta = { workspace = true } lightcraft-raw = { workspace = true } serde = { workspace = true } @@ -21,4 +20,5 @@ log = { workspace = true } web-time = { workspace = true } [dev-dependencies] +lightcraft-geom = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 2ed188a0..abeaadad 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -292,13 +292,6 @@ pub struct Photo { /// whether the user changed anything since (see [`crate::local`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub local_baseline: Option, - /// How the file's stored image is turned to be shown (its EXIF orientation, as the decoder - /// applies it; `width × height` is the turned size), recorded on import. Lightroom's crop - /// edges and saved Upright refer to the stored image, and Upright corrections move between - /// photos through it. `None` for photos imported before it was recorded: they are taken as - /// stored the way they are shown. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub file_orientation: Option, } /// What assisted culling measured on a photo. @@ -366,7 +359,6 @@ impl Photo { analysis: None, preview_only: None, local_baseline: None, - file_orientation: None, } } /// A raw file developed from its sensor data: not a rendered image, and not a raw shown from diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 34d3d467..7bb16816 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -246,7 +246,13 @@ pub fn specs() -> Vec { let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?; let label = str_param(p, "label").unwrap_or("Edit").to_string(); edit(s, "develop.merge", &label, |d| { + let before = d.clone(); *d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?; + // a turn turns the Upright correction the photo keeps, as Rotate does; a new mode + // drops it; a correction stated in the JSON is taken as given + if partial.pointer("/geometry/upright_transform").is_none() { + crate::upright_transfer::settle_edit(&before, d); + } Ok(()) }) }), @@ -788,7 +794,7 @@ pub fn specs() -> Vec { } let d = s.develop_of(id).unwrap_or_default(); s.clipboard = Some(lightcraft_develop::extract_groups(&d, &s.copy_groups)); - s.clipboard_frame = s.catalog.photo(id).and_then(|p| crate::upright_frames::frame_of(p, &d)); + s.clipboard_source = s.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, &d)); Ok(json!({"groups": s.copy_groups})) } ), @@ -806,11 +812,11 @@ pub fn specs() -> Vec { let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone()); let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups); let ids: Vec = s.targets(p).into_iter().filter(|id| *id != prev).collect(); - let source = s.catalog.photo(prev).and_then(|ph| crate::upright_frames::frame_of(ph, &ph.develop)); + let source = s.catalog.photo(prev).map(|ph| crate::upright_transfer::source_of(ph, &ph.develop)); let ops = ids .iter() .filter_map(|id| { - s.catalog.photo(*id).map(|ph| (*id, crate::upright_frames::transferred(ph, &ph.develop, &partial, source.as_ref()))) + s.catalog.photo(*id).map(|ph| (*id, crate::upright_transfer::transferred(ph, &ph.develop, &partial, source.as_ref()))) }) .filter_map(|(id, d)| s.develop_op(id, d, "Paste from Previous")) .collect::>(); @@ -835,11 +841,11 @@ pub fn specs() -> Vec { clip = lightcraft_develop::extract_groups(&full, &only); } let ids = s.targets(p); - let source = s.clipboard_frame; + let source = s.clipboard_source.clone(); let ops = ids .iter() .filter_map(|id| { - s.catalog.photo(*id).map(|ph| (*id, crate::upright_frames::transferred(ph, &ph.develop, &clip, source.as_ref()))) + s.catalog.photo(*id).map(|ph| (*id, crate::upright_transfer::transferred(ph, &ph.develop, &clip, source.as_ref()))) }) .filter_map(|(id, d)| s.develop_op(id, d, "Paste Settings")) .collect::>(); @@ -860,14 +866,16 @@ pub fn specs() -> Vec { let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy); let d = s.develop_of(id).unwrap_or_default(); let partial = lightcraft_develop::extract_groups(&d, &groups); - let source = s.catalog.photo(id).and_then(|ph| crate::upright_frames::frame_of(ph, &d)); + let source = s.catalog.photo(id).map(|ph| crate::upright_transfer::source_of(ph, &d)); let ops = s .selection .ids .clone() .into_iter() .filter(|x| *x != id) - .filter_map(|x| s.catalog.photo(x).map(|ph| (x, crate::upright_frames::transferred(ph, &ph.develop, &partial, source.as_ref())))) + .filter_map(|x| { + s.catalog.photo(x).map(|ph| (x, crate::upright_transfer::transferred(ph, &ph.develop, &partial, source.as_ref()))) + }) .filter_map(|(x, dd)| s.develop_op(x, dd, "Sync Settings")) .collect::>(); let n = ops.len(); @@ -982,10 +990,11 @@ pub fn specs() -> Vec { .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) .filter_map(|(id, d)| { - // a preset that turns the photo turns the Upright correction it keeps + // a preset carries no Upright correction: the photo keeps its own, turned with + // it, or solves its own for another mode let mut after = preset.apply(&d, amount); if let Some(ph) = s.catalog.photo(id) { - crate::upright_frames::settle(ph, &d, &mut after, &preset.settings, None); + crate::upright_transfer::settle(ph, &d, &mut after, &preset.settings, None); } s.develop_op(id, after, &format!("Preset: {}", preset.name)) }) @@ -1000,7 +1009,10 @@ pub fn specs() -> Vec { let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy); let d = s.develop_of(id).unwrap_or_default(); let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-")); - s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups)); + let mut preset = Preset::from_settings(&pid, name, group, &d, &groups); + // an Upright correction is this photo's own analysis: a preset keeps the mode + preset.settings = crate::upright_transfer::without_correction(preset.settings); + s.presets.push(preset); Ok(json!({"id": pid})) }), cmd!("preset.delete", "Delete Preset", [], None, "{id}", always, |s, p| { @@ -1043,14 +1055,14 @@ pub fn specs() -> Vec { let current = s.develop_of(id).unwrap_or_default().to_json(); let groups = groups_param(p); let pr = user_preset(s, p, c)?; - pr.settings = match groups { + pr.settings = crate::upright_transfer::without_correction(match groups { Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g), // the same keys as before, with the current values None => { let keys: Vec = pr.settings.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); Value::Object(keys.into_iter().filter_map(|k| current.get(&k).cloned().map(|v| (k, v))).collect()) } - }; + }); ok() } ), diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index e89e2a92..d98dc7ac 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -676,13 +676,6 @@ pub fn upright_in_frame(saved: &SavedUpright, orientation: Orientation, stored_w normalized(a.inverse()?.mul(&Homography(saved.matrix)).mul(&a)) } -/// The inverse of [`upright_in_frame`]: a correction in LightCraft's frame, as a matrix on the -/// stored image (Lightroom's convention), so it can be put in another photo's frame. -pub fn upright_on_stored(h: &Homography, orientation: Orientation, stored_w: f64, stored_h: f64) -> Option<[f64; 9]> { - let a = centred_to_stored(orientation, stored_w, stored_h)?; - normalized(a.mul(h).mul(&a.inverse()?)).map(|n| n.0) -} - /// Centred coordinates of the oriented image → normalized coordinates of the stored image. fn centred_to_stored(orientation: Orientation, stored_w: f64, stored_h: f64) -> Option { if !(stored_w.is_finite() && stored_h.is_finite() && stored_w > 0.0 && stored_h > 0.0) { diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9325f597..a74380ac 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -356,7 +356,10 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) { let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); match preset { Some(pr) => { - let look = std::sync::Arc::new(pr.apply(&base, 1.0)); + // a preset carries no Upright correction (an old one may still hold one: not this photo's) + let mut look = pr.apply(&base, 1.0); + crate::upright_transfer::settle(p, &base, &mut look, &pr.settings, None); + let look = std::sync::Arc::new(look); p.develop = look.clone(); p.import_look = (*look != base).then_some(look); } @@ -1316,7 +1319,6 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; p.preview_only = info.preview_only.clone(); - p.file_orientation = info.orientation; apply_import_defaults(s, &mut p); if let Some(sc) = &sidecar { crate::sidecar::merge_into(&mut p, sc, &now); @@ -1336,7 +1338,10 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare } } if let Some(preset) = &opts.preset { - let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0)); + // after the sidecar: the photo keeps the Upright correction it was given + let mut d = preset.apply(&p.develop, 1.0); + crate::upright_transfer::settle(&p, &p.develop, &mut d, &preset.settings, None); + let d = std::sync::Arc::new(d); let label = format!("Preset: {}", preset.name); p.develop = d.clone(); p.edited = Some(now.clone()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 37543bc0..7d51206c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,7 +62,7 @@ pub mod segment; pub mod sidecar; pub mod smart; pub mod sync; -pub mod upright_frames; +pub mod upright_transfer; mod view; pub mod walk; @@ -211,9 +211,9 @@ pub struct Session { pub(crate) faces: faces_index::FacesState, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, - /// The frame of the photo the clipboard was copied from (its Upright correction is put into - /// each target's frame on Paste: [`crate::upright_frames`]). - pub clipboard_frame: Option, + /// The photo the clipboard was copied from (an Upright correction moves only between entries of + /// the same file: [`crate::upright_transfer`]). + pub clipboard_source: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. pub browse: Option, /// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported @@ -353,7 +353,7 @@ impl Session { #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), clipboard: None, - clipboard_frame: None, + clipboard_source: None, meta_clipboard: None, browse: None, library_folder: None, @@ -789,8 +789,8 @@ impl Session { return Vec::new(); } } - // an Upright correction goes into each photo's own frame - let source = self.catalog.photo(id).and_then(|p| crate::upright_frames::frame_of(p, new)); + // an Upright correction moves only between entries of the same file + let source = self.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, new)); self.selection .ids .iter() @@ -798,7 +798,7 @@ impl Session { .filter_map(|x| self.develop_of(*x).map(|d| (*x, d))) .filter_map(|(x, d)| { let synced = match self.catalog.photo(x) { - Some(p) => crate::upright_frames::transferred(p, &d, &delta, source.as_ref()), + Some(p) => crate::upright_transfer::transferred(p, &d, &delta, source.as_ref()), None => lightcraft_develop::apply_partial(&d, &delta, 1.0), }; (synced != *d).then(|| self.develop_op(x, synced, label)).flatten() diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 86737070..80fa995e 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -136,13 +136,8 @@ impl StoredFrame { } } -/// Photo `p`'s stored image, from its catalog record (import records the file's orientation). -pub fn recorded_frame(p: &Photo) -> Option { - StoredFrame::of_shown(p.file_orientation?, f64::from(p.width), f64::from(p.height)) -} - -/// The stored image of the file at `path`, read from the file (photos imported before the -/// orientation was recorded): `None` when it can't be read. +/// The stored image of the file at `path` as it is now, read from the file (never recorded: a +/// file can change): `None` when it can't be read. pub fn probe_frame(path: &str) -> Option { let bytes = std::fs::read(path).ok()?; crate::files::probe_bytes(path, &bytes).ok()?.stored_frame() @@ -352,21 +347,24 @@ impl SidecarData { } /// For a photo already in the library whose file's orientation can't be learnt: leave its crop - /// as it is rather than take Lightroom's crop edges in an unknown frame, and say so. - pub fn hold_unframed_crop(&mut self) { + /// as it is rather than take Lightroom's crop edges in an unknown frame, and say so. Returns + /// whether a crop was held. + pub fn hold_unframed_crop(&mut self) -> bool { if self.frame.is_some() { - return; + return false; } - let Some(DevelopPatch::Partial(Value::Object(partial))) = self.develop.as_mut() else { return }; - if partial.remove("crop").is_some() { - self.warnings.push(crate::import::ImportWarning { - path: String::new(), - code: crate::import::ImportWarningCode::UnmappedXmp, - profile: None, - mode: None, - fields: vec!["Crop (the file's orientation couldn't be read)".into()], - }); + let Some(DevelopPatch::Partial(Value::Object(partial))) = self.develop.as_mut() else { return false }; + if partial.remove("crop").is_none() { + return false; } + self.warnings.push(crate::import::ImportWarning { + path: String::new(), + code: crate::import::ImportWarningCode::UnmappedXmp, + profile: None, + mode: None, + fields: vec!["Crop (the file's orientation couldn't be read)".into()], + }); + true } /// Read the label through the catalog's label names: other editors write the label's name @@ -661,11 +659,13 @@ impl Session { let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) }; let mut sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?; if sc.needs_frame() { - // Lightroom's crop and saved Upright are on the stored image: the catalog or the file says - // how it is oriented - let frame = recorded_frame(p).or_else(|| { - crate::import::probe_paths(self, &[orig.to_string()]).into_iter().next().and_then(std::result::Result::ok)?.stored_frame() - }); + // Lightroom's crop and saved Upright are on the stored image: the file, as it is now, + // says how it is oriented + let frame = crate::import::probe_paths(self, &[orig.to_string()]) + .into_iter() + .next() + .and_then(std::result::Result::ok) + .and_then(|i| i.stored_frame()); sc.set_frame(frame); sc.hold_unframed_crop(); } diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index f6f1a2fc..ec432d4e 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -430,8 +430,11 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool } let Ok(text) = std::fs::read_to_string(&file) else { return false }; let Ok(mut sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false }; - // compared in the photo's frame, as it would be read - framed(&mut sc, crate::sidecar::recorded_frame(p), path); + // compared in the photo's frame, as it would be read; a crop that can't be placed is still news, + // so the synchronize that reads it says why it was left + if framed(&mut sc, path) { + return true; + } let sc = sc.resolve_label(labels); let mut q = p.clone(); crate::sidecar::merge_into(&mut q, &sc, ""); @@ -468,8 +471,8 @@ pub struct SyncWork { moved: Vec, missing: Vec, /// (photo, its file, how its sidecar is named) - /// (photo, its file, sidecar naming, white balance target, its stored frame when recorded) - sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target, Option)>, + /// (photo, its file, sidecar naming, white balance target) + sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>, } /// The catalog half of a synchronize (see [`SyncJob`]). @@ -523,7 +526,7 @@ impl SyncJob { .zip(namings) .filter_map(|(m, naming)| { let p = s.catalog.photo(PhotoId(m.id))?; - Some((p.id, m.path, naming, crate::crs::Target::for_photo(p), crate::sidecar::recorded_frame(p))) + Some((p.id, m.path, naming, crate::crs::Target::for_photo(p))) }) .collect() } else { @@ -595,11 +598,11 @@ impl SyncWork { return; } let mut read = Vec::with_capacity(sidecars.len()); - for (id, path, naming, target, frame) in sidecars { + for (id, path, naming, target) in sidecars { if cancel.load(Relaxed) { break; } - read.push((id, path.clone(), read_sidecar(&path, naming, target, frame))); + read.push((id, path.clone(), read_sidecar(&path, naming, target))); step(1); } if !read.is_empty() { @@ -608,14 +611,9 @@ impl SyncWork { } } -/// Read and parse photo file `path`'s XMP sidecar file, its crop and saved Upright in the photo's -/// stored frame (`frame`, else read from the file), as import reads them. -fn read_sidecar( - path: &str, - naming: SidecarNaming, - target: crate::crs::Target, - frame: Option, -) -> std::result::Result { +/// Read and parse photo file `path`'s XMP sidecar file, its crop and saved Upright in the frame the +/// file has now, as import reads them. +fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) -> std::result::Result { let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?; let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len(); if len > MAX_SIDECAR_BYTES { @@ -623,18 +621,19 @@ fn read_sidecar( } let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; let mut sc = crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display()))?; - framed(&mut sc, frame, path); + framed(&mut sc, path); Ok(sc) } -/// Give a parsed sidecar of the photo whose file is `path` its stored frame: `frame` when the -/// catalog recorded it, else read from the file (only when the packet's crop or saved Upright -/// needs it). Without one, the photo's crop is left as it is and the report says so. -fn framed(sc: &mut crate::sidecar::SidecarData, frame: Option, path: &str) { - if sc.needs_frame() { - sc.set_frame(frame.or_else(|| crate::sidecar::probe_frame(path))); - sc.hold_unframed_crop(); +/// Give a parsed sidecar of the photo whose file is `path` the frame the file has now (read only +/// when the packet's crop or saved Upright needs it). Without one, the photo's crop is left as it +/// is and the report says so; returns whether a crop was held that way. +fn framed(sc: &mut crate::sidecar::SidecarData, path: &str) -> bool { + if !sc.needs_frame() { + return false; } + sc.set_frame(crate::sidecar::probe_frame(path)); + sc.hold_unframed_crop() } impl SyncCommit { diff --git a/crates/engine/src/tests_upright.rs b/crates/engine/src/tests_upright.rs index da91898f..7307b9ef 100644 --- a/crates/engine/src/tests_upright.rs +++ b/crates/engine/src/tests_upright.rs @@ -391,7 +391,12 @@ fn write_turned_jpeg(path: &Path) { /// A `w × h` JPEG (stored size) with EXIF orientation `exif`. fn write_jpeg(path: &Path, w: usize, h: usize, exif: u16) { - let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 6) as u8, (i / w * 9) as u8, 30, 255]).collect(); + write_jpeg_seeded(path, w, h, exif, 30); +} + +/// [`write_jpeg`] with its own pixels (`seed`), so two of them aren't duplicates. +fn write_jpeg_seeded(path: &Path, w: usize, h: usize, exif: u16, seed: u8) { + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 6) as u8, (i / w * 9) as u8, seed, 255]).collect(); let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; let exif = lightcraft_codecs::exif::minimal_exif(exif); let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() }; @@ -681,7 +686,8 @@ fn same(a: Option, b: Homography) -> bool { /// A 60 × 40 JPEG `name` with EXIF `exif` (and, with `sidecar`, that XMP next to it), imported. fn import_jpeg(s: &mut Session, dir: &Path, name: &str, exif: u16, sidecar: Option<&str>) -> lightcraft_catalog::PhotoId { let photo = dir.join(format!("{name}.jpg")); - write_jpeg(&photo, 60, 40, exif); + // each name its own pixels: identical files would be skipped as duplicates + write_jpeg_seeded(&photo, 60, 40, exif, name.bytes().fold(17u8, |a, b| a.wrapping_mul(31).wrapping_add(b))); if let Some(x) = sidecar { std::fs::write(dir.join(format!("{name}.xmp")), x).unwrap(); } @@ -718,25 +724,38 @@ fn transferred_upright_lands_in_the_target_photo_frame() { } } -/// Between files with different orientations the correction goes through the stored image, as -/// Lightroom keeps it: into an EXIF-8 photo, and into an EXIF-6 photo the user turned right. +/// A saved Upright matrix is one photo's own analysis: between different files only the mode and +/// the Transform sliders move (as Lightroom does when it syncs the Upright mode). A target with the +/// same mode keeps its own correction; one whose mode changes solves its own. (Converting the +/// source's matrix into another file's frame was wrong whenever a frame wasn't recorded.) #[test] -fn transferred_upright_follows_each_files_orientation() { - let src = temp_dir("upright-transfer-exif"); - let mut s = Session::new().with_fs(); - let a = import_jpeg(&mut s, &src, "a", 1, Some(&lightroom(1))); - let b = import_jpeg(&mut s, &src, "b", 8, None); - let c = import_jpeg(&mut s, &src, "c", 6, None); - s.execute("library.select", &json!({"ids": [c.0]})).unwrap(); - s.execute("photo.rotateRight", &json!({})).unwrap(); - s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); - s.execute("develop.copy", &json!({"groups": ["geometry"]})).unwrap(); - s.execute("library.select", &json!({"ids": [b.0, c.0]})).unwrap(); - s.execute("develop.paste", &json!({})).unwrap(); - assert!(same(dev(&s, b).geometry.upright_transform, placed(1, Orientation::Rotate270, 60.0, 40.0)), "EXIF 8: {:?}", dev(&s, b).geometry); - let c_frame = Orientation::Rotate90.then(Orientation::Rotate90); - assert!(same(dev(&s, c).geometry.upright_transform, placed(1, c_frame, 60.0, 40.0)), "EXIF 6 turned right: {:?}", dev(&s, c).geometry); - let _ = std::fs::remove_dir_all(&src); +fn transfers_between_files_carry_the_upright_mode_not_the_matrix() { + for command in ["develop.paste", "develop.sync", "develop.pastePrevious"] { + let src = temp_dir(&format!("upright-between-files-{}", command.replace('.', "-"))); + let mut s = Session::new().with_fs(); + let a = import_jpeg(&mut s, &src, "a", 8, Some(&lightroom(1))); + // b has its own saved Auto matrix (another one), c has Upright Off + let b = import_jpeg(&mut s, &src, "b", 8, Some(&lightroom(1).replace(SLOTS[1], SLOTS[2]))); + let c = import_jpeg(&mut s, &src, "c", 6, None); + let own_b = upright_in_frame(&SavedUpright { mode: Upright::Auto, matrix: matrix(SLOTS[2]) }, Orientation::Rotate270, 60.0, 40.0).unwrap(); + assert!(same(dev(&s, b).geometry.upright_transform, own_b)); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("develop.copy", &json!({"groups": ["geometry"]})).unwrap(); + for target in [b, c] { + if command == "develop.sync" { + s.selection = crate::Selection { ids: vec![a, target], active: Some(a) }; + } else { + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("library.select", &json!({"ids": [target.0]})).unwrap(); + } + s.execute(command, &json!({"groups": ["geometry"]})).unwrap(); + } + let gb = dev(&s, b).geometry; + assert!(gb.upright == Upright::Auto && same(gb.upright_transform, own_b), "{command}: b keeps its own: {gb:?}"); + let gc = dev(&s, c).geometry; + assert_eq!((gc.upright, gc.upright_transform), (Upright::Auto, None), "{command}: c solves its own"); + let _ = std::fs::remove_dir_all(&src); + } } /// A crop paste carries the source's rotation: the target's own Upright correction turns with it. @@ -830,3 +849,266 @@ fn synchronize_folder_reads_sidecars_in_the_photo_frame() { assert!(warned, "{r}"); let _ = std::fs::remove_dir_all(&src); } + +// ------------------------------------------------------------------ round three: presets, merge, stale frames + +/// The catalog keeps its format: nothing about the file's frame is written into it (a recorded +/// frame would be dropped by an older build that reads format 7, and go stale when the file +/// changes). +#[test] +fn catalogs_keep_their_format() { + let src = temp_dir("upright-catalog-format"); + let mut s = Session::new().with_fs(); + import_jpeg(&mut s, &src, "a", 8, Some(&lightroom(1))); + assert!(!s.catalog.to_snapshot().contains("file_orientation")); + let _ = std::fs::remove_dir_all(&src); +} + +/// Sidecars are read in the frame the file has now: a file whose EXIF orientation changed (8 to 6) +/// gets its crop and saved Upright in the new frame on Read Metadata from File and Synchronize +/// Folder (a frame recorded at import used to stay, 19 px off, the crop on the wrong end). +#[test] +fn sidecars_are_read_in_the_files_current_frame() { + let crop = r#"crs:HasCrop="True" crs:CropLeft="0.206655" crs:CropTop="0" crs:CropRight="1" crs:CropBottom="1" crs:CropAngle="0""#; + let packet = |exposure: &str| lightroom(1).replace(r#"crs:Exposure2012="+0.30""#, &format!(r#"crs:Exposure2012="{exposure}" {crop}"#)); + for via_sync in [false, true] { + let src = temp_dir(&format!("upright-current-frame-{via_sync}")); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "turned", 8, Some(&packet("+0.30"))); + write_jpeg_seeded(&src.join("turned.jpg"), 60, 40, 6, b"turned".iter().fold(17u8, |a, b| a.wrapping_mul(31).wrapping_add(*b))); + let sidecar = src.join("turned.xmp"); + std::fs::write(&sidecar, packet("+0.60")).unwrap(); + if via_sync { + bump(&sidecar, 5); + let r = s.execute("folder.synchronize", &json!({"path": src.to_string_lossy(), "readMetadata": true, "importNew": false})).unwrap(); + assert_eq!(r["read"], 1, "{r}"); + } else { + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + } + let d = dev(&s, id); + assert_eq!(d.light.exposure, 0.6); + let r = [d.crop.geometry.rect.x0, d.crop.geometry.rect.y0, d.crop.geometry.rect.x1, d.crop.geometry.rect.y1]; + assert_eq!(r.map(|v| (v * 1e6).round() / 1e6), [0.0, 0.206655, 1.0, 1.0], "sync: {via_sync}"); + assert!(same(d.geometry.upright_transform, placed(1, Orientation::Rotate90, 60.0, 40.0)), "sync: {via_sync}: {:?}", d.geometry); + let _ = std::fs::remove_dir_all(&src); + } +} + +/// A preset keeps the Upright mode and the Transform sliders, never a saved correction: made from a +/// photo turned right and applied after it is turned back, the photo keeps its own correction (the +/// preset used to restore the quarter-turned one); a photo with another mode solves its own. +#[test] +fn presets_carry_the_upright_mode_not_a_correction() { + let src = temp_dir("upright-presets"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "a", 1, Some(&lightroom(1))); + let other = import_jpeg(&mut s, &src, "b", 1, None); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + let pid = s.execute("preset.create", &json!({"name": "Upright", "groups": ["geometry"]})).unwrap()["id"].as_str().unwrap().to_string(); + let preset = s.presets.iter().find(|p| p.id == pid).unwrap().clone(); + assert!(preset.settings.pointer("/geometry/upright_transform").is_none_or(Value::is_null), "{}", preset.settings); + s.execute("photo.rotateLeft", &json!({})).unwrap(); + s.execute("preset.apply", &json!({"id": pid})).unwrap(); + assert!(same(dev(&s, id).geometry.upright_transform, placed(1, Orientation::Normal, 60.0, 40.0)), "{:?}", dev(&s, id).geometry); + s.execute("library.select", &json!({"ids": [other.0]})).unwrap(); + s.execute("preset.apply", &json!({"id": pid})).unwrap(); + assert_eq!((dev(&s, other).geometry.upright, dev(&s, other).geometry.upright_transform), (Upright::Auto, None)); + let _ = std::fs::remove_dir_all(&src); +} + +/// Settings JSON (develop.merge: MCP `set_develop`, the CLI) that only turns the photo turns the +/// correction it keeps, as Rotate does; one that changes the mode drops it to be solved again; an +/// explicit correction is taken as given. Undo restores each step. +#[test] +fn merging_settings_json_keeps_upright_in_the_photo_frame() { + let src = temp_dir("upright-merge"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "a", 1, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let original = placed(1, Orientation::Normal, 60.0, 40.0); + s.execute("photo.rotateRight", &json!({})).unwrap(); + s.execute("develop.merge", &json!({"settings": {"orientation": "Normal"}})).unwrap(); + assert!(same(dev(&s, id).geometry.upright_transform, original), "{:?}", dev(&s, id).geometry); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(same(dev(&s, id).geometry.upright_transform, placed(1, Orientation::Rotate90, 60.0, 40.0))); + s.execute("edit.redo", &json!({})).unwrap(); + let given = placed(2, Orientation::Rotate90, 60.0, 40.0); + s.execute("develop.merge", &json!({"settings": {"orientation": "Rotate90", "geometry": {"upright_transform": given}}})).unwrap(); + assert!(same(dev(&s, id).geometry.upright_transform, given)); + s.execute("develop.merge", &json!({"settings": {"geometry": {"upright": "vertical"}}})).unwrap(); + assert_eq!((dev(&s, id).geometry.upright, dev(&s, id).geometry.upright_transform), (Upright::Vertical, None)); + let _ = std::fs::remove_dir_all(&src); +} + +/// A sidecar whose only change is a crop the photo's file can't be read to place is still reported +/// by Synchronize Folder (holding the crop used to hide the change, and with it the warning). +#[test] +fn a_crop_only_change_that_cant_be_placed_is_reported() { + let src = temp_dir("upright-held-crop"); + let mut s = Session::new().with_fs(); + let crop = |left: &str| crop_packet(&format!(r#"crs:CropLeft="{left}" crs:CropTop="0" crs:CropRight="1" crs:CropBottom="1" crs:CropAngle="0""#)); + let id = import_jpeg(&mut s, &src, "gone", 8, Some(&crop("0.206655"))); + let before = dev(&s, id); + std::fs::write(src.join("gone.jpg"), b"not a photo any more").unwrap(); + let sidecar = src.join("gone.xmp"); + std::fs::write(&sidecar, crop("0.3")).unwrap(); + bump(&sidecar, 5); + let r = s.execute("folder.synchronize", &json!({"path": src.to_string_lossy(), "readMetadata": true, "importNew": false})).unwrap(); + assert_eq!(dev(&s, id).crop, before.crop, "{r}"); + let held = r["warnings"].as_array().into_iter().flatten().any(|w| w["code"] == "unmappedXmp" && w["fields"].to_string().contains("Crop")); + assert!(held, "{r}"); + let _ = std::fs::remove_dir_all(&src); +} + +// ------------------------------------------------------------------ sweeps over every frame + +/// The correction `m` (a matrix on the stored 60 × 40 image) in the frame of a photo whose file is +/// turned by `file` and then by the user's `user`, built from coordinate maps alone. +fn oracle(file: Orientation, user: Orientation, m: [f64; 9]) -> Homography { + let (sw, sh) = if file.swaps_axes() { (40.0, 60.0) } else { (60.0, 40.0) }; + let (ow, oh) = if user.swaps_axes() { (sh, sw) } else { (sw, sh) }; + let l = f64::max(ow, oh) / 2.0; + let at = |x: f64, y: f64| { + let (u, v) = file.map(x * 60.0, y * 40.0, 60.0, 40.0); + let (u, v) = user.map(u, v, sw, sh); + Point::new((u - ow / 2.0) / l, (v - oh / 2.0) / l) + }; + let (o, x, y) = (at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0)); + let c = Homography([x.x - o.x, y.x - o.x, o.x, x.y - o.y, y.y - o.y, o.y, 0.0, 0.0, 1.0]); + let h = c.mul(&Homography(m)).mul(&c.inverse().unwrap()); + Homography(h.0.map(|v| v / h.0[8])) +} + +const TURNS: [Orientation; 4] = [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate180, Orientation::Rotate270]; + +/// A photo of a 60 × 40 file (`path`) with EXIF `file`, shown turned `user`, with saved Auto +/// correction `m` in its frame. +fn sweep_photo(id: u64, path: &str, file: Orientation, user: Orientation, m: [f64; 9]) -> lightcraft_catalog::Photo { + let (w, h) = if file.swaps_axes() { (40, 60) } else { (60, 40) }; + let mut p = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(id), + lightcraft_catalog::Source::File { path: path.into() }, + "a.jpg", + "JPEG", + w, + h, + "2026-01-01T00:00:00", + ); + let mut d = DevelopSettings { orientation: user, ..Default::default() }; + d.geometry.upright = Upright::Auto; + d.geometry.upright_transform = Some(oracle(file, user, m)); + p.develop = std::sync::Arc::new(d); + p +} + +fn sweep_transfer(s: &mut Session, command: &str, groups: &[&str]) { + let (a, b) = (lightcraft_catalog::PhotoId(1), lightcraft_catalog::PhotoId(2)); + s.selection = crate::Selection::single(a); + s.execute("develop.copy", &json!({"groups": groups})).unwrap(); + if command == "develop.sync" { + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + } else { + s.previous_active = Some(a); + s.selection = crate::Selection::single(b); + } + s.execute(command, &json!({"groups": groups})).unwrap(); +} + +/// Paste, Sync and Paste from Previous, every source and target EXIF orientation and user turn and +/// the Geometry, Crop and both groups: between different files (9,216 cases) each target keeps its +/// own correction, in the frame it ends up in; between a photo and its virtual copy (1,152 cases) +/// the correction moves into the copy's frame. Undo restores every paste exactly. +#[test] +fn transfers_in_every_frame_and_group() { + let (mine, theirs) = (matrix(SLOTS[1]), matrix(SLOTS[2])); + let mut cases = 0; + for command in ["develop.paste", "develop.sync", "develop.pastePrevious"] { + for groups in [&["geometry"][..], &["crop"][..], &["geometry", "crop"][..]] { + for fa in ORIENTATIONS { + for fb in ORIENTATIONS { + for ua in TURNS { + for ub in TURNS { + let a = sweep_photo(1, "/sweep/a.jpg", fa, ua, mine); + let b = sweep_photo(2, "/sweep/b.jpg", fb, ub, theirs); + let mut s = Session::new(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(a) }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(b) }).unwrap(); + let before = dev(&s, lightcraft_catalog::PhotoId(2)); + sweep_transfer(&mut s, command, groups); + let got = dev(&s, lightcraft_catalog::PhotoId(2)); + let user = if groups.contains(&"crop") { ua } else { ub }; + assert_eq!(got.orientation, user); + assert!( + same(got.geometry.upright_transform, oracle(fb, user, theirs)), + "{command} {groups:?} {fa:?}/{ua:?} -> {fb:?}/{ub:?}" + ); + if command == "develop.paste" { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(dev(&s, lightcraft_catalog::PhotoId(2)), before); + } + cases += 1; + } + } + } + // the same file: a virtual copy of photo 1 + for ua in TURNS { + for ub in TURNS { + let a = sweep_photo(1, "/sweep/a.jpg", fa, ua, mine); + let mut b = sweep_photo(2, "/sweep/a.jpg", fa, ub, mine); + b.copy_of = Some(lightcraft_catalog::PhotoId(1)); + let mut s = Session::new(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(a) }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(b) }).unwrap(); + sweep_transfer(&mut s, command, groups); + let got = dev(&s, lightcraft_catalog::PhotoId(2)); + let user = if groups.contains(&"crop") { ua } else { ub }; + assert!( + same(got.geometry.upright_transform, oracle(fa, user, mine)), + "same file {command} {groups:?} {fa:?} {ua:?} -> {ub:?}" + ); + cases += 1; + } + } + } + } + } + assert_eq!(cases, 9216 + 1152); +} + +/// Auto Sync of an edited correction (develop.merge on the active photo) for every source and +/// target EXIF orientation and user turn (1,024 cases): a virtual copy of the same file gets it in +/// its own frame, another file keeps its own. +#[test] +fn auto_sync_in_every_frame() { + let (mine, theirs, edited) = (matrix(SLOTS[1]), matrix(SLOTS[2]), matrix(SLOTS[4])); + let mut cases = 0; + for fa in ORIENTATIONS { + for fb in ORIENTATIONS { + for ua in TURNS { + for ub in TURNS { + let same_file = fa == fb && cases % 2 == 0; + let a = sweep_photo(1, "/sweep/a.jpg", fa, ua, mine); + let mut b = if same_file { sweep_photo(2, "/sweep/a.jpg", fb, ub, mine) } else { sweep_photo(2, "/sweep/b.jpg", fb, ub, theirs) }; + if same_file { + b.copy_of = Some(lightcraft_catalog::PhotoId(1)); + } + let mut s = Session::new(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(a) }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(b) }).unwrap(); + s.selection = crate::Selection { + ids: vec![lightcraft_catalog::PhotoId(1), lightcraft_catalog::PhotoId(2)], + active: Some(lightcraft_catalog::PhotoId(1)), + }; + s.auto_sync = true; + s.execute("develop.merge", &json!({"settings": {"geometry": {"upright_transform": oracle(fa, ua, edited)}}})).unwrap(); + let got = dev(&s, lightcraft_catalog::PhotoId(2)).geometry.upright_transform; + let want = if same_file { oracle(fb, ub, edited) } else { oracle(fb, ub, theirs) }; + assert!(same(got, want), "same file {same_file}: {fa:?}/{ua:?} -> {fb:?}/{ub:?}"); + cases += 1; + } + } + } + } + assert_eq!(cases, 1024); +} diff --git a/crates/engine/src/upright_frames.rs b/crates/engine/src/upright_frames.rs deleted file mode 100644 index 46065caa..00000000 --- a/crates/engine/src/upright_frames.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Upright corrections belong to the frame of the photo they are stored with -//! (`Geometry::upright_transform`: centred coordinates of the photo as shown, after its file's -//! orientation and its own Rotate Left / Right). Settings moved between photos (Paste, Paste from -//! Previous, Sync, Auto Sync) re-express a correction in the target's frame through the stored -//! image, which is how Lightroom keeps it, and a settings patch that turns a photo turns the -//! correction it keeps (issue #205). - -use lightcraft_catalog::Photo; -use lightcraft_develop::DevelopSettings; -use lightcraft_geom::{Homography, Orientation}; -use serde_json::Value; - -use crate::sidecar::StoredFrame; - -/// A photo's frame for its Upright correction: its file's stored image, then its own rotation. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct PhotoFrame { - pub stored: StoredFrame, - pub user: Orientation, -} - -/// The frame of photo `p` with settings `s`. A photo imported before the file's orientation was -/// recorded is taken as stored the way it is shown. -pub fn frame_of(p: &Photo, s: &DevelopSettings) -> Option { - let stored = crate::sidecar::recorded_frame(p).or_else(|| StoredFrame::of_shown(Orientation::Normal, f64::from(p.width), f64::from(p.height)))?; - Some(PhotoFrame { stored, user: s.orientation }) -} - -/// Correction `h`, made in frame `from`, as the same correction of the stored image in frame `to`. -pub fn reexpress(h: &Homography, from: &PhotoFrame, to: &PhotoFrame) -> Option { - let on_stored = crate::crs::upright_on_stored(h, from.stored.orientation.then(from.user), from.stored.width, from.stored.height)?; - let saved = crate::crs::SavedUpright { mode: lightcraft_develop::Upright::Auto, matrix: on_stored }; - crate::crs::upright_in_frame(&saved, to.stored.orientation.then(to.user), to.stored.width, to.stored.height) -} - -/// Settings `after` of photo `p` (which had `before`) once `patch` is applied: a correction the -/// patch brought from photo frame `source` is put into `p`'s frame; a correction `p` keeps is -/// turned when the patch turned the photo. A patch from nowhere in particular (a preset) leaves a -/// correction it brings as it is. -pub fn settle(p: &Photo, before: &DevelopSettings, after: &mut DevelopSettings, patch: &Value, source: Option<&PhotoFrame>) { - let Some(target) = frame_of(p, after) else { return }; - let (from, h) = if patch.pointer("/geometry/upright_transform").is_some() { - (source.copied(), after.geometry.upright_transform) - } else if after.orientation != before.orientation { - (frame_of(p, before), after.geometry.upright_transform) - } else { - return; - }; - if let (Some(from), Some(h)) = (from, h) { - // a frame it can't be put in (no size) leaves it to be solved again - after.geometry.upright_transform = reexpress(&h, &from, &target); - } -} - -/// `patch` applied to settings `before` of photo `p` for a transfer from photo frame `source`, -/// with the Upright correction in `p`'s frame ([`settle`]). -pub fn transferred(p: &Photo, before: &DevelopSettings, patch: &Value, source: Option<&PhotoFrame>) -> DevelopSettings { - let mut after = lightcraft_develop::apply_partial(before, patch, 1.0); - settle(p, before, &mut after, patch, source); - after -} diff --git a/crates/engine/src/upright_transfer.rs b/crates/engine/src/upright_transfer.rs new file mode 100644 index 00000000..cfb445cf --- /dev/null +++ b/crates/engine/src/upright_transfer.rs @@ -0,0 +1,89 @@ +//! An Upright correction (`Geometry::upright_transform`) is one photo's own analysis result: the +//! correction found for that image (by LightCraft, or Lightroom's saved one for it), stored so +//! previews and exports agree, in the frame the photo is shown in. When settings move or change +//! (issue #205): +//! +//! - between entries of the same file (a photo and its virtual copies) the correction moves too, +//! turned by the difference in their Rotate Left / Right; +//! - between different files only the Upright mode and the Transform sliders move: the target +//! keeps its own correction while its mode stays the same, else solves its own, as Lightroom +//! does when it syncs the Upright mode (a correction found for one scene is wrong for another); +//! - presets never carry a correction (they are for other photos); +//! - a change that turns a photo turns the correction it keeps, and a change of mode drops it to +//! be solved again. + +use lightcraft_catalog::{Photo, PhotoId, Source}; +use lightcraft_develop::DevelopSettings; +use lightcraft_geom::Orientation; +use serde_json::Value; + +/// The photo settings were taken from, as far as its Upright correction is concerned. +#[derive(Clone, Debug, PartialEq)] +pub struct UprightSource { + source: Source, + master: PhotoId, + /// Its Rotate Left / Right when the settings were taken. + user: Orientation, +} + +/// Photo `p` with settings `s` as the source of a transfer. +pub fn source_of(p: &Photo, s: &DevelopSettings) -> UprightSource { + UprightSource { source: p.source.clone(), master: p.copy_of.unwrap_or(p.id), user: s.orientation } +} + +/// Whether photo `p` shows the same file as `from`: the photo itself, its original or another of +/// its virtual copies. +fn same_file(from: &UprightSource, p: &Photo) -> bool { + from.master == p.copy_of.unwrap_or(p.id) || from.source == p.source +} + +/// The turn that takes a photo shown with `from` to `to` (both after the file's own orientation). +fn turn(from: Orientation, to: Orientation) -> Orientation { + from.inverse().then(to) +} + +/// Settings `after` of photo `p` (which had `before`) once `patch` was applied, with its Upright +/// correction settled as the module describes. `source`: the photo the patch was taken from +/// (`None`: none, as for a preset). +pub fn settle(p: &Photo, before: &DevelopSettings, after: &mut DevelopSettings, patch: &Value, source: Option<&UprightSource>) { + if patch.pointer("/geometry/upright_transform").is_none() { + return settle_edit(before, after); + } + let g = &mut after.geometry; + g.upright_transform = match source.filter(|from| same_file(from, p)) { + // the same file's correction, in this entry's rotation + Some(from) => g.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn(from.user, after.orientation))), + // another file's (or a preset's): not this photo's; it keeps its own while the mode stays + None if g.upright == before.geometry.upright => { + before.geometry.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn(before.orientation, after.orientation))) + } + None => None, + }; +} + +/// A change of a photo's own settings (`before` to `after`) that doesn't state a correction: one +/// that turns the photo turns the correction it keeps; one that changes the Upright mode drops it +/// to be solved again. +pub fn settle_edit(before: &DevelopSettings, after: &mut DevelopSettings) { + let g = &mut after.geometry; + if g.upright != before.geometry.upright { + g.upright_transform = None; + } else if after.orientation != before.orientation { + g.upright_transform = g.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn(before.orientation, after.orientation))); + } +} + +/// `patch` applied to settings `before` of photo `p`, taken from `source` ([`settle`]). +pub fn transferred(p: &Photo, before: &DevelopSettings, patch: &Value, source: Option<&UprightSource>) -> DevelopSettings { + let mut after = lightcraft_develop::apply_partial(before, patch, 1.0); + settle(p, before, &mut after, patch, source); + after +} + +/// Preset settings without a saved Upright correction (the mode and the sliders stay). +pub fn without_correction(mut settings: Value) -> Value { + if let Some(g) = settings.get_mut("geometry").and_then(Value::as_object_mut) { + g.remove("upright_transform"); + } + settings +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index edae5e44..194be3f6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -215,7 +215,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | -| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP is kept as it is (LR-META-XMP); Rotate Left / Right turns a stored correction with the photo, and Paste / Sync / Auto Sync put it into each photo's frame | +| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP is kept as it is (LR-META-XMP); Rotate Left / Right turns a stored correction with the photo; a correction is the photo's own: Paste / Sync / Auto Sync turn it into virtual copies of the same file and carry only the mode and sliders to other files, presets keep the mode only | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | | LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | @@ -787,7 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | LR-META-XMP: Lightroom's saved Upright correction for the selected mode is kept from XMP instead of being solved again (issue #205; catalogs fall back and report it); `PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical; sidecar crops follow the file's orientation (Synchronize Folder too); LR-EDIT-GEOM-UPRIGHT: Rotate turns a stored correction, Paste / Sync carry it into each photo's frame; statuses unchanged | +| 2026-10-12 | minor | LR-META-XMP: Lightroom's saved Upright correction for the selected mode is kept from XMP instead of being solved again (issue #205; catalogs fall back and report it); `PerspectiveUpright` 2, 3, 4 read as Full, Level, Vertical; sidecar crops follow the file's orientation (Synchronize Folder too); LR-EDIT-GEOM-UPRIGHT: Rotate turns a stored correction; Paste / Sync / presets carry the Upright mode to other files and the correction only to virtual copies; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 6524bdfd..f93b6b0b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -12,8 +12,13 @@ and with Synchronize Folder. Lightroom Classic catalogs still have it worked out again for now, and the import says so. - Rotate Left and Rotate Right turn an Upright correction with the photo. It used to stay as it was, so the photo - came out skewed after a turn. Paste, Paste from Previous, Sync and Auto Sync now put a copied Upright correction - the right way round for each photo, for example on a turned virtual copy or a photo shot in portrait. + came out skewed after a turn. Settings JSON that changes the rotation (Apply Settings JSON, MCP `set_develop`, the + CLI) turns it the same way. +- An Upright correction stays with its photo: it is the analysis of that one image. Paste, Sync and Auto Sync give a + virtual copy the correction turned its way. Other photos get the Upright mode and the Transform sliders and keep + their own correction, or work out their own when the mode changes, as Lightroom does when it syncs the Upright + mode. Presets keep the mode, never a correction. LightCraft used to copy its own correction to other photos as it + was, which applied one scene's correction to another. - A crop read from an XMP sidecar now lands where Lightroom put it on photos shown on their side (portrait raws), and straightened crops keep Lightroom's frame. A portrait crop used to cut the wrong side of the photo. Synchronize Folder reads sidecars the same way, and its report lists what a sidecar couldn't carry over. diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 716ce39f..bca8748d 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -141,7 +141,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | | `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | | | `PerspectiveUpright` | `geometry.upright`, `geometry.upright_transform` | 0 off, 1 auto, 2 full, 3 level, 4 vertical, 5 guided; Lightroom's saved correction for the selected mode is kept as it is when it can be (see *Saved Upright geometry*), otherwise LightCraft works the mode out itself (Guided needs guides drawn in LightCraft: Lightroom's aren't read) and photo import says so | -| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | Lightroom's edges are on the stored image (before its orientation) and its straighten angle turns the other way: both go into the frame the photo is shown in (through the file's orientation and the photo's Rotate Left / Right), as the Lightroom catalog import does; the file's orientation is recorded on import (photos imported before that read it from the file); when it can't be known (a raw shown from its preview) the edges are taken as written on import, and an existing photo keeps its crop and the report says so; `HasCrop="False"` → no crop | +| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | Lightroom's edges are on the stored image (before its orientation) and its straighten angle turns the other way: both go into the frame the photo is shown in (through the file's orientation and the photo's Rotate Left / Right), as the Lightroom catalog import does; the file's orientation is read from the file each time (on import, Read Metadata from File and Synchronize Folder), so a file that changed is read as it is now; when it can't be known (a raw shown from its preview, a file that can't be read) the edges are taken as written on import, and an existing photo keeps its crop and the report says so; `HasCrop="False"` → no crop | Values pass through our control specs, so anything outside our slider ranges gets clamped. @@ -158,8 +158,9 @@ photo is framed as in Lightroom instead of being analysed again, when - `UprightVersion` is `151388160` (the only version seen so far; any other is solved again); - `UprightTransformCount` includes the mode's matrix, and the matrix is nine finite numbers that make a sensible correction (no part of the photo at or beyond the horizon, no mirror, no collapse); -- the file's orientation is known: on import, Read Metadata from File and Synchronize Folder (Lightroom catalog - import doesn't read saved Upright yet: see [lightroom-catalog-import.md](lightroom-catalog-import.md)); +- the file's orientation can be read from the file, as it is now: on import, Read Metadata from File and + Synchronize Folder (Lightroom catalog import doesn't read saved Upright yet: see + [lightroom-catalog-import.md](lightroom-catalog-import.md)); - the crop has no straighten angle (a straightened crop on top of a saved Upright isn't measured yet). The matrix's convention was measured on Lightroom's exports, not assumed (issue #205): a forward homography (photo @@ -170,18 +171,39 @@ to fill the frame already in it. LightCraft turns it with the file's orientation included) and applies the crop on top, as Lightroom does. Rotate Left / Right later turns the kept correction with the photo, as it does LightCraft's own Upright. -An Upright correction belongs to its photo's frame. Paste, Paste from Previous, Sync and Auto Sync put a copied -correction into each target's frame through the stored image (as Lightroom keeps it), so a turned virtual copy or a -photo shot in portrait gets it the right way round, and a pasted crop that turns a photo turns the correction it -keeps. A photo imported before the file's orientation was recorded is taken as stored the way it is shown. On three public NEF raws exported by Lightroom Classic 12 with Upright Auto (one shown in portrait, -one cropped with Constrain Crop on), LightCraft's geometry with the kept matrix matches the exports to about half a -pixel on average and under a pixel at worst, at full size; without it, the frames differ by 22 to 36 px on average. +On three public NEF raws exported by Lightroom Classic 12 with Upright Auto (one shown in portrait, one cropped +with Constrain Crop on), LightCraft's geometry with the kept matrix matches the exports to about half a pixel on +average and under a pixel at worst, at full size; without it, the frames differ by 22 to 36 px on average. + +### An Upright correction is the photo's own + +A saved Upright matrix, Lightroom's or the one LightCraft's own Upright found, is the analysis of one image: the +correction for that scene, in the frame that photo is shown in. So when settings move or change: + +- **Paste, Paste from Previous, Sync, Auto Sync** with the Geometry group: + - Between a photo and its virtual copies (the same file), the correction moves, turned to each one's Rotate + Left / Right. + - Between different files, only the Upright mode and the Transform sliders move. A target whose mode stays the + same keeps its own correction; one whose mode changes solves its own. This is what Lightroom does when it syncs + the Upright mode. Lightroom's separate option to copy the computed Upright transforms (identical geometry + across brackets) isn't offered. + - LightCraft used to copy its own solved correction verbatim to other files, applying one scene's correction to + another. That is no longer done. +- **A crop paste** carries the Rotate Left / Right with it, and the target's own correction turns with it. +- **Presets** keep the Upright mode and the Transform sliders, never a correction. A preset made before this, + which still holds one, has it ignored in the same way. +- **Rotate Left / Right** turns the correction with the photo. +- **Settings JSON** (`develop.merge`, MCP `set_develop`, the CLI): + - A change of rotation alone turns the correction the photo keeps, as Rotate does. + - A change of Upright mode drops the correction, to be solved again. + - A correction stated in the JSON is taken as given. Not measured yet: the Transform sliders (Vertical, Horizontal, Rotate, Aspect, Scale, X / Y Offset) together with a saved Upright (LightCraft applies its own slider mapping on top, as with its own Upright), Level, Vertical and Full exports (their matrices have those modes' shapes, but no export of them with its raw was available), photos with Lightroom lens profile corrections, and a straightened crop on top of Upright (solved again, with the warning). Guided -Upright's guides (`UprightFourSegments_*`) aren't read: import says so whenever a packet has them. +Upright's guides (`UprightFourSegments_*`) aren't read: import says so when the packet's mode is Guided (in other +modes Lightroom doesn't use them). **Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur, process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below. From 725eca27017374435810d8fb30a9f2ee65894700 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 21:47:06 +0200 Subject: [PATCH 5/6] LR-META-XMP: whole-settings JSON turns the Upright correction; Paste after opening another library keeps each photo's own (#205) Fourth review round on 6471d93b: - develop.merge: a stated correction equal to the photo's current one counts as not stated, so settings sent back whole (develop.get / MCP get_develop, rotation changed) turn the correction as Rotate does. It stayed in the old frame, 15.5 px mean off at 3200 x 4800. - The clipboard remembers the library it was copied in. After opening another library a photo with the copied photo's id is another file and keeps its own correction; it got the copied one. - The Presets panel's preview and thumbnails use Session::preset_look, the helper preset.apply and both import paths use, so the preview is what Apply stores (it was up to 15.4 px off). - "No crop" (HasCrop False, or the full frame unrotated) needs no frame: it applies on an unreadable or preview-only file without a warning and isn't listed again by Synchronize Folder. A held crop is news only while it differs from the photo's. The Crop* fields beside HasCrop False aren't reported as unimported. - Synchronize Folder's scan and read learn the file's orientation through the session's file probe, inside import's memory budget, not by reading whole files on their own. - Docs: guides and Constrain Crop move between files too; the Lightroom claim is attributed to community experts, with the conflicting Auto Sync reports and the stale same-mode correction noted. - Tests: the regressions above (failing on 6471d93b), an MCP set_develop round trip, an older preset holding a correction, and same-file sweeps from copy to original and copy to copy (12,672 cases). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/develop.rs | 51 +++--- crates/engine/src/crs.rs | 8 +- crates/engine/src/import.rs | 8 +- crates/engine/src/lib.rs | 9 +- crates/engine/src/sidecar.rs | 42 +++-- crates/engine/src/sync.rs | 65 +++++--- crates/engine/src/tests_upright.rs | 214 ++++++++++++++++++++++--- crates/engine/src/upright_transfer.rs | 45 +++++- crates/mcp/src/tools.rs | 2 +- crates/mcp/tests/upright_round_trip.rs | 54 +++++++ crates/ui-egui/src/panels/presets.rs | 10 +- docs/parity-checklist.md | 2 +- docs/whats-new.md | 11 +- docs/xmp-interop.md | 22 ++- 14 files changed, 430 insertions(+), 113 deletions(-) create mode 100644 crates/mcp/tests/upright_round_trip.rs diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 7bb16816..5b0a0688 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -242,20 +242,29 @@ pub fn specs() -> Vec { Ok(()) }) }), - cmd!("develop.merge", "Apply Settings JSON", [], None, "{settings: partial DevelopSettings JSON, label?}", has_active, |s, p| { - let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?; - let label = str_param(p, "label").unwrap_or("Edit").to_string(); - edit(s, "develop.merge", &label, |d| { - let before = d.clone(); - *d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?; - // a turn turns the Upright correction the photo keeps, as Rotate does; a new mode - // drops it; a correction stated in the JSON is taken as given - if partial.pointer("/geometry/upright_transform").is_none() { - crate::upright_transfer::settle_edit(&before, d); - } - Ok(()) - }) - }), + cmd!( + "develop.merge", + "Apply Settings JSON", + [], + None, + "{settings: partial DevelopSettings JSON, label?} (a change of `orientation` turns the photo's Upright correction with it unless `geometry.upright_transform` states a different one; a change of `geometry.upright` drops it to be solved again)", + has_active, + |s, p| { + let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?; + let label = str_param(p, "label").unwrap_or("Edit").to_string(); + edit(s, "develop.merge", &label, |d| { + let before = d.clone(); + *d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?; + // a turn turns the Upright correction the photo keeps, as Rotate does; a new mode + // drops it; a correction stated in the JSON is taken as given, unless it is the + // photo's own unchanged (settings sent back whole, as develop.get returned them) + if !crate::upright_transfer::states_new_correction(&partial, &before) { + crate::upright_transfer::settle_edit(&before, d); + } + Ok(()) + }) + } + ), cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| { let ids = s.targets(p); let ops = ids @@ -794,7 +803,7 @@ pub fn specs() -> Vec { } let d = s.develop_of(id).unwrap_or_default(); s.clipboard = Some(lightcraft_develop::extract_groups(&d, &s.copy_groups)); - s.clipboard_source = s.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, &d)); + s.clipboard_source = s.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, &d, &s.library_identity)); Ok(json!({"groups": s.copy_groups})) } ), @@ -812,7 +821,7 @@ pub fn specs() -> Vec { let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone()); let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups); let ids: Vec = s.targets(p).into_iter().filter(|id| *id != prev).collect(); - let source = s.catalog.photo(prev).map(|ph| crate::upright_transfer::source_of(ph, &ph.develop)); + let source = s.catalog.photo(prev).map(|ph| crate::upright_transfer::source_of(ph, &ph.develop, &s.library_identity)); let ops = ids .iter() .filter_map(|id| { @@ -841,7 +850,8 @@ pub fn specs() -> Vec { clip = lightcraft_develop::extract_groups(&full, &only); } let ids = s.targets(p); - let source = s.clipboard_source.clone(); + // copied in another library: its photo ids say nothing about this one's + let source = s.clipboard_source.clone().filter(|from| from.taken_in(&s.library_identity)); let ops = ids .iter() .filter_map(|id| { @@ -866,7 +876,7 @@ pub fn specs() -> Vec { let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy); let d = s.develop_of(id).unwrap_or_default(); let partial = lightcraft_develop::extract_groups(&d, &groups); - let source = s.catalog.photo(id).map(|ph| crate::upright_transfer::source_of(ph, &d)); + let source = s.catalog.photo(id).map(|ph| crate::upright_transfer::source_of(ph, &d, &s.library_identity)); let ops = s .selection .ids @@ -992,10 +1002,7 @@ pub fn specs() -> Vec { .filter_map(|(id, d)| { // a preset carries no Upright correction: the photo keeps its own, turned with // it, or solves its own for another mode - let mut after = preset.apply(&d, amount); - if let Some(ph) = s.catalog.photo(id) { - crate::upright_transfer::settle(ph, &d, &mut after, &preset.settings, None); - } + let after = s.preset_look(id, &preset, amount).unwrap_or_else(|| preset.apply(&d, amount)); s.develop_op(id, after, &format!("Preset: {}", preset.name)) }) .collect::>(); diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index d98dc7ac..cec3920f 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -442,7 +442,13 @@ pub fn to_partial(props: &Props, target: Target) -> Value { ); } } - Some(false) => put(o, "crop.geometry", json!({"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0})), + Some(false) => { + // "no crop": the edges Lightroom keeps writing beside it are read, not lost adjustments + for k in ["Left", "Top", "Right", "Bottom", "Angle"] { + note(&format!("crs:Crop{k}")); + } + put(o, "crop.geometry", json!({"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0})) + } None => {} } out diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index a74380ac..a22b44f5 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -357,9 +357,7 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) { match preset { Some(pr) => { // a preset carries no Upright correction (an old one may still hold one: not this photo's) - let mut look = pr.apply(&base, 1.0); - crate::upright_transfer::settle(p, &base, &mut look, &pr.settings, None); - let look = std::sync::Arc::new(look); + let look = std::sync::Arc::new(crate::upright_transfer::apply_preset(p, &base, pr, 1.0)); p.develop = look.clone(); p.import_look = (*look != base).then_some(look); } @@ -1339,9 +1337,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare } if let Some(preset) = &opts.preset { // after the sidecar: the photo keeps the Upright correction it was given - let mut d = preset.apply(&p.develop, 1.0); - crate::upright_transfer::settle(&p, &p.develop, &mut d, &preset.settings, None); - let d = std::sync::Arc::new(d); + let d = std::sync::Arc::new(crate::upright_transfer::apply_preset(&p, &p.develop, preset, 1.0)); let label = format!("Preset: {}", preset.name); p.develop = d.clone(); p.edited = Some(now.clone()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7d51206c..e6377883 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -790,7 +790,7 @@ impl Session { } } // an Upright correction moves only between entries of the same file - let source = self.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, new)); + let source = self.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, new, &self.library_identity)); self.selection .ids .iter() @@ -806,6 +806,13 @@ impl Session { .collect() } + /// What `preset` at `amount` makes of photo `id`'s settings, as Apply Preset stores it (its + /// Upright correction settled: [`crate::upright_transfer::apply_preset`]); for previews too. + pub fn preset_look(&self, id: PhotoId, preset: &lightcraft_develop::Preset, amount: f64) -> Option { + let p = self.catalog.photo(id)?; + Some(crate::upright_transfer::apply_preset(p, &p.develop, preset, amount)) + } + /// The op that sets a photo's develop settings and appends a History entry (for batches). pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option { self.catalog.photo(id)?; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 80fa995e..5c15e3fc 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -136,11 +136,23 @@ impl StoredFrame { } } -/// The stored image of the file at `path` as it is now, read from the file (never recorded: a -/// file can change): `None` when it can't be read. -pub fn probe_frame(path: &str) -> Option { - let bytes = std::fs::read(path).ok()?; - crate::files::probe_bytes(path, &bytes).ok()?.stored_frame() +/// Whether partial settings `v` hold a crop that depends on the frame it is read in: anything but +/// "no crop" (the whole image, not straightened), which is the same in every orientation. +fn crop_needs_frame(v: &Value) -> bool { + let Some(g) = v.pointer("/crop/geometry") else { return false }; + let at = |k: &str, want: f64| g.pointer(k).and_then(Value::as_f64).is_some_and(|x| x == want); + !(at("/rect/x0", 0.0) + && at("/rect/y0", 0.0) + && at("/rect/x1", 1.0) + && at("/rect/y1", 1.0) + && g.get("angle").and_then(Value::as_f64).is_none_or(|a| a == 0.0)) +} + +/// The stored image of the file at `path` as it is now, through the session's file probe `probe` +/// (the one import uses, inside its memory budget; never recorded: a file can change): `None` when +/// it can't be read. +pub fn probe_frame(probe: Option<&crate::media::FileProbe>, path: &str) -> Option { + probe?(path).ok()?.stored_frame() } #[derive(Clone, Debug, PartialEq)] @@ -341,22 +353,24 @@ impl SidecarData { self.placed_upright.is_some() } - /// Whether the packet's crop or saved Upright needs the photo's stored frame. + /// Whether the packet's crop or saved Upright needs the photo's stored frame. "No crop" (the + /// whole image, not straightened) is the same in every frame. pub fn needs_frame(&self) -> bool { - self.upright.is_some() || matches!(&self.develop, Some(DevelopPatch::Partial(v)) if v.pointer("/crop/geometry").is_some()) + self.upright.is_some() || matches!(&self.develop, Some(DevelopPatch::Partial(v)) if crop_needs_frame(v)) } /// For a photo already in the library whose file's orientation can't be learnt: leave its crop /// as it is rather than take Lightroom's crop edges in an unknown frame, and say so. Returns - /// whether a crop was held. - pub fn hold_unframed_crop(&mut self) -> bool { + /// the crop held (as the packet wrote it). + pub fn hold_unframed_crop(&mut self) -> Option { if self.frame.is_some() { - return false; + return None; } - let Some(DevelopPatch::Partial(Value::Object(partial))) = self.develop.as_mut() else { return false }; - if partial.remove("crop").is_none() { - return false; + let Some(DevelopPatch::Partial(partial)) = self.develop.as_mut() else { return None }; + if !crop_needs_frame(partial) { + return None; } + let held = partial.as_object_mut()?.remove("crop")?; self.warnings.push(crate::import::ImportWarning { path: String::new(), code: crate::import::ImportWarningCode::UnmappedXmp, @@ -364,7 +378,7 @@ impl SidecarData { mode: None, fields: vec!["Crop (the file's orientation couldn't be read)".into()], }); - true + Some(held) } /// Read the label through the catalog's label names: other editors write the label's name diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index ec432d4e..cab2c432 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -78,6 +78,9 @@ pub struct SyncInput { photos: Vec<(Arc, SidecarNaming)>, /// For the colour label names a sidecar may use. labels: Catalog, + /// The session's file probe (import's, inside its memory budget): how a sidecar's crop and + /// saved Upright learn the file's orientation. + probe: Option, } /// What changed in a folder since it came into the library. @@ -181,7 +184,7 @@ impl SyncInput { let namings = s.sidecar_namings(&ids); let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect(); let (scan, _) = ScanInput::new(s, &[path.to_string()]); - Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() }) + Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone(), probe: s.media.file_probe.clone() }) } } @@ -192,19 +195,21 @@ impl SyncInput { /// library's photos, which run on several threads too. Stops early when `progress.files.cancel` /// is set. pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { - let SyncInput { folder, scan, photos, labels } = input; + let SyncInput { folder, scan, photos, labels, probe } = input; let seen: Vec> = photos.iter().map(|(p, _)| Arc::clone(p)).collect(); if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() { return FolderChanges { path: folder, seen, offline: true, ..Default::default() }; } // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] - let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress)); + let (out, checks) = + (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, probe.as_ref(), progress)); #[cfg(not(target_arch = "wasm32"))] let (out, checks) = std::thread::scope(|sc| { let photos = &photos; let labels = &labels; - let checks = sc.spawn(move || check_photos(photos, labels, progress)); + let probe = probe.as_ref(); + let checks = sc.spawn(move || check_photos(photos, labels, probe, progress)); let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files); // (a check that panicked counts as nothing found; the scan still reports the rest) (out, checks.join().unwrap_or_default()) @@ -329,11 +334,11 @@ enum Check { News, } -fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { +fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, probe: Option<&crate::media::FileProbe>) -> Check { let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing }; if !Path::new(file).exists() { Check::Missing - } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) { + } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, probe) { Check::News } else { Check::Nothing @@ -342,12 +347,17 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { /// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the /// disk, not work for the CPU). Photos not reached before a cancel count as nothing found. -fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec { +fn check_photos( + photos: &[(Arc, SidecarNaming)], + labels: &Catalog, + probe: Option<&crate::media::FileProbe>, + progress: &SyncProgress, +) -> Vec { use std::sync::atomic::Ordering::Relaxed; progress.to_check.store(photos.len(), Relaxed); let cancel = &progress.files.cancel; let check = |p: &Photo, naming: SidecarNaming| { - let c = check_photo(p, naming, labels); + let c = check_photo(p, naming, labels, probe); progress.checked.fetch_add(1, Relaxed); c }; @@ -415,7 +425,7 @@ impl Session { /// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo /// (imported, or edited here) and says something the library doesn't. -fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool { +fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, probe: Option<&crate::media::FileProbe>) -> bool { let Source::File { path } = &p.source else { return false }; let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false }; let Ok(meta) = std::fs::metadata(&file) else { return false }; @@ -430,9 +440,11 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool } let Ok(text) = std::fs::read_to_string(&file) else { return false }; let Ok(mut sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false }; - // compared in the photo's frame, as it would be read; a crop that can't be placed is still news, - // so the synchronize that reads it says why it was left - if framed(&mut sc, path) { + // compared in the photo's frame, as it would be read; a crop that can't be placed (no frame) and + // differs from the photo's is still news, so the synchronize that reads it says why it was left + if let Some(held) = framed(&mut sc, path, probe) + && lightcraft_develop::apply_partial(&p.develop, &serde_json::json!({"crop": held}), 1.0).crop != p.develop.crop + { return true; } let sc = sc.resolve_label(labels); @@ -470,9 +482,10 @@ pub struct SyncWork { import: Option<(crate::import::ImportJob, Vec)>, moved: Vec, missing: Vec, - /// (photo, its file, how its sidecar is named) /// (photo, its file, sidecar naming, white balance target) sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>, + /// The session's file probe, for the files' orientations ([`SyncInput`]). + probe: Option, } /// The catalog half of a synchronize (see [`SyncJob`]). @@ -537,6 +550,7 @@ impl SyncJob { moved: if choice.relink_moved { moved } else { Vec::new() }, missing: if choice.remove_missing { missing } else { Vec::new() }, sidecars, + probe: s.media.file_probe.clone(), }; let commit = SyncCommit { opts, @@ -558,7 +572,7 @@ impl SyncWork { pub fn run(self, progress: &SyncProgress, mut send: impl FnMut(SyncStep) -> bool) { use std::sync::atomic::Ordering::Relaxed; let cancel = &progress.files.cancel; - let SyncWork { import, moved, missing, sidecars } = self; + let SyncWork { import, moved, missing, sidecars, probe } = self; let pieces = import.as_ref().map_or(0, |(_, f)| f.len()) + moved.len() + missing.len() + sidecars.len(); progress.to_check.store(pieces, Relaxed); progress.checked.store(0, Relaxed); @@ -602,7 +616,7 @@ impl SyncWork { if cancel.load(Relaxed) { break; } - read.push((id, path.clone(), read_sidecar(&path, naming, target))); + read.push((id, path.clone(), read_sidecar(&path, naming, target, probe.as_ref()))); step(1); } if !read.is_empty() { @@ -613,7 +627,12 @@ impl SyncWork { /// Read and parse photo file `path`'s XMP sidecar file, its crop and saved Upright in the frame the /// file has now, as import reads them. -fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) -> std::result::Result { +fn read_sidecar( + path: &str, + naming: SidecarNaming, + target: crate::crs::Target, + probe: Option<&crate::media::FileProbe>, +) -> std::result::Result { let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?; let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len(); if len > MAX_SIDECAR_BYTES { @@ -621,18 +640,18 @@ fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) - } let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; let mut sc = crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display()))?; - framed(&mut sc, path); + framed(&mut sc, path, probe); Ok(sc) } -/// Give a parsed sidecar of the photo whose file is `path` the frame the file has now (read only -/// when the packet's crop or saved Upright needs it). Without one, the photo's crop is left as it -/// is and the report says so; returns whether a crop was held that way. -fn framed(sc: &mut crate::sidecar::SidecarData, path: &str) -> bool { +/// Give a parsed sidecar of the photo whose file is `path` the frame the file has now, through the +/// session's file probe (only when the packet's crop or saved Upright needs it). Without one, the +/// photo's crop is left as it is and the report says so; returns the crop held that way. +fn framed(sc: &mut crate::sidecar::SidecarData, path: &str, probe: Option<&crate::media::FileProbe>) -> Option { if !sc.needs_frame() { - return false; + return None; } - sc.set_frame(crate::sidecar::probe_frame(path)); + sc.set_frame(crate::sidecar::probe_frame(probe, path)); sc.hold_unframed_crop() } diff --git a/crates/engine/src/tests_upright.rs b/crates/engine/src/tests_upright.rs index 7307b9ef..94c8f496 100644 --- a/crates/engine/src/tests_upright.rs +++ b/crates/engine/src/tests_upright.rs @@ -1016,9 +1016,10 @@ fn sweep_transfer(s: &mut Session, command: &str, groups: &[&str]) { } /// Paste, Sync and Paste from Previous, every source and target EXIF orientation and user turn and -/// the Geometry, Crop and both groups: between different files (9,216 cases) each target keeps its -/// own correction, in the frame it ends up in; between a photo and its virtual copy (1,152 cases) -/// the correction moves into the copy's frame. Undo restores every paste exactly. +/// the Geometry, Crop and both groups: between different files (9,216 cases, each with its own +/// scene) each target keeps its own correction, in the frame it ends up in; within one file +/// (original to copy, copy to original, copy to copy: 3,456 cases) the correction moves into the +/// target's frame. Undo restores every paste exactly. #[test] fn transfers_in_every_frame_and_group() { let (mine, theirs) = (matrix(SLOTS[1]), matrix(SLOTS[2])); @@ -1051,29 +1052,32 @@ fn transfers_in_every_frame_and_group() { } } } - // the same file: a virtual copy of photo 1 - for ua in TURNS { - for ub in TURNS { - let a = sweep_photo(1, "/sweep/a.jpg", fa, ua, mine); - let mut b = sweep_photo(2, "/sweep/a.jpg", fa, ub, mine); - b.copy_of = Some(lightcraft_catalog::PhotoId(1)); - let mut s = Session::new(); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(a) }).unwrap(); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(b) }).unwrap(); - sweep_transfer(&mut s, command, groups); - let got = dev(&s, lightcraft_catalog::PhotoId(2)); - let user = if groups.contains(&"crop") { ua } else { ub }; - assert!( - same(got.geometry.upright_transform, oracle(fa, user, mine)), - "same file {command} {groups:?} {fa:?} {ua:?} -> {ub:?}" - ); - cases += 1; + // the same file: original to copy, copy to original, copy to copy + for (copy_a, copy_b) in [(None, Some(1)), (Some(2), None), (Some(9), Some(9))] { + for ua in TURNS { + for ub in TURNS { + let mut a = sweep_photo(1, "/sweep/a.jpg", fa, ua, mine); + let mut b = sweep_photo(2, "/sweep/a.jpg", fa, ub, mine); + a.copy_of = copy_a.map(lightcraft_catalog::PhotoId); + b.copy_of = copy_b.map(lightcraft_catalog::PhotoId); + let mut s = Session::new(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(a) }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(b) }).unwrap(); + sweep_transfer(&mut s, command, groups); + let got = dev(&s, lightcraft_catalog::PhotoId(2)); + let user = if groups.contains(&"crop") { ua } else { ub }; + assert!( + same(got.geometry.upright_transform, oracle(fa, user, mine)), + "same file {copy_a:?}/{copy_b:?} {command} {groups:?} {fa:?} {ua:?} -> {ub:?}" + ); + cases += 1; + } } } } } } - assert_eq!(cases, 9216 + 1152); + assert_eq!(cases, 9216 + 3 * 1152); } /// Auto Sync of an edited correction (develop.merge on the active photo) for every source and @@ -1112,3 +1116,171 @@ fn auto_sync_in_every_frame() { } assert_eq!(cases, 1024); } + +// ------------------------------------------------------------------ round four + +/// Settings JSON sent back whole (`develop.get`, the rotation changed, `develop.merge`) states the +/// photo's unchanged correction: it is turned with the photo all the same (it used to stay in the +/// old frame, 15.5 px off), on the photo and, through Auto Sync, on its virtual copy. +#[test] +fn whole_settings_json_round_trip_turns_the_correction() { + let src = temp_dir("upright-round-trip"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "a", 6, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let copy = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap()); + let original = placed(1, Orientation::Rotate90, 60.0, 40.0); + s.selection = crate::Selection { ids: vec![id, copy], active: Some(id) }; + s.execute("photo.rotateRight", &json!({})).unwrap(); + s.selection = crate::Selection { ids: vec![id, copy], active: Some(id) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + let mut whole = s.execute("develop.get", &json!({"id": id.0})).unwrap(); + whole["orientation"] = json!("Normal"); + s.execute("develop.merge", &json!({"settings": whole})).unwrap(); + for x in [id, copy] { + let d = dev(&s, x); + assert_eq!(d.orientation, Orientation::Normal); + assert!(same(d.geometry.upright_transform, original), "{x:?}: {:?}", d.geometry); + } + let _ = std::fs::remove_dir_all(&src); +} + +/// Copy, open another library, Paste: photo ids start again in each library, so a photo there with +/// the copied photo's id is another file and keeps its own correction (it used to get the copied +/// one). +#[test] +fn paste_after_opening_another_library_keeps_each_photos_own() { + let (lib_a, lib_b, src) = (temp_dir("upright-lib-a"), temp_dir("upright-lib-b"), temp_dir("upright-lib-src")); + let mut s = Session::new().with_fs(); + s.open_library(&lib_a, false).unwrap(); + let a = import_jpeg(&mut s, &src, "a", 1, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("develop.copy", &json!({"groups": ["geometry"]})).unwrap(); + s.open_library(&lib_b, false).unwrap(); + let b = import_jpeg(&mut s, &src, "b", 1, Some(&lightroom(1).replace(SLOTS[1], SLOTS[2]))); + assert_eq!(a, b, "the regression needs the same id in both libraries"); + s.execute("library.select", &json!({"ids": [b.0]})).unwrap(); + s.execute("develop.paste", &json!({})).unwrap(); + let own = upright_in_frame(&SavedUpright { mode: Upright::Auto, matrix: matrix(SLOTS[2]) }, Orientation::Normal, 60.0, 40.0).unwrap(); + assert!(same(dev(&s, b).geometry.upright_transform, own), "{:?}", dev(&s, b).geometry); + for d in [lib_a, lib_b, src] { + let _ = std::fs::remove_dir_all(&d); + } +} + +/// "No crop" needs no frame: on a photo whose file can't be read, a sidecar saying HasCrop="False" +/// removes the crop without a warning (it used to be held, warned about, and listed again on every +/// Synchronize Folder). +#[test] +fn a_sidecar_without_a_crop_needs_no_frame() { + let src = temp_dir("upright-no-crop"); + let mut s = Session::new().with_fs(); + let crop = crop_packet(r#"crs:CropLeft="0.206655" crs:CropTop="0" crs:CropRight="1" crs:CropBottom="1" crs:CropAngle="0""#); + let id = import_jpeg(&mut s, &src, "gone", 8, Some(&crop)); + assert!(!dev(&s, id).crop.geometry.is_identity()); + std::fs::write(src.join("gone.jpg"), b"not a photo any more").unwrap(); + let sidecar = src.join("gone.xmp"); + std::fs::write(&sidecar, crop.replace(r#"crs:HasCrop="True""#, r#"crs:HasCrop="False""#)).unwrap(); + let dir = src.to_string_lossy().to_string(); + let sync = |s: &mut Session| s.execute("folder.synchronize", &json!({"path": dir, "readMetadata": true, "importNew": false})).unwrap(); + bump(&sidecar, 5); + let r = sync(&mut s); + assert!(dev(&s, id).crop.geometry.is_identity(), "{r}"); + assert!(r.get("warnings").is_none(), "{r}"); + bump(&sidecar, 10); + let r = sync(&mut s); + assert_eq!(r["read"], 0, "{r}"); + let _ = std::fs::remove_dir_all(&src); +} + +/// Synchronize Folder learns a file's orientation through the session's file probe (the one import +/// uses, inside its memory budget), not by reading the file on its own. +#[test] +fn synchronize_folder_probes_files_through_the_session() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let src = temp_dir("upright-sync-probe"); + let mut s = Session::new().with_fs(); + let (_, fs_probe) = crate::files::fs_hooks(); + let probes = std::sync::Arc::new(AtomicUsize::new(0)); + let counted = probes.clone(); + s.media.file_probe = Some(std::sync::Arc::new(move |p: &str| { + counted.fetch_add(1, Ordering::Relaxed); + fs_probe(p) + })); + let crop = r#"crs:HasCrop="True" crs:CropLeft="0.206655" crs:CropTop="0" crs:CropRight="1" crs:CropBottom="1" crs:CropAngle="0""#; + let packet = |exposure: &str| lightroom(1).replace(r#"crs:Exposure2012="+0.30""#, &format!(r#"crs:Exposure2012="{exposure}" {crop}"#)); + let id = import_jpeg(&mut s, &src, "portrait", 8, Some(&packet("+0.30"))); + let before = dev(&s, id); + probes.store(0, Ordering::Relaxed); + let sidecar = src.join("portrait.xmp"); + std::fs::write(&sidecar, packet("+0.70")).unwrap(); + bump(&sidecar, 5); + let r = s.execute("folder.synchronize", &json!({"path": src.to_string_lossy(), "readMetadata": true, "importNew": false})).unwrap(); + assert_eq!(dev(&s, id).light.exposure, 0.7, "{r}"); + assert_eq!((dev(&s, id).crop, dev(&s, id).geometry), (before.crop, before.geometry)); + assert!(probes.load(Ordering::Relaxed) > 0, "the file's orientation was read around the session's probe"); + let _ = std::fs::remove_dir_all(&src); +} + +/// A preset saved before presets stopped holding a correction still has one: it is ignored at any +/// amount and as an import preset; a photo keeps its own, or gets the mode and solves its own. +#[test] +fn older_presets_holding_a_correction_are_ignored() { + let src = temp_dir("upright-old-preset"); + let mut s = Session::new().with_fs(); + let theirs = upright_in_frame(&SavedUpright { mode: Upright::Auto, matrix: matrix(SLOTS[2]) }, Orientation::Normal, 60.0, 40.0).unwrap(); + s.presets.push(lightcraft_develop::Preset { + id: "user.old-upright".into(), + name: "Old Upright".into(), + group: "User Presets".into(), + settings: json!({"geometry": {"upright": "auto", "upright_transform": theirs}}), + favorite: false, + builtin: false, + }); + let own = import_jpeg(&mut s, &src, "own", 1, Some(&lightroom(1))); + let off = import_jpeg(&mut s, &src, "off", 1, None); + for amount in [50.0, 100.0, 150.0] { + s.execute("library.select", &json!({"ids": [own.0, off.0]})).unwrap(); + s.execute("preset.apply", &json!({"id": "user.old-upright", "amount": amount})).unwrap(); + assert!(same(dev(&s, own).geometry.upright_transform, placed(1, Orientation::Normal, 60.0, 40.0)), "{amount}"); + assert_eq!((dev(&s, off).geometry.upright, dev(&s, off).geometry.upright_transform), (Upright::Auto, None), "{amount}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + // as an import preset, after the sidecar + let photo = src.join("late.jpg"); + write_jpeg_seeded(&photo, 60, 40, 1, 211); + std::fs::write(src.join("late.xmp"), lightroom(1)).unwrap(); + s.execute("library.import", &json!({"paths": [photo.to_string_lossy()], "preset": "user.old-upright"})).unwrap(); + let late = s.catalog.photos().find(|p| p.file_name == "late.jpg").unwrap().id; + assert!(same(dev(&s, late).geometry.upright_transform, placed(1, Orientation::Normal, 60.0, 40.0))); + let _ = std::fs::remove_dir_all(&src); +} + +/// What the Presets panel previews (`Session::preset_look`) is what Apply stores: a preset that +/// changes the mode leaves no correction, one that turns the photo turns it (the panel used to +/// preview `Preset::apply` alone: up to 15.4 px off). +#[test] +fn preset_previews_match_what_apply_stores() { + let src = temp_dir("upright-preset-preview"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "a", 1, Some(&lightroom(1))); + let preset = |pid: &str, settings: Value| lightcraft_develop::Preset { + id: pid.into(), + name: pid.into(), + group: "User Presets".into(), + settings, + favorite: false, + builtin: false, + }; + for p in [preset("vertical", json!({"geometry": {"upright": "vertical"}})), preset("turned", json!({"orientation": "Rotate90"}))] { + s.presets.push(p.clone()); + let preview = s.preset_look(id, &p, 1.0).unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("preset.apply", &json!({"id": p.id})).unwrap(); + assert_eq!(dev(&s, id), preview, "{}", p.id); + s.execute("edit.undo", &json!({})).unwrap(); + } + let turned = s.preset_look(id, s.presets.iter().find(|p| p.id == "turned").unwrap(), 1.0).unwrap(); + assert!(same(turned.geometry.upright_transform, placed(1, Orientation::Rotate90, 60.0, 40.0))); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/upright_transfer.rs b/crates/engine/src/upright_transfer.rs index cfb445cf..268c24a2 100644 --- a/crates/engine/src/upright_transfer.rs +++ b/crates/engine/src/upright_transfer.rs @@ -18,21 +18,33 @@ use lightcraft_geom::Orientation; use serde_json::Value; /// The photo settings were taken from, as far as its Upright correction is concerned. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct UprightSource { source: Source, master: PhotoId, /// Its Rotate Left / Right when the settings were taken. user: Orientation, + /// The library it was in (photo ids start again in each library). + library: std::sync::Weak<()>, } -/// Photo `p` with settings `s` as the source of a transfer. -pub fn source_of(p: &Photo, s: &DevelopSettings) -> UprightSource { - UprightSource { source: p.source.clone(), master: p.copy_of.unwrap_or(p.id), user: s.orientation } +impl UprightSource { + /// Whether it was taken in the library `identity` is the identity of + /// (`Session::library_identity`). Photo ids belong to one library, so a source from another + /// one can't be matched to a photo by id. + pub fn taken_in(&self, identity: &std::sync::Arc<()>) -> bool { + std::sync::Weak::ptr_eq(&self.library, &std::sync::Arc::downgrade(identity)) + } } -/// Whether photo `p` shows the same file as `from`: the photo itself, its original or another of -/// its virtual copies. +/// Photo `p` with settings `s`, in the library whose identity is `library`, as the source of a +/// transfer. +pub fn source_of(p: &Photo, s: &DevelopSettings, library: &std::sync::Arc<()>) -> UprightSource { + UprightSource { source: p.source.clone(), master: p.copy_of.unwrap_or(p.id), user: s.orientation, library: std::sync::Arc::downgrade(library) } +} + +/// Whether photo `p` (in the library `from` was taken in) shows the same file as `from`: the photo +/// itself, its original or another of its virtual copies, or another entry of the file. fn same_file(from: &UprightSource, p: &Photo) -> bool { from.master == p.copy_of.unwrap_or(p.id) || from.source == p.source } @@ -43,8 +55,8 @@ fn turn(from: Orientation, to: Orientation) -> Orientation { } /// Settings `after` of photo `p` (which had `before`) once `patch` was applied, with its Upright -/// correction settled as the module describes. `source`: the photo the patch was taken from -/// (`None`: none, as for a preset). +/// correction settled as the module describes. `source`: the photo the patch was taken from, in +/// this photo's library (`None`: none, as for a preset). pub fn settle(p: &Photo, before: &DevelopSettings, after: &mut DevelopSettings, patch: &Value, source: Option<&UprightSource>) { if patch.pointer("/geometry/upright_transform").is_none() { return settle_edit(before, after); @@ -73,6 +85,15 @@ pub fn settle_edit(before: &DevelopSettings, after: &mut DevelopSettings) { } } +/// Whether settings JSON `patch` states a correction other than the one `before` holds: settings +/// sent back whole (as `develop.get` returned them) state the photo's own, unchanged, and that one +/// is turned with the photo like any kept correction. +pub fn states_new_correction(patch: &Value, before: &DevelopSettings) -> bool { + patch + .pointer("/geometry/upright_transform") + .is_some_and(|v| serde_json::from_value::>(v.clone()).ok() != Some(before.geometry.upright_transform)) +} + /// `patch` applied to settings `before` of photo `p`, taken from `source` ([`settle`]). pub fn transferred(p: &Photo, before: &DevelopSettings, patch: &Value, source: Option<&UprightSource>) -> DevelopSettings { let mut after = lightcraft_develop::apply_partial(before, patch, 1.0); @@ -80,6 +101,14 @@ pub fn transferred(p: &Photo, before: &DevelopSettings, patch: &Value, source: O after } +/// `preset` at `amount` applied to settings `before` of photo `p`, as Apply Preset stores it: a +/// preset carries no Upright correction ([`settle`] with no source photo). Previews use it too. +pub fn apply_preset(p: &Photo, before: &DevelopSettings, preset: &lightcraft_develop::Preset, amount: f64) -> DevelopSettings { + let mut after = preset.apply(before, amount); + settle(p, before, &mut after, &preset.settings, None); + after +} + /// Preset settings without a saved Upright correction (the mode and the sliders stay). pub fn without_correction(mut settings: Value) -> Value { if let Some(g) = settings.get_mut("geometry").and_then(Value::as_object_mut) { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d..0a9ae387 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -188,7 +188,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "set_develop", "Set develop settings", - "Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop).", + "Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop). A change of `orientation` turns the photo's Upright correction with it, also when the settings are sent back whole; only a different `geometry.upright_transform` replaces it.", json!({ "id": photo_id, "values": {"type": "object", "additionalProperties": {"type": "number"}, "description": "controlId → value"}, diff --git a/crates/mcp/tests/upright_round_trip.rs b/crates/mcp/tests/upright_round_trip.rs new file mode 100644 index 00000000..1d642092 --- /dev/null +++ b/crates/mcp/tests/upright_round_trip.rs @@ -0,0 +1,54 @@ +//! MCP `set_develop` with the settings `get_develop` returned, the rotation changed: the photo's +//! Upright correction is turned with it, as Rotate does (issue #205). The settings state the +//! unchanged correction; it used to be kept in the old frame. + +use lightcraft_engine::Session; +use lightcraft_mcp::{Headless, call_tool}; +use serde_json::{Value, json}; + +const AUTO: &str = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000"; + +/// A 60 × 40 JPEG stored on its side (EXIF 6) with a Lightroom sidecar holding a saved Auto matrix. +fn fixture() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-mcp-upright-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (w, h) = (60usize, 40usize); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i % w * 4) as u8, (i / w * 6) as u8, 91, 255]).collect() }; + let exif = lightcraft_codecs::exif::minimal_exif(6); + let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() }; + let bytes = + lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, lightcraft_codecs::ChromaSubsampling::S444, &meta).unwrap(); + std::fs::write(dir.join("a.jpg"), bytes).unwrap(); + let xmp = format!( + r#""# + ); + std::fs::write(dir.join("a.xmp"), xmp).unwrap(); + dir.join("a.jpg") +} + +fn correction(b: &Headless) -> Value { + let p = b.session.catalog.photos().next().unwrap(); + serde_json::to_value(p.develop.geometry.upright_transform).unwrap() +} + +#[test] +fn set_develop_with_get_develop_settings_turns_the_correction() { + let path = fixture(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [path]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id.0; + s.execute("library.select", &json!({"ids": [id]})).unwrap(); + let mut b = Headless::new(s); + let original = correction(&b); + assert!(!original.is_null()); + assert!(!call_tool(&mut b, "command_run", &json!({"id": "photo.rotateRight", "params": {}})).is_error); + assert_ne!(correction(&b), original); + let got = call_tool(&mut b, "get_develop", &json!({"id": id})); + let mut whole = got.structured.clone().unwrap(); + whole["orientation"] = json!("Normal"); + let r = call_tool(&mut b, "set_develop", &json!({"id": id, "settings": whole})); + assert!(!r.is_error, "{:?}", r.structured); + assert_eq!(correction(&b), original); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 0968ab67..8eb32097 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -121,7 +121,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7)); } - let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None }; + // as Apply stores it (an Upright correction settled with the photo) + let look = if thumbs || resp.hovered() { + active.and_then(|id| app.session.preset_look(id, &pr, 1.0)).or_else(|| current.as_ref().map(|d| pr.apply(d, 1.0))) + } else { + None + }; let mut text_x = r.left() + 40.0; if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) { let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0)); @@ -213,7 +218,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)) && saved_id == id { - app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) }); + let settings = app.session.preset_look(id, &saved_preset, 1.0).unwrap_or_else(|| saved_preset.apply(curr, 1.0)); + app.hover_preview = Some(HoverPreview { label: saved_label, settings }); } } else { ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 194be3f6..961f716f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -215,7 +215,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | -| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP is kept as it is (LR-META-XMP); Rotate Left / Right turns a stored correction with the photo; a correction is the photo's own: Paste / Sync / Auto Sync turn it into virtual copies of the same file and carry only the mode and sliders to other files, presets keep the mode only | +| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | LightCraft's own solver; Lightroom's saved Upright from XMP is kept as it is (LR-META-XMP); Rotate Left / Right turns a stored correction with the photo; a correction is the photo's own: Paste / Sync / Auto Sync turn it into virtual copies of the same file and carry the rest of the Geometry group, not the correction, to other files; presets never hold one | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | | LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | diff --git a/docs/whats-new.md b/docs/whats-new.md index f93b6b0b..53679eeb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,12 +13,13 @@ says so. - Rotate Left and Rotate Right turn an Upright correction with the photo. It used to stay as it was, so the photo came out skewed after a turn. Settings JSON that changes the rotation (Apply Settings JSON, MCP `set_develop`, the - CLI) turns it the same way. + CLI) turns it the same way, also when the settings are sent back whole. - An Upright correction stays with its photo: it is the analysis of that one image. Paste, Sync and Auto Sync give a - virtual copy the correction turned its way. Other photos get the Upright mode and the Transform sliders and keep - their own correction, or work out their own when the mode changes, as Lightroom does when it syncs the Upright - mode. Presets keep the mode, never a correction. LightCraft used to copy its own correction to other photos as it - was, which applied one scene's correction to another. + virtual copy the correction turned its way. Other photos get the rest of the Geometry settings (the Upright mode, + the Transform sliders, guides) and keep their own correction, or work out their own when the mode changes; Lightroom + community experts report that Lightroom's Sync with only Upright Mode checked works out each photo too. Presets keep + the mode, never a correction. LightCraft used to copy its own correction to other photos as it was, which applied + one scene's correction to another. - A crop read from an XMP sidecar now lands where Lightroom put it on photos shown on their side (portrait raws), and straightened crops keep Lightroom's frame. A portrait crop used to cut the wrong side of the photo. Synchronize Folder reads sidecars the same way, and its report lists what a sidecar couldn't carry over. diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index bca8748d..edad4263 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -183,20 +183,26 @@ correction for that scene, in the frame that photo is shown in. So when settings - **Paste, Paste from Previous, Sync, Auto Sync** with the Geometry group: - Between a photo and its virtual copies (the same file), the correction moves, turned to each one's Rotate Left / Right. - - Between different files, only the Upright mode and the Transform sliders move. A target whose mode stays the - same keeps its own correction; one whose mode changes solves its own. This is what Lightroom does when it syncs - the Upright mode. Lightroom's separate option to copy the computed Upright transforms (identical geometry - across brackets) isn't offered. + - Between different files, everything in the Geometry group moves except the correction itself: the Upright + mode, the Transform sliders, Guided Upright's guides and Constrain Crop. A target whose mode stays the same + keeps its own correction; one whose mode changes solves its own. Lightroom community experts report the same + for Lightroom's Synchronize and Copy Settings with only Upright Mode checked, which recompute each photo; + Adobe's documentation doesn't say, and reports about Auto Sync conflict. Lightroom recomputes even a target + whose mode stays the same: LightCraft keeps that target's stored correction, which goes stale if the same Sync + changes its lens corrections (choose the Upright mode again then). Lightroom's separate option to copy the + computed Upright transforms (identical geometry across brackets) isn't offered. - LightCraft used to copy its own solved correction verbatim to other files, applying one scene's correction to another. That is no longer done. - **A crop paste** carries the Rotate Left / Right with it, and the target's own correction turns with it. -- **Presets** keep the Upright mode and the Transform sliders, never a correction. A preset made before this, - which still holds one, has it ignored in the same way. +- **Presets** keep the Upright mode, the Transform sliders and the rest of the Geometry group, never a correction. A + preset made before this, which still holds one, has it ignored in the same way, on Apply, as an import preset and + in the Presets panel's preview. - **Rotate Left / Right** turns the correction with the photo. - **Settings JSON** (`develop.merge`, MCP `set_develop`, the CLI): - - A change of rotation alone turns the correction the photo keeps, as Rotate does. + - A change of rotation turns the correction the photo keeps, as Rotate does, also when the settings are sent + back whole (as `develop.get` returned them, the photo's own correction in them unchanged). - A change of Upright mode drops the correction, to be solved again. - - A correction stated in the JSON is taken as given. + - A different correction stated in the JSON is taken as given. Not measured yet: the Transform sliders (Vertical, Horizontal, Rotate, Aspect, Scale, X / Y Offset) together with a saved Upright (LightCraft applies its own slider mapping on top, as with its own Upright), Level, Vertical and Full From 78350843e495e0deec64a3e4457b7a66178c1f06 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Tue, 13 Oct 2026 01:08:32 +0200 Subject: [PATCH 6/6] LR-META-XMP: settings JSON sent back again or re-rounded keeps the Upright correction in the photo's frame (#205) Fifth review round on 725eca27: - develop.merge: a stated correction that is the photo's own, read in any of the 8 frames, counts as not stated, so the photo's own is kept and turned to the stated orientation. "The photo's own" is within 1e-5 per element, each matrix scaled so its last element is 1: rounding to 6 decimals moves an element by about 1e-6 at most, and corrections 1e-5 apart move no corner by more than about 0.2 px at 6000 px. A copy of the settings read once and sent again after each change, or written with fewer decimals, used to store the correction in the old frame (15.5 px mean, 28.2 px max at 3200 x 4800). - set_develop help: mentions the mode rule, and points agents to Copy / Paste or Sync for copying edits between photos (settings JSON from one photo carries that photo's correction). - upright_transfer's module comment no longer says only the mode and sliders move between files, as Lightroom does. - Docs: the settings JSON rules in xmp-interop.md and whats-new.md. - Tests, failing on 725eca27: a local copy sent whole three times, re-rounded matrices (and corrections further away taken as given), a sweep of 24,576 sends over every file orientation, read orientation and later orientation, the same through MCP set_develop and through a lightcraft-cli run script. Co-Authored-By: Claude Opus 5.5 --- .../tests/upright_round_trip.rs | 72 +++++++++++ crates/engine/src/cmd/develop.rs | 8 +- crates/engine/src/tests_upright.rs | 115 ++++++++++++++++++ crates/engine/src/upright_transfer.rs | 80 ++++++++++-- crates/mcp/src/tools.rs | 2 +- crates/mcp/tests/upright_round_trip.rs | 42 ++++++- docs/whats-new.md | 3 +- docs/xmp-interop.md | 10 +- 8 files changed, 306 insertions(+), 26 deletions(-) create mode 100644 apps/lightcraft-cli/tests/upright_round_trip.rs diff --git a/apps/lightcraft-cli/tests/upright_round_trip.rs b/apps/lightcraft-cli/tests/upright_round_trip.rs new file mode 100644 index 00000000..4d33931a --- /dev/null +++ b/apps/lightcraft-cli/tests/upright_round_trip.rs @@ -0,0 +1,72 @@ +//! `lightcraft-cli run` with a script that sends the settings `develop.get` returned back whole after +//! each change: the photo's Upright correction stays in its frame (issue #205; the second send used +//! to put it back in the frame the settings were read in). + +use std::io::Write; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli"); + +const AUTO: &str = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000"; + +/// A 60 × 40 JPEG stored on its side (EXIF 6) with a Lightroom sidecar holding a saved Auto matrix. +fn fixture(dir: &std::path::Path) -> std::path::PathBuf { + let (w, h) = (60usize, 40usize); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i % w * 4) as u8, (i / w * 6) as u8, 57, 255]).collect() }; + let exif = lightcraft_codecs::exif::minimal_exif(6); + let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() }; + let bytes = + lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, lightcraft_codecs::ChromaSubsampling::S444, &meta).unwrap(); + std::fs::write(dir.join("a.jpg"), bytes).unwrap(); + let xmp = format!( + r#""# + ); + std::fs::write(dir.join("a.xmp"), xmp).unwrap(); + dir.join("a.jpg") +} + +/// `lightcraft-cli run` with `args` and the JSON-lines `script` on stdin: each command's result. +fn run(args: &[&str], script: &str) -> Vec { + let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap(); + let o = child.wait_with_output().unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + String::from_utf8_lossy(&o.stdout) + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .inspect(|l| assert_eq!(l["ok"], true, "{l}")) + .map(|l| l["result"].clone()) + .collect() +} + +fn line(command: &str, params: Value) -> String { + format!("{}\n", json!({"command": command, "params": params})) +} + +#[test] +fn a_script_sending_the_settings_back_whole_after_each_change_keeps_the_correction() { + let dir = std::env::temp_dir().join(format!("lightcraft-cli-upright-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let photo = fixture(&dir); + let lib = dir.join("library"); + let args = ["--library", lib.to_str().unwrap(), "--import", photo.to_str().unwrap(), "--script", "-"]; + // the original correction, then Rotate Right and the settings read once + let out = run(&args, &(line("develop.get", json!({})) + &line("photo.rotateRight", json!({})) + &line("develop.get", json!({})))); + let original = out[0]["geometry"]["upright_transform"].clone(); + assert!(original.is_array(), "{original}"); + let mut local = out[2].clone(); + assert_ne!(local["geometry"]["upright_transform"], original); + // sent back whole: the rotation set back to Normal, then also Exposure + local["orientation"] = json!("Normal"); + let mut script = line("develop.merge", json!({"settings": local})); + local["light"]["exposure"] = json!(0.5); + script += &(line("develop.merge", json!({"settings": local})) + &line("develop.get", json!({}))); + let out = run(&args, &script); + let now = &out[2]; + assert_eq!((&now["orientation"], &now["light"]["exposure"]), (&json!("Normal"), &json!(0.5))); + assert_eq!(now["geometry"]["upright_transform"], original); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 5b0a0688..0cfa24dc 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -247,7 +247,7 @@ pub fn specs() -> Vec { "Apply Settings JSON", [], None, - "{settings: partial DevelopSettings JSON, label?} (a change of `orientation` turns the photo's Upright correction with it unless `geometry.upright_transform` states a different one; a change of `geometry.upright` drops it to be solved again)", + "{settings: partial DevelopSettings JSON, label?} (a change of `orientation` turns the photo's Upright correction with it, also when `geometry.upright_transform` states the photo's own as it was read: before a turn or with its numbers rounded; a different one is taken as given; a change of `geometry.upright` drops it to be solved again)", has_active, |s, p| { let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?; @@ -257,10 +257,8 @@ pub fn specs() -> Vec { *d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?; // a turn turns the Upright correction the photo keeps, as Rotate does; a new mode // drops it; a correction stated in the JSON is taken as given, unless it is the - // photo's own unchanged (settings sent back whole, as develop.get returned them) - if !crate::upright_transfer::states_new_correction(&partial, &before) { - crate::upright_transfer::settle_edit(&before, d); - } + // photo's own as it was read (settings sent back whole, in any frame) + crate::upright_transfer::settle_json(&before, d, &partial); Ok(()) }) } diff --git a/crates/engine/src/tests_upright.rs b/crates/engine/src/tests_upright.rs index 94c8f496..592620b9 100644 --- a/crates/engine/src/tests_upright.rs +++ b/crates/engine/src/tests_upright.rs @@ -1145,6 +1145,121 @@ fn whole_settings_json_round_trip_turns_the_correction() { let _ = std::fs::remove_dir_all(&src); } +/// The matrix in settings JSON `v` with each number rounded to `digits` decimals, as a program that +/// writes numbers its own way would send it. +fn rounded(mut v: Value, digits: i32) -> Value { + let k = 10f64.powi(digits); + for x in v.pointer_mut("/geometry/upright_transform").and_then(Value::as_array_mut).into_iter().flatten() { + *x = json!((x.as_f64().unwrap() * k).round() / k); + } + v +} + +/// An agent reads the settings once and sends its copy back whole after each change: the rotation +/// set back to Normal, the same copy again, then with Exposure changed. After the first send the +/// copy's correction is no longer the photo's current one; it is still the photo's own, in the +/// frame it was read in, so every send leaves the correction in the photo's frame (the second and +/// third used to put it back in the old frame: 15.5 px mean, 28.2 px max at 3200 x 4800). +#[test] +fn a_local_copy_of_the_settings_sent_whole_again_keeps_the_correction_in_frame() { + let src = temp_dir("upright-local-copy"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "a", 6, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let original = placed(1, Orientation::Rotate90, 60.0, 40.0); + s.execute("photo.rotateRight", &json!({})).unwrap(); + let mut local = s.execute("develop.get", &json!({"id": id.0})).unwrap(); + local["orientation"] = json!("Normal"); + for send in ["first", "the same again", "with Exposure"] { + if send == "with Exposure" { + local["light"]["exposure"] = json!(0.5); + } + s.execute("develop.merge", &json!({"settings": local})).unwrap(); + let d = dev(&s, id); + assert_eq!(d.orientation, Orientation::Normal, "{send}"); + assert!(same(d.geometry.upright_transform, original), "{send}: {:?}", d.geometry.upright_transform); + } + assert_eq!(dev(&s, id).light.exposure, 0.5); + let _ = std::fs::remove_dir_all(&src); +} + +/// Settings JSON whose matrix another program wrote with fewer decimals (12, 9, 6) still names the +/// photo's own correction, so a change of rotation turns it (it used to be stored as given, in the +/// old frame). A correction that differs by more than rounding (1e-4 in one element), or another +/// analysis's, is taken as given. +#[test] +fn settings_json_with_re_rounded_numbers_names_the_photos_own_correction() { + let src = temp_dir("upright-re-rounded"); + let mut s = Session::new().with_fs(); + let id = import_jpeg(&mut s, &src, "a", 6, Some(&lightroom(1))); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let original = placed(1, Orientation::Rotate90, 60.0, 40.0); + s.execute("photo.rotateRight", &json!({})).unwrap(); + let mut read = s.execute("develop.get", &json!({"id": id.0})).unwrap(); + read["orientation"] = json!("Normal"); + for digits in [12, 9, 6] { + s.execute("develop.merge", &json!({"settings": rounded(read.clone(), digits)})).unwrap(); + let d = dev(&s, id); + assert!(same(d.geometry.upright_transform, original), "{digits}: {:?}", d.geometry); + s.execute("edit.undo", &json!({})).unwrap(); + } + let mut nudged = placed(1, Orientation::Rotate90, 60.0, 40.0); + nudged.0[1] += 1e-4; + let other = placed(2, Orientation::Rotate90, 60.0, 40.0); + for given in [nudged, other] { + let mut sent = read.clone(); + sent["geometry"]["upright_transform"] = serde_json::to_value(given).unwrap(); + s.execute("develop.merge", &json!({"settings": sent})).unwrap(); + assert_eq!(dev(&s, id).geometry.upright_transform, Some(given)); + s.execute("edit.undo", &json!({})).unwrap(); + } + let _ = std::fs::remove_dir_all(&src); +} + +/// Every file orientation, every orientation the settings were read at and two later ones: the +/// copy read once is sent whole with the first, the same copy again, then with the second and an +/// Exposure change, exact and with its numbers rounded to 6 decimals (24,576 sends). After each, +/// the correction is the photo's own in the stated orientation's frame, from coordinate maps alone. +#[test] +fn settings_json_sent_whole_in_every_frame() { + let m = matrix(SLOTS[1]); + let mut sends = 0; + for file in ORIENTATIONS { + for read_at in ORIENTATIONS { + for digits in [None, Some(6)] { + for first in ORIENTATIONS { + for then in ORIENTATIONS { + let mut s = Session::new(); + s.catalog + .apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(sweep_photo(1, "/sweep/a.jpg", file, read_at, m)) }) + .unwrap(); + let id = lightcraft_catalog::PhotoId(1); + s.selection = crate::Selection::single(id); + let read = s.execute("develop.get", &json!({"id": 1})).unwrap(); + let mut local = digits.map_or(read.clone(), |n| rounded(read, n)); + for (i, o) in [first, first, then].into_iter().enumerate() { + local["orientation"] = serde_json::to_value(o).unwrap(); + if i == 2 { + local["light"]["exposure"] = json!(0.5); + } + s.execute("develop.merge", &json!({"settings": local})).unwrap(); + let d = dev(&s, id); + assert_eq!(d.orientation, o); + assert!( + same(d.geometry.upright_transform, oracle(file, o, m)), + "file {file:?}, read at {read_at:?}, {digits:?} decimals, send {i} to {o:?}: {:?}", + d.geometry.upright_transform + ); + sends += 1; + } + } + } + } + } + } + assert_eq!(sends, 24_576); +} + /// Copy, open another library, Paste: photo ids start again in each library, so a photo there with /// the copied photo's id is another file and keeps its own correction (it used to get the copied /// one). diff --git a/crates/engine/src/upright_transfer.rs b/crates/engine/src/upright_transfer.rs index 268c24a2..a2fe205c 100644 --- a/crates/engine/src/upright_transfer.rs +++ b/crates/engine/src/upright_transfer.rs @@ -5,16 +5,20 @@ //! //! - between entries of the same file (a photo and its virtual copies) the correction moves too, //! turned by the difference in their Rotate Left / Right; -//! - between different files only the Upright mode and the Transform sliders move: the target -//! keeps its own correction while its mode stays the same, else solves its own, as Lightroom -//! does when it syncs the Upright mode (a correction found for one scene is wrong for another); +//! - between different files the rest of the Geometry group moves (the Upright mode, the Transform +//! sliders, Guided guides, Constrain Crop), not the correction: the target keeps its own while +//! its mode stays the same, else solves its own (a correction found for one scene is wrong for +//! another); //! - presets never carry a correction (they are for other photos); //! - a change that turns a photo turns the correction it keeps, and a change of mode drops it to -//! be solved again. +//! be solved again; +//! - settings JSON (`develop.merge`) that states the photo's own correction, in whatever frame it +//! was read and with its numbers rounded, names that correction, which is kept as above; any +//! other correction it states is taken as given. use lightcraft_catalog::{Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; -use lightcraft_geom::Orientation; +use lightcraft_geom::{Homography, Orientation}; use serde_json::Value; /// The photo settings were taken from, as far as its Upright correction is concerned. @@ -85,13 +89,65 @@ pub fn settle_edit(before: &DevelopSettings, after: &mut DevelopSettings) { } } -/// Whether settings JSON `patch` states a correction other than the one `before` holds: settings -/// sent back whole (as `develop.get` returned them) state the photo's own, unchanged, and that one -/// is turned with the photo like any kept correction. -pub fn states_new_correction(patch: &Value, before: &DevelopSettings) -> bool { - patch - .pointer("/geometry/upright_transform") - .is_some_and(|v| serde_json::from_value::>(v.clone()).ok() != Some(before.geometry.upright_transform)) +/// Settings `after` of a photo (which had `before`) once settings JSON `patch` was merged into them +/// (`develop.merge`), with its Upright correction settled. A correction the JSON states that is the +/// photo's own ([`names_own`]) counts as not stated: the photo keeps its correction, turned to the +/// stated orientation, or drops it for a new mode ([`settle_edit`]). Settings sent back whole carry +/// the correction as they were read: before a turn, or before an earlier send turned it, and maybe +/// with the numbers rewritten. Any other stated correction is taken as given. +pub fn settle_json(before: &DevelopSettings, after: &mut DevelopSettings, patch: &Value) { + if let Some(v) = patch.pointer("/geometry/upright_transform") { + let stated = serde_json::from_value::>(v.clone()).ok(); + if !stated.is_some_and(|h| names_own(h, before.geometry.upright_transform)) { + return; + } + after.geometry.upright_transform = before.geometry.upright_transform; + } + settle_edit(before, after); +} + +/// The eight frames a photo's correction can be read in (any Rotate Left / Right and flip). +const FRAMES: [Orientation; 8] = [ + Orientation::Normal, + Orientation::FlipH, + Orientation::Rotate180, + Orientation::FlipV, + Orientation::Transpose, + Orientation::Rotate90, + Orientation::Transverse, + Orientation::Rotate270, +]; + +/// Whether correction `stated` is the photo's own correction `own`, read in any of its frames +/// (`upright::turned` only swaps and negates elements, so each frame's copy is exact), up to +/// [`SAME_CORRECTION`]. Whichever frames match, keeping `own` turned to the stated orientation gives +/// the same result. +fn names_own(stated: Option, own: Option) -> bool { + match (stated, own) { + (None, None) => true, + (Some(s), Some(o)) => FRAMES.iter().any(|f| same_correction(&s, &lightcraft_pipeline::upright::turned(&o, *f))), + _ => false, + } +} + +/// How far apart two corrections may be, per element with each scaled so its last element is 1, +/// and still be the same correction. +/// +/// - A program that writes the numbers its own way may round them. At 6 decimals that moves an +/// element by at most 5e-7, about twice that after the scaling, and at 9 or 12 by far less: the +/// limit is ten times the 6-decimal error. +/// - Corrections this close make the same picture. Moving each element of a typical correction by +/// 1e-5 moves no corner of the photo by more than about 6e-5 of half its long edge: under 0.2 px at +/// 6000 px. So a correction stated on purpose and found this close to the photo's own (two +/// photos' Level corrections can be) loses at most that much by being taken as the photo's own, +/// and anything further apart is taken as given. +const SAME_CORRECTION: f64 = 1e-5; + +/// Whether corrections `a` and `b` are the same up to [`SAME_CORRECTION`] (never for a matrix that +/// can't be scaled: a last element of 0 or not finite). +fn same_correction(a: &Homography, b: &Homography) -> bool { + let ([.., sa], [.., sb]) = (a.0, b.0); + sa.is_finite() && sb.is_finite() && sa != 0.0 && sb != 0.0 && a.0.iter().zip(b.0.iter()).all(|(x, y)| (x / sa - y / sb).abs() <= SAME_CORRECTION) } /// `patch` applied to settings `before` of photo `p`, taken from `source` ([`settle`]). diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0a9ae387..a60383f0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -188,7 +188,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "set_develop", "Set develop settings", - "Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop). A change of `orientation` turns the photo's Upright correction with it, also when the settings are sent back whole; only a different `geometry.upright_transform` replaces it.", + "Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop). A change of `orientation` turns the photo's Upright correction with it, also when the settings are sent back whole (a copy read before a turn, or with its numbers rounded, still names the photo's own correction); a different `geometry.upright_transform` is taken as given, and a change of `geometry.upright` drops it to be solved again. To copy edits to other photos, run `develop.copy` and `develop.paste`, or `develop.sync`, with command_run: they keep each photo's own correction, while settings JSON taken from one photo carries that photo's.", json!({ "id": photo_id, "values": {"type": "object", "additionalProperties": {"type": "number"}, "description": "controlId → value"}, diff --git a/crates/mcp/tests/upright_round_trip.rs b/crates/mcp/tests/upright_round_trip.rs index 1d642092..21dcf7f3 100644 --- a/crates/mcp/tests/upright_round_trip.rs +++ b/crates/mcp/tests/upright_round_trip.rs @@ -8,9 +8,10 @@ use serde_json::{Value, json}; const AUTO: &str = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000"; -/// A 60 × 40 JPEG stored on its side (EXIF 6) with a Lightroom sidecar holding a saved Auto matrix. -fn fixture() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("lc-mcp-upright-{}", std::process::id())); +/// A 60 × 40 JPEG stored on its side (EXIF 6) with a Lightroom sidecar holding a saved Auto matrix, +/// in its own folder `name`. +fn fixture(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-mcp-upright-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let (w, h) = (60usize, 40usize); @@ -34,7 +35,7 @@ fn correction(b: &Headless) -> Value { #[test] fn set_develop_with_get_develop_settings_turns_the_correction() { - let path = fixture(); + let path = fixture("once"); let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [path]})).unwrap(); let id = s.catalog.photos().next().unwrap().id.0; @@ -52,3 +53,36 @@ fn set_develop_with_get_develop_settings_turns_the_correction() { assert_eq!(correction(&b), original); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + +/// An agent's local copy of `get_develop`, read once and sent back whole after each change (the +/// rotation, then Exposure, then the same copy with its matrix written to 9 decimals): the +/// correction stays the photo's own in its frame (the second send used to put it back in the old +/// one). +#[test] +fn set_develop_of_a_local_copy_after_each_change_keeps_the_correction() { + let path = fixture("local"); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [path]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id.0; + s.execute("library.select", &json!({"ids": [id]})).unwrap(); + let mut b = Headless::new(s); + let original = correction(&b); + assert!(!call_tool(&mut b, "command_run", &json!({"id": "photo.rotateRight", "params": {}})).is_error); + let mut local = call_tool(&mut b, "get_develop", &json!({"id": id})).structured.unwrap(); + local["orientation"] = json!("Normal"); + for change in ["rotation", "exposure", "9 decimals"] { + match change { + "exposure" => local["light"]["exposure"] = json!(0.5), + "9 decimals" => { + for x in local["geometry"]["upright_transform"].as_array_mut().unwrap() { + *x = json!((x.as_f64().unwrap() * 1e9).round() / 1e9); + } + } + _ => {} + } + let r = call_tool(&mut b, "set_develop", &json!({"id": id, "settings": local})); + assert!(!r.is_error, "{:?}", r.structured); + assert_eq!(correction(&b), original, "after the {change} send"); + } + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 53679eeb..d3711873 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,7 +13,8 @@ says so. - Rotate Left and Rotate Right turn an Upright correction with the photo. It used to stay as it was, so the photo came out skewed after a turn. Settings JSON that changes the rotation (Apply Settings JSON, MCP `set_develop`, the - CLI) turns it the same way, also when the settings are sent back whole. + CLI) turns it the same way, also when the settings are sent back whole, again after each change or with their + numbers rounded. - An Upright correction stays with its photo: it is the analysis of that one image. Paste, Sync and Auto Sync give a virtual copy the correction turned its way. Other photos get the rest of the Geometry settings (the Upright mode, the Transform sliders, guides) and keep their own correction, or work out their own when the mode changes; Lightroom diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index edad4263..69fd72b7 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -199,10 +199,14 @@ correction for that scene, in the frame that photo is shown in. So when settings in the Presets panel's preview. - **Rotate Left / Right** turns the correction with the photo. - **Settings JSON** (`develop.merge`, MCP `set_develop`, the CLI): - - A change of rotation turns the correction the photo keeps, as Rotate does, also when the settings are sent - back whole (as `develop.get` returned them, the photo's own correction in them unchanged). + - A change of rotation turns the correction the photo keeps, as Rotate does. That holds when the settings are + sent back whole: a copy read once and sent again after each change, read before a turn, or with its numbers + rounded (to 6 decimals or more) still states the photo's own correction, in whatever frame it was read. - A change of Upright mode drops the correction, to be solved again. - - A different correction stated in the JSON is taken as given. + - A different correction stated in the JSON is taken as given. One within 1e-5 per element of the photo's own, + which makes about the same picture (within 0.2 px at 6000 px), counts as the photo's own. + - Settings JSON taken from one photo and sent to another file carries the first photo's correction. To copy edits + between photos, use Copy / Paste or Sync, which keep each photo's own. Not measured yet: the Transform sliders (Vertical, Horizontal, Rotate, Aspect, Scale, X / Y Offset) together with a saved Upright (LightCraft applies its own slider mapping on top, as with its own Upright), Level, Vertical and Full