From 5773533e230d2c9521435204247c606a9fbd07cb Mon Sep 17 00:00:00 2001 From: Val Enfys Date: Sun, 11 Oct 2026 17:01:02 +0100 Subject: [PATCH] LR-IMP-FORMATS: preview-only photos record the app version that decided it (#662) Catalog format v9 adds Photo.preview_checked and Op::SetContent.preview_checked: the app version (CARGO_PKG_VERSION) whose decoder set preview_only, empty when it isn't set. Import and content_op (Reload, Relink) stamp it; undo swaps it back. Catalogs from before v9 read an empty stamp, so their preview-only photos count as stale (Photo::preview_only_stale) and will be rechecked once. The version guard test now also accepts a format version that only adds fields. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/journal.rs | 3 ++- crates/catalog/src/lib.rs | 7 ++++- crates/catalog/src/model.rs | 27 +++++++++++++++++++ crates/catalog/src/tests.rs | 21 +++++++++++++-- crates/catalog/src/tests_format_version.rs | 30 ++++++++++++++++++++-- crates/engine/src/cmd/convert.rs | 9 +++++-- crates/engine/src/files.rs | 6 +++++ crates/engine/src/import.rs | 2 +- crates/engine/src/media.rs | 1 + crates/engine/src/tests_denoise.rs | 1 + crates/ui-egui/src/headless.rs | 1 + crates/ui-egui/src/render.rs | 1 + 12 files changed, 100 insertions(+), 9 deletions(-) diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 98fd616a3..289ee4fb8 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -51,6 +51,7 @@ //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | +//! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -75,7 +76,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 = 8; +pub const VERSION: u32 = 9; /// 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 ed4cb75b4..1e0248147 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -214,6 +214,10 @@ pub enum Op { /// `None` = its raw data decodes (or it isn't a raw). #[serde(default, skip_serializing_if = "Option::is_none")] preview_only: Option, + /// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty + /// when it is `None`. + #[serde(default, skip_serializing_if = "String::is_empty")] + preview_checked: String, }, /// The lens data a photo's file carries (what Reload finds when it was read after import). SetEmbeddedLens { @@ -700,7 +704,7 @@ impl Catalog { let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format } } - Op::SetContent { id, width, height, file_size, content_hash, preview_only } => { + Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => { let p = self.photo_mut(id)?; Op::SetContent { id, @@ -709,6 +713,7 @@ impl Catalog { file_size: std::mem::replace(&mut p.file_size, file_size), content_hash: std::mem::replace(&mut p.content_hash, content_hash), preview_only: std::mem::replace(&mut p.preview_only, preview_only), + preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked), } } Op::SetEmbeddedLens { id, lens } => { diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e..0fcab3c4f 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -293,6 +293,12 @@ pub struct Photo { /// picture style baked in — so it is treated as a rendered (non-raw) source. #[serde(default, skip_serializing_if = "Option::is_none")] pub preview_only: Option, + /// The app version (`CARGO_PKG_VERSION`) whose decoder set [`Photo::preview_only`]; empty when + /// it isn't set, and in catalogs from before format v9. A preview-only photo whose stamp + /// differs from the running version is rechecked when its library opens (issue #662): newer + /// builds may decode the file, older ones may decode less. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub preview_checked: String, /// A Local record: the fingerprint of its state when the browse catalogued it, to tell /// whether the user changed anything since (see [`crate::local`]). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -332,6 +338,16 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool { relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera) } +/// This build's version: what a photo's [`Photo::preview_checked`] stamp is compared with. Every +/// workspace crate shares one version, so it is the app's. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The [`Photo::preview_checked`] stamp for a `preview_only` decided by this build: its version +/// when the photo is preview-only, empty otherwise. +pub fn preview_stamp(preview_only: &Option) -> String { + if preview_only.is_some() { APP_VERSION.to_string() } else { String::new() } +} + impl Photo { /// The History step the settings are at (the cursor, clamped to the list; `None` = no history). pub fn history_index(&self) -> Option { @@ -371,9 +387,20 @@ impl Photo { import_look: None, analysis: None, preview_only: None, + preview_checked: String::new(), local_baseline: None, } } + /// Sets [`Photo::preview_only`] as this build decided it, with its stamp. + pub fn set_preview_only(&mut self, preview_only: Option) { + self.preview_checked = preview_stamp(&preview_only); + self.preview_only = preview_only; + } + /// A preview-only photo whose flag was set by another app version (or before stamps existed), + /// so this build should probe its file again (issue #662). + pub fn preview_only_stale(&self) -> bool { + self.preview_only.is_some() && self.preview_checked != APP_VERSION + } /// A raw file developed from its sensor data: not a rendered image, and not a raw shown from /// its embedded preview ([`Photo::preview_only`]). pub fn develops_raw(&self) -> bool { diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..b2d7571d8 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -265,13 +265,30 @@ fn preview_only_survives_serde_and_set_content_undo() { let old: Catalog = serde_json::from_value(v).unwrap(); assert_eq!(old.photo(a).unwrap().preview_only, None); assert!(old.photo(a).unwrap().develops_raw()); - // Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it - let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None }; + // a newer build still can't decode it: the reason and the stamp of the version that decided + // it are recorded together (#662) + let op = Op::SetContent { + id: a, + width: 6000, + height: 4000, + file_size: 1, + content_hash: None, + preview_only: Some("Nikon Huffman-compressed NEF".into()), + preview_checked: "0.6.0".into(), + }; + c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); + // Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON) + // restores both + let op = + Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() }; let inv = c.apply(op).unwrap(); assert!(c.photo(a).unwrap().develops_raw()); + assert_eq!(c.photo(a).unwrap().preview_checked, ""); let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); c.apply(inv).unwrap(); assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF")); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); assert_eq!(c.photo(a).unwrap().width, 6000); } diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index e2461781a..e31d7fe0c 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -74,6 +74,29 @@ fn v3_library_loads_and_is_upgraded() { assert_eq!(snapshot_version(&m), u64::from(VERSION)); } +/// A v6 library (before preview-only photos recorded which version decided it, #662) opens with +/// an empty stamp on its preview-only photos, so they are rechecked once, and is rewritten in the +/// current format. +#[test] +fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() { + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/p/a.cr3".into() }, "a.cr3", "CR3", 160, 120, "2026-01-01"); + p.kind = MediaKind::Raw; + p.preview_only = Some("Cr3 files are not decoded yet".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let snap = c.to_snapshot(); + assert!(!snap.contains("preview_checked"), "an empty stamp isn't written"); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":6,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes()); + let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap(); + let p = loaded.photo(id).unwrap(); + assert_eq!(p.preview_only.as_deref(), Some("Cr3 files are not decoded yet")); + assert_eq!(p.preview_checked, ""); + assert_eq!(r.upgraded_from, Some(6)); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + #[test] fn versionless_log_only_library_is_upgraded() { let mut c = Catalog::new(); @@ -171,7 +194,8 @@ fn bad_crc_is_still_a_torn_tail() { /// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks /// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the -/// variant here under the new version. +/// variant here under the new version. A version that only adds serialized fields raises +/// `NEWEST_FIELDS` instead. #[test] fn op_variants_are_versioned() { fn since(op: &Op) -> u32 { @@ -214,6 +238,8 @@ fn op_variants_are_versioned() { Op::SetHistoryCursor { .. } => 8, } } + /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`. + const NEWEST_FIELDS: u32 = 9; let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); - assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); + assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version"); } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..a5846d22b 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -201,12 +201,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: /// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or /// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different -/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`]. +/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a +/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed +/// again (see [`lightcraft_catalog::Photo::preview_checked`]). pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option { + let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only); let same = info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) - && info.preview_only == ph.preview_only; + && info.preview_only == ph.preview_only + && preview_checked == ph.preview_checked; (!same).then_some(Op::SetContent { id, width: info.width, @@ -214,6 +218,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo file_size: info.file_size, content_hash: info.content_hash, preview_only: info.preview_only, + preview_checked, }) } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index e2144eb85..20d454001 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -2157,6 +2157,9 @@ mod tests { let p = s.catalog.photo(id).unwrap().clone(); let why = p.preview_only.clone().expect("marked preview only"); assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + // stamped with the version that decided it, so a later version rechecks it (#662) + assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); + assert!(!p.preview_only_stale()); assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); assert!(!p.develops_raw()); // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults @@ -2174,14 +2177,17 @@ mod tests { let mut s = crate::Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); assert_eq!(r["reloaded"], json!([id.0]), "{r}"); assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // relinking to a file that decodes clears it too let dng = files.join("DSC_0001.dng"); std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ecb80741e..039a32bee 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1386,7 +1386,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; - p.preview_only = info.preview_only.clone(); + p.set_preview_only(info.preview_only.clone()); apply_import_defaults(s, &mut p); if let Some(sc) = &sidecar { crate::sidecar::merge_into(&mut p, sc, &now); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 0688410ed..59d4c1d23 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1720,6 +1720,7 @@ mod thumbnail_hash_tests { file_size: 99, content_hash: Some("replacement".into()), preview_only: None, + preview_checked: String::new(), }) .unwrap(); s.media.forget(PhotoId(1)); diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index 1cba9f648..cc9ad2eed 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -705,6 +705,7 @@ fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() { file_size: p.file_size + 1, content_hash: Some("ffffffffffffffffffffffffffffffff".into()), preview_only: None, + preview_checked: String::new(), }, ) .unwrap(); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..783224377 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2365,6 +2365,7 @@ mod tests { file_size: ph.file_size, content_hash: ph.content_hash.clone(), preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), + preview_checked: lightcraft_catalog::APP_VERSION.into(), }) .unwrap(); h.settle(SETTLE); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index fd0f0e9bf..d1de43c68 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -962,6 +962,7 @@ mod thumbnail_tests { file_size: 99, content_hash: Some("changed".into()), preview_only: None, + preview_checked: String::new(), }) .unwrap(); assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10));