diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e10b31a1e..81586af59 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -451,6 +451,46 @@ impl crate::Session { }) } + /// A render of `id` with `settings` in place of its own, fitting `max_w × max_h` (the loupe's + /// temporary preview while hovering a preset or profile; nothing is committed or cached). + pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option { + let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; + job.settings = Arc::new(settings.clone()); + job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60; + Some(job) + } + + /// Cache key of a variant thumbnail ([`Self::variant_job`]). + pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { + Hasher128::new().str(&content_key(p)).str("variant").u64(settings.hash64()).u64(edge as u64).u64(RENDER_CACHE_VERSION).finish() + } + + /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset + /// browsers): from the thumbnail-level source, long edge `edge` (≤ 512), cropped. The result + /// is cached (memory + the library's disk cache) under the photo's content and the settings + /// hash, so a variant renders once; `key` is derived from the same hash, so a frontend can + /// keep one texture per variant. + pub fn variant_job(&mut self, id: PhotoId, settings: &DevelopSettings, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); + let level = SourceLevel::Thumb; + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, settings, edge); + Some(RenderJob { + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings.clone()), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + }) + } + /// Size-independent cache key of a photo's view render (loupe) for its current settings. fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish() @@ -576,4 +616,44 @@ mod tests { let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); assert_ne!(a.key, b.key); } + + #[test] + fn variant_jobs_have_their_own_keys_and_hit_the_cache() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let mine = (*s.develop_of(id).unwrap()).clone(); + let with = |pid: &str| { + let mut d = mine.clone(); + d.profile.id = pid.into(); + d + }; + let (a, b) = (with("lc.vivid"), with("lc.bw.sepia")); + let ja = s.variant_job(id, &a, 128).unwrap(); + let jb = s.variant_job(id, &b, 128).unwrap(); + assert_ne!(ja.key, jb.key, "keys differ per settings"); + assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable"); + assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size"); + assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail"); + assert_eq!(ja.level, SourceLevel::Thumb); + let (cache, ck) = ja.cache.clone().unwrap(); + assert!(cache.get(ck).is_none()); + let r = ja.run(); + s.accept(&r); + let first = r.rendered.unwrap().image; + assert!(first.width.max(first.height) <= 128); + assert!(cache.get(ck).is_some(), "rendered variants are cached"); + assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none()); + // a second job for the same variant is served from the cache, pixel for pixel + let again = s.variant_job(id, &a, 128).unwrap().run(); + assert!(again.loaded.is_none()); + assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes()); + let other = jb.run().rendered.unwrap().image; + assert_ne!(other.as_bytes(), first.as_bytes()); + // the photo's own settings never changed + assert_eq!(*s.develop_of(id).unwrap(), mine); + // hover previews: a loupe-sized job with other settings, its own key + let pv = s.preview_job(id, 800, 600, true, &a).unwrap(); + assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); + assert_eq!(*pv.settings, a); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 16a9240db..2537e5ea0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,8 +77,9 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, + "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), + "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ff44f85dc..c0ba22ecc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,9 @@ pub struct LightcraftApp { pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, + /// The look the loupe shows while the pointer rests on a preset or profile (set by the + /// panels each frame; nothing is committed, no history entry). + pub hover_preview: Option, } impl LightcraftApp { @@ -124,6 +127,7 @@ impl LightcraftApp { gesture: None, loupe_shown: None, merge: merge::MergeState::default(), + hover_preview: None, } } @@ -340,6 +344,8 @@ impl LightcraftApp { return; } let t0 = now_ms(); + // panels set it again this frame while the pointer rests on a preset or profile + self.hover_preview = None; // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, // right panels and left panel run to the bottom; the bottom bar sits between them). panels::topbar::show(self, ui); @@ -395,6 +401,15 @@ pub fn now_ms() -> f64 { } /// Whether the settings render in black & white. +/// A temporary look for the loupe (hovering a preset or profile). +#[derive(Clone, Debug, PartialEq)] +pub struct HoverPreview { + /// What is previewed (e.g. "Preset: Warm Glow"). + pub label: String, + /// The photo's settings with the look applied. + pub settings: lightcraft_develop::DevelopSettings, +} + pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.") } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 54e2575ef..f442b7161 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -170,6 +170,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = job.with_overlay(view_overlay(app, &d)); app.renderer.request(Slot::Main, job, 100); } + // hovering a preset or profile: the photo with that look, shown instead of the loupe render + // once it is ready (nothing is committed) + let hover_key = match app.hover_preview.clone() { + Some(h) if !interacting => app.session.preview_job(id, rw.max(8), rh.max(8), !crop_tool, &h.settings).map(|job| { + let key = job.key; + app.renderer.request(Slot::Hover, job, 105); + (key, h.label) + }), + _ => None, + }; // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and // kept, view render cached) so stepping to them is instant if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { @@ -230,6 +240,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if show_before { shown = draw(Slot::Before, img_rect); p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text); + } else if let Some((key, label)) = &hover_key + && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id && t.key == *key) + { + p.image(tex.tex.id(), img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); + let bg = Rect::from_min_size(img_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); + shown = "hover"; } else { shown = draw(Slot::Main, img_rect); if shown == "none" { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 3703dcb82..0ba004761 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -37,6 +37,12 @@ pub enum Slot { /// 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, } pub struct Tex { @@ -69,6 +75,11 @@ struct Queued { job: RenderJob, } +/// Variant thumbnails kept as textures (LRU). +pub const VARIANT_TEXTURES: usize = 96; +/// Priority of variant thumbnail jobs: below on-screen grid thumbnails and the loupe. +const VARIANT_PRIORITY: u32 = 6; + pub struct Renderer { pool: JobPool, /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. @@ -90,6 +101,10 @@ pub struct Renderer { quick_tried: HashMap, /// 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, + /// Frames polled so far. + frame: u64, } impl Default for Renderer { @@ -108,6 +123,8 @@ impl Default for Renderer { stages: HashMap::new(), quick_tried: HashMap::new(), prefetched: HashMap::new(), + variant_used: HashMap::new(), + frame: 0, } } } @@ -140,7 +157,8 @@ impl Renderer { } self.pending.insert(slot, (job.key, priority)); // (an offload keeps its own per-view stage caches; the flag tells it to) - let job = if matches!(slot, Slot::Main | Slot::Before) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; + let job = + if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) { 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); @@ -207,12 +225,73 @@ impl Renderer { } /// 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) + } + + /// 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); + } + let pending = &mut self.pending; + self.queue.retain(|q| match q.slot { + Slot::Variant(k) if stale(used.get(&k)) => { + pending.remove(&q.slot); + false + } + _ => true, + }); + let n = self.variant_textures(); + if n > VARIANT_TEXTURES { + 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 - VARIANT_TEXTURES) { + self.textures.remove(&Slot::Variant(k)); + self.variant_used.remove(&k); + } + } + if self.variant_used.len() > 4 * VARIANT_TEXTURES { + 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 { + self.frame += 1; // wasm: run one job per frame on this thread, timed with the host clock #[cfg(target_arch = "wasm32")] let inline_ms = { @@ -292,6 +371,7 @@ impl Renderer { } changed = true; } + self.evict_variants(); if !self.pending.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); }