//! Background rendering: engine [`RenderJob`]s run on a [`JobPool`]; results become textures. //! //! Each visible thing that needs pixels is a *slot* (a grid thumbnail, the loupe, the "before" //! image…). Requests are deduplicated per slot (a newer request replaces a queued older one) and //! prioritised (loupe first, then on-screen thumbnails, then prefetch); thumbnails scrolled far //! out of view are dropped from the queue. Thumbnail jobs hit the engine's preview cache (memory + //! disk) before rendering. On wasm the jobs run inline, one per frame, unless the host installs a //! [`RenderOffload`] (the browser build's Web Workers): then queued jobs are handed to it as it has //! room, and its results are collected every frame. //! //! Stand-ins ([`QuickJob`]s, once per photo and settings): [`Slot::Preview`] holds what the loupe //! shows until [`Slot::Main`] has the photo's render (cached view render, embedded camera JPEG or //! a thumbnail render); [`Slot::ThumbQuick`] holds a raw's embedded preview in the grid until its //! rendered thumbnail arrives (a cached thumbnail found by the quick job becomes the //! [`Slot::Thumb`] texture directly). use std::collections::HashMap; use lightcraft_catalog::PhotoId; use lightcraft_engine::Session; use lightcraft_engine::media::{QuickJob, QuickSource, RenderJob, RenderResult}; use lightcraft_engine::pipeline::StageCache; use lightcraft_preview::JobPool; use lightcraft_raster::Histogram; use std::sync::Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Slot { Thumb(PhotoId), /// A thumbnail's stand-in (embedded preview of an unedited raw). ThumbQuick(PhotoId), Main, /// The loupe zoomed past what `Main` can hold: the window of the frame that is on screen, /// rendered at the zoom scale and drawn over `Main` (see [`crate::region`]). Region, /// The Before side of a Before/After view zoomed the same way (see [`Slot::Region`]). RegionBefore, /// The loupe's stand-in until `Main` has the photo. Preview, Before, Compare(u8), /// Compare or Reference zoomed past what `Compare(i)` holds: the window of pane `i`'s frame /// that is on screen, drawn over it (see [`crate::region`] and `panels::compare`). CompareRegion(u8), /// Background preparation of a neighbouring photo (no texture: its decoded source and view /// render are cached by the engine). 0 = next, 1 = previous. Prefetch(u8), /// A variant thumbnail (profile / preset browsers), by its job key ([`Session::variant_job`]). /// Kept in a bounded LRU ([`VARIANT_TEXTURES`]). Variant(u64), /// The loupe while hovering a preset or profile: the photo with the hovered look (nothing is /// committed). Hover, /// An import candidate's thumbnail in the import review dialog (by index). Import(u32), /// The second window's view of the active photo. Second, /// A slider drag's live HDR histogram: a small full-quality render of an HDR edit, beside the /// drafts (which have no HDR histogram), with no texture ([`Renderer::live_hdr_histogram`]). HdrHistogram, } pub struct Tex { pub key: u64, pub photo: PhotoId, pub tex: egui::TextureHandle, pub size: [usize; 2], pub histogram: Option, pub ms: f64, /// Set for stand-ins: where the image came from. pub quick: Option, /// CPU copy of the pixels (only with [`Renderer::keep_pixels`]; for headless screenshots). pub pixels: Option>, } /// Runs render jobs outside this thread's job pool (the browser build: Web Workers, each with its /// own wasm instance). The [`Renderer`] keeps the queue (priorities, per-slot de-duplication) and /// hands jobs over one at a time. pub trait RenderOffload { /// Start `job` if an executor is free; otherwise give it back (`Some`: it stays queued). fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option; /// Finished jobs since the last call: (slot, result, run time in ms). fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)>; } struct Queued { slot: Slot, priority: u32, seq: u64, job: RenderJob, } /// The texture of a render: its pixels as egui wants them. Renders are opaque, so the common case /// is a plain copy; only a pixel with alpha pays for premultiplying. (The generic conversion, on /// the UI thread, was most of the time a 5 MP zoom window took to show.) pub(crate) fn color_image(img: &lightcraft_raster::Rgba8) -> egui::ColorImage { let pixels = img .data .iter() .map(|p| if p[3] == 255 { egui::Color32::from_rgb(p[0], p[1], p[2]) } else { egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) }) .collect(); egui::ColorImage { size: [img.width, img.height], source_size: egui::vec2(img.width as f32, img.height as f32), pixels } } /// How far over the budget the open photo's window and before caches may run before they are cut. const HARD_FACTOR: usize = 4; /// Which stage caches to clear, in order, so that the rest fit `budget` bytes. /// /// What nothing is looking at goes first, the cheapest to rebuild and least reused first (hover, /// compare, second window, background work). The open photo's own views are what a slider drag /// reuses on every tick, and a cache cleared every frame is rebuilt every frame (a drag on a /// 2800 px canvas got 3.6x slower), so they stay — counted and reported — up to /// [`HARD_FACTOR`] times the budget; past that (a runaway: 8:1 on a huge canvas, both sides of /// Before/After) the before side's and the zoom window's go, the before side first. The /// whole-frame view's cache is never named. Empty caches are never named. pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec { let idle_rank = |s: Slot| match s { Slot::Hover => 0, Slot::Compare(_) | Slot::CompareRegion(_) => 2, Slot::Second => 3, _ => 5, }; let view_rank = |s: Slot| match s { Slot::RegionBefore => Some(0), Slot::Before => Some(1), Slot::Region => Some(2), _ => None, }; let is_view = |s: Slot| matches!(s, Slot::Main) || view_rank(s).is_some(); let mut held: usize = sizes.iter().map(|(_, b)| *b).fold(0, usize::saturating_add); let mut out = Vec::new(); let mut idle: Vec<(Slot, usize)> = sizes.iter().copied().filter(|(s, b)| *b > 0 && !is_view(*s)).collect(); idle.sort_by_key(|(s, _)| idle_rank(*s)); for (slot, bytes) in idle { if held <= budget { break; } held = held.saturating_sub(bytes); out.push(slot); } let hard = budget.saturating_mul(HARD_FACTOR); let mut views: Vec<(Slot, usize, u8)> = sizes.iter().filter_map(|(s, b)| view_rank(*s).filter(|_| *b > 0).map(|r| (*s, *b, r))).collect(); views.sort_by_key(|(_, _, r)| *r); for (slot, bytes, _) in views { if held <= hard { break; } held = held.saturating_sub(bytes); out.push(slot); } out } /// The share of the memory budget the per-view stage caches (the loupe's, the zoom window's, the /// before / hover renders') may hold together before the idle ones are cleared (see /// [`stage_trim_order`] for the open photo's own): they are the decoded-image sized buffers that the /// engine's own caches do not count. const STAGE_BUDGET_SHARE: usize = 4; /// Variant thumbnails kept as textures (LRU), when nothing asks for more. pub const VARIANT_TEXTURES: usize = 96; /// The most a view can raise that to ([`Renderer::want_variants`]): a screenful of small faces is a few hundred pictures of /// a few tens of kilobytes each. pub const MAX_VARIANT_TEXTURES: usize = 800; /// Priority of variant thumbnail jobs: below on-screen grid thumbnails and the loupe. const VARIANT_PRIORITY: u32 = 6; pub struct Renderer { #[cfg(test)] pub(crate) thumb_jobs_built: usize, request_ids: HashMap, /// Request id of the pixels each interactive view (loupe, before, hover) shows: a draft that /// finishes after a newer request was made still replaces older pixels, so a long slider drag /// keeps updating the loupe instead of waiting for the drag to pause. shown_ids: HashMap, /// Requests made before this id belong to a library or preview cache that is gone. epoch: u64, preview_generation: Option<(std::sync::Weak, u64)>, /// Last thumbnail inputs; weak identities do not retain photo histories or force deep clones. /// (photo, size bucket, job key, whether the request went through an embedded stand-in) thumb_inputs: HashMap, usize, u64, bool)>, /// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it). embedded_thumbs: HashMap, usize, u64)>, pool: JobPool, /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. offload: Option>, queue: Vec, seq: u64, /// Slot → (key, priority) of the request in flight (queued or running). pending: HashMap, pub textures: HashMap, /// The last [`Slot::HdrHistogram`] result: (photo, job key, histogram). pub live_hdr_histogram: Option<(PhotoId, u64, Histogram)>, pub last_main_ms: f64, /// Jobs finished since start (for inspect/perf). pub completed: u64, /// Keep a CPU copy of every texture so the UI can be rasterized headlessly (set when the app /// is driven by the control channel). pub keep_pixels: bool, /// Per-view intermediate results (loupe and "before"), so slider drags redo only what changed. stages: HashMap>, /// Slot → key of the last quick job requested for it (each is tried once). quick_tried: HashMap, /// Jobs that failed (unreadable or missing file…), by slot and job key, with the error and /// the photo: not requested again until the key changes (an edit, another size) — the grid /// asks every frame. failed: HashMap, /// The catalog revision seen at the last poll: a failure is retried once the catalog changes /// (a relinked or re-imported file). catalog_rev: u64, /// Prefetch slot → key of the last job submitted (each runs once). prefetched: HashMap, /// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures). variant_used: HashMap, /// How many variants the last frame's views showed at once (see [`Self::want_variants`]). variant_want: usize, /// Frames polled so far. frame: u64, /// Stage caches cleared to stay in budget (see [`stage_trim_order`]). stages_trimmed: u64, /// A budget other than the memory budget's share (tests). #[cfg(test)] pub(crate) stage_budget_override: Option, /// The monitor profile textures are made for (`app.displayProfile`; `None`: sRGB). display: Option>, /// Since when nothing has been pending, and whether the GPU pool was trimmed since. #[cfg(not(target_arch = "wasm32"))] idle: Option<(std::time::Instant, bool)>, } /// After this long without renders the GPU renderer's pool of recycled buffers is freed. #[cfg(not(target_arch = "wasm32"))] const IDLE_TRIM: std::time::Duration = std::time::Duration::from_secs(3); impl Slot { /// An interactive view of the open photo: slider drags redo only the stages they feed, and a /// draft that finishes late still replaces older pixels. pub fn is_view(self) -> bool { matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover | Slot::HdrHistogram) } } impl Default for Renderer { fn default() -> Self { let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; Self::with_worker_threads(threads) } } impl Renderer { /// Explicit worker count for hosts and bounded validation runs (one to six on native). /// Browser hosts continue to use inline jobs or their installed worker offload. pub fn with_worker_threads(threads: usize) -> Self { let threads = if cfg!(target_arch = "wasm32") { 0 } else { threads.clamp(1, 6) }; Renderer { #[cfg(test)] thumb_jobs_built: 0, request_ids: HashMap::new(), shown_ids: HashMap::new(), epoch: 0, preview_generation: None, pool: JobPool::new(threads), thumb_inputs: HashMap::new(), embedded_thumbs: HashMap::new(), offload: None, queue: Vec::new(), seq: 0, pending: HashMap::new(), textures: HashMap::new(), live_hdr_histogram: None, last_main_ms: 0.0, completed: 0, keep_pixels: false, stages: HashMap::new(), quick_tried: HashMap::new(), failed: HashMap::new(), catalog_rev: 0, prefetched: HashMap::new(), variant_used: HashMap::new(), variant_want: 0, frame: 0, stages_trimmed: 0, #[cfg(test)] stage_budget_override: None, display: None, #[cfg(not(target_arch = "wasm32"))] idle: None, } } } impl Renderer { /// Would asking for this thumbnail again (at `priority`) change nothing? pub fn thumb_current(&self, photo: &Arc, bucket: usize, priority: u32) -> bool { let Some(&(ref old, size, key, quick)) = self.thumb_inputs.get(&photo.id) else { return false }; if old.as_ptr() != Arc::as_ptr(photo) || size != bucket { return false; } let slot = Slot::Thumb(photo.id); if quick && !self.textures.contains_key(&slot) { // Stand-in path (`grid::request_thumb`): the embedded preview is still on its way, or // the real thumbnail is requested at the background priority whatever the caller's. return self.pending.get(&Slot::ThumbQuick(photo.id)).is_some_and(|p| p.0 == key) || !self.request_needed(slot, key, crate::panels::grid::BACKGROUND_THUMB_PRIORITY); } !self.request_needed(slot, key, priority) } /// Remember the inputs of the thumbnail just requested (`quick`: through a stand-in). /// Whether the camera's embedded preview *is* this photo's thumbnail: its quick render arrived /// (so `Slot::ThumbQuick` holds the picture) and no edit has moved the photo on to a settings /// hash of its own. The grid stops here instead of decoding the raw behind a thumbnail the /// embedded JPEG already covers — see `grid::skips_the_real_render`. pub fn thumb_quick_is_final(&self, photo: &Arc, bucket: usize, priority: u32) -> bool { let Some((old, size, key)) = self.embedded_thumbs.get(&photo.id) else { return false }; old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::ThumbQuick(photo.id), *key, priority) } /// Whether this photo's embedded-preview thumbnail is being fetched right now: the grid has /// asked, and neither the picture nor a render behind it is owed. pub fn thumb_quick_pending(&self, photo: &Arc, bucket: usize) -> bool { let Some((old, size, _, quick)) = self.thumb_inputs.get(&photo.id) else { return false }; old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && *quick && self.pending.contains_key(&Slot::ThumbQuick(photo.id)) } /// Remember that the camera's embedded preview is this photo's final thumbnail, so the grid's /// per-frame requests stop at it (the quick render's key, which [`Self::thumb_quick_is_final`] /// compares against). pub fn remember_embedded_thumb(&mut self, photo: &Arc, bucket: usize, key: u64) { self.remember_thumb(photo, bucket, key, true); self.embedded_thumbs.insert(photo.id, (Arc::downgrade(photo), bucket, key)); } pub fn remember_thumb(&mut self, photo: &Arc, bucket: usize, key: u64, quick: bool) { if self.thumb_inputs.len() >= 1024 && !self.thumb_inputs.contains_key(&photo.id) { self.thumb_inputs.retain(|_, (p, _, _, _)| p.strong_count() > 0); if self.thumb_inputs.len() >= 1024 && let Some(id) = self.thumb_inputs.keys().next().copied() { self.thumb_inputs.remove(&id); } } self.thumb_inputs.insert(photo.id, (Arc::downgrade(photo), bucket, key, quick)); } /// Views that render straight into the display profile's primaries (wide gamut); everything /// else is rendered for sRGB and converted ([`lightcraft_engine::display::present`]). fn renders_for_display(slot: Slot) -> bool { matches!( slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::CompareRegion(_) | Slot::Second ) } /// `job` as [`Self::request`] would run it for `slot` (its key included): views render for /// the display profile in use. pub fn job_for(&self, slot: Slot, job: RenderJob) -> RenderJob { if Self::renders_for_display(slot) { job.with_display(self.display.clone()) } else { job } } /// The display profile in use (id). pub fn display_id(&self) -> Option { self.display.as_ref().map(|d| d.id()) } /// The display profile textures are made for (`None`: sRGB). pub fn display(&self) -> Option<&Arc> { self.display.as_ref() } /// Show previews through `display` from now on (`app.displayProfile`): when it changes, every /// texture is made again for the new one. pub fn set_display(&mut self, display: Option>) { if display.as_ref().map(|d| d.id()) != self.display_id() { self.forget_all(); } self.display = display; } /// Run `job` and make its image ready for `display` (on the worker thread). fn run_for(job: impl FnOnce() -> RenderResult, display: &Option>) -> RenderResult { let mut r = job(); if let Some(d) = display { lightcraft_engine::display::present(&mut r, d); } r } fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool { // A matching old texture must not mask a different newer request (e.g. undo). if let Some(&(k, p)) = self.pending.get(&slot) { return k != key || (p != priority && self.is_queued(slot)); } if self.textures.get(&slot).is_some_and(|t| t.key == key) || self.failed.get(&slot).is_some_and(|f| f.0 == key && f.2 == self.catalog_rev) { return false; } true } /// Free a view slot's texture and stage cache (the view is no longer shown). pub fn release(&mut self, slot: Slot) { self.textures.remove(&slot); self.stages.remove(&slot); } /// Run jobs through `offload` from now on (instead of the job pool / inline). pub fn set_offload(&mut self, offload: Box) { self.offload = Some(offload); } fn is_queued(&self, slot: Slot) -> bool { self.queue.iter().any(|q| q.slot == slot) || self.pool.is_queued(slot) } /// Is the slot's current texture (or pending request) already for `key`? /// Key of what `slot` shows or is rendering next (the pending request wins). pub fn wanted(&self, slot: Slot) -> Option { self.pending.get(&slot).map(|p| p.0).or_else(|| self.textures.get(&slot).map(|t| t.key)) } pub fn is_current(&self, slot: Slot, key: u64) -> bool { self.wanted(slot) == Some(key) } /// Request a render for `slot` (no-op if already current or pending at the same priority). pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) { let mut job = self.job_for(slot, job); if !self.request_needed(slot, job.key, priority) { return; } self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); // (an offload keeps its own per-view stage caches; the flag tells it to) let job = if slot.is_view() { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; if self.offload.is_some() { self.seq += 1; self.queue.retain(|q| q.slot != slot); self.queue.push(Queued { slot, priority, seq: self.seq, job }); self.dispatch(); // an idle worker starts now rather than next frame return; } let key = job.key; let background = matches!(slot, Slot::Thumb(_) | Slot::ThumbQuick(_) | Slot::Prefetch(_)); let display = self.display.clone(); self.pool.submit( slot, key, priority, Box::new(move || { let run = || if background { lightcraft_engine::memory::in_background(|| job.run()) } else { job.run() }; Self::run_for(run, &display) }), ); } /// Request a stand-in for `slot` (once per job key). pub fn request_quick(&mut self, slot: Slot, mut job: QuickJob, priority: u32) { if self.quick_tried.get(&slot) == Some(&job.key) { return; } self.quick_tried.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); let key = job.key; let display = self.display.clone(); self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display))); } /// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one /// replaces it while queued). Memory stays bounded by the engine's source caches. pub fn prefetch(&mut self, slot: Slot, mut job: RenderJob, priority: u32) { if self.prefetched.get(&slot) == Some(&job.key) { return; } self.prefetched.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); let key = job.key; self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); } /// Is a request for `slot` queued or running? /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). pub fn forget_all(&mut self) { self.request_ids.clear(); self.shown_ids.clear(); self.epoch = lightcraft_preview::next_tick(); self.preview_generation = None; self.pool.reprioritize(|_, _| None); self.thumb_inputs.clear(); self.textures.clear(); self.stages.clear(); self.quick_tried.clear(); self.failed.clear(); self.prefetched.clear(); self.pending.clear(); self.queue.clear(); } pub fn is_pending(&self, slot: Slot) -> bool { self.pending.contains_key(&slot) } /// The app is closing: drop the jobs that have not started and wait up to `timeout` for the /// ones running on the worker threads. Returns whether all of them have ended. Nothing is /// rendered afterwards. pub fn shutdown(&mut self, timeout: std::time::Duration) -> bool { self.queue.clear(); self.pool.shutdown(timeout) } /// Worker threads that are still alive (0 before the first render). pub fn live_workers(&self) -> usize { self.pool.live_workers() } /// The texture to show for a grid/filmstrip thumbnail: the rendered one, else its stand-in. pub fn thumb(&self, id: PhotoId) -> Option<&Tex> { self.textures.get(&Slot::Thumb(id)).or_else(|| self.textures.get(&Slot::ThumbQuick(id))) } pub fn queued(&self) -> usize { self.pool.queued() + self.queue.len() } /// Hand queued jobs to the offload (best first) while it has room. fn dispatch(&mut self) { let Some(off) = self.offload.as_mut() else { return }; while let Some(i) = self.queue.iter().enumerate().max_by_key(|(_, q)| (q.priority, q.seq)).map(|(i, _)| i) { let q = self.queue.swap_remove(i); if let Some(job) = off.try_start(q.slot, q.job) { self.queue.push(Queued { job, ..q }); break; } } } /// Requests queued or running. pub fn in_flight(&self) -> usize { self.pending.len() } /// Why the last render for `slot` failed (until a render with another key succeeds). pub fn failure(&self, slot: Slot) -> Option<&str> { self.failed.get(&slot).map(|f| f.1.as_str()) } /// Why the last render of `photo` for `slot` failed: a view slot shows one photo after /// another, and another photo's error isn't this one's. pub fn failure_of(&self, slot: Slot, photo: PhotoId) -> Option<&str> { self.failed.get(&slot).filter(|f| f.3 == photo).map(|f| f.1.as_str()) } /// Whether `slot`'s pending render asks again for the key that last failed (a library change /// retries failures): what is on screen stays what the failure left until it answers. pub fn retrying(&self, slot: Slot) -> bool { self.pending.get(&slot).zip(self.failed.get(&slot)).is_some_and(|(p, f)| p.0 == f.0) } /// The slots with a render pending (diagnostics: `ui.inspect`). pub fn pending_slots(&self) -> Vec { self.pending.keys().map(|s| format!("{s:?}")).collect() } /// Thumbnail textures currently loaded. /// A variant thumbnail ([`Slot::Variant`]): its texture once rendered; until then the job is /// queued at low priority (below on-screen grid thumbnails). Call it every frame the variant /// is visible: variants not asked for during the last frames leave the queue, and textures /// beyond [`VARIANT_TEXTURES`] are evicted least recently used first. pub fn variant(&mut self, job: RenderJob) -> Option<&Tex> { let key = job.key; let slot = Slot::Variant(key); self.variant_used.insert(key, self.frame); if !self.textures.contains_key(&slot) { self.request(slot, job, VARIANT_PRIORITY); } self.textures.get(&slot) } /// A view that shows `n` variants at once (the People view's faces) says so each frame, so the cache holds them all: /// with a fixed budget a screen of more than [`VARIANT_TEXTURES`] pictures evicts, and re-requests, the same few every /// frame, and those tiles stay blank. The budget is the most asked for in the last frame, a quarter more, up to /// [`MAX_VARIANT_TEXTURES`]. pub fn want_variants(&mut self, n: usize) { self.variant_want = self.variant_want.max(n); } /// How many variant textures are kept now. fn variant_budget(&self) -> usize { VARIANT_TEXTURES.max(self.variant_want + self.variant_want / 4).min(MAX_VARIANT_TEXTURES) } /// Variant thumbnail textures currently loaded. pub fn variant_textures(&self) -> usize { self.textures.keys().filter(|s| matches!(s, Slot::Variant(_))).count() } /// Drop variant jobs nobody asked for in the last frames, and the least recently used /// variant textures beyond the budget. fn evict_variants(&mut self) { let now = self.frame; let stale = |used: Option<&u64>| used.is_none_or(|f| now.saturating_sub(*f) > 2); let used = &self.variant_used; let dropped = self.pool.reprioritize(|s, p| match s { Slot::Variant(k) if stale(used.get(k)) => None, _ => Some(p), }); for s in dropped { self.pending.remove(&s); self.request_ids.remove(&s); } let request_ids = &mut self.request_ids; let pending = &mut self.pending; self.queue.retain(|q| match q.slot { Slot::Variant(k) if stale(used.get(&k)) => { pending.remove(&q.slot); request_ids.remove(&q.slot); false } _ => true, }); let budget = self.variant_budget(); self.variant_want = 0; let n = self.variant_textures(); if n > budget { let mut have: Vec<(u64, u64)> = self .textures .keys() .filter_map(|s| if let Slot::Variant(k) = s { Some((self.variant_used.get(k).copied().unwrap_or(0), *k)) } else { None }) .collect(); have.sort_unstable(); for (_, k) in have.into_iter().take(n - budget) { self.textures.remove(&Slot::Variant(k)); self.variant_used.remove(&k); } } if self.variant_used.len() > 4 * budget { let textures = &self.textures; let pending = &self.pending; self.variant_used.retain(|k, _| textures.contains_key(&Slot::Variant(*k)) || pending.contains_key(&Slot::Variant(*k))); } } pub fn thumb_textures(&self) -> usize { self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count() } /// Collect finished jobs into textures. Returns true if anything changed. pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool { let cache = &session.media.rendered; let generation = cache.generation(); if self.preview_generation.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation) { self.forget_all(); } self.preview_generation = Some((Arc::downgrade(cache), generation)); self.catalog_rev = session.catalog.revision; self.frame += 1; self.trim_stages(); // wasm: run one job per frame on this thread, timed with the host clock #[cfg(target_arch = "wasm32")] let inline_ms = { let t0 = crate::now_ms(); self.pool.run_inline(1); crate::now_ms() - t0 }; #[cfg(not(target_arch = "wasm32"))] let inline_ms = 0.0; let changed = self.collect(ctx, session, inline_ms); self.evict_variants(); #[cfg(not(target_arch = "wasm32"))] self.trim_when_idle(ctx); if !self.pending.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } changed } /// In a slider drag, give the loupe's draft (just requested) until `budget` to finish, so that it /// is drawn in this frame rather than picked up at the start of the next one. Only when the last /// loupe render took well under the budget (a GPU draft takes a few milliseconds; a CPU render /// that would only make the frame late is not waited for). #[cfg(not(target_arch = "wasm32"))] pub fn wait_for_draft(&mut self, ctx: &egui::Context, session: &mut Session, budget: std::time::Duration) { if self.offload.is_some() || self.last_main_ms * 1.5 >= budget.as_secs_f64() * 1e3 { return; } let Some(deadline) = std::time::Instant::now().checked_add(budget) else { return }; while self.is_pending(Slot::Main) { let left = deadline.saturating_duration_since(std::time::Instant::now()); if left.is_zero() || !self.pool.wait(left) { break; } self.collect(ctx, session, 0.0); } } /// Turn finished jobs into textures. Returns true if anything changed. fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool { let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms })); } if let Some(off) = self.offload.as_mut() { finished.extend(off.finished()); } self.dispatch(); let mut changed = false; for (mut slot, mut r, ms) in finished { self.completed += 1; // made for another display profile than the one in use: obsolete if r.display.is_some_and(|id| Some(id) != self.display_id()) { continue; } // Ignore obsolete pixels, failures and decoded sources, even for the same render key; // an interactive view still takes a superseded draft that is newer than what it // shows, and the source it decoded: a drag supersedes every request before it // finishes, and a source thrown away with them is decoded again for every tick. if self.request_ids.get(&slot) != Some(&r.request_id) { let newer_draft = slot.is_view() && self.request_ids.contains_key(&slot) && r.request_id >= self.epoch && self.shown_ids.get(&slot).is_none_or(|shown| r.request_id > *shown) && r.rendered.is_ok(); if !newer_draft { continue; } session.accept(&r); } else { self.request_ids.remove(&slot); session.accept(&r); } if slot.is_view() { self.shown_ids.insert(slot, r.request_id); } // (results from an offload, which doesn't know the display profile: converted here) if let Some(d) = &self.display { lightcraft_engine::display::present(&mut r, d); } if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { self.pending.remove(&slot); } let rendered = match r.rendered { Ok(x) => { self.failed.remove(&slot); x } Err(e) => { if !matches!(slot, Slot::Prefetch(_)) { log::warn!("render {slot:?}: {e}"); self.failed.insert(slot, (r.key, e, self.catalog_rev, r.photo)); } continue; } }; if matches!(slot, Slot::Prefetch(_)) { continue; } if slot == Slot::HdrHistogram { if rendered.histogram.hdr.is_some() { self.live_hdr_histogram = Some((r.photo, r.key, rendered.histogram)); changed = true; } continue; } if let Slot::ThumbQuick(id) = slot { if self.textures.contains_key(&Slot::Thumb(id)) { continue; // the real thumbnail won the race } if r.quick == Some(QuickSource::Cached) { // the photo's own cached thumbnail: final slot = Slot::Thumb(id); } } if let Slot::Thumb(id) = slot { self.textures.remove(&Slot::ThumbQuick(id)); } let img = &rendered.image; let color = std::sync::Arc::new(color_image(img)); let pixels = self.keep_pixels.then(|| color.clone()); let name = format!("{slot:?}"); match self.textures.get_mut(&slot) { Some(t) => { t.tex.set(color, egui::TextureOptions::LINEAR); t.pixels = pixels; t.key = r.key; t.photo = r.photo; t.size = [img.width, img.height]; t.histogram = Some(rendered.histogram); t.ms = ms; t.quick = r.quick; } None => { let tex = ctx.load_texture(name, color, egui::TextureOptions::LINEAR); self.textures.insert( slot, Tex { key: r.key, photo: r.photo, tex, size: [img.width, img.height], histogram: Some(rendered.histogram), ms, quick: r.quick, pixels, }, ); } } if slot == Slot::Main { self.last_main_ms = ms; } changed = true; } changed } /// 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). 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() || self.textures.contains_key(&Slot::Region) || self.textures.contains_key(&Slot::RegionBefore) { self.idle = None; return; } let now = std::time::Instant::now(); let (since, trimmed) = *self.idle.get_or_insert((now, false)); if trimmed { return; } let waited = now.duration_since(since); if waited >= IDLE_TRIM { lightcraft_engine::gpu::trim_pool(0); lightcraft_engine::memory::release(); self.idle = Some((since, true)); } else { ctx.request_repaint_after(IDLE_TRIM - waited); } } /// Bytes the per-view stage caches (CPU images and GPU buffers) may hold together. pub fn stage_budget(&self) -> usize { #[cfg(test)] if let Some(b) = self.stage_budget_override { return b; } lightcraft_engine::memory::budget() / STAGE_BUDGET_SHARE } /// Clear stage caches, least useful first, until what they hold fits [`Self::stage_budget`]. fn trim_stages(&mut self) { let sizes: Vec<(Slot, usize)> = self.stages.iter().map(|(slot, c)| (*slot, c.bytes() + lightcraft_engine::gpu::stage_bytes(c))).collect(); for slot in stage_trim_order(&sizes, self.stage_budget()) { if let Some(c) = self.stages.get(&slot) { c.clear(); self.stages_trimmed += 1; } } } /// What the renderer holds: per-view stage caches (CPU images and GPU buffers) and textures. pub fn memory(&self) -> serde_json::Value { let cpu: usize = self.stages.values().map(|s| s.bytes()).sum(); let gpu: usize = self.stages.values().map(|s| lightcraft_engine::gpu::stage_bytes(s)).sum(); let tex: usize = self.textures.values().map(|t| t.size[0] * t.size[1] * 4).sum(); let copies: usize = self.textures.values().filter_map(|t| t.pixels.as_ref()).map(|p| p.pixels.len() * 4).sum(); serde_json::json!({ "stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu, "budgetBytes": self.stage_budget(), "trimmed": self.stages_trimmed, "sharedSourceBytes": lightcraft_engine::gpu::shared_source_bytes()}, "textures": {"count": self.textures.len(), "bytes": tex, "cpuCopyBytes": copies}, }) } /// CPU copies of the current textures by id (see [`Self::keep_pixels`]). pub fn cpu_textures(&self) -> HashMap { self.textures.values().filter_map(|t| Some((t.tex.id(), crate::softpaint::CpuTexture::linear(t.pixels.clone()?)))).collect() } /// Drop the import review thumbnails (a new review, or the dialog closed). pub fn forget_imports(&mut self) { self.pool.reprioritize(|s, p| if matches!(s, Slot::Import(_)) { None } else { Some(p) }); self.queue.retain(|q| !matches!(q.slot, Slot::Import(_))); self.pending.retain(|s, _| !matches!(s, Slot::Import(_))); self.request_ids.retain(|s, _| !matches!(s, Slot::Import(_))); self.textures.retain(|s, _| !matches!(s, Slot::Import(_))); self.quick_tried.retain(|s, _| !matches!(s, Slot::Import(_))); } /// Drop thumbnails that aren't in `keep` (bounded memory for huge libraries), and queued /// thumbnail jobs for photos that scrolled out of `keep`. pub fn evict_thumbs(&mut self, keep: &std::collections::HashSet, max: usize) { self.thumb_inputs.retain(|id, _| keep.contains(id)); let dropped = self.pool.reprioritize(|s, p| match s { Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id) && p <= 11 => None, _ => Some(p), }); for s in dropped { self.pending.remove(&s); self.request_ids.remove(&s); self.quick_tried.remove(&s); } let request_ids = &mut self.request_ids; let pending = &mut self.pending; self.queue.retain(|q| match q.slot { Slot::Thumb(id) if !keep.contains(&id) && q.priority <= 10 => { pending.remove(&q.slot); request_ids.remove(&q.slot); false } _ => true, }); let thumbs = self.thumb_textures(); if thumbs <= max { return; } self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id))); self.quick_tried.retain(|s, _| !matches!(s, Slot::ThumbQuick(id) if !keep.contains(id))); } } #[cfg(test)] mod thumbnail_tests { use super::*; use lightcraft_catalog::{Catalog, Op, Photo, Source}; struct Blocked; impl RenderOffload for Blocked { fn try_start(&mut self, _: Slot, job: RenderJob) -> Option { Some(job) } fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { vec![] } } fn photo() -> Arc { Arc::new(Photo::new(PhotoId(1), Source::File { path: "audit.jpg".into() }, "audit.jpg", "JPEG", 6000, 4000, "2026-01-01")) } #[test] fn snapshot_tracks_cow_edits_undo_reload_and_replacement() { let mut catalog = Catalog::default(); catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); let old = catalog.photo(PhotoId(1)).unwrap().clone(); let mut r = Renderer::default(); r.remember_thumb(&old, 384, 77, false); r.pending.insert(Slot::Thumb(PhotoId(1)), (77, 10)); assert!(r.thumb_current(&old, 384, 10)); assert!(!r.thumb_current(&old, 512, 10)); let mut settings = lightcraft_develop::DevelopSettings::default(); settings.light.exposure = 1.0; let undo = catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "edit".into(), edited: None }).unwrap(); assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); catalog.apply(undo).unwrap(); assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); catalog .apply(Op::SetContent { id: PhotoId(1), width: 6000, height: 4000, file_size: 99, content_hash: Some("changed".into()), preview_only: None, }) .unwrap(); assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); assert!(!r.thumb_current(&photo(), 384, 10)); r.forget_all(); assert!(r.thumb_inputs.is_empty()); } #[test] fn queue_promotions_running_work_and_cancellation() { let p = photo(); let slot = Slot::Thumb(p.id); let mut session = Session::new(); session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap(); let job = session.thumb_job(p.id, 384).unwrap(); let key = job.key; let mut r = Renderer::default(); r.set_offload(Box::new(Blocked)); r.remember_thumb(&p, 384, key, false); r.request(slot, job, 5); assert!(r.thumb_current(&p, 384, 5)); assert!(!r.thumb_current(&p, 384, 10)); r.request(slot, session.thumb_job(p.id, 384).unwrap(), 10); assert_eq!(r.pending.get(&slot), Some(&(key, 10))); r.queue.clear(); // running work: cannot reprioritize, just as Renderer::request assert!(r.thumb_current(&p, 384, 5)); r.pending.clear(); assert!(!r.thumb_current(&p, 384, 10)); r.evict_thumbs(&Default::default(), 600); assert!(r.thumb_inputs.is_empty()); } #[test] fn failures_retry_after_revision_and_quick_only_is_not_final() { let p = photo(); let slot = Slot::Thumb(p.id); let mut r = Renderer::default(); r.remember_thumb(&p, 384, 77, false); r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); assert!(!r.thumb_current(&p, 384, 10)); r.failed.insert(slot, (77, "no decoder".into(), 0, p.id)); assert!(r.thumb_current(&p, 384, 10)); assert_eq!(r.failure_of(slot, p.id), Some("no decoder")); assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error"); r.catalog_rev = 1; assert!(!r.thumb_current(&p, 384, 10)); } #[test] fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { let mut s = Session::new(); s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); let mut r = Renderer::with_worker_threads(1); let ctx = egui::Context::default(); let budget = std::time::Duration::from_secs(10); // the last loupe render took longer than the budget (a CPU render): not waited for r.last_main_ms = 1e6; r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); r.wait_for_draft(&ctx, &mut s, budget); assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); // a fast one (GPU drafts): the texture is the new render when the wait returns r.last_main_ms = 1.0; r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); let want = r.wanted(Slot::Main); r.wait_for_draft(&ctx, &mut s, budget); assert!(!r.is_pending(Slot::Main)); assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); } #[derive(Default)] struct Work { jobs: Vec<(Slot, RenderJob)>, finished: Vec<(Slot, RenderResult, f64)>, } struct Manual(std::rc::Rc>); impl RenderOffload for Manual { fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { self.0.borrow_mut().jobs.push((slot, job)); None } fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { std::mem::take(&mut self.0.borrow_mut().finished) } } fn fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { fixture_with(Session::new(), (*photo()).clone()) } fn fixture_with(mut s: Session, photo: Photo) -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { s.media.file_loader = Some(Arc::new(|_, _| { let mut image = lightcraft_raster::Rgb32f::new(8, 8); image.data.fill([0.3, 0.2, 0.1]); Ok((image, Default::default())) })); s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); let mut app = crate::LightcraftApp::new(s, crate::Services::default()); app.renderer.keep_pixels = true; let work = std::rc::Rc::new(std::cell::RefCell::new(Work::default())); app.renderer.set_offload(Box::new(Manual(work.clone()))); let ctx = egui::Context::default(); app.renderer.poll(&ctx, &mut app.session); (app, work, ctx) } fn take_job(work: &std::rc::Rc>) -> (Slot, RenderJob) { work.borrow_mut().jobs.pop().unwrap() } fn finish(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context, slot: Slot, result: RenderResult) { work.borrow_mut().finished.push((slot, result, 0.0)); app.renderer.poll(ctx, &mut app.session); } fn request(app: &mut crate::LightcraftApp) { crate::panels::grid::request_thumb(app, PhotoId(1), 256, 10); } #[test] fn unchanged_ready_and_running_frames_build_no_jobs() { let (mut app, work, ctx) = fixture(); request(&mut app); assert_eq!(app.renderer.thumb_jobs_built, 1); for _ in 0..180 { request(&mut app); } assert_eq!(app.renderer.thumb_jobs_built, 1); let (slot, job) = take_job(&work); finish(&mut app, &work, &ctx, slot, job.run()); for _ in 0..180 { request(&mut app); } assert_eq!(app.renderer.thumb_jobs_built, 1); assert!(work.borrow().jobs.is_empty()); } #[test] fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { let (mut app, work, ctx) = fixture(); request(&mut app); let (slot, job) = take_job(&work); finish(&mut app, &work, &ctx, slot, job.run()); app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); for v in [0.1, 0.2, 0.3] { app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); app.renderer.poll(&ctx, &mut app.session); request(&mut app); } assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); assert!(work.borrow().jobs.is_empty()); app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); request(&mut app); assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); } #[test] fn unchanged_queued_frames_build_no_jobs() { let (mut app, _, _) = fixture(); app.renderer.set_offload(Box::new(Blocked)); request(&mut app); for _ in 0..180 { request(&mut app); } assert_eq!(app.renderer.thumb_jobs_built, 1); assert_eq!(app.renderer.queue.len(), 1); } #[test] fn resizing_the_disk_cache_keeps_textures_and_skips_rebuilds() { let lib = std::env::temp_dir().join(format!("lc-ui-thumb-resize-{}", std::process::id())); let _ = std::fs::remove_dir_all(&lib); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); let (mut app, work, ctx) = fixture_with(s, (*photo()).clone()); request(&mut app); let (slot, job) = take_job(&work); finish(&mut app, &work, &ctx, slot, job.run()); assert!(app.renderer.thumb(PhotoId(1)).is_some()); let cache = app.session.media.rendered.clone(); app.session.execute("library.preferences", &serde_json::json!({"cacheMb": 300})).unwrap(); app.renderer.poll(&ctx, &mut app.session); // thumbnails are keyed by content: a new budget leaves them (and the cache) valid assert!(app.renderer.thumb(PhotoId(1)).is_some()); assert!(Arc::ptr_eq(&cache, &app.session.media.rendered)); for _ in 0..30 { request(&mut app); } assert_eq!(app.renderer.thumb_jobs_built, 1); assert!(work.borrow().jobs.is_empty()); // an explicit clear still drops them app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); app.renderer.poll(&ctx, &mut app.session); assert!(app.renderer.thumb(PhotoId(1)).is_none()); drop(app); let _ = std::fs::remove_dir_all(&lib); } /// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting, /// the error is kept (not retried every frame), and the pool's workers live on. Before, the /// worker died without an answer and the slot stayed pending forever. #[test] fn a_panicking_render_is_a_failure_not_a_dead_worker() { let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); app.renderer.offload = None; // the real job pool let slot = Slot::Thumb(PhotoId(1)); let ask = |app: &mut crate::LightcraftApp| { if let Some(job) = app.session.thumb_job(PhotoId(1), 256) { app.renderer.request(slot, job, 30); } app.renderer.poll(&ctx, &mut app.session); }; ask(&mut app); let t0 = std::time::Instant::now(); while app.renderer.is_pending(slot) { assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); std::thread::sleep(std::time::Duration::from_millis(5)); app.renderer.poll(&ctx, &mut app.session); } assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug")); let workers = app.renderer.live_workers(); assert!(workers > 0, "the worker that panicked is still there"); let done = app.renderer.completed; for _ in 0..30 { ask(&mut app); } assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop"); assert_eq!(app.renderer.live_workers(), workers); } #[test] fn raw_quick_path_frames_build_no_jobs() { // an unedited raw: the grid shows its embedded preview (held here until released) and stops // there — the camera JPEG at 4 416 × 2 944 already covers a 512 px thumbnail, so decoding the // whole raw behind it only costs the file read (45 s a photo on a 1.2 MB/s share) let gate = Arc::new(std::sync::Mutex::new(())); let held = gate.lock().unwrap(); let mut s = Session::new(); let g = gate.clone(); s.media.preview_loader = Some(Arc::new(move |_: &str, _| { drop(g.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] }) })); let mut raw = (*photo()).clone(); raw.kind = lightcraft_catalog::MediaKind::Raw; raw.develop = Arc::new(raw.camera_defaults()); let (mut app, _, ctx) = fixture_with(s, raw); app.renderer.set_offload(Box::new(Blocked)); let quick = Slot::ThumbQuick(PhotoId(1)); // the stand-in is running for _ in 0..180 { request(&mut app); } assert!(app.renderer.is_pending(quick)); assert_eq!(app.renderer.thumb_jobs_built, 1); drop(held); let t0 = std::time::Instant::now(); while !app.renderer.textures.contains_key(&quick) { assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the embedded preview never arrived"); std::thread::sleep(std::time::Duration::from_millis(5)); app.renderer.poll(&ctx, &mut app.session); } // the embedded preview is the thumbnail: no real render is queued behind it, and the grid // asking every frame neither rebuilds the job nor re-requests the preview for _ in 0..180 { request(&mut app); } assert_eq!(app.renderer.thumb_jobs_built, 1, "an unedited raw's embedded preview is the thumbnail"); assert!(app.renderer.queue.is_empty(), "nothing is decoded behind it"); assert!(!app.renderer.is_pending(Slot::Thumb(PhotoId(1)))); // an edit takes the embedded preview out of the picture: the real thumbnail is rendered then let mut edited = (*photo()).clone(); edited.kind = lightcraft_catalog::MediaKind::Raw; edited.develop = Arc::new({ let mut d = edited.camera_defaults(); d.light.exposure = 0.5; d }); let (mut app, _, _ctx) = fixture_with(Session::new(), edited); app.renderer.set_offload(Box::new(Blocked)); for _ in 0..180 { request(&mut app); } assert_eq!(app.renderer.thumb_jobs_built, 1); assert!(app.renderer.is_pending(Slot::Thumb(PhotoId(1))) || !app.renderer.queue.is_empty(), "an edited raw renders its thumbnail"); } #[test] fn closing_import_review_cancels_queued_and_running_identities() { let (mut app, work, ctx) = fixture(); let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); app.renderer.request(Slot::Import(0), job.clone(), 10); let (slot, running) = take_job(&work); app.renderer.set_offload(Box::new(Blocked)); app.renderer.request(Slot::Import(1), job, 10); app.renderer.forget_imports(); assert!(!app.renderer.is_pending(Slot::Import(0))); assert!(!app.renderer.is_pending(Slot::Import(1))); assert!(app.renderer.queue.is_empty()); work.borrow_mut().finished.push((slot, running.run(), 0.0)); app.renderer.set_offload(Box::new(Manual(work.clone()))); app.renderer.poll(&ctx, &mut app.session); assert!(!app.renderer.textures.contains_key(&slot)); assert_eq!(app.session.media.source_usage().0, 0); } #[test] fn loupe_shows_superseded_drafts_newer_than_its_pixels_during_a_drag() { let (mut app, work, ctx) = fixture(); let mut jobs = Vec::new(); for exposure in [0.0, 1.0, 2.0] { let mut edit = lightcraft_develop::DevelopSettings::default(); edit.light.exposure = exposure; app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); let job = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft(); app.renderer.request(Slot::Main, job, 100); jobs.push(take_job(&work).1); } let keys: Vec = jobs.iter().map(|j| j.key).collect(); let mut jobs = jobs.into_iter(); let (first, second, last) = (jobs.next().unwrap(), jobs.next().unwrap(), jobs.next().unwrap()); // the first draft lands while the newest is still rendering: the loupe moves on finish(&mut app, &work, &ctx, Slot::Main, first.run()); assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[0])); assert!(app.renderer.is_pending(Slot::Main)); finish(&mut app, &work, &ctx, Slot::Main, last.run()); assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); // a draft older than the pixels shown never replaces them finish(&mut app, &work, &ctx, Slot::Main, second.run()); assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2])); assert!(!app.renderer.is_pending(Slot::Main)); } #[test] fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { let (mut app, work, ctx) = fixture(); let mut jobs = Vec::new(); for exposure in [1.0, 2.0] { let mut edit = lightcraft_develop::DevelopSettings::default(); edit.hdr.enabled = true; edit.light.exposure = exposure; app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); app.renderer.request(Slot::HdrHistogram, job, 95); jobs.push(take_job(&work).1); } let keys: Vec = jobs.iter().map(|j| j.key).collect(); let mut jobs = jobs.into_iter(); let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); // superseded while the drag goes on, it still lands: the histogram keeps moving finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); assert!(live.2.hdr.is_some(), "HDR bins"); finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); // only the histogram is kept: no texture assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); } #[test] fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { let (mut app, work, ctx) = fixture(); request(&mut app); let (slot, old) = take_job(&work); let old_result = old.run(); app.renderer.forget_all(); let photo = app.session.catalog.photo(PhotoId(1)).unwrap().as_ref().clone(); app.session = lightcraft_engine::Session::new(); app.session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); app.session.media.file_loader = Some(Arc::new(|_, _| { let mut image = lightcraft_raster::Rgb32f::new(8, 8); image.data.fill([0.1, 0.2, 0.9]); Ok((image, Default::default())) })); app.renderer.poll(&ctx, &mut app.session); request(&mut app); let (_, new) = take_job(&work); assert_eq!(old_result.key, new.key); assert_ne!(old_result.request_id, new.request_id); let expected = new.clone().run().rendered.unwrap().image.as_bytes(); finish(&mut app, &work, &ctx, slot, new.run()); finish(&mut app, &work, &ctx, slot, old_result); assert_eq!( app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), expected ); } #[test] fn late_results_and_errors_cannot_replace_newest_pixels_or_sources() { for order in [0, 1, 2] { let (mut app, work, ctx) = fixture(); request(&mut app); let (slot, old) = take_job(&work); let mut edit = lightcraft_develop::DevelopSettings::default(); edit.light.exposure = 1.0; app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); request(&mut app); let (_, new) = take_job(&work); let expected = new.clone().run().rendered.unwrap().image.as_bytes(); if order != 0 { finish(&mut app, &work, &ctx, slot, new.run()); // An obsolete source and error must not poison the current successful request. let mut stale = old.run(); if order == 2 { stale.rendered = Err("old failure".into()); } finish(&mut app, &work, &ctx, slot, stale); } else { finish(&mut app, &work, &ctx, slot, old.run()); assert!(app.renderer.thumb(PhotoId(1)).is_none()); assert_eq!(app.session.media.source_usage().0, 0); finish(&mut app, &work, &ctx, slot, new.run()); } assert_eq!( app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), expected ); assert!(app.renderer.failure(slot).is_none()); } } #[test] fn undo_to_existing_texture_supersedes_pending_edit() { let (mut app, work, ctx) = fixture(); request(&mut app); let (slot, first) = take_job(&work); let key = first.key; finish(&mut app, &work, &ctx, slot, first.run()); let original = app.renderer.thumb(PhotoId(1)).unwrap().pixels.clone().unwrap(); let mut edit = lightcraft_develop::DevelopSettings::default(); edit.light.exposure = 1.0; let undo = app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); request(&mut app); let (_, edited) = take_job(&work); app.session.catalog.apply(undo).unwrap(); request(&mut app); let (_, restored) = take_job(&work); assert_eq!(restored.key, key); finish(&mut app, &work, &ctx, slot, restored.run()); finish(&mut app, &work, &ctx, slot, edited.run()); assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); } #[test] fn explicit_clear_repairs_same_key_bad_pixels_and_rejects_old_same_key_job() { let (mut app, work, ctx) = fixture(); let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); let expected = job.clone().run().rendered.unwrap().image; let (cache, key) = job.cache.clone().unwrap(); let mut bad = expected.clone(); bad.data.fill([255, 0, 255, 255]); cache.put(key, Arc::new(bad.clone())); request(&mut app); let (slot, corrupt) = take_job(&work); finish(&mut app, &work, &ctx, slot, corrupt.run()); assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels[0].to_array(), [255, 0, 255, 255]); // An old job can have the SAME render key and stale decoded pixels. let mut old = job.clone(); old.source = lightcraft_engine::media::SourceRef::Loaded(Box::new(lightcraft_engine::media::DecodedSource::new( Arc::new(lightcraft_raster::Rgb32f::new(8, 8)), None, ))); app.renderer.textures.clear(); app.renderer.request(slot, old, 10); let (_, old) = take_job(&work); app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); app.renderer.poll(&ctx, &mut app.session); assert!(app.renderer.thumb(PhotoId(1)).is_none()); request(&mut app); let (_, repaired) = take_job(&work); assert_eq!(repaired.key, job.key); finish(&mut app, &work, &ctx, slot, repaired.run()); finish(&mut app, &work, &ctx, slot, old.run()); assert_eq!(cache.get(key).unwrap().as_bytes(), expected.as_bytes()); assert_eq!( app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), expected.as_bytes() ); } } #[cfg(test)] mod stage_budget_tests { use super::*; 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() { let sizes = [(Slot::Main, 300 * MB), (Slot::Region, 100 * MB)]; assert_eq!(stage_trim_order(&sizes, 512 * MB), vec![]); } // Given too much held, the caches nothing is looking at go first (a hover render's, a compare // tile's: rebuilt cheaply), the least useful first, and only as many as needed #[test] fn the_idle_caches_go_first_and_only_as_many_as_needed() { let sizes = [(Slot::Main, 300 * MB), (Slot::Hover, 100 * MB), (Slot::Second, 80 * MB), (Slot::Compare(0), 60 * MB), (Slot::Compare(1), 60 * MB)]; // 600 held, 500 allowed: the hover render's 100 is enough assert_eq!(stage_trim_order(&sizes, 500 * MB), vec![Slot::Hover]); // 400 allowed: the compare tiles' too (380 left), then, at 350, the second window's assert_eq!(stage_trim_order(&sizes, 400 * MB), vec![Slot::Hover, Slot::Compare(0), Slot::Compare(1)]); assert_eq!(stage_trim_order(&sizes, 350 * MB), vec![Slot::Hover, Slot::Compare(0), Slot::Compare(1), Slot::Second]); } // Given the open photo's views over the budget, they are not trimmed: they are what a slider // drag reuses on every tick, and clearing them every frame costs far more than the memory // (issue #323 review: 3.6x slower drags on a 2800 px canvas). They are counted and reported, // and only a runaway (more than HARD_FACTOR times the budget) cuts the window and before // caches, never the whole-frame view's. #[test] fn the_open_photos_views_are_trimmed_only_in_a_runaway_and_never_the_whole_frame() { let views = [(Slot::Main, 400 * MB), (Slot::Region, 500 * MB), (Slot::RegionBefore, 500 * MB), (Slot::Before, 100 * MB)]; assert_eq!(stage_trim_order(&views, 400 * MB), vec![], "1.5 GB against a 400 MB budget is a busy Before/After, not a runaway"); // against a 100 MB budget (HARD_FACTOR 4 = 400 MB) it is: the before side goes first, then the window assert_eq!(stage_trim_order(&views, 100 * MB), vec![Slot::RegionBefore, Slot::Before, Slot::Region]); let mut with_idle = views.to_vec(); with_idle.push((Slot::Hover, 50 * MB)); assert_eq!(stage_trim_order(&with_idle, 400 * MB), vec![Slot::Hover]); // a whole-frame view of any size stays assert_eq!(stage_trim_order(&[(Slot::Main, 9000 * MB)], MB), vec![]); } // Given caches of slots that hold nothing, they are never "trimmed" #[test] fn empty_caches_are_left_alone() { assert_eq!(stage_trim_order(&[(Slot::Hover, 0), (Slot::Second, 900 * MB)], 100 * MB), vec![Slot::Second]); } // Given real stage caches over the budget, the renderer clears them (the open photo's last) #[test] fn the_renderer_trims_real_stage_caches() { use lightcraft_engine::pipeline::{RenderRequest, SourceInfo, render_cached}; let src = std::sync::Arc::new(lightcraft_raster::Rgb32f::from_fn(320, 240, |x, y| [x as f32 / 320.0, y as f32 / 240.0, 0.3])); let mut r = Renderer::default(); for slot in [Slot::Main, Slot::Hover] { let cache: Arc = Default::default(); let mut s = lightcraft_engine::develop::DevelopSettings::default(); s.effects.clarity = 50.0; render_cached(&src, &SourceInfo::default(), &s, &RenderRequest::fit(320, 240), &cache); assert!(cache.bytes() > 0); r.stages.insert(slot, cache); } let total: usize = r.stages.values().map(|c| c.bytes()).sum(); r.stage_budget_override = Some(total * 3 / 4); r.trim_stages(); assert_eq!(r.stages[&Slot::Hover].bytes(), 0, "the hover render's cache went"); assert!(r.stages[&Slot::Main].bytes() > 0, "the open photo's stayed"); assert_eq!(r.stages_trimmed, 1); r.stage_budget_override = Some(0); r.trim_stages(); assert!(r.stages[&Slot::Main].bytes() > 0, "the open photo's view is never trimmed"); r.trim_stages(); assert_eq!(r.stages_trimmed, 1, "and nothing is trimmed again"); } // The renderer counts and trims what it holds, and says so #[test] fn the_renderer_reports_its_stage_budget() { let r = Renderer::default(); let m = r.memory(); assert!(m["stageCaches"]["budgetBytes"].as_u64().unwrap() > 0, "{m}"); assert_eq!(m["stageCaches"]["trimmed"], 0); assert!(m["stageCaches"]["sharedSourceBytes"].is_u64(), "the one device copy of a big original is reported: {m}"); } } #[cfg(test)] mod color_image_tests { use super::*; // The texture a render becomes is what egui would make of its bytes, whatever the alpha #[test] fn the_fast_conversion_equals_eguis() { let img = lightcraft_raster::Rgba8::from_fn(37, 29, |x, y| { let a = match (x + y) % 5 { 0 => 0, 1 => 17, 2 => 128, _ => 255, }; [(x * 7) as u8, (y * 9) as u8, (x * y) as u8, a] }); let want = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()); let got = color_image(&img); assert_eq!(got.size, want.size); assert_eq!(got.pixels, want.pixels); } #[test] fn an_empty_image_is_an_empty_texture() { let img = lightcraft_raster::Rgba8::new(0, 0); assert!(color_image(&img).pixels.is_empty()); } }