mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
@@ -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(¤t);
|
||||
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(¤t, &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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "キーワード",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ключевое слово",
|
||||
|
||||
@@ -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": "Призначення ключових слів",
|
||||
|
||||
@@ -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": "关键字",
|
||||
|
||||
@@ -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": "關鍵字",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | 🚫 | | |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user