This commit is contained in:
Brandon Thomas
2026-10-09 20:36:41 -04:00
7 changed files with 165 additions and 19 deletions
+2 -1
View File
@@ -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)]
+9
View File
@@ -206,6 +206,11 @@ pub enum Op {
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
},
/// The lens data a photo's file carries (what Reload finds when it was read after import).
SetEmbeddedLens {
id: PhotoId,
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
},
/// 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()));
+2 -1
View File
@@ -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);
+19
View File
@@ -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();
+4 -3
View File
@@ -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");
}
+128 -13
View File
@@ -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<Op> {
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<Value> {
@@ -229,21 +248,19 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
// 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<Op> = 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<Op> = 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<CommandSpec> {
"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");
}
}
+1 -1
View File
@@ -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` | |