mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e1b2055897
commit
e82d8d0dcd
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user