M1.5: thumbnail/preview cache (disk + memory LRU, prioritized job pool)

- new crate lightcraft-preview (L3): 128-bit content hashing (SipHash-1-3),
  cost-bounded Lru, DiskCache (JPEG q90 files keyed by content hash +
  settings hash + size + RENDER_CACHE_VERSION; temp+rename writes; LRU prune
  by mtime to 80% of budget; corrupt files dropped), PreviewCache (memory
  LRU over disk), JobPool (per-slot dedupe, priorities, reprioritize/drop,
  inline mode for wasm).
- engine: Session::thumb_job buckets thumbnails to 128/256/384/512 and serves
  them from the cache without decoding the source; decoded thumbnail
  sources now in a 384 MB LRU (was unbounded); library attaches
  <library>/thumbs; library.info cache stats; library.clearPreviews.
- ui-egui: Renderer runs on JobPool; thumbnails scrolled out of the
  prefetch area are dropped from the queue; on-screen thumbnails get
  re-prioritized; ui.inspect reports in-flight renders + thumb textures.
- Demo library (24 photos): grid fully drawn 4.4 s cold -> 0.21 s warm
  (24/24 disk hits, no source decoded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 23:51:47 -04:00
parent e7b3724b5f
commit bcecab7d84
19 changed files with 1007 additions and 110 deletions

14
Cargo.lock generated
View File

@ -2056,6 +2056,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-meta",
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
@ -2100,6 +2101,18 @@ dependencies = [
"serde",
]
[[package]]
name = "lightcraft-preview"
version = "0.1.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
"log",
"siphasher",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "lightcraft-raster"
version = "0.1.0"
@ -2158,6 +2171,7 @@ dependencies = [
"lightcraft-engine",
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"log",
"serde",

View File

@ -22,6 +22,7 @@ lightcraft-develop = { path = "crates/develop" }
lightcraft-scenes = { path = "crates/scenes" }
lightcraft-pipeline = { path = "crates/pipeline" }
lightcraft-catalog = { path = "crates/catalog" }
lightcraft-preview = { path = "crates/preview" }
lightcraft-engine = { path = "crates/engine" }
lightcraft-ui-egui = { path = "crates/ui-egui" }
lightcraft-mcp = { path = "crates/mcp" }

View File

@ -16,6 +16,7 @@ lightcraft-develop = { workspace = true }
lightcraft-scenes = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-catalog = { workspace = true }
lightcraft-preview = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-meta = { workspace = true }

View File

@ -386,8 +386,25 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| {
let photos = s.catalog.len();
let albums = s.catalog.albums().count();
let (rendered_n, rendered_bytes) = s.media.rendered.mem_usage();
let (sources_n, sources_bytes) = s.media.source_usage();
let disk = s.media.rendered.disk().map(|d| {
use std::sync::atomic::Ordering::Relaxed;
json!({
"path": d.dir().display().to_string(),
"bytes": d.size(),
"hits": d.hits.load(Relaxed),
"misses": d.misses.load(Relaxed),
"writes": d.writes.load(Relaxed),
})
});
let cache = json!({
"renderedInMemory": rendered_n, "renderedBytes": rendered_bytes,
"sourcesInMemory": sources_n, "sourceBytes": sources_bytes,
"disk": disk,
});
let Some(lib) = &s.library else {
return Ok(json!({"persistent": false, "photos": photos, "albums": albums}));
return Ok(json!({"persistent": false, "photos": photos, "albums": albums, "cache": cache}));
};
let j = lib.journal();
Ok(json!({
@ -400,6 +417,7 @@ pub fn specs() -> Vec<CommandSpec> {
"logRecords": j.log_records(),
"logBytes": j.log_bytes(),
"lastError": lib.last_error,
"cache": cache,
"load": {
"created": lib.report.created,
"replayed": lib.report.replayed,
@ -412,6 +430,10 @@ pub fn specs() -> Vec<CommandSpec> {
s.compact_library()?;
ok()
}),
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
s.media.rendered.clear();
ok()
}),
]
}

View File

@ -132,6 +132,7 @@ impl Session {
self.selection = Selection::single(*first);
}
let presets_written = presets_json(&self.presets);
self.media.attach_disk_cache(&dir.join("thumbs"));
self.library = Some(Library { dir, journal, report, last_error: None, presets_written });
Ok(&self.library.as_ref().expect("just set").report)
}

View File

@ -1,19 +1,36 @@
//! Source proxies and render jobs.
//!
//! Sources are decoded (or generated) once per level and cached: `Thumb` (≤ 512 px long edge) for
//! the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe. A [`RenderJob`] is self-contained
//! and `Send`: frontends run it on a worker thread; if the source wasn't cached yet the job loads it
//! and hands it back in the [`RenderResult`] so the cache can keep it.
//! Sources are decoded (or generated) once per level and cached in memory LRUs: `Thumb` (≤ 512 px
//! long edge) for the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe. A [`RenderJob`] is
//! self-contained and `Send`: frontends run it on a worker thread; if the source wasn't cached yet
//! the job loads it and hands it back in the [`RenderResult`] so the cache can keep it.
//!
//! Rendered thumbnails are cached too ([`lightcraft_preview::PreviewCache`]: memory LRU, plus a
//! disk cache in the library's `thumbs/` folder), keyed by the photo's content hash, the develop
//! settings hash, the size and [`RENDER_CACHE_VERSION`] — so reopening a library shows its grid
//! without decoding a single original, and an edit simply produces a new key.
use std::collections::HashMap;
use std::sync::Arc;
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo};
use lightcraft_raster::Rgb32f;
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_raster::{Histogram, Rgb32f};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 1;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
/// Memory budgets.
const THUMB_SOURCE_BYTES: usize = 384 << 20;
const RENDERED_MEM_BYTES: usize = 128 << 20;
/// Disk budget for the thumbnail cache.
pub const DISK_CACHE_BYTES: u64 = 2 << 30;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceLevel {
@ -57,19 +74,40 @@ impl SourceRef {
}
}
#[derive(Default)]
pub struct MediaCache {
thumbs: HashMap<PhotoId, Arc<Rgb32f>>,
/// Decoded thumbnail-level sources (LRU by bytes).
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
previews: Vec<(PhotoId, Arc<Rgb32f>)>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
pub file_loader: Option<FileLoader>,
pub file_probe: Option<FileProbe>,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
}
impl Default for MediaCache {
fn default() -> Self {
MediaCache {
thumbs: Lru::new(THUMB_SOURCE_BYTES),
previews: Vec::new(),
preview_capacity: 0,
file_loader: None,
file_probe: None,
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)),
}
}
}
impl MediaCache {
pub fn get(&self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
/// Keep rendered thumbnails on disk in `dir` as well.
pub fn attach_disk_cache(&mut self, dir: &std::path::Path) {
self.rendered = Arc::new(PreviewCache::with_disk(RENDERED_MEM_BYTES, dir, DISK_CACHE_BYTES));
}
pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
match level {
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
SourceLevel::Preview => self.previews.iter().find(|(p, _)| *p == id).map(|(_, a)| a.clone()),
@ -79,7 +117,8 @@ impl MediaCache {
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
match level {
SourceLevel::Thumb => {
self.thumbs.insert(id, img);
let cost = img.width * img.height * 12 + 64;
self.thumbs.insert(id, img, cost);
}
SourceLevel::Preview => {
self.previews.retain(|(p, _)| *p != id);
@ -92,6 +131,17 @@ impl MediaCache {
}
}
/// Forget a photo's decoded sources (e.g. after its file changed).
pub fn forget(&mut self, id: PhotoId) {
self.thumbs.remove(&id);
self.previews.retain(|(p, _)| *p != id);
}
/// (decoded thumbnail sources, bytes).
pub fn source_usage(&self) -> (usize, usize) {
(self.thumbs.len(), self.thumbs.cost())
}
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
if let Some(a) = self.get(p.id, level) {
return SourceRef::Loaded(a);
@ -122,6 +172,8 @@ pub struct RenderJob {
pub request: RenderRequest,
/// Identifies the result for caching: hash of settings + request.
pub key: u64,
/// Rendered-thumbnail cache and this job's key in it.
pub cache: Option<(Arc<PreviewCache>, Hash128)>,
}
pub struct RenderResult {
@ -135,10 +187,20 @@ pub struct RenderResult {
impl RenderJob {
pub fn run(self) -> RenderResult {
if let Some((cache, key)) = &self.cache
&& let Some(img) = cache.get(*key)
{
let image = Arc::unwrap_or_clone(img);
let histogram = Histogram::of_srgb8(&image);
return RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram }), loaded: None };
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load() {
Ok(src) => {
let rendered = lightcraft_pipeline::render(&src, &self.info, &self.settings, &self.request);
if let Some((cache, key)) = &self.cache {
cache.put(*key, Arc::new(rendered.image.clone()));
}
RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(rendered), loaded: (!was_loaded).then_some(src) }
}
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None },
@ -146,6 +208,15 @@ impl RenderJob {
}
}
/// What identifies a photo's pixels for caching: its content hash, else its source.
pub fn content_key(p: &Photo) -> String {
match (&p.content_hash, &p.source) {
(Some(h), _) => h.clone(),
(None, Source::Demo { scene }) => format!("demo:{scene}"),
(None, Source::File { path }) => format!("file:{path}:{}", p.file_size),
}
}
pub fn source_info(p: &Photo) -> SourceInfo {
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
if matches!(p.source, Source::Demo { .. }) {
@ -157,6 +228,25 @@ pub fn source_info(p: &Photo) -> SourceInfo {
impl crate::Session {
/// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look.
pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option<RenderJob> {
self.build_job(id, max_w, max_h, before, apply_crop, None)
}
/// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`]
/// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk).
pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option<RenderJob> {
let b = THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(THUMB_SIZES[THUMB_SIZES.len() - 1]);
self.build_job(id, b, b, false, true, Some(b))
}
fn build_job(
&mut self,
id: PhotoId,
max_w: usize,
max_h: usize,
before: bool,
apply_crop: bool,
thumb_bucket: Option<usize>,
) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
let level = SourceLevel::for_size(max_w.max(max_h));
let source = self.media.source_ref(&p, level);
@ -167,7 +257,11 @@ impl crate::Session {
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ ((level == SourceLevel::Preview) as u64) << 60;
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key })
let cache = thumb_bucket.map(|b| {
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
(self.media.rendered.clone(), k)
});
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache })
}
/// Accept a finished job's loaded source into the cache.

View File

@ -71,6 +71,46 @@ fn close_writes_snapshot_and_view_state() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Thumbnails rendered in one session are served from the disk cache in the next, without
/// loading the source; an edit changes the key.
#[test]
fn thumbnails_come_from_the_disk_cache_after_restart() {
let dir = temp_dir("thumbs");
let mut s = open(&dir, true);
let ids: Vec<_> = s.visible_cloned().into_iter().take(3).collect();
for id in &ids {
let job = s.thumb_job(*id, 230).unwrap();
assert_eq!(job.request.max_w, 256, "bucketed");
let r = job.run();
assert!(r.loaded.is_some(), "first render decodes the source");
s.accept(&r);
let img = r.rendered.unwrap().image;
assert_eq!(img.width.max(img.height), 256);
}
drop(s);
let mut s = open(&dir, true);
for id in &ids {
let r = s.thumb_job(*id, 240).unwrap().run();
assert!(r.loaded.is_none(), "served from cache");
let img = r.rendered.unwrap().image;
assert_eq!(img.width.max(img.height), 256);
}
let info = s.execute("library.info", &json!({})).unwrap();
assert_eq!(info["cache"]["disk"]["hits"], 3, "{info}");
// an edit is a new key: rendered (from the source), then cached
s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
let r = s.thumb_job(ids[0], 230).unwrap().run();
assert!(r.loaded.is_some());
// exact-size renders (loupe, before/after, exports) bypass the thumbnail cache
assert!(s.render_job(ids[0], 230, 230, true, true).unwrap().cache.is_none());
assert!(s.render_job(ids[0], 230, 230, false, true).unwrap().cache.is_none());
s.execute("library.clearPreviews", &json!({})).unwrap();
assert!(s.thumb_job(ids[1], 230).unwrap().run().loaded.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn new_library_without_seed_is_empty_and_compacts() {
let dir = temp_dir("empty");

17
crates/preview/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "lightcraft-preview"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft preview cache: content hashing, memory LRU, disk thumbnail cache, prioritized job pool (L3)"
[lints]
workspace = true
[dependencies]
lightcraft-raster = { workspace = true }
siphasher = { workspace = true }
jpeg-encoder = { workspace = true }
zune-jpeg = { workspace = true }
zune-core = { workspace = true }
log = { workspace = true }

176
crates/preview/src/disk.rs Normal file
View File

@ -0,0 +1,176 @@
//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.jpg`, bounded in bytes.
//!
//! The cache is disposable: files are written via temp + rename (readers never see partial
//! files) but not fsynced; unreadable files are deleted and re-rendered. When the total size
//! exceeds the budget, the least recently used files (by modification time, refreshed on read)
//! are removed down to 80 % of the budget.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use lightcraft_raster::Rgba8;
use crate::Hash128;
const QUALITY: u8 = 90;
pub struct DiskCache {
dir: PathBuf,
budget: u64,
/// Bytes on disk (`None` until first scanned).
total: Mutex<Option<u64>>,
pub hits: AtomicU64,
pub misses: AtomicU64,
pub writes: AtomicU64,
}
impl DiskCache {
pub fn new(dir: &Path, budget: u64) -> DiskCache {
DiskCache {
dir: dir.to_path_buf(),
budget,
total: Mutex::new(None),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
writes: AtomicU64::new(0),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn path(&self, key: Hash128) -> PathBuf {
let hex = key.to_string();
self.dir.join(&hex[..2]).join(format!("{hex}.jpg"))
}
pub fn get(&self, key: Hash128) -> Option<Rgba8> {
let p = self.path(key);
let Ok(bytes) = std::fs::read(&p) else {
self.misses.fetch_add(1, Ordering::Relaxed);
return None;
};
match decode(&bytes) {
Some(img) => {
self.hits.fetch_add(1, Ordering::Relaxed);
// refresh recency for pruning
if let Ok(f) = std::fs::File::options().write(true).open(&p) {
let _ = f.set_modified(std::time::SystemTime::now());
}
Some(img)
}
None => {
log::warn!("preview cache: dropping unreadable {}", p.display());
let _ = std::fs::remove_file(&p);
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
}
pub fn put(&self, key: Hash128, img: &Rgba8) {
let Some(bytes) = encode(img) else { return };
let p = self.path(key);
let Some(parent) = p.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
return;
}
// know the current total before adding (the first scan must not count this file twice)
let _ = self.size();
let tmp = p.with_extension(format!("tmp{:?}", std::thread::current().id()).replace(['(', ')'], ""));
if std::fs::write(&tmp, &bytes).is_err() || std::fs::rename(&tmp, &p).is_err() {
let _ = std::fs::remove_file(&tmp);
return;
}
self.writes.fetch_add(1, Ordering::Relaxed);
let over = {
let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner());
let total = t.get_or_insert(0);
*total += bytes.len() as u64;
*total > self.budget
};
if over {
self.prune();
}
}
/// All cache files: (path, size, modified).
fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.dir) else { return out };
for sub in rd.flatten() {
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
if let Ok(m) = f.metadata()
&& m.is_file()
{
out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
}
}
}
out
}
/// Remove least recently used files until the cache is at most 80 % of its budget.
pub fn prune(&self) {
let mut files = self.scan();
let mut total: u64 = files.iter().map(|f| f.1).sum();
let target = self.budget / 5 * 4;
files.sort_by_key(|f| f.2);
for (p, len, _) in files {
if total <= target {
break;
}
if std::fs::remove_file(&p).is_ok() {
total -= len;
}
}
*self.total.lock().unwrap_or_else(|e| e.into_inner()) = Some(total);
}
/// Bytes on disk (scans if not yet known).
pub fn size(&self) -> u64 {
let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner());
*t.get_or_insert_with(|| self.scan().iter().map(|f| f.1).sum())
}
pub fn clear(&self) {
for (p, _, _) in self.scan() {
let _ = std::fs::remove_file(p);
}
*self.total.lock().unwrap_or_else(|e| e.into_inner()) = Some(0);
}
}
fn encode(img: &Rgba8) -> Option<Vec<u8>> {
if img.width == 0 || img.height == 0 || img.width > u16::MAX as usize || img.height > u16::MAX as usize {
return None;
}
let mut rgb = Vec::with_capacity(img.width * img.height * 3);
for p in &img.data {
rgb.extend_from_slice(&p[..3]);
}
let mut out = Vec::new();
let mut enc = jpeg_encoder::Encoder::new(&mut out, QUALITY);
enc.set_sampling_factor(jpeg_encoder::SamplingFactor::R_4_2_0);
enc.encode(&rgb, img.width as u16, img.height as u16, jpeg_encoder::ColorType::Rgb).ok()?;
Some(out)
}
fn decode(bytes: &[u8]) -> Option<Rgba8> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB);
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), opts);
let px = d.decode().ok()?;
let info = d.info()?;
let (w, h) = (info.width as usize, info.height as usize);
if px.len() < w * h * 3 {
return None;
}
let data = px.chunks_exact(3).take(w * h).map(|c| [c[0], c[1], c[2], 255]).collect();
Some(Rgba8 { width: w, height: h, data })
}

View File

@ -0,0 +1,61 @@
//! 128-bit content hashing (SipHash-1-3, fixed key). Not cryptographic — it identifies files
//! for duplicate detection and keys caches; collisions are astronomically unlikely by accident.
use std::fmt;
use std::hash::Hasher;
use siphasher::sip128::{Hasher128 as _, SipHasher13};
/// A 128-bit hash, shown as 32 hex digits.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Hash128(pub u128);
impl fmt::Display for Hash128 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:032x}", self.0)
}
}
impl Hash128 {
pub fn parse(s: &str) -> Option<Hash128> {
(s.len() == 32).then(|| u128::from_str_radix(s, 16).ok().map(Hash128)).flatten()
}
}
/// Streaming hasher.
#[derive(Clone, Debug)]
pub struct Hasher128(SipHasher13);
impl Default for Hasher128 {
fn default() -> Self {
Hasher128(SipHasher13::new_with_keys(0x4c69_6768_7443_7261, 0x6674_2043_6f6e_7465))
}
}
impl Hasher128 {
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, bytes: &[u8]) -> &mut Self {
self.0.write(bytes);
self
}
/// Length-prefixed string (so `("ab","c")` and `("a","bc")` differ).
pub fn str(&mut self, s: &str) -> &mut Self {
self.0.write(&(s.len() as u64).to_le_bytes());
self.0.write(s.as_bytes());
self
}
pub fn u64(&mut self, v: u64) -> &mut Self {
self.0.write(&v.to_le_bytes());
self
}
pub fn finish(&self) -> Hash128 {
Hash128(self.0.finish128().as_u128())
}
}
/// Hash of a whole byte string.
pub fn hash_bytes(bytes: &[u8]) -> Hash128 {
Hasher128::new().update(bytes).finish()
}

