Files
lightcraft/crates/preview/src/lib.rs
T
f2f7a1cb26 T0: optimize thumbnail requests and reject obsolete preview work
Skip unchanged thumbnail jobs and reuse bounded settings hashes (both maps
capped at 1024 entries). Reject stale render results and cache writes after
edits, library changes and preview clears: render requests carry a request
id and cache generation, PreviewCache writes and reads hold the generation
lock that clear() takes, and library.clearPreviews also drops cached
sources. The web worker index follows the same generation rule.

The decoder guards, Windows path fixes and test fixture changes that were
bundled in a327b98 are separate commits; the benchmark follows this one.

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:04:03 -04:00

166 lines
6.1 KiB
Rust

//! 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)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod disk;
pub mod hash;
pub mod lru;
pub mod pool;
use std::path::Path;
use std::sync::{Arc, Mutex, RwLock};
pub use disk::{DiskCache, decode_jpeg, encode_jpeg};
pub use hash::{Hash128, Hasher128, hash_bytes};
use lightcraft_raster::Rgba8;
pub use lru::{Lru, next_tick};
pub use pool::JobPool;
/// Rendered-thumbnail cache: memory LRU over an optional disk cache.
pub struct PreviewCache {
generation: RwLock<u64>,
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 { generation: RwLock::new(0), 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 { generation: RwLock::new(0), 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>> {
self.get_at(self.generation(), key)
}
/// Changes when explicitly cleared; jobs captured before that clear cannot repopulate it.
pub fn generation(&self) -> u64 {
*self.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn get_at(&self, generation: u64, key: Hash128) -> Option<Arc<Rgba8>> {
let current = self.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner);
if *current != generation {
return None;
}
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>) {
self.put_at(self.generation(), key, img);
}
pub fn put_at(&self, generation: u64, key: Hash128, img: Arc<Rgba8>) {
let current = self.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner);
if *current != generation {
return;
}
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);
}
/// Like [`Self::put`], but the disk write (a JPEG encode) happens on a background thread, so
/// the caller (a render about to hand its result to the screen) isn't held up.
pub fn put_deferred(self: &Arc<Self>, key: Hash128, img: Arc<Rgba8>) {
self.put_deferred_at(self.generation(), key, img);
}
pub fn put_deferred_at(self: &Arc<Self>, generation: u64, key: Hash128, img: Arc<Rgba8>) {
let current = self.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner);
if *current != generation {
return;
}
let c = cost(&img);
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), c);
drop(current);
if self.disk.is_none() {
return;
}
let me = self.clone();
let write = move || {
let current = me.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner);
if *current != generation {
return;
}
if let Some(d) = &me.disk {
d.put(key, &img);
}
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("lc-preview-write".into()).spawn(write).is_err() {
log::warn!("preview cache: could not start a writer thread");
}
#[cfg(target_arch = "wasm32")]
write();
}
/// Drop everything (memory and disk).
pub fn clear(&self) {
let mut generation = self.generation.write().unwrap_or_else(std::sync::PoisonError::into_inner);
*generation = generation.wrapping_add(1);
self.mem.lock().unwrap_or_else(|e| e.into_inner()).clear();
if let Some(d) = &self.disk {
d.clear();
}
}
/// Use tick ([`next_tick`]) of the least recently used image in memory.
pub fn oldest_tick(&self) -> Option<u64> {
self.mem.lock().unwrap_or_else(|e| e.into_inner()).oldest_tick()
}
/// Drop the least recently used image from memory (it stays on disk); returns its bytes.
pub fn evict_oldest(&self) -> Option<usize> {
self.mem.lock().unwrap_or_else(|e| e.into_inner()).pop_oldest()
}
/// Change the memory budget (bytes), evicting at once if needed.
pub fn set_mem_budget(&self, bytes: usize) {
let mut m = self.mem.lock().unwrap_or_else(|e| e.into_inner());
m.set_budget(bytes);
while m.cost() > bytes && m.len() > 1 {
m.pop_oldest();
}
}
/// (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;