# Conflicts:
#	crates/pipeline/src/spots.rs
This commit is contained in:
bflatastic
2026-10-13 12:28:46 +07:00
20 changed files with 436 additions and 49 deletions
+121
View File
@@ -472,6 +472,117 @@ fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// The thumbnail cache folder: where it is, the library's default, whether it is custom, what it
/// holds, whether it can be used now (a chosen folder on an unplugged drive is `available: false`).
#[cfg(not(target_arch = "wasm32"))]
fn thumbs_location_json(s: &Session) -> Value {
let dir = s.thumb_cache_dir();
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.thumbs_dir());
let (count, bytes) = dir.as_deref().map_or((0, 0), lightcraft_preview::disk::cache_stats);
json!({
"path": dir.as_ref().map(|d| d.to_string_lossy()),
"default": default.map(|d| d.to_string_lossy().to_string()),
"custom": s.thumbs_dir.is_some(),
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.thumbs_dir.is_none(),
"count": count,
"bytes": bytes,
})
}
/// Apply `what` to the cached thumbnails in `from` for the new folder `to` → (moved or removed,
/// failed). Only the cache's own files are touched (`lightcraft_preview::disk::cache_files`).
#[cfg(not(target_arch = "wasm32"))]
fn thumbs_migrate(from: &std::path::Path, to: &std::path::Path, what: crate::smart::Existing) -> (usize, Vec<String>) {
use crate::smart::Existing;
let (mut done, mut failed) = (0, Vec::new());
if what == Existing::Leave {
return (done, failed);
}
for (src, _, _) in lightcraft_preview::disk::cache_files(from) {
let r = match what {
Existing::Discard => std::fs::remove_file(&src),
_ => {
let (Some(name), Some(shard)) = (src.file_name(), src.parent().and_then(|p| p.file_name())) else { continue };
let dir = to.join(shard);
let dst = dir.join(name);
// create only the shard, never missing parents on an unplugged drive
let prepare = if dir.is_dir() { Ok(()) } else { std::fs::create_dir(&dir) };
// keyed by content: one already there is the same thumbnail; across drives a copy
// goes through a temp file (the cache never sees a partial one)
prepare.and_then(|()| {
if dst.is_file() {
std::fs::remove_file(&src)
} else {
std::fs::rename(&src, &dst).or_else(|_| {
std::fs::read(&src)
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
.and_then(|()| std::fs::remove_file(&src))
})
}
})
}
};
match r {
Ok(()) => done += 1,
Err(err) => failed.push(format!("{}: {err}", src.display())),
}
}
(done, failed)
}
/// Show or change where the thumbnail cache is kept (per library), like
/// [`smart_location`]: no fallback to another drive, and an explicit choice for what is cached.
#[cfg(not(target_arch = "wasm32"))]
fn thumbs_location(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.thumbnailCacheLocation";
let current = s.thumb_cache_dir().ok_or_else(|| bad(C, "the thumbnail cache folder needs a library on disk"))?;
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
if !reset && chosen.is_none() {
return Ok(thumbs_location_json(s));
}
let default = s
.library
.as_ref()
.filter(|l| l.on_disk)
.map(|l| l.thumbs_dir())
.ok_or_else(|| bad(C, "the thumbnail cache folder needs a library on disk"))?;
let (new_dir, custom) = match chosen {
Some(x) if !reset => {
let d = std::path::PathBuf::from(x.trim());
if !d.is_absolute() {
return Err(bad(C, "path must be an absolute folder path"));
}
(d.clone(), (d != default).then_some(d))
}
_ => (default, None),
};
if new_dir == current {
return Ok(thumbs_location_json(s));
}
// the new place must work before anything moves; there is no fallback to another drive
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
let (held, _) = lightcraft_preview::disk::cache_stats(&current);
let existing = match str_param(p, "existing") {
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
None if held == 0 => Some(crate::smart::Existing::Leave),
None => None,
};
let Some(existing) = existing else {
return Err(bad(C, format!("{held} cached thumbnail(s) are in {}: pass existing = move, leave or discard", current.display())));
};
let (moved, failed) = thumbs_migrate(&current, &new_dir, existing);
s.thumbs_dir = custom;
// the old cache is retired (render jobs holding it can't write there any more); the new
// folder fills as thumbnails are rendered
s.media.attach_disk_cache(&new_dir, s.cache_bytes(), s.thumbs_dir.is_some());
s.save_prefs()?;
let mut r = thumbs_location_json(s);
r["handled"] = json!(moved);
r["failed"] = json!(failed);
Ok(r)
}
/// Whether photo `id` has a smart preview.
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
match (&s.media.smart_dir, s.catalog.photo(id)) {
@@ -502,6 +613,16 @@ pub fn specs() -> Vec<CommandSpec> {
always,
smart_location
),
#[cfg(not(target_arch = "wasm32"))]
cmd!(
"library.thumbnailCacheLocation",
"Thumbnail Cache Location",
[],
None,
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its thumbnail cache → {path, default, custom, available, count, bytes}. With path (or reset = back to `thumbs` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive; a chosen folder missing when the library opens is reported and thumbnails stay in memory). Thumbnails already in the old folder need `existing`: move them, leave them (the new folder fills as the grid renders), or discard them → also {handled, failed}",
always,
thumbs_location
),
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
+4
View File
@@ -296,6 +296,9 @@ pub struct Session {
pub cache_mb: u32,
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
pub smart_previews_dir: Option<std::path::PathBuf>,
/// Where the thumbnail cache is kept when not in the library's `thumbs` folder (persisted in
/// prefs.json; see [`Session::thumb_cache_dir`]).
pub thumbs_dir: Option<std::path::PathBuf>,
/// Untouched Local records of folders not browsed for this many days are forgotten when the
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
pub forget_local_days: u32,
@@ -393,6 +396,7 @@ impl Session {
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
smart_previews_dir: None,
thumbs_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
#[cfg(not(target_arch = "wasm32"))]
apple_photos: None,
+26 -5
View File
@@ -6,7 +6,7 @@
//! catalog.snap catalog.log (lightcraft-catalog journal)
//! presets.json view.json (user presets + favourites; last source/sort/selection)
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! thumbs/ (rendered thumbnail cache, safe to delete; prefs.json may name another folder)
//! Originals/ (photos imported with "copy into library")
//! ```
//!
@@ -192,6 +192,7 @@ impl Library {
pub(crate) fn journal_mut(&mut self) -> &mut Journal {
&mut self.journal
}
/// The library's own thumbnail cache folder (the default; see [`Session::thumb_cache_dir`]).
pub fn thumbs_dir(&self) -> PathBuf {
self.dir.join("thumbs")
}
@@ -229,6 +230,8 @@ struct PrefsFile {
cache_mb: u32,
/// Folder for smart previews (default: `Smart Previews` in the library).
smart_previews_dir: Option<String>,
/// Folder for the thumbnail cache (default: `thumbs` in the library).
thumbs_dir: Option<String>,
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
/// the default, 0 = never).
forget_local_days: Option<u32>,
@@ -459,6 +462,15 @@ impl Session {
if let Some(d) = &self.smart_previews_dir {
self.media.smart_dir = Some(d.clone());
}
self.thumbs_dir = prefs.thumbs_dir.filter(|_| on_disk).map(PathBuf::from);
// a chosen folder that is gone is reported, and stays the cache folder: thumbnails are
// then kept in memory only (nothing is written to the library drive instead)
if let Some(d) = self.thumbs_dir.as_ref().filter(|d| !d.is_dir()) {
settings.warnings.push(format!(
"The thumbnail cache folder {} is not available (drive disconnected?): thumbnails are kept in memory only until it is back or you choose another folder in Settings",
d.display()
));
}
// presets
if let Some(f) = settings.read::<PresetsFile>(files.as_mut(), "presets.json") {
for p in &mut self.presets {
@@ -503,7 +515,8 @@ impl Session {
// photo ids are per library: drop decoded sources of the previous one
self.media.clear_sources();
if on_disk {
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
let thumbs = self.thumbs_dir.clone().unwrap_or_else(|| dir.join("thumbs"));
self.media.attach_disk_cache(&thumbs, self.cache_bytes(), self.thumbs_dir.is_some());
}
let view_written = self.view_json();
self.library = Some(Library {
@@ -698,6 +711,7 @@ impl Session {
import: self.import_defaults.clone(),
cache_mb: self.cache_mb,
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
thumbs_dir: self.thumbs_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
forget_local_days: Some(self.forget_local_days),
})
.unwrap_or_default();
@@ -719,6 +733,14 @@ impl Session {
new
}
/// Where the open library keeps its thumbnail cache: the chosen folder
/// ([`Session::thumbs_dir`], which may be unavailable), else `thumbs` in the library. `None`
/// without a library on disk.
pub fn thumb_cache_dir(&self) -> Option<PathBuf> {
let lib = self.library.as_ref().filter(|l| l.on_disk)?;
Some(self.thumbs_dir.clone().unwrap_or_else(|| lib.thumbs_dir()))
}
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
pub fn cache_bytes(&self) -> u64 {
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
@@ -729,9 +751,8 @@ impl Session {
/// Saved with the library preferences.
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
self.cache_mb = mb;
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
let dir = lib.thumbs_dir();
self.media.set_disk_cache_bytes(&dir, self.cache_bytes());
if let Some(dir) = self.thumb_cache_dir() {
self.media.set_disk_cache_bytes(&dir, self.cache_bytes(), self.thumbs_dir.is_some());
}
self.save_prefs()
}
+9 -6
View File
@@ -22,7 +22,7 @@ use std::sync::{Arc, Weak};
use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_preview::{DiskCache, Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
@@ -431,19 +431,22 @@ fn rendered_budget(share: usize) -> usize {
impl MediaCache {
/// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired
/// (its files stay): render jobs still holding it can't write through it any more, even
/// into a directory a later clear emptied (`dir` may be the same one).
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
/// into a directory a later clear emptied (`dir` may be the same one). A `chosen` folder
/// (the user's, not the library's own) is never created: while it is missing, thumbnails
/// are kept in memory only.
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64, chosen: bool) {
self.rendered.retire();
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
let disk = if chosen { DiskCache::chosen(dir, disk_bytes) } else { DiskCache::new(dir, disk_bytes) };
self.rendered = Arc::new(PreviewCache::with_disk_cache(rendered_budget(self.budget), disk));
}
/// Change the disk budget of the cache in `dir`: in place when that cache is attached (its
/// thumbnails are keyed by content, so they and the textures shown from them stay valid,
/// and the same cache object keeps its generation), else attach it.
pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) {
pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64, chosen: bool) {
match self.rendered.disk().filter(|d| d.dir() == dir) {
Some(d) => d.set_budget(disk_bytes),
None => self.attach_disk_cache(dir, disk_bytes),
None => self.attach_disk_cache(dir, disk_bytes, chosen),
}
}
+76
View File
@@ -889,6 +889,82 @@ fn smart_previews_folder_is_chosen_per_library() {
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
}
/// The thumbnail cache can live outside the library, like smart previews (issue #903): the
/// folder is saved per library, relative paths and missing drives are refused, the thumbnails
/// already cached need an explicit choice, and a chosen folder that is gone when the library
/// opens is reported (never a silent fallback to the library drive, nothing created there).
#[test]
fn thumbnail_cache_folder_is_chosen_per_library() {
let lib = temp_dir("thumbloc-lib");
let away = temp_dir("thumbloc-away");
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
const C: &str = "library.thumbnailCacheLocation";
let loc = s.execute(C, &json!({})).unwrap();
assert_eq!((loc["custom"].clone(), loc["available"].clone()), (json!(false), json!(true)), "{loc}");
assert_eq!(loc["path"], lib.join("thumbs").to_string_lossy().as_ref());
assert_eq!(loc["default"], lib.join("thumbs").to_string_lossy().as_ref());
// a cached thumbnail in the library's folder
let key = lightcraft_preview::hash_bytes(b"thumb");
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[90, 120, 200, 255]; 16] };
s.media.rendered.disk().unwrap().put(key, &img);
assert_eq!(s.execute(C, &json!({})).unwrap()["count"], 1);
// relative paths and missing drives are refused, and nothing is created on this drive
let err = s.execute(C, &json!({"path": "rel/thumbs"})).unwrap_err().to_string();
assert!(err.contains("absolute"), "{err}");
let gone = away.join("unplugged/thumbs");
let err = s.execute(C, &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("not available"), "{err}");
assert!(!away.join("unplugged").exists());
// the thumbnails already cached need an explicit choice
let to = away.join("thumbs");
let err = s.execute(C, &json!({"path": to.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("existing"), "{err}");
assert_eq!(s.execute(C, &json!({})).unwrap()["custom"], false);
let r = s.execute(C, &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap();
assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}");
assert_eq!(s.media.rendered.disk().unwrap().dir(), to.as_path(), "new thumbnails go to the chosen folder");
assert!(s.media.rendered.disk().unwrap().get(key).is_some(), "the moved thumbnail is used");
assert_eq!(lightcraft_preview::disk::cache_stats(&lib.join("thumbs")).0, 0);
// the setting is saved with the library
drop(s); // one session per library (issue #99)
let mut again = Session::new().with_fs();
again.open_library(&lib, false).unwrap();
assert!(again.take_library_warnings().is_empty());
assert_eq!(again.execute(C, &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
assert_eq!(again.media.rendered.disk().unwrap().dir(), to.as_path());
// leaving the old thumbnails keeps them where they are; the new folder fills as the grid renders
let drive = away.join("drive");
std::fs::create_dir_all(&drive).unwrap();
let on_drive = drive.join("thumbs");
let r = again.execute(C, &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap();
assert_eq!((r["handled"].clone(), r["count"].clone()), (json!(0), json!(0)), "{r}");
assert_eq!(lightcraft_preview::disk::cache_stats(&to).0, 1, "left in the old folder");
// the chosen folder is gone when the library opens (its drive's mount point is still
// there): reported, and the folder is not created again
drop(again);
std::fs::remove_dir_all(&on_drive).unwrap();
let mut third = Session::new().with_fs();
third.open_library(&lib, false).unwrap();
let warnings = third.take_library_warnings();
assert!(warnings.iter().any(|w| w.contains("not available") && w.contains("thumbs")), "{warnings:?}");
let loc = third.execute(C, &json!({})).unwrap();
assert_eq!((loc["available"].clone(), loc["path"].clone()), (json!(false), json!(on_drive.to_string_lossy())), "{loc}");
third.media.rendered.disk().unwrap().put(key, &img);
assert!(!on_drive.exists(), "the missing chosen folder is not recreated");
// back to the library's own folder
let r = third.execute(C, &json!({"reset": true})).unwrap();
assert_eq!((r["custom"].clone(), r["available"].clone()), (json!(false), json!(true)), "{r}");
assert_eq!(r["path"], lib.join("thumbs").to_string_lossy().as_ref());
assert_eq!(third.media.rendered.disk().unwrap().dir(), lib.join("thumbs").as_path());
}
/// Missing Photos covers library photos only: a small library next to many Local browse
/// records (and a deleted photo) checks — and lists, and counts — the library files alone.
#[test]
+62 -25
View File
@@ -22,6 +22,9 @@ const QUALITY: u8 = 90;
pub struct DiskCache {
dir: PathBuf,
/// Whether a missing `dir` may be created (when its parent exists). Not for a folder the user
/// chose: see [`DiskCache::chosen`].
create_dir: bool,
/// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]).
budget: AtomicU64,
/// Bytes on disk (`None` until first scanned).
@@ -35,6 +38,7 @@ impl DiskCache {
pub fn new(dir: &Path, budget: u64) -> DiskCache {
DiskCache {
dir: dir.to_path_buf(),
create_dir: true,
budget: AtomicU64::new(budget),
total: Mutex::new(None),
hits: AtomicU64::new(0),
@@ -43,6 +47,12 @@ impl DiskCache {
}
}
/// A cache in a folder the user chose (on another drive, perhaps): it is never created, so
/// while it is missing (drive unplugged) nothing is written and every read misses.
pub fn chosen(dir: &Path, budget: u64) -> DiskCache {
DiskCache { create_dir: false, ..DiskCache::new(dir, budget) }
}
pub fn dir(&self) -> &Path {
&self.dir
}
@@ -91,7 +101,7 @@ impl DiskCache {
let Some(bytes) = encode_jpeg(img) else { return };
let p = self.path(key);
let Some(parent) = p.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
if !parent.is_dir() && !self.make_shard(parent) {
return;
}
// Serialize the file replacement and its size accounting. A repeated render of
@@ -113,33 +123,26 @@ impl DiskCache {
}
}
/// All cache files (only names of the cache's own shape, see [`is_cache_file`]):
/// (path, size, modified).
fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.dir) else { return out };
for sub in rd.flatten() {
let sub_name = sub.file_name();
let Some(sub_name) = sub_name.to_str() else { continue };
if !is_shard_name(sub_name) {
continue;
/// Create the shard directory `shard` for a write, and the cache directory itself when its
/// parent exists and it wasn't [`DiskCache::chosen`]. Never creates missing parents: a cache
/// folder on an unplugged drive is not silently recreated on the system drive (its mount
/// point), the write is just skipped.
fn make_shard(&self, shard: &Path) -> bool {
if !self.dir.is_dir() {
if !self.create_dir || !self.dir.parent().is_some_and(Path::is_dir) {
return false;
}
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
let name = f.file_name();
let Some(name) = name.to_str() else { continue };
if !is_cache_file(sub_name, name) {
continue;
}
// `DirEntry::metadata` does not follow symlinks: a link is never a cache file
if let Ok(m) = f.metadata()
&& m.is_file()
{
out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
}
// another writer may have just made it
if std::fs::create_dir(&self.dir).is_err() && !self.dir.is_dir() {
return false;
}
}
out
std::fs::create_dir(shard).is_ok() || shard.is_dir()
}
/// All cache files: see [`cache_files`].
fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
cache_files(&self.dir)
}
/// Remove least recently used files until the cache is at most 80 % of its budget.
@@ -173,6 +176,40 @@ impl DiskCache {
}
}
/// All files of a thumbnail cache in `dir` (only names of the cache's own shape, see
/// [`is_cache_file`]; symlinks are never followed): (path, size, modified).
pub fn cache_files(dir: &Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for sub in rd.flatten() {
let sub_name = sub.file_name();
let Some(sub_name) = sub_name.to_str() else { continue };
if !is_shard_name(sub_name) {
continue;
}
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
let name = f.file_name();
let Some(name) = name.to_str() else { continue };
if !is_cache_file(sub_name, name) {
continue;
}
// `DirEntry::metadata` does not follow symlinks: a link is never a cache file
if let Ok(m) = f.metadata()
&& m.is_file()
{
out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
}
}
}
out
}
/// The number of cached thumbnails in `dir` and their total size in bytes.
pub fn cache_stats(dir: &Path) -> (usize, u64) {
cache_files(dir).iter().fold((0usize, 0u64), |(n, bytes), f| (n.saturating_add(1), bytes.saturating_add(f.1)))
}
fn is_lower_hex(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
+6 -1
View File
@@ -42,7 +42,12 @@ impl PreviewCache {
/// Memory LRU + disk cache in `dir` (at most `disk_bytes`).
pub fn with_disk(mem_bytes: usize, dir: &Path, disk_bytes: u64) -> PreviewCache {
PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) }
PreviewCache::with_disk_cache(mem_bytes, DiskCache::new(dir, disk_bytes))
}
/// Memory LRU + the given disk cache (e.g. a [`DiskCache::chosen`] one).
pub fn with_disk_cache(mem_bytes: usize, disk: DiskCache) -> PreviewCache {
PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(disk) }
}
pub fn disk(&self) -> Option<&DiskCache> {
+26
View File
@@ -123,6 +123,32 @@ fn disk_cache_roundtrip_and_prune() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A missing cache folder is made when its parent exists, never with missing parents; a folder
/// the user chose is never made again (issue #903: an unplugged drive's folder stays missing).
#[test]
fn disk_cache_creates_only_its_own_missing_folder() {
let root = temp_dir("mkdir");
std::fs::create_dir_all(&root).unwrap();
let img = gradient(16, 16, 3);
let k = hash_bytes(b"mk");
let own = root.join("thumbs");
DiskCache::new(&own, 1 << 20).put(k, &img);
assert!(DiskCache::new(&own, 1 << 20).get(k).is_some(), "the library's own folder is made");
let deep = root.join("unplugged/thumbs");
DiskCache::new(&deep, 1 << 20).put(k, &img);
assert!(!root.join("unplugged").exists(), "no missing parents");
let chosen = root.join("chosen");
let d = DiskCache::chosen(&chosen, 1 << 20);
d.put(k, &img);
assert!(!chosen.exists(), "a chosen folder is not made");
assert!(d.get(k).is_none());
std::fs::create_dir(&chosen).unwrap();
d.put(k, &img);
assert!(d.get(k).is_some(), "written once it is back");
assert_eq!(super::disk::cache_stats(&chosen).0, 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() {
let dir = temp_dir("overwrite");
+1 -1
View File
@@ -733,7 +733,7 @@
"Join the ArtCraft Discord…": "ArtCraft auf Discord beitreten…",
"Join the ArtCraft community on Discord": "Der ArtCraft-Community auf Discord beitreten",
"Keep in Local": "In „Lokal“ behalten",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Wähle ein Laufwerk mit genügend Platz: Smart-Vorschauen benötigen etwa 1 MB pro Foto. Der Miniaturbild-Cache bleibt in der Bibliothek.",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "Wähle ein Laufwerk mit genügend Platz: Smart-Vorschauen benötigen etwa 1 MB pro Foto.",
"Keyboard Shortcuts": "Tastenkürzel",
"Keyboard shortcuts": "Tastenkürzel",
"Keyword": "Stichwort",
+1 -1
View File
@@ -711,7 +711,7 @@
"Join the ArtCraft Discord…": "Unirse al Discord de ArtCraft…",
"Join the ArtCraft community on Discord": "Unirse a la comunidad de ArtCraft en Discord",
"Keep in Local": "Conservar en Local",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Conviene un disco con espacio: las vistas previas inteligentes ocupan aproximadamente 1 MB por foto. La caché de miniaturas permanece en la biblioteca.",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "Conviene un disco con espacio: las vistas previas inteligentes ocupan aproximadamente 1 MB por foto.",
"Keyboard Shortcuts": "Métodos abreviados de teclado",
"Keyboard shortcuts": "Métodos abreviados de teclado",
"Keyword": "Palabra clave",
+1 -1
View File
@@ -692,7 +692,7 @@
"Join the ArtCraft Discord…": "Rejoindre le Discord d'ArtCraft…",
"Join the ArtCraft community on Discord": "Rejoindre la communauté ArtCraft sur Discord",
"Keep in Local": "Conserver dans Local",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Choisissez un disque avec de la place : les aperçus dynamiques font environ 1 Mo par photo. Le cache des miniatures reste dans la bibliothèque.",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "Choisissez un disque avec de la place : les aperçus dynamiques font environ 1 Mo par photo.",
"Keyboard Shortcuts": "Raccourcis clavier",
"Keyboard shortcuts": "Raccourcis clavier",
"Keyword": "Mot-clé",
+1 -1
View File
@@ -826,7 +826,7 @@
"Invert": "反転",
"Invert Mask": "マスクを反転",
"Keep in Local": "ローカルに残す",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "スマートプレビューは1枚あたり約1MBです。空き容量のあるドライブを指定してください。サムネイルキャッシュはライブラリに残ります",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "スマートプレビューは1枚あたり約1MBです。空き容量のあるドライブを指定してください。",
"Keyboard Shortcuts": "キーボードショートカット",
"Keyboard shortcuts": "キーボードショートカット",
"Keyword": "キーワード",
+1 -1
View File
@@ -701,7 +701,7 @@
"Join the ArtCraft Discord…": "Entrar no Discord do ArtCraft…",
"Join the ArtCraft community on Discord": "Participe da comunidade ArtCraft no Discord",
"Keep in Local": "Manter em Local",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Guarde em um disco com espaço: as pré-visualizações inteligentes ocupam cerca de 1 MB por foto. O cache de miniaturas permanece na biblioteca.",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "Guarde em um disco com espaço: as pré-visualizações inteligentes ocupam cerca de 1 MB por foto.",
"Keyboard Shortcuts": "Atalhos de teclado",
"Keyboard shortcuts": "Atalhos de teclado",
"Keyword": "Palavra-chave",
+1 -1
View File
@@ -579,7 +579,7 @@
"Join the ArtCraft Discord": "Присоединяйтесь к Discord ArtCraft",
"Join the ArtCraft Discord…": "Присоединиться к Discord ArtCraft…",
"Keep in Local": "Оставить в «Локальном»",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Храните их на диске со свободным местом: смарт-просмотр занимает около 1 МБ на фото. Кэш миниатюр остаётся в библиотеке.",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "Храните их на диске со свободным местом: смарт-просмотр занимает около 1 МБ на фото.",
"Keyboard Shortcuts": "Сочетания клавиш",
"Keyboard shortcuts": "Сочетания клавиш",
"Keyword": "Ключевое слово",
+1 -1
View File
@@ -996,7 +996,7 @@
"Info overlay": "Накладання інформації",
"Into one folder": "До однієї теки",
"Keep in Local": "Залишити в локальних",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Зберігайте на диску з вільним місцем: смарт-перегляд займає приблизно 1 МБ на фото. Кеш мініатюр залишається в бібліотеці.",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "Зберігайте на диску з вільним місцем: смарт-перегляд займає приблизно 1 МБ на фото.",
"Keyboard shortcuts": "Клавіатурні скорочення",
"Keyword": "Ключове слово",
"Keyword Painter": "Призначення ключових слів",
+1 -1
View File
@@ -805,7 +805,7 @@
"Join the ArtCraft Discord": "加入 ArtCraft Discord",
"Join the ArtCraft Discord…": "加入 ArtCraft Discord…",
"Keep in Local": "保留在本地",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。缩略图缓存仍保留在图库中。",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。",
"Keyboard Shortcuts": "键盘快捷键",
"Keyboard shortcuts": "键盘快捷键",
"Keyword": "关键字",
+1 -1
View File
@@ -805,7 +805,7 @@
"Join the ArtCraft Discord": "加入 ArtCraft Discord",
"Join the ArtCraft Discord…": "加入 ArtCraft Discord…",
"Keep in Local": "保留在本機",
"Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "請放在空間充足的磁碟:每張智慧型預覽約 1 MB。縮圖快取仍保留在照片庫中。",
"Keep it on a drive with room: smart previews are about 1 MB per photo.": "請放在空間充足的磁碟:每張智慧型預覽約 1 MB。",
"Keyboard Shortcuts": "鍵盤快速鍵",
"Keyboard shortcuts": "鍵盤快速鍵",
"Keyword": "關鍵字",
+84 -1
View File
@@ -562,6 +562,8 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let _ = app.run("library.clearPreviews", json!({}));
}
});
#[cfg(not(target_arch = "wasm32"))]
thumb_cache_folder(app, ui, t);
heading(ui, t, crate::i18n::tr("Local folders"));
row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| {
let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")];
@@ -581,6 +583,87 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
smart_previews(app, ui, t);
}
/// Where this library keeps its thumbnail cache (issue #903): the effective folder, and choosing
/// another one, like the smart previews folder below.
#[cfg(not(target_arch = "wasm32"))]
fn thumb_cache_folder(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
// listing a big cache every frame would be slow: refresh every 2 s and after a change
let cache = egui::Id::new("thumbs-location");
let now = ui.input(|i| i.time);
let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) {
Some((at, v)) if now - at < 2.0 => v,
_ => {
let v = app.run("library.thumbnailCacheLocation", json!({})).unwrap_or(Value::Null);
ui.data_mut(|d| d.insert_temp(cache, (now, v.clone())));
v
}
};
if loc.is_null() {
return;
}
let path = loc["path"].as_str().unwrap_or_default().to_string();
let available = loc["available"].as_bool().unwrap_or(true);
let custom = loc["custom"].as_bool().unwrap_or(false);
row(ui, t, crate::i18n::tr("Folder"), |ui| {
let text = format!("{path}{}", if custom { "" } else { " (library default)" });
ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text);
});
if !available {
hint(
ui,
t,
crate::i18n::tr(
"This folder is not available (drive disconnected?). Thumbnails are kept in memory only until it is back or you choose another folder.",
),
);
}
// what happens to the thumbnails already cached when the folder changes
let mode_id = egui::Id::new("thumbs-existing");
let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0);
if loc["count"].as_u64().unwrap_or(0) > 0 {
row(ui, t, crate::i18n::tr("Existing thumbnails"), |ui| {
if choices(ui, "settingsThumbsExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) {
ui.data_mut(|d| d.insert_temp(mode_id, mode));
}
});
}
let existing = ["move", "leave", "discard"][mode.min(2) as usize];
let mut result = None;
ui.horizontal(|ui| {
ui.add_space(LABEL_W);
if app.services.pick_folder.is_some() {
let r = ui.button(crate::i18n::tr("Choose Folder…"));
register(ui.ctx(), "button:settingsThumbsChoose", r.rect);
if r.clicked()
&& let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f())
{
result = Some(app.run("library.thumbnailCacheLocation", json!({"path": dir, "existing": existing})));
}
}
let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder")));
register(ui.ctx(), "button:settingsThumbsReset", r.rect);
if r.clicked() {
result = Some(app.run("library.thumbnailCacheLocation", json!({"reset": true, "existing": existing})));
}
});
if let Some(r) = result {
ui.data_mut(|d| d.remove::<(f64, Value)>(cache));
match r {
Ok(v) => {
let failed = v["failed"].as_array().map_or(0, Vec::len);
let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) {
("move", n) if n > 0 => format!("Thumbnail cache folder changed; moved {n}"),
("discard", n) if n > 0 => format!("Thumbnail cache folder changed; deleted {n}"),
_ => "Thumbnail cache folder changed".to_string(),
};
app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg });
}
Err(e) => app.toast(ui.ctx(), e),
}
}
hint(ui, t, crate::i18n::tr("A fast local drive keeps the grid quick when the library itself is on a network or slow drive."));
}
/// Where this library keeps its smart previews (the offline-editing proxies, which can be large):
/// the effective folder, what is in it, and choosing another one.
#[cfg(not(target_arch = "wasm32"))]
@@ -675,7 +758,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
Err(e) => app.toast(ui.ctx(), e),
}
}
hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library."));
hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo."));
}
// ---------------------------------------------------------------------------------- Interface
+2 -2
View File
@@ -424,7 +424,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-<time>` and reported, an unreadable one is not overwritten that session (`library.info` → `settingsWarnings`); quitting with unsaved changes retries, then asks |
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact`, `cmd:library.thumbnailCacheLocation` | thumbnail cache size + clear in Settings → Performance; the thumbnail cache folder is chosen per library there too (saved in the library; absolute paths only, the cached thumbnails are moved, left or deleted on a change, and a chosen folder that is missing is reported, never a fallback to the library drive; issue #903); library location + Open Library… in General |
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: Windows' WCS profile (needs FFI, so `unsafe`), X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) |
@@ -441,7 +441,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | |
| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews (on a worker thread, progress and Stop like Build Previews): ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). Each proxy records the version of the camera-look fit that wrote it (`look_version`, `LOOK_VERSION` in `crates/engine/src/camera_preview.rs`, bumped whenever the fit changes); only raws with the per-file camera look go stale (JPEG / PNG / DNG proxies are never rebuilt for it); one from an older fit is rebuilt from its original by a background scan the first time the library is opened at a new version (a `look-version` marker in the proxy folder keeps it to once per version) and by Build Smart Previews, and kept as it is while the original is offline (`docs/camera-preview-colour.md`). The thumbnail cache stays in the library |
| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews (on a worker thread, progress and Stop like Build Previews): ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). Each proxy records the version of the camera-look fit that wrote it (`look_version`, `LOOK_VERSION` in `crates/engine/src/camera_preview.rs`, bumped whenever the fit changes); only raws with the per-file camera look go stale (JPEG / PNG / DNG proxies are never rebuilt for it); one from an older fit is rebuilt from its original by a background scan the first time the library is opened at a new version (a `look-version` marker in the proxy folder keeps it to once per version) and by Build Smart Previews, and kept as it is while the original is offline (`docs/camera-preview-colour.md`). The thumbnail cache has its own folder setting (`cmd:library.thumbnailCacheLocation`, issue #903) |
| LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | |
| LR-AI-CREDITS | Generative credits | OOS | 🚫 | | |
+11
View File
@@ -133,6 +133,17 @@
- Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in
noticeably duller.
### Thumbnail cache folder
- The thumbnail cache can live outside the library (issue #903): Settings ▸ Performance ▸ Thumbnail cache ▸ Choose
Folder…, next to the size limit. Useful when the library sits on a network share or a slow disk and the grid
waits on it. The choice is saved per library, like the smart previews folder.
- Thumbnails already cached can be moved, left where they are or deleted; the new folder fills as the grid renders.
Use Library Folder goes back to `thumbs` in the library.
- The folder must be an absolute path on a drive that is there. If it is missing when the library opens (drive
disconnected), LightCraft says so and keeps thumbnails in memory only; nothing is written to the library drive
instead.
- Agents use `library.thumbnailCacheLocation {path?, reset?, existing?: move|leave|discard}`.
### Open Library knows a Lightroom catalog
- Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /
`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The