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 <noreply@anthropic.com>
This commit is contained in:
Val Enfys
2026-10-14 08:19:17 +01:00
co-authored by Claude Opus 5.5
parent a4b53b28cc
commit 5773533e23
12 changed files with 100 additions and 9 deletions
+2 -1
View File
@@ -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)]
+6 -1
View File
@@ -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<String>,
/// 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 } => {
+27
View File
@@ -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<String>,
/// 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>) -> 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<usize> {
@@ -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<String>) {
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 {
+19 -2
View File
@@ -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);
}
+28 -2
View File
@@ -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");
}
+7 -2
View File
@@ -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<Op> {
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,
})
}
+6
View File
@@ -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();
+1 -1
View File
@@ -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);
+1
View File
@@ -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));
+1
View File
@@ -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();
+1
View File
@@ -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);
+1
View File
@@ -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));