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 1ae72e48..5c44b9d7 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -242,14 +242,27 @@ 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| { - *d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?; - 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, 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"))?; + 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 as it was read (settings sent back whole, in any frame) + crate::upright_transfer::settle_json(&before, d, &partial); + Ok(()) + }) + } + ), cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| { let ids = s.targets(p); let ops = ids @@ -788,6 +801,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.library_identity)); Ok(json!({"groups": s.copy_groups})) } ), @@ -805,10 +819,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).map(|ph| crate::upright_transfer::source_of(ph, &ph.develop, &s.library_identity)); 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_transfer::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 +848,14 @@ pub fn specs() -> Vec { clip = lightcraft_develop::extract_groups(&full, &only); } let ids = s.targets(p); + // 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| 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_transfer::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 +874,17 @@ 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, &s.library_identity)); 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_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(); s.commit("Sync Settings", Op::Batch { ops })?; @@ -973,7 +997,12 @@ 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 carries no Upright correction: the photo keeps its own, turned with + // it, or solves its own for another mode + 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::>(); s.commit(&format!("Preset: {}", preset.name), Op::Batch { ops })?; ok() @@ -985,7 +1014,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| { @@ -1028,14 +1060,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/cmd/library.rs b/crates/engine/src/cmd/library.rs index 503c01e8..e606bf12 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -374,6 +374,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/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 b7488a02..cec3920f 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::{Affine, Homography, Orientation, Point, Rect}; 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::set_frame`]) + put(o, "geometry.upright_transform", Value::Null); } // ---- Crop (normalized edges of the unrotated image + straighten angle) @@ -445,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 @@ -485,8 +488,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 +517,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 +527,213 @@ 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) +} + +/// 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 +/// 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 { + let a = centred_to_stored(orientation, stored_w, stored_h)?; + normalized(a.inverse()?.mul(&Homography(saved.matrix)).mul(&a)) +} + +/// 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) }; + 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) +} + +/// 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)); +} + +/// 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 { @@ -604,7 +817,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 dedb41f9..ad132940 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -182,6 +182,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")?; @@ -215,6 +216,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 7e29617a..7763f579 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -359,7 +359,8 @@ 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 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); } @@ -986,7 +987,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 crop and saved Upright refer to the file's stored image + sc.set_frame(info.stored_frame()); + Some(sc) + } Err(e) => { log::warn!("import {path}: XMP: {e}"); None @@ -1356,7 +1361,8 @@ 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 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 156ac667..97056016 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -64,6 +64,7 @@ pub mod segment; pub mod sidecar; pub mod smart; pub mod sync; +pub mod upright_transfer; mod view; pub mod walk; @@ -214,6 +215,9 @@ pub struct Session { pub(crate) faces: faces_index::FacesState, /// Copied develop settings (partial JSON) for Paste. pub clipboard: 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 @@ -357,6 +361,7 @@ impl Session { #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), clipboard: None, + clipboard_source: None, meta_clipboard: None, browse: None, library_folder: None, @@ -793,18 +798,30 @@ impl Session { return Vec::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.library_identity)); 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_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() }) .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)?; @@ -1102,4 +1119,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 c82192a1..c9782d93 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, FileSource, 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}; @@ -542,7 +542,13 @@ 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); + 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 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()); } @@ -572,34 +578,25 @@ 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; +/// 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(), } - 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)); +} + +/// 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 { @@ -769,12 +766,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)), @@ -806,13 +808,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; } @@ -986,7 +991,7 @@ fn stack_ops(s: &mut crate::Session, data: &CatalogImport, ids: &HashMap Row { serde_json::from_value(v).unwrap() } @@ -1286,6 +1291,73 @@ mod tests { assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); } + /// 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_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"; + 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 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:?}"); + } + + /// 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 2fdccedc..174c102d 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1593,6 +1593,18 @@ 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::set_frame`]). + pub fn stored_frame(&self) -> Option { + crate::sidecar::StoredFrame::of_shown(self.orientation?, f64::from(self.width), f64::from(self.height)) + } } pub type FileProbe = Arc Result + Send + Sync>; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 9aaf7e93..4af31846 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -107,6 +107,52 @@ 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::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, +} + +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 }) + } +} + +/// 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)] @@ -183,23 +229,10 @@ 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); + // 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, @@ -208,15 +241,9 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu fields: Vec::new(), }); } - 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(); + 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() { out.warnings.push(crate::import::ImportWarning { path: String::new(), @@ -280,6 +307,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); } @@ -296,6 +332,55 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool { } impl SidecarData { + /// 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; + }; + 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() + } + + /// 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 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 + /// the crop held (as the packet wrote it). + pub fn hold_unframed_crop(&mut self) -> Option { + if self.frame.is_some() { + return None; + } + 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, + profile: None, + mode: None, + fields: vec!["Crop (the file's orientation couldn't be read)".into()], + }); + Some(held) + } + /// 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 { @@ -591,7 +676,18 @@ 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.needs_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(); + } let mut warnings = sc.warnings.clone(); for warning in &mut warnings { warning.path = orig.to_string(); diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index b049fe70..b6ff49b7 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. @@ -150,6 +153,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 { @@ -179,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() }) } } @@ -190,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()) @@ -336,11 +343,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 @@ -349,12 +356,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 }; @@ -434,7 +446,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 }; @@ -448,7 +460,14 @@ 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; 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); let mut q = p.clone(); crate::sidecar::merge_into(&mut q, &sc, ""); @@ -484,8 +503,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`]). @@ -550,6 +571,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, @@ -571,7 +593,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); @@ -615,7 +637,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() { @@ -624,15 +646,34 @@ 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 frame the +/// file has now, as import reads them. +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 { 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, path, probe); + Ok(sc) +} + +/// 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 None; + } + sc.set_frame(crate::sidecar::probe_frame(probe, path)); + sc.hold_unframed_crop() } impl SyncCommit { @@ -692,6 +733,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 new file mode 100644 index 00000000..592620b9 --- /dev/null +++ b/crates/engine/src/tests_upright.rs @@ -0,0 +1,1401 @@ +//! 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) { + 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) { + 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() }; + 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); +} + +/// 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); +} + +// ------------------------------------------------------------------ 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")); + // 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(); + } + 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); + } +} + +/// 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 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. +#[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); +} + +// ------------------------------------------------------------------ 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 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])); + 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: 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 + 3 * 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); +} + +// ------------------------------------------------------------------ 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); +} + +/// 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). +#[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/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a1..bc6302eb 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -285,9 +285,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"); @@ -296,7 +298,7 @@ fn foreign_profile_and_upright_import_diagnostics_are_actionable() { let xmp = r#" "#; @@ -348,12 +350,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/crates/engine/src/upright_transfer.rs b/crates/engine/src/upright_transfer.rs new file mode 100644 index 00000000..a2fe205c --- /dev/null +++ b/crates/engine/src/upright_transfer.rs @@ -0,0 +1,174 @@ +//! 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 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; +//! - 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::{Homography, Orientation}; +use serde_json::Value; + +/// The photo settings were taken from, as far as its Upright correction is concerned. +#[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<()>, +} + +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)) + } +} + +/// 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 +} + +/// 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, 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); + } + 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))); + } +} + +/// 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`]). +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` 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) { + g.remove("upright_transform"); + } + settings +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 4b8f623e..20ddf360 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -342,6 +342,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 { @@ -444,6 +453,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/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b71e3d16..fdc175c8 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 (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 new file mode 100644 index 00000000..21dcf7f3 --- /dev/null +++ b/crates/mcp/tests/upright_round_trip.rs @@ -0,0 +1,88 @@ +//! 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, +/// 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); + 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("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; + 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()); +} + +/// 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/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/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 3d90c00c..0b9fc3f4 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -171,7 +171,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)); @@ -262,7 +267,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/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 73a3981a..c0301f08 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -49,7 +49,11 @@ 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 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`, 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 and original smart-collection rules. Smart collections become smart albums when every rule has an exact equivalent: rating, pick diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1d3a829a..806c1261 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -168,6 +168,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | +| 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 | | 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-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`, `crates/meta/src/heif.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; HEIC, HEIF and AVIF: the primary image's `Exif` and XMP items (or those of the images it is made from, such as a grid's or an `iden` turn's input) are read from the container alone, in builds with or without the HEIC decoder (issue #693) | @@ -217,7 +218,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 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 | @@ -822,6 +823,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-LIB-FLAG: filled Pick badges and selected controls, outlined inactive controls (issue #1013); status unchanged | | 2026-10-12 | minor | LR-EXP-METADATA: "remove location" refuses an original HEIF whose GPS is in any EXIF or XMP item (a thumbnail's, a second item), and offers DNG only for raws; LR-META-EXIF-FULL: a derived primary image's inputs count as the photo; statuses unchanged | | 2026-10-12 | minor | LR-META-EXIF-FULL: HEIC, HEIF and AVIF files give their EXIF and XMP (camera, capture time, All Metadata), read from the container without decoding (#693); status 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/target-app-parity.md b/docs/target-app-parity.md index de0d7c13..3ea56d3a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -162,6 +162,8 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). | 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) | | 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) | +| 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 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% @@ -305,6 +307,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | 2026-10-11 | minor | Stability evidence: tests in CI on Linux, macOS and Windows (`ci.yml`, #1050), GPU equivalence on software adapters; not green on `main` yet, so the ×0.92 stability discount and the numbers are unchanged | | 2026-10-12 | minor | Minolta MRW decoded (raw-parity.md): 241 models verified, 222 preview-only; use-weighted shares and the rounded numbers unchanged | | 2026-10-12 | minor | File-exchange discount: "HEIC trouble (#693)" is no longer among its reasons (HEIC decodes in release builds since #609 and keeps its EXIF and XMP since #693); the factor (×0.92 / ×0.95) is not re-measured and stays as it is | +| 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 f9c6b115..2aaa654d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -90,6 +90,34 @@ camera's JPEG to well under a pixel. Optics ▸ Enable Lens Corrections turns it off and Profile Distortion scales it; photos imported before this pick it up with Photo ▸ Reload from Disk. Vignetting and chromatic aberration from Sony files are not read yet. +## 2026-10-12 + +### Lightroom Upright comes across +- 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. 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, 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 + 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. +- 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 diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d..0b1d0465 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,81 @@ 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 | -| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop | +| `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 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. `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 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 +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, 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. + +### 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, 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, 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 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. 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 +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 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.