# Conflicts:
#	docs/whats-new.md
This commit is contained in:
bflatastic
2026-10-13 11:05:55 +07:00
8 changed files with 180 additions and 21 deletions
+60 -3
View File
@@ -328,6 +328,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
@@ -405,6 +407,7 @@ impl Default for MediaCache {
previews: Vec::new(),
full: Vec::new(),
full_capacity: 1,
on_screen: None,
preview_capacity: 0,
budget,
file_loader: None,
@@ -461,6 +464,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);
@@ -489,15 +502,20 @@ 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();
// 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 = [
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 };
@@ -1872,6 +1890,45 @@ 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.
#[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;
+11
View File
@@ -409,7 +409,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);
/// 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;
+4 -3
View File
@@ -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)
+13 -12
View File
@@ -183,11 +183,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
@@ -440,14 +442,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
+23 -2
View File
@@ -805,10 +805,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;
}
@@ -1406,6 +1408,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() {
+52
View File
@@ -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]
@@ -452,6 +467,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() {
@@ -596,6 +633,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![] };
+1 -1
View File
@@ -273,7 +273,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)
+16
View File
@@ -65,6 +65,22 @@
instead of opening as their embedded preview. Photos imported earlier switch over with Photo ▸ Reload from Disk.
- An uncompressed Leaf Aptus 22 MOS no longer opens as a dark, colourless picture of its sensor mosaic: it imports
as a raw and shows its embedded preview (preview only, with the reason), as the compressed MOS files do.
## 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.
- 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.
- 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