diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..b0ff1b265 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -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, /// 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) { + 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; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..3b5ac08d4 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -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; diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..8efdebe3a 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -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![] }; diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..994a5a055 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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