LR-PROF-BROWSER (infra): variant thumbnail jobs (own cache key, thumbnail-level source), Variant slot LRU and loupe hover preview slot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 11:53:16 -04:00
co-authored by Claude Opus 5.5
parent e1b2055897
commit e82d8d0dcd
5 changed files with 197 additions and 2 deletions
+80
View File
@@ -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<RenderJob> {
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<RenderJob> {
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);
}
}
+2 -1
View File
@@ -77,8 +77,9 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
"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,
})
}
+15
View File
@@ -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<HoverPreview>,
}
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.")
}
+19
View File
@@ -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" {
+81 -1
View File
@@ -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<Slot, RenderResult>,
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
@@ -90,6 +101,10 @@ pub struct Renderer {
quick_tried: HashMap<Slot, u64>,
/// Prefetch slot → key of the last job submitted (each runs once).
prefetched: HashMap<Slot, u64>,
/// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures).
variant_used: HashMap<u64, u64>,
/// 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));
}