diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4382160a7..e9e3bf635 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -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() } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 313a0b2e9..e45cc92ef 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -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(); diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 06cad3a6c..9b343bfd3 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -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); +} diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 7ec2dcfe9..10a7166c6 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -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>, 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 { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 6186b3b6f..a3ba94513 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -692,7 +692,9 @@ mod thumbnail_tests { } } fn fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { - let mut s = Session::new(); + fixture_with(Session::new()) + } + fn fixture_with(mut s: Session) -> (crate::LightcraftApp, std::rc::Rc>, 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();