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.