diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 5973975d6..3b97c9623 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -46,6 +46,7 @@ //! | 1 | up to v0.2.0 | | //! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` | //! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` | +//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -70,7 +71,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 3; +pub const VERSION: u32 = 4; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 884ec6583..d30d2fb87 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -206,6 +206,11 @@ pub enum Op { #[serde(default, skip_serializing_if = "Option::is_none")] preview_only: Option, }, + /// The lens data a photo's file carries (what Reload finds when it was read after import). + SetEmbeddedLens { + id: PhotoId, + lens: Option>, + }, /// The name shown for a colour label (`None` = its colour's name). SetLabelName { label: ColorLabel, @@ -596,6 +601,10 @@ impl Catalog { preview_only: std::mem::replace(&mut p.preview_only, preview_only), } } + Op::SetEmbeddedLens { id, lens } => { + let p = self.photo_mut(id)?; + Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) } + } Op::SetFile { id, file_name, source } => { if file_name.trim().is_empty() { return Err(CatalogError::Invalid("empty file name".into())); diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 4291f45fa..188d93414 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -283,7 +283,8 @@ impl Op { | Op::SetAnalysis { id, .. } | Op::SetFile { id, .. } | Op::Relink { id, .. } - | Op::SetContent { id, .. } => out(*id), + | Op::SetContent { id, .. } + | Op::SetEmbeddedLens { id, .. } => out(*id), Op::AddAlbum { album } => { album.photos.iter().for_each(|p| out(*p)); album.cover.into_iter().for_each(&mut *out); diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 4c2bb33eb..67ba0f791 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -457,6 +457,25 @@ fn random_sort_has_no_date_headers() { assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty()); } +/// Reload stores the lens data a file carries; the inverse (through the journal's JSON) takes it back. +#[test] +fn set_embedded_lens_is_undoable_and_journaled() { + let mut c = Catalog::default(); + let a = c.alloc_photo_id(); + c.apply(Op::AddPhoto { + photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")), + }) + .unwrap(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None }; + let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) }; + let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap(); + let inv = c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens)); + let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); + c.apply(inv).unwrap(); + assert_eq!(c.photo(a).unwrap().embedded_lens, None); +} + #[test] fn undated_photos_group_under_unknown_date_and_sort_together() { let mut c = Catalog::new(); diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index cb80ce67e..08e9abbc0 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -47,7 +47,7 @@ fn v1_library_loads_and_is_upgraded() { assert_eq!((r.replayed, r.upgraded_from), (1, None)); } -/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3. +/// A library saved before albums could be ordered by hand opens as it was and is rewritten in the current format. #[test] fn v2_library_loads_and_is_upgraded() { let (base, log, full) = legacy_parts(); @@ -57,7 +57,7 @@ fn v2_library_loads_and_is_upgraded() { let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap(); assert_eq!(c.to_snapshot(), full.to_snapshot()); assert_eq!((r.replayed, r.upgraded_from), (1, Some(2))); - assert_eq!(snapshot_version(&m), 3); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); } #[test] @@ -193,8 +193,9 @@ fn op_variants_are_versioned() { | Op::Batch { .. } => 1, Op::SetBrowsed { .. } => 2, Op::SetAlbumOrder { .. } => 3, + Op::SetEmbeddedLens { .. } => 4, } } - let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None }); + let newest = since(&Op::SetEmbeddedLens { id: PhotoId(0), lens: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index e946d1fa9..2b1b368b2 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -210,6 +210,25 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P ops } +/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when +/// it already has them. A catalog from before the lens data was read at import has none, so such a +/// photo's correction stayed off. When the photo gains lens data and its develop settings are still +/// what import gave it, the lens correction is turned on as import would; an edited photo keeps +/// its settings. +pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec { + if info.embedded_lens == ph.embedded_lens { + return Vec::new(); + } + let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }]; + // (a photo with a default-preset look keeps it: that look is its import state) + if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile { + let mut d = (*ph.develop).clone(); + d.optics.lens_profile = true; + ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() }); + } + ops +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -229,21 +248,19 @@ fn reload(s: &mut Session, p: &Value) -> Result { // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; // nothing already set is overwritten let meta_ops = fill_missing_meta(id, ph, &info); - let Some(op) = content_op(id, ph, info.clone()) else { - if !meta_ops.is_empty() { - ops.extend(meta_ops); - reloaded.push(id); - } - continue; - }; - ops.push(op); - ops.extend(meta_ops); + let mut mine: Vec = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect(); + if !mine.is_empty() { + ops.append(&mut mine); + reloaded.push(id); + } // virtual copies share the file for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { - ops.extend(content_op(c.id, c, info.clone())); - reloaded.push(c.id); + let theirs: Vec = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect(); + if !theirs.is_empty() { + ops.extend(theirs); + reloaded.push(c.id); + } } - reloaded.push(id); } for id in &reloaded { s.media.forget(*id); @@ -316,7 +333,7 @@ pub fn edit_specs() -> Vec { "Reload from Disk", ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}", has_selection, reload ), @@ -359,4 +376,102 @@ mod tests { ph.captured = Some("2026-10-04T08:16:11".into()); assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + + fn lens() -> lightcraft_develop::EmbeddedLens { + lightcraft_develop::EmbeddedLens { + warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), + vignette: None, + } + } + fn old_rw2() -> lightcraft_catalog::Photo { + // as an older build catalogued it: no lens data, correction off + lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(1), + Source::File { path: "/x.rw2".into() }, + "x.rw2", + "RW2", + 4592, + 3448, + "2026-10-06T00:00:00", + ) + } + + #[test] + fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() { + let ph = old_rw2(); + let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() }; + let ops = lens_ops(ph.id, &ph, &info); + assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}"); + let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") }; + assert!(settings.optics.lens_profile); + // once applied, the photo is still "unedited" and has what import would have given it + let mut c = lightcraft_catalog::Catalog::default(); + c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap(); + for op in ops { + c.apply(op).unwrap(); + } + let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap(); + assert_eq!(after.embedded_lens, Some(lens())); + assert!(!after.is_edited()); + assert_eq!(*after.develop, after.camera_defaults()); + assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time"); + } + + #[test] + fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() { + let mut ph = old_rw2(); + let mut d = (*ph.develop).clone(); + d.light.exposure = 0.7; + ph.develop = std::sync::Arc::new(d); + assert!(ph.is_edited()); + let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() }; + let ops = lens_ops(ph.id, &ph, &info); + assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}"); + } + + #[test] + fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() { + let mut ph = old_rw2(); + ph.embedded_lens = Some(lens()); + let mut d = ph.camera_defaults(); + d.optics.lens_profile = false; + ph.develop = std::sync::Arc::new(d); + let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() }; + assert!(lens_ops(ph.id, &ph, &info).is_empty()); + } + + /// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read + /// gets it on Reload, the render changes to the corrected one, and Undo takes it all back. + #[test] + fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() { + let root = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + let f = root.join("raw/rw2-panasonic-gx80.rw2"); + if !f.exists() { + eprintln!("skip: {} absent", f.display()); + return; + } + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let ph = s.catalog.photo(id).unwrap().clone(); + assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it"); + // back to how 0.4.0 catalogued it + let mut old = (*ph.develop).clone(); + old.optics.lens_profile = false; + s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap(); + s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap(); + s.media.forget(id); + let before = s.render_now(id, 160, 120).unwrap().image.data; + let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["reloaded"], json!([id.0])); + let now = s.catalog.photo(id).unwrap().clone(); + assert_eq!(now.embedded_lens, ph.embedded_lens); + assert!(now.develop.optics.lens_profile && !now.is_edited()); + assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs"); + s.execute("edit.undo", &json!({})).unwrap(); + let undone = s.catalog.photo(id).unwrap(); + assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step"); + } } diff --git a/docs/parity.md b/docs/parity.md index ded0a4745..fb56aafd3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -272,7 +272,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); no lens-profile database | ) | 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` | |