86
crates/preview/src/lib.rs Normal file
View File

@ -0,0 +1,86 @@
//! Previews: everything that makes a big library show up instantly.
//!
//! - [`hash`]: 128-bit content hashes (file bytes → duplicate detection and cache keys).
//! - [`Lru`]: a cost-bounded memory LRU (decoded sources, rendered thumbnails).
//! - [`DiskCache`]: rendered thumbnails as JPEG files keyed by `(content hash, settings hash,
//! size, renderer version)`, bounded in bytes (least recently used files are pruned).
//! - [`PreviewCache`]: memory LRU in front of the disk cache; shared by render workers.
//! - [`JobPool`]: worker threads with per-slot de-duplication and priorities (visible thumbnails
//! before off-screen prefetch); runs jobs inline on wasm.
//!
//! No UI dependencies (L3): the egui frontend, the CLI and the MCP server share it.
#![forbid(unsafe_code)]
pub mod disk;
pub mod hash;
pub mod lru;
pub mod pool;
use std::path::Path;
use std::sync::{Arc, Mutex};
pub use disk::DiskCache;
pub use hash::{Hash128, Hasher128, hash_bytes};
use lightcraft_raster::Rgba8;
pub use lru::Lru;
pub use pool::JobPool;
/// Rendered-thumbnail cache: memory LRU over an optional disk cache.
pub struct PreviewCache {
mem: Mutex<Lru<Hash128, Arc<Rgba8>>>,
disk: Option<DiskCache>,
}
impl PreviewCache {
/// Memory-only cache of at most `mem_bytes`.
pub fn memory(mem_bytes: usize) -> PreviewCache {
PreviewCache { mem: Mutex::new(Lru::new(mem_bytes)), disk: None }
}
/// Memory LRU + disk cache in `dir` (at most `disk_bytes`).
pub fn with_disk(mem_bytes: usize, dir: &Path, disk_bytes: u64) -> PreviewCache {
PreviewCache { mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) }
}
pub fn disk(&self) -> Option<&DiskCache> {
self.disk.as_ref()
}
pub fn get(&self, key: Hash128) -> Option<Arc<Rgba8>> {
if let Some(v) = self.mem.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return Some(v.clone());
}
let img = Arc::new(self.disk.as_ref()?.get(key)?);
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), cost(&img));
Some(img)
}
pub fn put(&self, key: Hash128, img: Arc<Rgba8>) {
if let Some(d) = &self.disk {
d.put(key, &img);
}
let c = cost(&img);
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img, c);
}
/// Drop everything (memory and disk).
pub fn clear(&self) {
self.mem.lock().unwrap_or_else(|e| e.into_inner()).clear();
if let Some(d) = &self.disk {
d.clear();
}
}
/// (entries, bytes) held in memory.
pub fn mem_usage(&self) -> (usize, usize) {
let m = self.mem.lock().unwrap_or_else(|e| e.into_inner());
(m.len(), m.cost())
}
}
fn cost(img: &Rgba8) -> usize {
img.width * img.height * 4 + 64
}
#[cfg(test)]
mod tests;

