M5.6: the Detail photo's original stays over the memory budget (64 MP raws re-decoded on every pan)

A decoded 64 MP original (Sony A7R VI: 9984×6656 linear float, 797 MB) is bigger than the
default cache share (half of 1.5 GiB). MediaCache::enforce_budget evicted it as soon as the
render handed it over, so every pan of a 1:1 view and every slider move while zoomed decoded
the raw again (and lost the shared GPU copy, keyed by the CPU Arc).

The frontend now tells the cache which photo Detail shows (MediaCache::set_on_screen, set per
frame in panels::compare::prepare); that photo's original is not evicted for the budget and is
let go as soon as another photo or view is shown. Keying it on "no newer preview of another
photo" instead does not work: the neighbour prefetch stores previews after the original.
This commit is contained in:
dev-null-undefined
2026-10-12 13:26:08 +02:00
parent 25ef65c81e
commit 208aa023b0
4 changed files with 63 additions and 2 deletions
+37 -2
View File
@@ -314,6 +314,8 @@ pub struct MediaCache {
full: Vec<(PhotoId, DecodedSource, u64)>,
/// How many full-resolution originals to keep (LRU, at least one).
full_capacity: usize,
/// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget.
on_screen: Option<PhotoId>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
@@ -391,6 +393,7 @@ impl Default for MediaCache {
previews: Vec::new(),
full: Vec::new(),
full_capacity: 1,
on_screen: None,
preview_capacity: 0,
budget,
file_loader: None,
@@ -447,6 +450,16 @@ impl MediaCache {
self.trim_full();
}
/// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays
/// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it
/// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once.
pub fn set_on_screen(&mut self, id: Option<PhotoId>) {
if self.on_screen != id {
self.on_screen = id;
self.enforce_budget();
}
}
fn trim_full(&mut self) {
while self.full.len() > self.full_capacity {
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
@@ -475,7 +488,8 @@ impl MediaCache {
/// Evict least recently used entries across the source caches and the rendered previews
/// until they fit the budget. The newest preview-level source (the photo on screen, or the
/// one just decoded) is never evicted here.
/// 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();
while held > self.budget {
@@ -483,7 +497,7 @@ impl MediaCache {
let candidates = [
self.thumbs.oldest_tick().map(|t| (t, 0)),
self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)),
self.full.iter().map(|e| e.2).min().map(|t| (t, 2)),
self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)),
self.rendered.oldest_tick().map(|t| (t, 3)),
];
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
@@ -1793,6 +1807,27 @@ mod tests {
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
}
// 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.
#[test]
fn the_original_on_screen_stays_even_over_budget() {
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::Full, img(1500)); // 27 MB
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
m.insert(PhotoId(3), SourceLevel::Thumb, img(100));
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.set_on_screen(Some(PhotoId(2)));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full));
assert!(m.held() <= 20 * mb, "{}", m.held());
}
#[test]
fn work_gate_waits_for_room() {
use crate::memory::WorkGate;
+3
View File
@@ -377,7 +377,10 @@ fn draw_window(p: &egui::Painter, app: &LightcraftApp, pane: u8, id: PhotoId, lo
/// Per frame, before the central view is shown: Compare and Reference keep two photos' originals
/// decoded (their windows are cut from both); other views keep one and free the panes' windows.
/// The Detail view's photo keeps its original even over the memory budget (a zoomed 64 MP raw).
pub fn prepare(app: &mut LightcraftApp) {
let shown = if app.ui.view == ViewMode::Detail { app.session.active() } else { None };
app.session.media.set_on_screen(shown);
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) {
app.session.media.set_full_capacity(WINDOW_PANES as usize);
return;
+15
View File
@@ -596,6 +596,21 @@ mod in_the_loupe {
assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on");
}
// Given an original bigger than the cache budget (a 64 MP raw's 797 MB exceeds the default
// share), zoomed to 1:1 it stays decoded, so a pan does not decode the raw again; back in the
// grid it is let go
#[test]
fn a_zoomed_original_over_the_budget_stays_until_the_view_changes() {
let (mut h, _) = detail();
h.app.session.media.set_budget(16 << 20);
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert!(h.app.session.memory_report().full_source.bytes > 16 << 20, "the original stayed over budget");
h.request("ui.set", json!({"view": "photoGrid"}), T);
h.step();
assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "the grid needs no original");
}
/// The meshes the last frame drew with `slot`'s texture: (paint order, clip rect, bounds).
fn drawn(h: &Headless, slot: Slot) -> Vec<(usize, egui::Rect, egui::Rect)> {
let Some(tex) = h.app.renderer.textures.get(&slot) else { return vec![] };
+8
View File
@@ -1,5 +1,13 @@
# What's new in LightCraft
## 2026-10-12
### Zoomed 64 MP raws stay decoded
- Panning a photo zoomed to 1:1, or moving a slider while zoomed, no longer decodes the whole raw again each time on
high-resolution cameras such as the Sony A7R VI (64 MP). Their decoded original (~800 MB) is bigger than the
default cache share (768 MB), so it was evicted as soon as it was stored. The original of the photo shown in Detail
now stays, like its preview, until another photo or view is shown.
## 2026-10-11
### JPEG XL export