mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #974
# 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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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データから現像できるようになりました"
|
||||
}
|
||||
|
||||
@@ -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の形式はまだデコードできません"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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 пока не удаётся декодировать"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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 поки що не вдається декодувати"
|
||||
}
|
||||
|
||||
@@ -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 数据冲印"
|
||||
}
|
||||
|
||||
@@ -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 变体仍无法解码"
|
||||
}
|
||||
|
||||
@@ -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 資料沖印"
|
||||
}
|
||||
|
||||
@@ -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 變體仍無法解碼"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user