M5.6: the pinned original no longer evicts the photo's other sources (64 MP edits decoded the raw again)

MediaCache::enforce_budget never evicts the original of the photo on screen (set_on_screen), but it still
counted it. A 64 MP original (Sony A7R VI: 9984×6656 linear float, 760 MiB) nearly fills the default cache
share (768 MiB) alone, so the loop evicted everything else instead: the photo's preview and thumbnail sources,
the rendered previews and the prefetched neighbours. Every edit at 1:1 (the whole-frame render and the
thumbnail), every zoom out and every step to the next photo then decoded the raw again.

The pinned original is now left out of what is compared with the budget. The rest still fits the share; the
original sits over it, as the first commit of this PR already allowed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
dev-null-undefined
2026-10-13 00:41:09 +02:00
co-authored by Claude
parent 63bcaa147f
commit dead4367b0
2 changed files with 26 additions and 1 deletions
+23 -1
View File
@@ -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.
+3
View File
@@ -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