97
crates/preview/src/lru.rs Normal file
View File

@ -0,0 +1,97 @@
//! A cost-bounded least-recently-used map.
use std::collections::{BTreeMap, HashMap};
use std::hash::Hash;
pub struct Lru<K, V> {
map: HashMap<K, (V, usize, u64)>,
/// Use tick → key, oldest first.
order: BTreeMap<u64, K>,
tick: u64,
cost: usize,
budget: usize,
}
impl<K: Eq + Hash + Clone, V> Lru<K, V> {
/// An LRU holding at most `budget` cost units (e.g. bytes). The most recent entry is always
/// kept, even if it alone exceeds the budget.
pub fn new(budget: usize) -> Self {
Lru { map: HashMap::new(), order: BTreeMap::new(), tick: 0, cost: 0, budget }
}
fn touch(&mut self, k: &K) {
self.tick += 1;
let t = self.tick;
if let Some(e) = self.map.get_mut(k) {
self.order.remove(&e.2);
e.2 = t;
self.order.insert(t, k.clone());
}
}
pub fn get(&mut self, k: &K) -> Option<&V> {
if !self.map.contains_key(k) {
return None;
}
self.touch(k);
self.map.get(k).map(|e| &e.0)
}
pub fn peek(&self, k: &K) -> Option<&V> {
self.map.get(k).map(|e| &e.0)
}
pub fn contains(&self, k: &K) -> bool {
self.map.contains_key(k)
}
pub fn insert(&mut self, k: K, v: V, cost: usize) {
self.remove(&k);
self.tick += 1;
self.order.insert(self.tick, k.clone());
self.map.insert(k, (v, cost, self.tick));
self.cost += cost;
while self.cost > self.budget && self.map.len() > 1 {
let Some((_, old)) = self.order.pop_first() else { break };
if let Some((_, c, _)) = self.map.remove(&old) {
self.cost -= c;
}
}
}
pub fn remove(&mut self, k: &K) -> Option<V> {
let (v, c, t) = self.map.remove(k)?;
self.order.remove(&t);
self.cost -= c;
Some(v)
}
pub fn retain(&mut self, mut f: impl FnMut(&K) -> bool) {
let drop: Vec<K> = self.map.keys().filter(|k| !f(k)).cloned().collect();
for k in drop {
self.remove(&k);
}
}
pub fn clear(&mut self) {
self.map.clear();
self.order.clear();
self.cost = 0;
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn cost(&self) -> usize {
self.cost
}
pub fn budget(&self) -> usize {
self.budget
}
pub fn set_budget(&mut self, budget: usize) {
self.budget = budget;
}
}

179
crates/preview/src/pool.rs Normal file
View File

@ -0,0 +1,179 @@
//! A prioritized job pool with per-slot de-duplication.
//!
//! Each job targets a *slot* (a grid cell, the loupe…). Submitting for a slot replaces a job for
//! that slot that hasn't started yet, so scrolling or dragging never builds a backlog. Workers
//! take the highest priority first (newest first among equals). Frontends re-prioritize or drop
//! queued jobs when the view changes (e.g. thumbnails scrolled out of view).
//!
//! Native: a fixed set of worker threads, started on first use. wasm32 (no threads): the host
//! calls [`JobPool::run_inline`] once per frame.
use std::hash::Hash;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Condvar, Mutex};
pub type Job<R> = Box<dyn FnOnce() -> R + Send>;
struct Entry<S, R> {
slot: S,
key: u64,
priority: u32,
seq: u64,
job: Job<R>,
}
struct Shared<S, R> {
queue: Mutex<Vec<Entry<S, R>>>,
cv: Condvar,
shutdown: AtomicBool,
}
/// A finished job.
pub struct Done<S, R> {
pub slot: S,
/// The key the job was submitted with (to drop stale results).
pub key: u64,
pub result: R,
/// Run time in milliseconds (0 on wasm).
pub ms: f64,
}
pub struct JobPool<S, R> {
shared: Arc<Shared<S, R>>,
tx: Sender<Done<S, R>>,
rx: Receiver<Done<S, R>>,
threads: usize,
started: bool,
seq: u64,
}
impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
/// A pool with `threads` workers (none on wasm; `0` = jobs only run via [`JobPool::run_inline`]).
pub fn new(threads: usize) -> Self {
let (tx, rx) = channel();
JobPool {
shared: Arc::new(Shared { queue: Mutex::new(Vec::new()), cv: Condvar::new(), shutdown: AtomicBool::new(false) }),
tx,
rx,
threads,
started: false,
seq: 0,
}
}
/// Workers for this machine: cores − 1, between 2 and 8.
pub fn default_threads() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::available_parallelism().map(|n| n.get().saturating_sub(1)).unwrap_or(4).clamp(2, 8)
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
fn start(&mut self) {
if self.started {
return;
}
self.started = true;
#[cfg(not(target_arch = "wasm32"))]
for i in 0..self.threads {
let shared = self.shared.clone();
let tx = self.tx.clone();
let _ = std::thread::Builder::new().name(format!("lc-job-{i}")).spawn(move || {
while let Some(e) = take(&shared, true) {
let t0 = std::time::Instant::now();
let result = (e.job)();
let done = Done { slot: e.slot, key: e.key, result, ms: t0.elapsed().as_secs_f64() * 1000.0 };
if tx.send(done).is_err() {
break;
}
}
});
}
}
/// Queue `job` for `slot`, replacing a queued (not yet running) job for the same slot.
pub fn submit(&mut self, slot: S, key: u64, priority: u32, job: Job<R>) {
self.start();
self.seq += 1;
let (lock, cv) = (&self.shared.queue, &self.shared.cv);
let mut q = lock.lock().unwrap_or_else(|e| e.into_inner());
q.retain(|e| e.slot != slot);
q.push(Entry { slot, key, priority, seq: self.seq, job });
cv.notify_one();
}
/// Change queued jobs' priorities: `f(slot, priority)` returns the new priority, or `None` to
/// drop the job. Returns the dropped slots.
pub fn reprioritize(&self, mut f: impl FnMut(&S, u32) -> Option<u32>) -> Vec<S> {
let mut dropped = Vec::new();
let mut q = self.shared.queue.lock().unwrap_or_else(|e| e.into_inner());
q.retain_mut(|e| match f(&e.slot, e.priority) {
Some(p) => {
e.priority = p;
true
}
None => {
dropped.push(e.slot);
false
}
});
dropped
}
/// Queued (not started) jobs.
pub fn queued(&self) -> usize {
self.shared.queue.lock().map(|q| q.len()).unwrap_or(0)
}
/// Is a job for `slot` queued?
pub fn is_queued(&self, slot: S) -> bool {
self.shared.queue.lock().map(|q| q.iter().any(|e| e.slot == slot)).unwrap_or(false)
}
/// A finished job, if any.
pub fn try_recv(&self) -> Option<Done<S, R>> {
self.rx.try_recv().ok()
}
/// Run up to `n` jobs on the calling thread (wasm, tests). Returns how many ran.
pub fn run_inline(&mut self, n: usize) -> usize {
let mut ran = 0;
while ran < n {
let Some(e) = take(&self.shared, false) else { break };
let result = (e.job)();
let _ = self.tx.send(Done { slot: e.slot, key: e.key, result, ms: 0.0 });
ran += 1;
}
ran
}
}
/// Pop the best job (highest priority, newest first). Blocks when `wait` until one is queued or
/// the pool shuts down.
fn take<S, R>(shared: &Shared<S, R>, wait: bool) -> Option<Entry<S, R>> {
let mut q = shared.queue.lock().unwrap_or_else(|e| e.into_inner());
loop {
if shared.shutdown.load(Ordering::Relaxed) {
return None;
}
if let Some(i) = q.iter().enumerate().max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i) {
return Some(q.swap_remove(i));
}
if !wait {
return None;
}
q = shared.cv.wait(q).unwrap_or_else(|e| e.into_inner());
}
}
impl<S, R> Drop for JobPool<S, R> {
fn drop(&mut self) {
self.shared.shutdown.store(true, Ordering::Relaxed);
self.shared.cv.notify_all();
}
}

