mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e7b3724b5f
commit
bcecab7d84
@@ -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()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+105
-11
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user