mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
Thumbnails: keep textures when the disk cache is resized
Changing the disk cache size (library.preferences cacheMb) re-attached a new PreviewCache object, and the renderer treats a new cache object as a library reset: every texture, the loupe included, was dropped and re-rendered. Thumbnails are keyed by content, so a new budget leaves them valid. The budget now changes in place on the attached cache (same object, same generation); the next write trims to it, as before. A preview clear, a generation bump and a new library still invalidate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c7f5664ce2
commit
73cf0d50bb
@@ -557,13 +557,14 @@ impl Session {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
}
|
||||
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which
|
||||
/// trims it to the new size. Saved with the library preferences.
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default) in place: the cached thumbnails
|
||||
/// stay (the next write trims to the new size), and so do the textures shown from them.
|
||||
/// 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.attach_disk_cache(&dir, self.cache_bytes());
|
||||
self.media.set_disk_cache_bytes(&dir, self.cache_bytes());
|
||||
}
|
||||
self.save_prefs()
|
||||
}
|
||||
|
||||
@@ -249,6 +249,16 @@ impl MediaCache {
|
||||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
match self.rendered.disk().filter(|d| d.dir() == dir) {
|
||||
Some(d) => d.set_budget(disk_bytes),
|
||||
None => self.attach_disk_cache(dir, disk_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
|
||||
pub fn clear_sources(&mut self) {
|
||||
self.settings_hashes = SettingsHashes::default();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! persistence in prefs.json.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::MediaKind;
|
||||
use lightcraft_develop::Preset;
|
||||
@@ -263,3 +264,27 @@ fn curve_presets_save_apply_export_import_and_persist() {
|
||||
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() {
|
||||
let lib = temp_dir("resize");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let cache = s.media.rendered.clone();
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[10, 20, 30, 255]; 16] };
|
||||
cache.put(key, Arc::new(img));
|
||||
let generation = cache.generation();
|
||||
s.execute("library.preferences", &json!({"cacheMb": 300})).unwrap();
|
||||
// the same cache object (the UI keeps its textures), the same generation, the new budget
|
||||
assert!(Arc::ptr_eq(&cache, &s.media.rendered));
|
||||
assert_eq!(s.media.rendered.generation(), generation);
|
||||
assert_eq!(s.media.rendered.disk().unwrap().budget(), 300 << 20);
|
||||
assert!(s.media.rendered.get(key).is_some());
|
||||
// an explicit clear still invalidates
|
||||
s.execute("library.clearPreviews", &json!({})).unwrap();
|
||||
assert_ne!(s.media.rendered.generation(), generation);
|
||||
assert!(s.media.rendered.get(key).is_none());
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ const QUALITY: u8 = 90;
|
||||
|
||||
pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
budget: u64,
|
||||
/// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]).
|
||||
budget: AtomicU64,
|
||||
/// Bytes on disk (`None` until first scanned).
|
||||
total: Mutex<Option<u64>>,
|
||||
pub hits: AtomicU64,
|
||||
@@ -34,7 +35,7 @@ impl DiskCache {
|
||||
pub fn new(dir: &Path, budget: u64) -> DiskCache {
|
||||
DiskCache {
|
||||
dir: dir.to_path_buf(),
|
||||
budget,
|
||||
budget: AtomicU64::new(budget),
|
||||
total: Mutex::new(None),
|
||||
hits: AtomicU64::new(0),
|
||||
misses: AtomicU64::new(0),
|
||||
@@ -46,6 +47,17 @@ impl DiskCache {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Bytes the cache may hold.
|
||||
pub fn budget(&self) -> u64 {
|
||||
self.budget.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Change the budget in place: the files stay valid (they are keyed by content), so a resize
|
||||
/// keeps them; a smaller budget is enforced by the next write, like any other overflow.
|
||||
pub fn set_budget(&self, bytes: u64) {
|
||||
self.budget.store(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn path(&self, key: Hash128) -> PathBuf {
|
||||
let hex = key.to_string();
|
||||
self.dir.join(&hex[..2]).join(format!("{hex}.jpg"))
|
||||
@@ -94,7 +106,7 @@ impl DiskCache {
|
||||
let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let total = t.get_or_insert(0);
|
||||
*total += bytes.len() as u64;
|
||||
*total > self.budget
|
||||
*total > self.budget()
|
||||
};
|
||||
if over {
|
||||
self.prune();
|
||||
@@ -134,7 +146,7 @@ impl DiskCache {
|
||||
pub fn prune(&self) {
|
||||
let mut files = self.scan();
|
||||
let mut total: u64 = files.iter().map(|f| f.1).sum();
|
||||
let target = self.budget / 5 * 4;
|
||||
let target = self.budget() / 5 * 4;
|
||||
files.sort_by_key(|f| f.2);
|
||||
for (p, len, _) in files {
|
||||
if total <= target {
|
||||
|
||||
@@ -692,7 +692,9 @@ mod thumbnail_tests {
|
||||
}
|
||||
}
|
||||
fn fixture() -> (crate::LightcraftApp, std::rc::Rc<std::cell::RefCell<Work>>, egui::Context) {
|
||||
let mut s = Session::new();
|
||||
fixture_with(Session::new())
|
||||
}
|
||||
fn fixture_with(mut s: Session) -> (crate::LightcraftApp, std::rc::Rc<std::cell::RefCell<Work>>, egui::Context) {
|
||||
s.media.file_loader = Some(Arc::new(|_, _| {
|
||||
let mut image = lightcraft_raster::Rgb32f::new(8, 8);
|
||||
image.data.fill([0.3, 0.2, 0.1]);
|
||||
@@ -748,6 +750,36 @@ mod thumbnail_tests {
|
||||
assert_eq!(app.renderer.queue.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_the_disk_cache_keeps_textures_and_skips_rebuilds() {
|
||||
let lib = std::env::temp_dir().join(format!("lc-ui-thumb-resize-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let (mut app, work, ctx) = fixture_with(s);
|
||||
request(&mut app);
|
||||
let (slot, job) = take_job(&work);
|
||||
finish(&mut app, &work, &ctx, slot, job.run());
|
||||
assert!(app.renderer.thumb(PhotoId(1)).is_some());
|
||||
let cache = app.session.media.rendered.clone();
|
||||
app.session.execute("library.preferences", &serde_json::json!({"cacheMb": 300})).unwrap();
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
// thumbnails are keyed by content: a new budget leaves them (and the cache) valid
|
||||
assert!(app.renderer.thumb(PhotoId(1)).is_some());
|
||||
assert!(Arc::ptr_eq(&cache, &app.session.media.rendered));
|
||||
for _ in 0..30 {
|
||||
request(&mut app);
|
||||
}
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 1);
|
||||
assert!(work.borrow().jobs.is_empty());
|
||||
// an explicit clear still drops them
|
||||
app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap();
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
assert!(app.renderer.thumb(PhotoId(1)).is_none());
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_import_review_cancels_queued_and_running_identities() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
|
||||
Reference in New Issue
Block a user