diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 6d0483a94..2efabebb5 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -428,8 +428,9 @@ impl WebApp { let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize); cores.saturating_sub(1).clamp(1, 4) }); + let cache = app.session.media.rendered.clone(); let workers = - (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, cc.egui_ctx.clone())); + (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone())); if let Some(w) = &workers { app.renderer.set_offload(Box::new(w.clone())); } diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 372e84c1e..0474e2a08 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -11,13 +11,13 @@ //! ([`ThumbIndex`]). use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use lightcraft_engine::catalog::Source; use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens}; use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef}; use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; -use lightcraft_preview::{Hash128, Lru}; +use lightcraft_preview::{Hash128, Lru, PreviewCache}; use serde::{Deserialize, Serialize}; use crate::store::hash_of_path; @@ -299,6 +299,33 @@ impl ThumbIndex { } } +/// Which rendered-thumbnail cache, at which generation, the stored thumbnails ([`ThumbIndex`]) +/// belong to (main thread). Clearing the previews bumps the cache's generation, and opening +/// another library replaces the cache: either makes every stored thumbnail obsolete. +pub struct CacheWatch(Option<(Weak, u64)>); + +impl CacheWatch { + /// Watch `active`, the session's cache when the workers start: the persisted index belongs + /// to it at its current generation. (Without this baseline, the first observation after a + /// clear would only record the cleared generation and keep the obsolete thumbnails.) + pub fn new(active: &Arc) -> CacheWatch { + CacheWatch(Some((Arc::downgrade(active), active.generation()))) + } + + /// The watched cache, while it's alive. + pub fn cache(&self) -> Option> { + self.0.as_ref().and_then(|(cache, _)| cache.upgrade()) + } + + /// `cache` at `generation` is the active one now. If it was cleared or replaced since the + /// last observation, invalidate `index` and return the keys whose files should be deleted. + pub fn observe(&mut self, cache: &Arc, generation: u64, index: &mut ThumbIndex) -> Vec { + let changed = self.0.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation); + self.0 = Some((Arc::downgrade(cache), generation)); + if changed { index.invalidate() } else { Vec::new() } + } +} + #[cfg(test)] mod tests { use super::*; @@ -381,6 +408,62 @@ mod tests { assert_eq!(core.needs_original(&refreshed).as_deref(), Some(hash.as_str()), "explicit refresh must not reuse the old decoded source"); } + /// The persisted index as a restarted page loads it: one stored thumbnail. + fn persisted_index(key: Hash128) -> (ThumbIndex, String) { + let mut index = ThumbIndex::default(); + let hex = index.cache_key(key); + index.insert(&hex, 100); + (ThumbIndex::from_json(&index.to_json()), hex) + } + + #[test] + fn clearing_previews_before_the_first_request_invalidates_stored_thumbnails() { + let key = lightcraft_preview::hash_bytes(b"thumbnail"); + let (mut index, old) = persisted_index(key); + let cache = Arc::new(PreviewCache::memory(1 << 20)); + let mut watch = CacheWatch::new(&cache); + let mut gone = Vec::new(); + // each frame (`Workers::finished`) observes the watched cache, even with no requests + let mut frame = |watch: &mut CacheWatch, index: &mut ThumbIndex| { + if let Some(c) = watch.cache() { + gone.extend(watch.observe(&c, c.generation(), index)); + } + }; + frame(&mut watch, &mut index); // an empty filtered view: no render request yet + cache.clear(); // File ▸ Clear Preview Cache + frame(&mut watch, &mut index); + // the filter is removed: the first thumbnail request (`Workers::try_start`) + gone.extend(watch.observe(&cache, cache.generation(), &mut index)); + let disk_key = index.cache_key(key); + assert!(!index.touch(&disk_key), "the pre-clear thumbnail must not be read back"); + assert_eq!(gone, vec![old.clone()], "the stored file is deleted"); + assert_ne!(disk_key, old, "a fresh namespace: a late old write can't come back"); + assert!(index.dirty, "the invalidated index is saved"); + } + + #[test] + fn replacing_the_cache_before_the_first_request_invalidates_stored_thumbnails() { + let key = lightcraft_preview::hash_bytes(b"thumbnail"); + let (mut index, old) = persisted_index(key); + let first = Arc::new(PreviewCache::memory(1 << 20)); + let mut watch = CacheWatch::new(&first); + let other = Arc::new(PreviewCache::memory(1 << 20)); // another library, generation 0 again + assert_eq!(watch.observe(&other, other.generation(), &mut index), vec![old]); + } + + #[test] + fn a_normal_start_keeps_stored_thumbnails() { + let key = lightcraft_preview::hash_bytes(b"thumbnail"); + let (mut index, old) = persisted_index(key); + let cache = Arc::new(PreviewCache::memory(1 << 20)); + let mut watch = CacheWatch::new(&cache); + for _ in 0..3 { + assert!(watch.observe(&cache, cache.generation(), &mut index).is_empty()); + } + assert_eq!(index.cache_key(key), old); + assert!(index.touch(&old), "persisted thumbnails survive a restart"); + } + #[test] fn thumb_index_prunes_lru() { let mut i = ThumbIndex::default(); diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 21e90f7d6..a90ed61e5 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -27,7 +27,7 @@ use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use crate::backend::Backend; -use crate::wire::{ThumbIndex, WireJob, WorkerCore, thumb_storage_key}; +use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key}; /// Storage key of the thumbnail index. pub const THUMB_INDEX: &str = "thumbs/index.json"; @@ -175,7 +175,8 @@ struct W { } struct Inner { - preview_generation: Option<(std::sync::Weak, u64)>, + /// The rendered-thumbnail cache the stored thumbnails belong to. + preview_generation: CacheWatch, workers: Vec, done: Vec<(Slot, RenderResult, f64)>, next_id: u32, @@ -192,17 +193,16 @@ struct Inner { impl Inner { fn observe_cache(&mut self, cache: &Arc, generation: u64) { - if self.preview_generation.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation) { - let gone = self.index.invalidate(); - if let Some(backend) = self.backend.clone() { - wasm_bindgen_futures::spawn_local(async move { - for key in gone { - let _ = backend.remove(&thumb_storage_key(&key)).await; - } - }); - } + let gone = self.preview_generation.observe(cache, generation, &mut self.index); + if !gone.is_empty() + && let Some(backend) = self.backend.clone() + { + wasm_bindgen_futures::spawn_local(async move { + for key in gone { + let _ = backend.remove(&thumb_storage_key(&key)).await; + } + }); } - self.preview_generation = Some((Arc::downgrade(cache), generation)); } } @@ -212,9 +212,11 @@ pub struct Workers(Rc>); impl Workers { /// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers. - pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, ctx: egui::Context) -> Workers { + /// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache + /// now: watching it from the start, a clear before the first render request still counts. + pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, cache: &Arc, ctx: egui::Context) -> Workers { let w = Workers(Rc::new(RefCell::new(Inner { - preview_generation: None, + preview_generation: CacheWatch::new(cache), workers: Vec::new(), done: Vec::new(), next_id: 0, @@ -471,7 +473,7 @@ impl RenderOffload for Workers { fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { let mut g = self.0.borrow_mut(); // Persist invalidation even when the cleared library has no visible render requests. - if let Some(cache) = g.preview_generation.as_ref().and_then(|(cache, _)| cache.upgrade()) { + if let Some(cache) = g.preview_generation.cache() { g.observe_cache(&cache, cache.generation()); } g.inline_this_frame = false;