156
crates/preview/src/tests.rs Normal file
View File

@ -0,0 +1,156 @@
use std::sync::Arc;
use lightcraft_raster::Rgba8;
use super::*;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-preview-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn gradient(w: usize, h: usize, seed: u8) -> Rgba8 {
let data = (0..w * h).map(|i| [((i % w) * 255 / w) as u8, ((i / w) * 255 / h) as u8, seed, 255]).collect();
Rgba8 { width: w, height: h, data }
}
#[test]
fn hashes_are_stable_and_distinct() {
let a = hash_bytes(b"hello");
assert_eq!(a, hash_bytes(b"hello"));
assert_ne!(a, hash_bytes(b"hellp"));
assert_eq!(Hash128::parse(&a.to_string()), Some(a));
assert_ne!(Hasher128::new().str("ab").str("c").finish(), Hasher128::new().str("a").str("bc").finish());
// streaming == one shot
assert_eq!(Hasher128::new().update(b"hel").update(b"lo").finish(), a);
}
#[test]
fn lru_evicts_least_recent_by_cost() {
let mut l = Lru::new(10);
l.insert(1, "a", 4);
l.insert(2, "b", 4);
assert_eq!(l.get(&1), Some(&"a")); // 1 is now most recent
l.insert(3, "c", 4); // over budget: evicts 2
assert!(l.contains(&1) && !l.contains(&2) && l.contains(&3));
assert_eq!(l.cost(), 8);
l.insert(4, "huge", 50); // alone over budget: kept, everything else evicted
assert_eq!((l.len(), l.cost()), (1, 50));
l.insert(4, "small", 1);
assert_eq!((l.len(), l.cost()), (1, 1));
l.retain(|k| *k != 4);
assert!(l.is_empty());
}
#[test]
fn disk_cache_roundtrip_and_prune() {
let dir = temp_dir("disk");
let d = DiskCache::new(&dir, 40_000);
let img = gradient(160, 100, 7);
let k = hash_bytes(b"k1");
assert!(d.get(k).is_none());
d.put(k, &img);
let back = d.get(k).expect("hit");
assert_eq!((back.width, back.height), (160, 100));
let err: i64 = img.data.iter().zip(&back.data).map(|(a, b)| (a[0] as i64 - b[0] as i64).abs() + (a[1] as i64 - b[1] as i64).abs()).max().unwrap();
assert!(err < 24, "JPEG error {err}");
assert!(back.data.iter().all(|p| p[3] == 255));
// fill past the budget: the oldest entries go, the newest stay
let keys: Vec<_> = (0..40u8).map(|i| hash_bytes(&[i, 1])).collect();
for (i, k) in keys.iter().enumerate() {
d.put(*k, &gradient(160, 100, i as u8));
}
assert!(d.size() <= 40_000, "{}", d.size());
assert!(d.get(*keys.last().unwrap()).is_some());
assert!(d.get(keys[0]).is_none());
// a corrupt file is a miss and gets removed
let k2 = hash_bytes(b"corrupt");
d.put(k2, &img);
let hex = k2.to_string();
let p = dir.join(&hex[..2]).join(format!("{hex}.jpg"));
std::fs::write(&p, b"not a jpeg").unwrap();
assert!(d.get(k2).is_none());
assert!(!p.exists());
d.clear();
assert_eq!(d.size(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn preview_cache_memory_then_disk() {
let dir = temp_dir("pc");
let k = hash_bytes(b"x");
{
let c = PreviewCache::with_disk(1 << 20, &dir, 1 << 24);
c.put(k, Arc::new(gradient(64, 48, 1)));
assert_eq!(c.mem_usage().0, 1);
assert!(c.get(k).is_some());
}
// a new process: memory is empty, disk serves it
let c = PreviewCache::with_disk(1 << 20, &dir, 1 << 24);
assert_eq!(c.mem_usage().0, 0);
assert_eq!(c.get(k).map(|i| i.width), Some(64));
assert_eq!(c.mem_usage().0, 1);
assert_eq!(c.disk().unwrap().hits.load(std::sync::atomic::Ordering::Relaxed), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pool_priorities_dedupe_and_drop() {
let mut p: JobPool<u32, u32> = JobPool::new(1);
// One worker, held busy by a gate job while the others queue up.
let gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
let g = gate.clone();
p.submit(
999,
0,
1000,
Box::new(move || {
let (m, cv) = &*g;
let mut open = m.lock().unwrap();
while !*open {
open = cv.wait(open).unwrap();
}
999
}),
);
// wait until the worker took the gate job
while p.queued() > 0 {
std::thread::yield_now();
}
p.submit(1, 1, 5, Box::new(|| 1)); // off-screen thumb
p.submit(2, 1, 10, Box::new(|| 2)); // visible thumb
p.submit(3, 1, 100, Box::new(|| 3)); // loupe
p.submit(2, 2, 10, Box::new(|| 22)); // newer request for slot 2 replaces the queued one
p.submit(4, 1, 5, Box::new(|| 4));
assert_eq!(p.queued(), 4);
let dropped = p.reprioritize(|s, pr| if *s == 4 { None } else { Some(pr) });
assert_eq!(dropped, vec![4]);
{
let (m, cv) = &*gate;
*m.lock().unwrap() = true;
cv.notify_all();
}
let mut order = Vec::new();
while order.len() < 4 {
if let Some(d) = p.try_recv() {
order.push((d.slot, d.key, d.result));
} else {
std::thread::yield_now();
}
}
assert_eq!(order, vec![(999, 0, 999), (3, 1, 3), (2, 2, 22), (1, 1, 1)]);
}
#[test]
fn pool_runs_inline() {
// No workers: run_inline drains the queue on the caller's thread (the wasm path).
let mut p: JobPool<u8, u8> = JobPool::new(0);
p.submit(1, 0, 1, Box::new(|| 1));
p.submit(2, 0, 9, Box::new(|| 2));
assert_eq!(p.run_inline(1), 1);
assert_eq!(p.try_recv().map(|d| d.result), Some(2));
assert_eq!(p.run_inline(10), 1);
assert_eq!(p.try_recv().map(|d| d.result), Some(1));
}

View File

@ -16,6 +16,7 @@ lightcraft-develop = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-catalog = { workspace = true }
lightcraft-engine = { workspace = true }
lightcraft-preview = { workspace = true }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@ -77,7 +77,7 @@ 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(), "rendersDone": app.renderer.completed},
"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(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures()},
"status": app.ui.status,
})
}

View File

@ -626,7 +626,7 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
}
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
if let Some(job) = app.session.render_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 256, false, true) {
if let Some(job) = app.session.thumb_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2) {
app.renderer.request(Slot::Thumb(*id), job, 8);
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(*id)) {

View File

@ -144,7 +144,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
};
// thumbnail
let size = thumb_px(img_rect.width().max(img_rect.height()), ppp);
if let Some(job) = app.session.render_job(id, size, size, false, true) {
if let Some(job) = app.session.thumb_job(id, size) {
app.renderer.request(Slot::Thumb(id), job, if onscreen { 10 } else { 5 });
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {

View File

@ -1,16 +1,17 @@
//! Background rendering: a small worker pool runs engine [`RenderJob`]s; results become textures.
//! 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 visible thumbnails). On wasm the jobs run inline, one per frame.
//! 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.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Condvar, Mutex};
use lightcraft_catalog::PhotoId;
use lightcraft_engine::Session;
use lightcraft_engine::media::{RenderJob, RenderResult};
use lightcraft_preview::JobPool;
use lightcraft_raster::Histogram;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -30,15 +31,11 @@ pub struct Tex {
pub ms: f64,
}
type Queue = Arc<(Mutex<Vec<(Slot, u32, RenderJob)>>, Condvar)>;
pub struct Renderer {
queue: Queue,
results_tx: Sender<(Slot, RenderResult, f64)>,
results: Receiver<(Slot, RenderResult, f64)>,
pending: HashMap<Slot, u64>,
pool: JobPool<Slot, RenderResult>,
/// Slot → (key, priority) of the request in flight (queued or running).
pending: HashMap<Slot, (u64, u32)>,
pub textures: HashMap<Slot, Tex>,
started: bool,
pub last_main_ms: f64,
/// Jobs finished since start (for inspect/perf).
pub completed: u64,
@ -46,111 +43,57 @@ pub struct Renderer {
impl Default for Renderer {
fn default() -> Self {
let (tx, rx) = channel();
Renderer {
queue: Arc::new((Mutex::new(Vec::new()), Condvar::new())),
results_tx: tx,
results: rx,
pending: HashMap::new(),
textures: HashMap::new(),
started: false,
last_main_ms: 0.0,
completed: 0,
}
}
}
fn now() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
0.0
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) };
Renderer { pool: JobPool::new(threads), pending: HashMap::new(), textures: HashMap::new(), last_main_ms: 0.0, completed: 0 }
}
}
impl Renderer {
fn start(&mut self) {
if self.started {
return;
}
self.started = true;
#[cfg(not(target_arch = "wasm32"))]
{
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 6);
for i in 0..n {
let q = self.queue.clone();
let tx = self.results_tx.clone();
let _ = std::thread::Builder::new().name(format!("lc-render-{i}")).spawn(move || {
loop {
let job = {
let (lock, cv) = &*q;
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
while g.is_empty() {
g = cv.wait(g).unwrap_or_else(|e| e.into_inner());
}
// highest priority, newest first
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i).unwrap_or(0);
g.remove(best)
};
let t0 = now();
let (slot, _, job) = job;
let r = job.run();
if tx.send((slot, r, now() - t0)).is_err() {
break;
}
}
});
}
}
}
/// Is the slot's current texture (or pending request) already for `key`?
pub fn is_current(&self, slot: Slot, key: u64) -> bool {
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot) == Some(&key)
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot).is_some_and(|p| p.0 == key)
}
/// Request a render for `slot` (no-op if already current or pending).
/// 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) {
if self.is_current(slot, job.key) {
if self.textures.get(&slot).is_some_and(|t| t.key == job.key) {
return;
}
self.start();
self.pending.insert(slot, job.key);
let (lock, cv) = &*self.queue;
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
g.retain(|(s, _, _)| *s != slot);
g.push((slot, priority, job));
cv.notify_one();
if let Some(&(key, prio)) = self.pending.get(&slot)
&& key == job.key
&& (prio == priority || !self.pool.is_queued(slot))
{
return;
}
self.pending.insert(slot, (job.key, priority));
let key = job.key;
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
}
pub fn queued(&self) -> usize {
self.queue.0.lock().map(|g| g.len()).unwrap_or(0)
self.pool.queued()
}
/// Requests queued or running.
pub fn in_flight(&self) -> usize {
self.pending.len()
}
/// Thumbnail textures currently loaded.
pub fn thumb_textures(&self) -> usize {
self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count()
}
/// Collect finished jobs into textures. Returns true if anything changed.
pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool {
#[cfg(target_arch = "wasm32")]
{
// inline: run one job per frame
let job = {
let mut g = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i);
best.map(|b| g.remove(b))
};
if let Some((slot, _, job)) = job {
let r = job.run();
let _ = self.results_tx.send((slot, r, 0.0));
}
}
self.pool.run_inline(1);
let mut changed = false;
while let Ok((slot, r, ms)) = self.results.try_recv() {
while let Some(done) = self.pool.try_recv() {
let (slot, r, ms) = (done.slot, done.result, done.ms);
session.accept(&r);
self.completed += 1;
if self.pending.get(&slot) == Some(&r.key) {
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
self.pending.remove(&slot);
}
let Ok(rendered) = r.rendered else {
@ -187,8 +130,16 @@ impl Renderer {
changed
}
/// Drop thumbnails that aren't in `keep` (bounded memory for huge libraries).
/// 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<PhotoId>, max: usize) {
let dropped = self.pool.reprioritize(|s, p| match s {
Slot::Thumb(id) if !keep.contains(id) && p <= 10 => None,
_ => Some(p),
});
for s in dropped {
self.pending.remove(&s);
}
let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
if thumbs <= max {
return;