mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a4b53b28cc
commit
5773533e23
@@ -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)]
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user