From 208aa023b0bbc3a8415cc9881987fd4ad67dab71 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 13:26:08 +0200 Subject: [PATCH 1/4] M5.6: the Detail photo's original stays over the memory budget (64 MP raws re-decoded on every pan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/engine/src/media.rs | 39 +++++++++++++++++++++-- crates/ui-egui/src/panels/compare.rs | 3 ++ crates/ui-egui/src/tests_preview_limit.rs | 15 +++++++++ docs/whats-new.md | 8 +++++ 4 files changed, 63 insertions(+), 2 deletions(-) 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 From 33accc389287bccaee1be4d8c1cf8e91f6c3cf77 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 13:48:05 +0200 Subject: [PATCH 2/4] M5.6: a zoomed window waits for the original another window is decoding (decoded 2-3 times) The Before window waited for the original only when the After's window job was already pending, but side by side the Before is asked for first in the frame, so both decoded the 64 MP raw at once; and a window asked for again while the first was still decoding (a pan, the layout settling at startup) started another full decode beside it (three decodes seen at startup). Any window now waits while a window job is pending and the original isn't held, and follows from the decoded original. --- crates/ui-egui/src/panels/detail.rs | 7 ++++--- crates/ui-egui/src/region.rs | 25 ++++++++++++----------- crates/ui-egui/src/tests_preview_limit.rs | 22 ++++++++++++++++++++ docs/whats-new.md | 3 +++ 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..83b7b8b48 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -309,10 +309,11 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt to_px * (v.visible.bottom() - v.target.top()), ); let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; - // the Before side waits for the original the After is decoding (see `defer_before_window`) + // a window waits for the original another window job is decoding (see `defer_window`) let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); - if crate::region::defer_before_window(v.before, app.renderer.is_pending(Slot::Region), original_held) { - return app.region_before_view.filter(|w| w.photo == c.id); + let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore); + if crate::region::defer_window(window_pending, original_held) { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); } let job = if v.before { app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..184a8319a 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -174,11 +174,13 @@ pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egu clip.intersect(canvas) } -/// Whether the Before window waits for the After's. Both are cut from the same decoded original; -/// asked for together while it isn't held yet, each worker decodes its own copy (two full-size -/// decodes, two uploads, one thrown away). The Before follows once the original is in. -pub fn defer_before_window(before_side: bool, after_pending: bool, original_held: bool) -> bool { - before_side && after_pending && !original_held +/// Whether a window waits for the one already being rendered. All windows are cut from the same +/// decoded original; asked for while it isn't held yet (the Before and After together, in either +/// order, or a new window while the first is still decoding), each worker decodes its own copy +/// (two or three full-size decodes and uploads, all but one thrown away). The waiting window +/// follows once the original is in. +pub fn defer_window(window_pending: bool, original_held: bool) -> bool { + window_pending && !original_held } /// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel @@ -427,14 +429,13 @@ mod tests { assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); } - // Given Before and After windows asked for at once at first open, only the After's goes: the - // Before's waits for the decoded original instead of decoding another copy + // Given a window asked for while another is rendering and the original isn't decoded yet, it + // waits for the decoded original instead of decoding another copy #[test] - fn the_before_window_waits_for_the_original_the_after_is_decoding() { - assert!(defer_before_window(true, true, false)); - assert!(!defer_before_window(true, true, true), "the original is in"); - assert!(!defer_before_window(true, false, false), "nothing is decoding it"); - assert!(!defer_before_window(false, true, false), "the After never waits"); + fn a_window_waits_for_the_original_another_is_decoding() { + assert!(defer_window(true, false)); + assert!(!defer_window(true, true), "the original is in"); + assert!(!defer_window(false, false), "nothing is decoding it"); } // Hostile numbers give a plan, never a panic diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 8efdebe3a..a8b547387 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -452,6 +452,28 @@ mod in_the_loupe { assert_eq!(main.size, before_main.size, "and the same whole-frame size under them"); } + // Given Before/After side by side, when I zoom to 1:1, then one window job decodes the original + // and the other side waits for it (the Before, asked for first, used to start a second decode + // beside the After's); both windows still arrive + #[test] + fn side_by_side_at_one_to_one_decodes_the_original_once() { + let (mut h, _) = detail(); + let id = h.app.session.active().expect("a photo is open"); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + let pending = |h: &Headless| [Slot::Region, Slot::RegionBefore].into_iter().filter(|s| h.app.renderer.is_pending(*s)).count(); + for _ in 0..100 { + h.step(); + if pending(&h) > 0 { + break; + } + } + assert!(!h.app.session.media.has_source(id, lightcraft_engine::SourceLevel::Full), "the original is still decoding"); + assert_eq!(pending(&h), 1, "one window job decodes it"); + h.settle(SETTLE); + assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some(), "both windows arrived"); + } + // Given the Before view alone (\), only the Before gets a window #[test] fn the_original_view_windows_only_the_before() { diff --git a/docs/whats-new.md b/docs/whats-new.md index 994a5a055..0d4300dbc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,6 +7,9 @@ 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. +- Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy + beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling) + started yet another decode. ## 2026-10-11 From 63bcaa147fe006fc4fc846d7df621401abd7307e Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 13:57:05 +0200 Subject: [PATCH 3/4] M5.6: a zoomed view keeps the GPU copy of its original while idle (re-uploaded after 3 s) The idle trim (3 s without renders) released the shared device copy of the original a zoom window is cut from, so pausing between pans made the next pan upload it again: 800 MB for a 64 MP raw, ~1.4 s of "gpu sample" measured (5 ms when it is still there). While a Detail window texture is held the renderer is not idle; once the windows are gone (zoomed out, another photo, or another view, which now frees the Detail view's windows) the trim runs as before. --- crates/ui-egui/src/panels/compare.rs | 10 ++++++++- crates/ui-egui/src/render.rs | 25 +++++++++++++++++++++-- crates/ui-egui/src/tests_preview_limit.rs | 15 ++++++++++++++ docs/gpu-pipeline.md | 2 +- docs/whats-new.md | 3 +++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 3b5ac08d4..fd6d69063 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -377,10 +377,18 @@ 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). +/// The Detail view's photo keeps its original even over the memory budget (a zoomed 64 MP raw); +/// other views free the Detail view's windows (while one is shown the GPU copy of its original stays). 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 app.ui.view != ViewMode::Detail { + app.region_view = None; + app.region_before_view = None; + app.region_tiles.clear(); + app.renderer.release(Slot::Region); + app.renderer.release(Slot::RegionBefore); + } 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/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..6afecbc3b 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -767,10 +767,12 @@ impl Renderer { } /// Free the GPU renderer's recycled buffers once nothing has rendered for [`IDLE_TRIM`] (they - /// are reallocated by the next render; while working, renders reuse them). + /// are reallocated by the next render; while working, renders reuse them). A zoomed view + /// showing its windows is not idle: the device copy of the original they are cut from stays for + /// the next pan (a 64 MP raw is 800 MB to upload again, over a second). #[cfg(not(target_arch = "wasm32"))] fn trim_when_idle(&mut self, ctx: &egui::Context) { - if !self.pending.is_empty() { + if !self.pending.is_empty() || self.textures.contains_key(&Slot::Region) || self.textures.contains_key(&Slot::RegionBefore) { self.idle = None; return; } @@ -1291,6 +1293,25 @@ mod stage_budget_tests { const MB: usize = 1 << 20; + // Given a zoomed view showing a window, the app idling does not free the GPU copy of the + // original the next window is cut from; once the windows are gone the idle trim runs again + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_zoomed_view_is_not_trimmed_while_idle() { + let ctx = egui::Context::default(); + let mut r = Renderer::default(); + let tex = ctx.load_texture("window", egui::ColorImage::new([1, 1], vec![egui::Color32::BLACK]), Default::default()); + r.textures.insert(Slot::Region, Tex { key: 1, photo: PhotoId(1), tex, size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None }); + let long_ago = std::time::Instant::now().checked_sub(IDLE_TRIM * 2).unwrap(); + r.idle = Some((long_ago, false)); + r.trim_when_idle(&ctx); + assert_eq!(r.idle, None, "a zoomed view is not idle"); + r.release(Slot::Region); + r.idle = Some((long_ago, false)); + r.trim_when_idle(&ctx); + assert_eq!(r.idle, Some((long_ago, true)), "trimmed once the window is gone"); + } + // Given stage caches within the budget, nothing is cleared #[test] fn nothing_is_trimmed_within_the_budget() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index a8b547387..7ebbd9b2a 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -335,6 +335,21 @@ mod in_the_loupe { assert_eq!(h.app.region_view, None); } + // Given a zoomed view with a window render, when I go to the grid, then its windows are freed + // too (while one is held the app never counts as idle and keeps the GPU copy of the original) + #[test] + fn leaving_a_zoomed_detail_view_frees_its_windows() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some()); + h.request("ui.set", json!({"view": "photoGrid"}), T); + h.step(); + assert_eq!((tile(&h, Slot::Region), tile(&h, Slot::RegionBefore)), (None, None)); + assert_eq!((h.app.region_view, h.app.region_before_view), (None, None)); + } + // Given a zoomed view, when the photo is edited, then the old window (made with the old look) // is no longer current and is replaced by one for the new look #[test] diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 883c3a0bf..cc9f19f57 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -267,7 +267,7 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo developed image in ~50 ms. ## Memory (M5.6) -A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles (issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). +A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles with no zoom window on screen (a zoomed view keeps it for its next pan: a 64 MP original is 800 MB to upload again; issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). - `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) diff --git a/docs/whats-new.md b/docs/whats-new.md index 0d4300dbc..191360f58 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,6 +10,9 @@ - Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling) started yet another decode. +- 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. ## 2026-10-11 From dead4367b00ed165e7cbcd47c71126c4d18dac46 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 00:40:59 +0200 Subject: [PATCH 4/4] M5.6: the pinned original no longer evicts the photo's other sources (64 MP edits decoded the raw again) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/engine/src/media.rs | 24 +++++++++++++++++++++++- docs/whats-new.md | 3 +++ 2 files changed, 26 insertions(+), 1 deletion(-) 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