diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b0ff1b265..0fce578d7 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -491,7 +491,11 @@ impl MediaCache { /// one just decoded) is never evicted here, nor is the original of the photo on screen /// ([`Self::set_on_screen`]). pub fn enforce_budget(&mut self) { - let mut held = self.held(); + // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) + // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and + // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. + let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); + let mut held = self.held().saturating_sub(pinned); while held > self.budget { let newest_preview = self.previews.iter().map(|e| e.2).max(); let candidates = [ @@ -1807,6 +1811,24 @@ mod tests { assert!(m.has_source(PhotoId(3), SourceLevel::Full)); } + // Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay + // (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) + #[test] + fn the_on_screen_original_does_not_evict_the_other_sources() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { + assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); + } + } + // A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): // evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo // on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. diff --git a/docs/whats-new.md b/docs/whats-new.md index 191360f58..385cf92c8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,6 +13,9 @@ - A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no longer frees it, which cost a re-upload of about a second on the next pan (800 MB for a 64 MP raw). It is freed once you zoom out or move on. +- The kept original no longer pushes everything else out of the cache: on a 64 MP raw it filled the cache share + alone, so the photo's preview and thumbnail sources and its neighbours' were decoded from the raw again on every + edit at 1:1, every zoom out and every step to the next photo. ## 2026-10-11