# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/mcp.md
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas
2026-10-14 21:55:01 -04:00
43 changed files with 689 additions and 58 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");
}
+40 -21
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,
})
}
@@ -271,6 +276,37 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// The ops that bring photo `id` and its virtual copies up to date with what their file is now
/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`.
/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it.
pub(crate) fn reload_ops(
cat: &lightcraft_catalog::Catalog,
id: lightcraft_catalog::PhotoId,
info: &crate::media::ProbeInfo,
ops: &mut Vec<Op>,
changed: &mut Vec<lightcraft_catalog::PhotoId>,
) {
let Some(ph) = cat.photo(id) else { return };
// 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 mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
changed.push(id);
}
// virtual copies share the file
for c in cat.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
changed.push(c.id);
}
}
}
/// 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> {
@@ -286,25 +322,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
// 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 mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(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)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
}
}
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
+2
View File
@@ -34,6 +34,7 @@ mod preset_files;
pub mod previews;
mod query;
mod slideshow;
pub mod recheck;
mod sync;
mod xmp;
@@ -159,6 +160,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(recheck::specs());
v.extend(sync::specs());
v.extend(metadata::specs());
v.extend(geocode::specs());
+129
View File
@@ -0,0 +1,129 @@
//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or
//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file.
//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a
//! library opens in another version, its stale preview-only photos are probed again and get
//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that
//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again.
//!
//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no
//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the
//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use
//! `library.recheckPreviewOnly`, which does all three at once.
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::media::{FileProbe, ProbeInfo};
use crate::{Result, Session};
/// A preview-only photo to probe, and what the library knew about it when it was listed (so a
/// result for a photo that changed meanwhile is dropped).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stale {
pub id: PhotoId,
pub path: String,
preview_only: Option<String>,
preview_checked: String,
}
impl Stale {
fn of(p: &Photo, path: &str) -> Stale {
Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() }
}
/// The photo is still as it was listed: same file, flag and stamp.
fn current(&self, p: &Photo) -> bool {
crate::cmd::missing::checked_path(p) == Some(self.path.as_str())
&& p.preview_only == self.preview_only
&& p.preview_checked == self.preview_checked
}
}
/// The library's preview-only photos whose flag another app version set (or every preview-only
/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they
/// were copied from). No file-system calls.
pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> {
cat.photos()
.filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale()))
.filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path)))
.collect()
}
/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no
/// file access (a demo or browser session), where nothing can be rechecked.
pub fn file_probe(s: &Session) -> Option<FileProbe> {
s.media.file_probe.clone()
}
/// Probe each listed photo's file as Reload would. Doesn't touch the session.
pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> {
let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect();
stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect()
}
/// What a recheck changed.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rechecked {
/// Photos brought up to date (virtual copies included).
pub updated: Vec<PhotoId>,
/// Of those, the ones that develop from their raw data now.
pub cleared: Vec<PhotoId>,
}
impl Rechecked {
pub fn to_json(&self) -> Value {
json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()})
}
}
/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is
/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing,
/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded,
/// removed) is skipped.
pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> {
let mut ops = Vec::new();
let mut out = Rechecked::default();
for (item, info) in results {
let Ok(info) = info else { continue };
if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) {
continue;
}
let first = out.updated.len();
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated);
if info.preview_only.is_none() {
out.cleared.extend_from_slice(&out.updated[first..]);
}
}
if !ops.is_empty() {
s.apply_system(Op::Batch { ops })?;
}
for id in &out.updated {
s.media.forget(*id);
}
Ok(out)
}
fn recheck(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.recheckPreviewOnly";
let all = p.get("all").and_then(Value::as_bool).unwrap_or(false);
// without file access every probe "succeeds" with nothing known, which would clear every flag
let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?;
let listed = stale(&s.catalog, all);
let n = listed.len();
let mut r = apply(s, probe(&fp, listed))?.to_json();
r["checked"] = json!(n);
Ok(r)
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"library.recheckPreviewOnly",
"Recheck Preview-Only Raws",
[],
None,
"{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}",
always,
recheck
)]
}
+8 -2
View File
@@ -1048,7 +1048,7 @@ impl crate::Session {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
/// them; an exported `travel|Italy|Rome` comes back as itself.
@@ -1633,7 +1633,7 @@ mod tests {
}
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec();
@@ -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();
+7 -1
View File
@@ -470,6 +470,12 @@ pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
}
/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread.
pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let progress = ScanProgress::default();
probe_all(Some(probe), paths, &progress.done, &progress.cancel)
}
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
/// `cancel` is set: the files not reached come back as errors.
fn probe_all(
@@ -1386,7 +1392,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);
+4 -2
View File
@@ -585,8 +585,8 @@ impl Session {
}
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
/// redo stack alone.
/// scan, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not
/// an undo step, and it leaves the redo stack alone.
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
let fwd = op.clone();
self.catalog.apply(op)?;
@@ -1090,6 +1090,8 @@ mod tests_presets_scale;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_recheck;
#[cfg(test)]
mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
+1
View File
@@ -1749,6 +1749,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();
+155
View File
@@ -0,0 +1,155 @@
//! Issue #662: preview-only raws are rechecked when a library opens in another app version.
use std::path::PathBuf;
use lightcraft_catalog::{APP_VERSION, Op, PhotoId};
use serde_json::json;
use crate::Session;
use crate::cmd::recheck;
use crate::files::tests::cr3_with_preview;
/// A version other than this build's: the flag was set by an older (or newer) LightCraft.
const OTHER: &str = "0.5.0";
struct Lib {
s: Session,
dir: PathBuf,
lib: PathBuf,
file: PathBuf,
id: PhotoId,
}
impl Drop for Lib {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// A library with one preview-only CR3, its flag stamped by [`OTHER`] as a library saved by
/// another version would be.
fn library(name: &str) -> Lib {
let dir = std::env::temp_dir().join(format!("lc-recheck-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (lib, files) = (dir.join("lib"), dir.join("files"));
std::fs::create_dir_all(&files).unwrap();
let file = files.join("IMG_0001.cr3");
std::fs::write(&file, cr3_with_preview(96, 64)).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = PhotoId(r["imported"][0].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
assert!(p.preview_only.is_some() && !p.preview_only_stale(), "just imported: decided by this version");
s.apply_system(Op::SetContent {
id,
width: p.width,
height: p.height,
file_size: p.file_size,
content_hash: p.content_hash.clone(),
preview_only: p.preview_only.clone(),
preview_checked: OTHER.into(),
})
.unwrap();
assert!(s.catalog.photo(id).unwrap().preview_only_stale());
Lib { s, dir, lib, file, id }
}
fn decodable_now(x: &Lib) {
std::fs::write(&x.file, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
}
/// The acceptance test: a stale flag on a file that decodes now is cleared without a manual
/// Reload, adds no undo step, leaves develop settings and history alone, and survives a reopen.
#[test]
fn stale_flag_on_a_file_that_decodes_now_is_cleared() {
let mut x = library("decodes");
x.s.execute("develop.set", &json!({"ids": [x.id.0], "control": "light.exposure", "value": 0.5})).unwrap();
let before = x.s.catalog.photo(x.id).unwrap().clone();
let undo = x.s.undo.len();
decodable_now(&x);
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": [x.id.0]}));
let p = x.s.catalog.photo(x.id).unwrap().clone();
assert_eq!(p.preview_only, None);
assert_eq!(p.preview_checked, "", "the stamp goes with the flag");
assert!(p.develops_raw());
assert_eq!(x.s.undo.len(), undo, "not an undo step");
assert_eq!(*p.develop, *before.develop);
assert_eq!(p.history.len(), before.history.len());
// journaled: a reopen keeps it
x.s.persist().unwrap();
x.s = Session::new().with_fs();
x.s.open_library(&x.lib, false).unwrap();
assert_eq!(x.s.catalog.photo(x.id).unwrap().preview_only, None);
}
/// A file that still doesn't decode is stamped by this version (with the decoder's current
/// reason), so the next open doesn't probe it again.
#[test]
fn still_undecodable_file_gets_a_fresh_stamp_and_isnt_probed_again() {
let mut x = library("still");
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": []}));
let p = x.s.catalog.photo(x.id).unwrap();
assert!(p.preview_only.is_some());
assert_eq!(p.preview_checked, APP_VERSION);
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["checked"], 0);
// `all` ignores stamps (testing a decoder build of the same version)
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({"all": true})).unwrap()["checked"], 1);
}
/// A file that can't be read (unplugged drive) changes nothing and is tried again next time.
#[test]
fn missing_file_keeps_its_flag_and_stamp_and_is_retried() {
let mut x = library("missing");
std::fs::remove_file(&x.file).unwrap();
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r, json!({"checked": 1, "updated": [], "cleared": []}));
let p = x.s.catalog.photo(x.id).unwrap();
assert!(p.preview_only.is_some());
assert_eq!(p.preview_checked, OTHER);
// back again, and decodable now
decodable_now(&x);
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["cleared"], json!([x.id.0]));
}
/// A photo that changed between the probe and applying it (here: relinked) is left alone.
#[test]
fn photo_changed_while_probing_is_skipped() {
let mut x = library("changed");
let listed = recheck::stale(&x.s.catalog, false);
assert_eq!(listed.iter().map(|i| i.id).collect::<Vec<_>>(), vec![x.id]);
let probed = recheck::probe(&recheck::file_probe(&x.s).unwrap(), listed);
decodable_now(&x);
let other = x.dir.join("files").join("IMG_0001-moved.cr3");
std::fs::write(&other, cr3_with_preview(96, 64)).unwrap();
x.s.execute("photo.relink", &json!({"id": x.id.0, "path": other.to_string_lossy()})).unwrap();
let relinked = x.s.catalog.photo(x.id).unwrap().clone();
assert_eq!(recheck::apply(&mut x.s, probed).unwrap(), recheck::Rechecked::default());
assert_eq!(*x.s.catalog.photo(x.id).unwrap().clone(), *relinked);
}
/// Virtual copies share the file: they follow the photo they were copied from.
#[test]
fn virtual_copies_follow_their_photo() {
let mut x = library("copies");
let r = x.s.execute("photo.virtualCopy", &json!({"ids": [x.id.0]})).unwrap();
let copy = PhotoId(r["ids"][0].as_u64().unwrap());
assert!(x.s.catalog.photo(copy).unwrap().preview_only.is_some());
assert_eq!(recheck::stale(&x.s.catalog, true).len(), 1, "copies aren't probed on their own");
decodable_now(&x);
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r["cleared"], json!([x.id.0, copy.0]));
assert!(x.s.catalog.photo(copy).unwrap().develops_raw());
}
/// Without file access every probe would "succeed" knowing nothing and clear every flag: refused.
#[test]
fn session_without_file_access_refuses() {
let mut x = library("nofs");
x.s.media.file_probe = None;
let e = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap_err().to_string();
assert!(e.contains("no file access"), "{e}");
assert!(x.s.catalog.photo(x.id).unwrap().preview_only.is_some());
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "{n} JPEG-Dateien",
"{s} s of AV1 video": "{s} s AV1-Video",
"Slideshow exported: {what}": "Diashow exportiert: {what}",
" · {n} skipped (could not be rendered)": " · {n} übersprungen (nicht darstellbar)"
" · {n} skipped (could not be rendered)": " · {n} übersprungen (nicht darstellbar)",
"{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden"
}
+5 -1
View File
@@ -2216,5 +2216,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Nummerierte JPEG-Dateien (name-001.jpg…) in einem neuen Ordner, der im nächsten Schritt benannt wird.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jede Folie für ihre Dauer mit ihren Überblendungen. Kein Ton: Musik wird noch nicht exportiert.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Folien verwenden Layout, Überlagerungen sowie Intro- und Abspannbild der Diashow, in der Reihenfolge der Ansicht.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste."
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste.",
"Checking raw files": "RAW-Dateien werden geprüft",
"1 raw can now be developed from its raw data": "1 RAW-Datei kann jetzt aus ihren Rohdaten entwickelt werden",
"This raw is now developed from its raw data": "Diese RAW-Datei wird jetzt aus ihren Rohdaten entwickelt",
"This raw variant still can't be decoded": "Diese RAW-Variante kann noch nicht dekodiert werden"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "{n} archivos JPEG",
"{s} s of AV1 video": "{s} s de vídeo AV1",
"Slideshow exported: {what}": "Presentación exportada: {what}",
" · {n} skipped (could not be rendered)": " · {n} omitidas (no se pudieron representar)"
" · {n} skipped (could not be rendered)": " · {n} omitidas (no se pudieron representar)",
"{n} raws can now be developed from their raw data": "{n} RAW ya se pueden revelar a partir de sus datos RAW"
}
+5 -1
View File
@@ -2093,5 +2093,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Archivos JPEG numerados (nombre-001.jpg…) en una carpeta nueva cuyo nombre se elige en el paso siguiente.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada diapositiva durante su duración con sus fundidos. Sin sonido: la música aún no se exporta.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Las diapositivas usan el diseño, las superposiciones y las pantallas de inicio y final de la presentación, en el orden de la vista.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Las fotos de la vista como diapositivas, con los ajustes de arriba. El progreso aparece en la pila de actividad."
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Las fotos de la vista como diapositivas, con los ajustes de arriba. El progreso aparece en la pila de actividad.",
"Checking raw files": "Comprobando archivos RAW",
"1 raw can now be developed from its raw data": "1 RAW ya se puede revelar a partir de sus datos RAW",
"This raw is now developed from its raw data": "Este RAW ya se revela a partir de sus datos RAW",
"This raw variant still can't be decoded": "Esta variante RAW aún no se puede decodificar"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "{n} fichiers JPEG",
"{s} s of AV1 video": "{s} s de vidéo AV1",
"Slideshow exported: {what}": "Diaporama exporté : {what}",
" · {n} skipped (could not be rendered)": " · {n} ignorées (rendu impossible)"
" · {n} skipped (could not be rendered)": " · {n} ignorées (rendu impossible)",
"{n} raws can now be developed from their raw data": "{n} fichiers RAW peuvent désormais être développés à partir de leurs données RAW"
}
+5 -1
View File
@@ -2055,5 +2055,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Fichiers JPEG numérotés (nom-001.jpg…) dans un nouveau dossier nommé à l'étape suivante.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vidéo AV1, chaque diapositive affichée pendant sa durée avec ses fondus. Sans son : la musique n'est pas encore exportée.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Les diapositives utilisent la mise en page, les incrustations et les écrans d'ouverture et de fin du diaporama, dans l'ordre de l'affichage.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Les photos affichées en diapositives, avec les réglages ci-dessus. La progression s'affiche dans la pile d'activités."
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Les photos affichées en diapositives, avec les réglages ci-dessus. La progression s'affiche dans la pile d'activités.",
"Checking raw files": "Vérification des fichiers RAW",
"1 raw can now be developed from its raw data": "1 fichier RAW peut désormais être développé à partir de ses données RAW",
"This raw is now developed from its raw data": "Ce fichier RAW est désormais développé à partir de ses données RAW",
"This raw variant still can't be decoded": "Cette variante RAW ne peut toujours pas être décodée"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "JPEG ファイル {n} 個",
"{s} s of AV1 video": "AV1 ビデオ {s} 秒",
"Slideshow exported: {what}": "スライドショーを書き出しました:{what}",
" · {n} skipped (could not be rendered)": " · {n} 枚をスキップ(レンダリングできません)"
" · {n} skipped (could not be rendered)": " · {n} 枚をスキップ(レンダリングできません)",
"{n} raws can now be developed from their raw data": "{n}枚のRAWをRAWデータから現像できるようになりました"
}
+5 -1
View File
@@ -2059,5 +2059,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "番号付きの JPEG ファイル(name-001.jpg…)を、次の手順で名前を付ける新しいフォルダーに書き出します。",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 ビデオ。各スライドを表示時間のあいだフェード付きで表示します。音声なし:音楽はまだ書き出されません。",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "スライドにはスライドショーのレイアウト、オーバーレイ、イントロ画面とエンディング画面が表示順で使われます。",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "表示中の写真を上の設定でスライドにします。進行状況はアクティビティ一覧に表示されます。"
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "表示中の写真を上の設定でスライドにします。進行状況はアクティビティ一覧に表示されます。",
"Checking raw files": "RAWファイルを確認中",
"1 raw can now be developed from its raw data": "1枚のRAWをRAWデータから現像できるようになりました",
"This raw is now developed from its raw data": "このRAWはRAWデータから現像されるようになりました",
"This raw variant still can't be decoded": "このRAWの形式はまだデコードできません"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "{n} arquivos JPEG",
"{s} s of AV1 video": "{s} s de vídeo AV1",
"Slideshow exported: {what}": "Apresentação exportada: {what}",
" · {n} skipped (could not be rendered)": " · {n} ignoradas (não foi possível renderizar)"
" · {n} skipped (could not be rendered)": " · {n} ignoradas (não foi possível renderizar)",
"{n} raws can now be developed from their raw data": "{n} RAW agora podem ser revelados a partir dos dados RAW"
}
+5 -1
View File
@@ -2096,5 +2096,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Arquivos JPEG numerados (nome-001.jpg…) em uma pasta nova cujo nome é escolhido na próxima etapa.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada slide exibido pela sua duração com seus esmaecimentos. Sem som: a música ainda não é exportada.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Os slides usam o layout, as sobreposições e as telas de abertura e encerramento da apresentação, na ordem da visualização.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "As fotos da visualização como slides, com os ajustes acima. O progresso aparece na pilha de atividades."
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "As fotos da visualização como slides, com os ajustes acima. O progresso aparece na pilha de atividades.",
"Checking raw files": "Verificando arquivos RAW",
"1 raw can now be developed from its raw data": "1 RAW agora pode ser revelado a partir dos dados RAW",
"This raw is now developed from its raw data": "Este RAW agora é revelado a partir dos dados RAW",
"This raw variant still can't be decoded": "Esta variante RAW ainda não pode ser decodificada"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "Файлов JPEG: {n}",
"{s} s of AV1 video": "{s} с видео AV1",
"Slideshow exported: {what}": "Слайд-шоу экспортировано: {what}",
" · {n} skipped (could not be rendered)": " · пропущено: {n} (не удалось отрисовать)"
" · {n} skipped (could not be rendered)": " · пропущено: {n} (не удалось отрисовать)",
"{n} raws can now be developed from their raw data": "RAW-файлов, которые теперь можно проявлять из RAW-данных: {n}"
}
+5 -2
View File
@@ -2220,7 +2220,6 @@
"New template name": "Имя нового шаблона",
"Save as Template": "Сохранить как шаблон",
"Import…": "Импорт…",
"Apply": "Применить",
"Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Для своих шаблонов нужна папка настроек, которой в этой сборке нет; встроенные работают.",
"Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Щёлкните шаблон, чтобы применить его; наведите указатель, чтобы увидеть его на выбранном фото. Щёлкните свой шаблон правой кнопкой, чтобы обновить, переименовать, экспортировать или удалить его.",
"Import Slideshow Templates": "Импорт шаблонов слайд-шоу",
@@ -2250,5 +2249,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумерованные файлы JPEG (имя-001.jpg…) в новой папке, имя которой задаётся на следующем шаге.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Видео AV1: каждый слайд показывается свою длительность, с переходами. Без звука: музыка пока не экспортируется.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайды используют макет, наложения, вступительный и финальный экраны слайд-шоу, в порядке просмотра.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографии в просмотре как слайды, с настройками выше. Ход выполнения виден в списке задач."
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографии в просмотре как слайды, с настройками выше. Ход выполнения виден в списке задач.",
"Checking raw files": "Проверка RAW-файлов",
"1 raw can now be developed from its raw data": "1 RAW-файл теперь можно проявлять из RAW-данных",
"This raw is now developed from its raw data": "Этот RAW-файл теперь проявляется из RAW-данных",
"This raw variant still can't be decoded": "Этот вариант RAW пока не удаётся декодировать"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "Файлів JPEG: {n}",
"{s} s of AV1 video": "{s} с відео AV1",
"Slideshow exported: {what}": "Слайд-шоу експортовано: {what}",
" · {n} skipped (could not be rendered)": " · пропущено: {n} (не вдалося відтворити)"
" · {n} skipped (could not be rendered)": " · пропущено: {n} (не вдалося відтворити)",
"{n} raws can now be developed from their raw data": "RAW-файлів, які тепер можна проявляти з RAW-даних: {n}"
}
+5 -1
View File
@@ -2249,5 +2249,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумеровані файли JPEG (назва-001.jpg…) у новій теці, назву якої задаєте на наступному кроці.",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Відео AV1: кожен слайд показується свою тривалість, з переходами. Без звуку: музика поки не експортується.",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайди використовують макет, накладання, вступний і фінальний екрани слайд-шоу, у порядку перегляду.",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографії в перегляді як слайди, з налаштуваннями вище. Поступ видно у списку завдань."
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографії в перегляді як слайди, з налаштуваннями вище. Поступ видно у списку завдань.",
"Checking raw files": "Перевірка RAW-файлів",
"1 raw can now be developed from its raw data": "1 RAW-файл тепер можна проявляти з RAW-даних",
"This raw is now developed from its raw data": "Цей RAW-файл тепер проявляється з RAW-даних",
"This raw variant still can't be decoded": "Цей варіант RAW поки що не вдається декодувати"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "{n} 个 JPEG 文件",
"{s} s of AV1 video": "{s} 秒 AV1 视频",
"Slideshow exported: {what}": "幻灯片放映已导出:{what}",
" · {n} skipped (could not be rendered)": " · 已跳过 {n} 张(无法渲染)"
" · {n} skipped (could not be rendered)": " · 已跳过 {n} 张(无法渲染)",
"{n} raws can now be developed from their raw data": "{n} 个 RAW 现在可以从 RAW 数据冲印"
}
+5 -1
View File
@@ -2095,5 +2095,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "带编号的 JPEG 文件(name-001.jpg…),放在下一步命名的新文件夹中。",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 视频,每张幻灯片按其时长显示并带有淡化效果。无声音:暂不导出音乐。",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻灯片使用幻灯片放映的版面、叠加以及片头和片尾画面,按视图顺序排列。",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "将视图中的照片按上方设置制作为幻灯片。进度显示在活动列表中。"
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "将视图中的照片按上方设置制作为幻灯片。进度显示在活动列表中。",
"Checking raw files": "正在检查 RAW 文件",
"1 raw can now be developed from its raw data": "1 个 RAW 现在可以从 RAW 数据冲印",
"This raw is now developed from its raw data": "此 RAW 现在从 RAW 数据冲印",
"This raw variant still can't be decoded": "这种 RAW 变体仍无法解码"
}
+2 -1
View File
@@ -265,5 +265,6 @@
"{n} JPEG files": "{n} 個 JPEG 檔案",
"{s} s of AV1 video": "{s} 秒 AV1 視訊",
"Slideshow exported: {what}": "幻燈片播放已匯出:{what}",
" · {n} skipped (could not be rendered)": " · 已略過 {n} 張(無法算繪)"
" · {n} skipped (could not be rendered)": " · 已略過 {n} 張(無法算繪)",
"{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印"
}
+5 -1
View File
@@ -2057,5 +2057,9 @@
"Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "編號的 JPEG 檔案(name-001.jpg…),放在下一步命名的新資料夾中。",
"AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 視訊,每張幻燈片依其時長顯示並附有淡化效果。無聲音:尚未匯出音樂。",
"Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻燈片使用幻燈片播放的版面、疊加以及片頭與片尾畫面,依檢視順序排列。",
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "將檢視中的照片依上方設定製作為幻燈片。進度顯示在活動清單中。"
"The photos in view as slides, with the settings above. Progress shows in the activity stack.": "將檢視中的照片依上方設定製作為幻燈片。進度顯示在活動清單中。",
"Checking raw files": "正在檢查 RAW 檔案",
"1 raw can now be developed from its raw data": "1 個 RAW 現在可以從 RAW 資料沖印",
"This raw is now developed from its raw data": "此 RAW 現在從 RAW 資料沖印",
"This raw variant still can't be decoded": "這種 RAW 變體仍無法解碼"
}
+9
View File
@@ -2370,6 +2370,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);
@@ -2386,10 +2387,18 @@ mod tests {
h.settle(SETTLE);
let n = ids(&mut h, "notice:previewOnly:");
assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}");
// with a Reload from Disk button (issue #662)
assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:edit".to_string()]);
// Info
h.app.ui.right = crate::state::RightPanel::Info;
h.settle(SETTLE);
assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string()));
assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:info".to_string()]);
// …which re-reads the file and says what came of it (a demo photo has none to re-read)
let r = h.request("ui.clickWidget", json!({"id": "button:reloadPreviewOnly:info"}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.toast.clone().map(|x| x.0).as_deref(), Some("This raw variant still can't be decoded"));
// a regular photo: no notice
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t);
h.app.ui.right = crate::state::RightPanel::Edit;
+4
View File
@@ -27,6 +27,7 @@ pub mod merge;
mod model_setup;
pub mod panels;
pub mod pick;
pub mod recheck;
pub mod region;
pub mod render;
pub mod shortcuts;
@@ -112,6 +113,8 @@ mod tests_preview_limit;
#[cfg(test)]
mod tests_quit_unsaved;
#[cfg(test)]
mod tests_recheck;
#[cfg(test)]
mod tests_sam_setup;
#[cfg(test)]
mod tests_scroll;
@@ -1033,6 +1036,7 @@ impl LightcraftApp {
sync::poll_run(self, ctx);
import::tick(self, ctx);
lightroom_import::tick(self, ctx);
recheck::tick(self);
tasks::poll(self, ctx);
self.preview_build_status(ctx);
self.save_status(ctx);
+19 -3
View File
@@ -133,9 +133,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
});
if let Some(why) = &preview_only {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 }).show(ui, |ui| {
crate::widgets::preview_only_notice(ui, "edit", why);
});
let reload = egui::Frame::NONE
.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 })
.show(ui, |ui| crate::widgets::preview_only_notice(ui, "edit", why))
.inner;
if reload {
reload_preview_only(app, ui.ctx(), id);
}
}
let n = app.session.selection.ids.len();
if n > 1 && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
@@ -1738,6 +1742,18 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) {
});
}
/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can
/// decode, whose drive was offline when the library opened), and say what came of it.
pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) {
let r = app.run("photo.reload", json!({"ids": [id.0]}));
let decoded = app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none());
match r {
Err(e) => app.toast_error(ctx, e),
Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")),
Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")),
}
}
#[cfg(test)]
mod curve_histogram_tests {
use super::*;
+3 -1
View File
@@ -534,7 +534,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
camera_card(ui, &p);
ui.add_space(6.0);
if let Some(why) = &p.preview_only {
crate::widgets::preview_only_notice(ui, "info", why);
if crate::widgets::preview_only_notice(ui, "info", why) {
crate::panels::edit::reload_preview_only(app, ui.ctx(), id);
}
ui.add_space(6.0);
}
if let Some(name) = &p.copy_name {
+46
View File
@@ -0,0 +1,46 @@
//! Preview-only raws rechecked when a library opens (issue #662): a raw imported while its format
//! couldn't be decoded is probed again when the library opens in another LightCraft version (see
//! `lightcraft_engine::cmd::recheck`). The probe runs on a worker thread, so opening never waits
//! for it; the result is applied back here, unless another library was opened meanwhile.
use lightcraft_engine::cmd::recheck;
use crate::LightcraftApp;
/// The activity stack's label while the files are read.
pub const LABEL: &str = "Checking raw files";
/// Once per opened library (called every frame): start the recheck when it has stale photos.
pub fn tick(app: &mut LightcraftApp) {
let library = app.session.library_generation();
if app.ui.rechecked_library == Some(library) {
return;
}
app.ui.rechecked_library = Some(library);
// a demo or browser session has no files to read
let Some(probe) = recheck::file_probe(&app.session) else { return };
let stale = recheck::stale(&app.session.catalog, false);
if stale.is_empty() {
return;
}
let work = move || recheck::probe(&probe, stale);
let done = move |app: &mut LightcraftApp, ctx: &egui::Context, results| {
if app.session.library_generation() != library {
return;
}
match recheck::apply(&mut app.session, results) {
Ok(r) => {
let n = r.cleared.len();
if n == 1 {
app.toast(ctx, crate::i18n::tr("1 raw can now be developed from its raw data"));
} else if n > 1 {
app.toast(ctx, crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n));
}
}
Err(e) => log::warn!("recheck of preview-only raws: {e}"),
}
};
if let Err(e) = crate::tasks::spawn(app, LABEL, Some("recheck"), work, done) {
log::warn!("{e}");
}
}
+1
View File
@@ -1017,6 +1017,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));
+5
View File
@@ -750,6 +750,10 @@ pub struct UiState {
#[cfg(test)]
#[serde(skip)]
pub find_missing_test_delay: Option<std::time::Duration>,
/// The library (its `Session::library_generation`) whose preview-only raws were rechecked
/// (`crate::recheck`).
#[serde(skip)]
pub rechecked_library: Option<u64>,
#[serde(skip)]
pub status: String,
#[serde(skip)]
@@ -1168,6 +1172,7 @@ impl Default for UiState {
last_find_missing_result: None,
#[cfg(test)]
find_missing_test_delay: None,
rechecked_library: None,
status: String::new(),
dialog: None,
}
+90
View File
@@ -0,0 +1,90 @@
//! Issue #662: opening a library rechecks its preview-only raws in the background.
use std::path::{Path, PathBuf};
use std::time::Duration;
use lightcraft_catalog::{Op, PhotoId};
use serde_json::json;
use crate::LightcraftApp;
struct Dir(PathBuf);
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// An app on a library at `dir/lib` with one photo whose preview-only flag an older version set.
/// Its file (a PNG) reads fine, as a raw a newer decoder handles would.
fn app_with_stale_photo(dir: &Path) -> (LightcraftApp, PhotoId) {
let _ = std::fs::remove_dir_all(dir);
std::fs::create_dir_all(dir.join("files")).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]);
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
let file = dir.join("files/one.png");
std::fs::write(&file, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
let mut session = lightcraft_engine::Session::new().with_fs();
session.open_library(dir.join("lib"), false).unwrap();
let r = session.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = PhotoId(r["imported"][0].as_u64().unwrap());
let p = session.catalog.photo(id).unwrap().clone();
session
.catalog
.apply(Op::SetContent {
id,
width: p.width,
height: p.height,
file_size: p.file_size,
content_hash: p.content_hash.clone(),
preview_only: Some("Cr3 files are not decoded yet".into()),
preview_checked: "0.5.0".into(),
})
.unwrap();
(LightcraftApp::new(session, Default::default()), id)
}
#[test]
fn opening_a_library_rechecks_stale_preview_only_raws_in_the_background() {
let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-{}", std::process::id())));
let (mut app, id) = app_with_stale_photo(&dir.0);
let ctx = egui::Context::default();
crate::recheck::tick(&mut app);
// it shows in the activity stack while it runs
let rows = app.session.activity.list();
assert!(rows.iter().any(|r| r.kind == "recheck" && r.label == crate::recheck::LABEL), "{rows:?}");
assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));
assert!(app.session.activity.list().iter().all(|r| r.kind != "recheck"), "the row goes with the task");
let p = app.session.catalog.photo(id).unwrap();
assert_eq!(p.preview_only, None);
assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "1 raw can now be developed from its raw data"), "{:?}", app.ui.toast);
// once per opened library
app.ui.toast = None;
crate::recheck::tick(&mut app);
assert!(app.tasks.is_empty());
}
/// A result for a library that was closed meanwhile is dropped, not applied to the new one.
#[test]
fn recheck_result_is_dropped_after_switching_libraries() {
let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-switch-{}", std::process::id())));
let (mut app, id) = app_with_stale_photo(&dir.0);
let ctx = egui::Context::default();
crate::recheck::tick(&mut app);
app.run("app.openLibrary", json!({"path": dir.0.join("other").to_string_lossy()})).unwrap();
assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));
assert!(app.ui.toast.is_none(), "{:?}", app.ui.toast);
assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched");
}
/// The notice's Reload from Disk re-reads the file and says what came of it.
#[test]
fn notice_reload_button_rereads_the_file() {
let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id())));
let (mut app, id) = app_with_stale_photo(&dir.0);
let ctx = egui::Context::default();
crate::panels::edit::reload_preview_only(&mut app, &ctx, id);
assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None);
assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast);
}
+8 -2
View File
@@ -94,8 +94,11 @@ pub fn preview_only_explanation(reason: &str) -> String {
}
/// A panel notice for a raw shown from its embedded preview (Edit, Info): an amber info icon,
/// "Preview only" and the explanation. Registered as `notice:previewOnly:{key}`.
pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) {
/// "Preview only", the explanation and a Reload from Disk button (`button:reloadPreviewOnly:{key}`:
/// for a file a newer version can decode that was offline when its library opened). Registered as
/// `notice:previewOnly:{key}`. `true` when the button was clicked.
pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) -> bool {
let mut reload = false;
let t = Tokens::get(ui.ctx());
let r = egui::Frame::NONE
.fill(t.canvas)
@@ -111,11 +114,14 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) {
});
ui.add_space(2.0);
ui.label(egui::RichText::new(preview_only_explanation(reason)).size(11.5).color(t.text_label));
ui.add_space(6.0);
reload = text_button(ui, &format!("reloadPreviewOnly:{key}"), "Reload from Disk", false).clicked();
})
.response;
let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG")));
register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect);
reload
}
// ------------------------------------------------------------------ colour helpers
+10
View File
@@ -224,6 +224,16 @@ sequence in a folder, or an AV1 video (no audio). It runs in the background and
`cmd_slideshow_exportStatus` gives the result (`pages`, `files`, or `frames` / `samples` / `seconds` for video) or the
error. With `wait: true` the call blocks and replies with the result.
### Preview-only raws after an update
A raw LightCraft couldn't decode when it was imported is shown from its embedded preview
(`previewOnly` in `catalog.query`). The desktop app rechecks such photos in the background when a
library opens in a newer (or older) LightCraft version, so a raw the new decoder handles develops
from its raw data without a manual Reload. Headless sessions don't do this on their own: call
`cmd_library_recheckPreviewOnly` (`{all: true}` ignores the version stamps, e.g. to test a decoder
build) → `{checked, updated, cleared}`. Like the app's recheck, it isn't an undo step and keeps
develop settings and history.
## Resources
`resources/list` / `resources/read` serve JSON snapshots:
File diff suppressed because one or more lines are too long