Merge PR #1386
# Conflicts: # docs/parity-checklist.md # docs/target-app-parity.md # docs/whats-new.md
This commit is contained in:
commit
1bbe92214c
|
|
@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PerspectiveUpright="1" crs:UprightVersion="151388160" crs:UprightTransformCount="6" crs:UprightTransform_1="{AUTO}"/></rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
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<Value> {
|
||||
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::<Value>(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);
|
||||
}
|
||||
|
|
@ -242,14 +242,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
}
|
||||
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<CommandSpec> {
|
|||
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<PhotoId> = 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::<Vec<_>>();
|
||||
let n = ops.len();
|
||||
s.commit("Paste from Previous", Op::Batch { ops })?;
|
||||
|
|
@ -831,10 +848,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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::<Vec<_>>();
|
||||
let n = ops.len();
|
||||
s.commit("Paste Settings", Op::Batch { ops })?;
|
||||
|
|
@ -853,14 +874,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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::<Vec<_>>();
|
||||
let n = ops.len();
|
||||
s.commit("Sync Settings", Op::Batch { ops })?;
|
||||
|
|
@ -973,7 +997,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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::<Vec<_>>();
|
||||
s.commit(&format!("Preset: {}", preset.name), Op::Batch { ops })?;
|
||||
ok()
|
||||
|
|
@ -985,7 +1014,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
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<String> = 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()
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -374,6 +374,9 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
|||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<Value> {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<f64>().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<Upright> {
|
||||
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<Upright> {
|
||||
num(props, "crs:PerspectiveUpright").and_then(upright_mode)
|
||||
}
|
||||
|
||||
/// Whether a packet has Guided Upright guides (`crs:UprightFourSegments_<n>`, or a positive
|
||||
/// `crs:UprightFourSegmentsCount`). LightCraft doesn't read them.
|
||||
pub fn has_upright_guides(props: &Props) -> bool {
|
||||
let stated = |values: &Vec<String>| 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<crate::import::ImportUprightMode> {
|
||||
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_<value>`,
|
||||
/// 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<SavedUpright> {
|
||||
// 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::<u64>().ok()? != UPRIGHT_VERSION {
|
||||
return None;
|
||||
}
|
||||
let count = first(props, "crs:UprightTransformCount")?.parse::<usize>().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::<f64>().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<Homography> {
|
||||
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<Homography> {
|
||||
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<Homography> {
|
||||
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<Preset> {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
|||
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<ProbeInfo, String> {
|
|||
embedded_lens: None,
|
||||
xmp: None,
|
||||
preview_only: None,
|
||||
orientation: Some(o),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<Value>,
|
||||
/// 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<crate::upright_transfer::UprightSource>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
pub browse: Option<Browse>,
|
||||
/// 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<DevelopSettings> {
|
||||
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<Op> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Orientation> {
|
|||
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<String> {
|
||||
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<String> = 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<i64, Ph
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_geom::CropGeometry;
|
||||
use lightcraft_geom::{Affine, CropGeometry, Point, Rect};
|
||||
fn row(v: Value) -> Row {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
|
@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="+0.30" {fields}/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
};
|
||||
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<String> = 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();
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<lightcraft_geom::Orientation>,
|
||||
}
|
||||
|
||||
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> {
|
||||
crate::sidecar::StoredFrame::of_shown(self.orientation?, f64::from(self.width), f64::from(self.height))
|
||||
}
|
||||
}
|
||||
|
||||
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|
||||
|
|
|
|||
|
|
@ -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<crate::import::ImportWarning>,
|
||||
/// 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<crate::crs::SavedUpright>,
|
||||
/// The stored image the packet's crop and saved Upright refer to, when the caller knows it.
|
||||
pub frame: Option<StoredFrame>,
|
||||
/// [`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<lightcraft_geom::Homography>,
|
||||
}
|
||||
|
||||
/// 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<StoredFrame> {
|
||||
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<StoredFrame> {
|
||||
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<StoredFrame>) -> 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<Value> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ pub struct SyncInput {
|
|||
photos: Vec<(Arc<Photo>, 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<crate::media::FileProbe>,
|
||||
}
|
||||
|
||||
/// 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<crate::import::ImportWarning>,
|
||||
}
|
||||
|
||||
fn invalid(msg: impl Into<String>) -> 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<Arc<Photo>> = 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<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
|
||||
fn check_photos(
|
||||
photos: &[(Arc<Photo>, SidecarNaming)],
|
||||
labels: &Catalog,
|
||||
probe: Option<&crate::media::FileProbe>,
|
||||
progress: &SyncProgress,
|
||||
) -> Vec<Check> {
|
||||
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<String>)>,
|
||||
moved: Vec<FolderMoved>,
|
||||
missing: Vec<FolderPhoto>,
|
||||
/// (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<crate::media::FileProbe>,
|
||||
}
|
||||
|
||||
/// 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<crate::sidecar::SidecarData, String> {
|
||||
/// 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<crate::sidecar::SidecarData, String> {
|
||||
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<serde_json::Value> {
|
||||
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())),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:CameraProfile="Camera Faithful" crs:Exposure2012="1.68" crs:PerspectiveUpright="1"
|
||||
crs:UprightVersion="151388160" crs:UprightTransformCount="6"
|
||||
crs:UprightVersion="151388161" crs:UprightTransformCount="6"
|
||||
crs:UprightCenterNormX="0.554156" crs:UprightCenterNormY="0.529745"
|
||||
crs:UprightTransform_1="0.897233402,-0.013204641,-0.030578613,-0.096087436,0.953621255,0.003270197,-0.157075841,-0.043520282,1.000000000"/>
|
||||
</rdf:RDF></x:xmpmeta>"#;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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::<Option<Homography>>(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<Homography>, own: Option<Homography>) -> 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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
|||
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"},
|
||||
|
|
|
|||
|
|
@ -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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PerspectiveUpright="1" crs:UprightVersion="151388160" crs:UprightTransformCount="6" crs:UprightTransform_1="{AUTO}"/></rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
|
@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (`<file>.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 (`<file>.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 (`<file>.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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue