diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 05960f7e..372e84c1 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -17,7 +17,7 @@ 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::Lru; +use lightcraft_preview::{Hash128, Lru}; use serde::{Deserialize, Serialize}; use crate::store::hash_of_path; @@ -25,6 +25,10 @@ use crate::store::hash_of_path; /// One render request for a worker. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct WireJob { + #[serde(default)] + pub cache_generation: u64, + #[serde(default)] + pub source_identity: Option, pub level: SourceLevel, pub origin: Source, /// Decode the source at most this long. @@ -60,6 +64,8 @@ impl WireJob { SourceRef::Smart { .. } => 2560, }; WireJob { + cache_generation: job.cache_generation, + source_identity: job.source_key.map(|key| key.to_string()), level: job.level, origin: job.origin.clone(), max_edge, @@ -108,8 +114,8 @@ impl WireJob { /// Identifies the decoded source this job needs. fn source_key(&self) -> String { match &self.origin { - Source::File { path } => format!("{}:{path}", self.max_edge), - Source::Demo { scene } => format!("{}:demo:{scene}", self.max_edge), + Source::File { path } => format!("{}:{:?}:{}:{path}", self.cache_generation, self.source_identity, self.max_edge), + Source::Demo { scene } => format!("{}:{:?}:{}:demo:{scene}", self.cache_generation, self.source_identity, self.max_edge), } } @@ -186,6 +192,8 @@ impl WorkerCore { /// pruned like the native disk cache (least recently used first, down to 80 % of the budget). #[derive(Default, Serialize, Deserialize)] pub struct ThumbIndex { + #[serde(default)] + namespace: u64, /// hex key → (bytes, last use). entries: HashMap, clock: u64, @@ -196,6 +204,24 @@ pub struct ThumbIndex { } impl ThumbIndex { + pub fn namespace(&self) -> u64 { + self.namespace + } + + /// A persistent new namespace prevents late old workers restoring an invalidated disk hit. + pub fn cache_key(&self, key: Hash128) -> String { + if self.namespace == 0 { + return key.to_string(); + } + lightcraft_preview::Hasher128::new().str(&key.to_string()).str("cleared").u64(self.namespace).finish().to_string() + } + + pub fn invalidate(&mut self) -> Vec { + self.namespace = self.namespace.wrapping_add(1); + self.total = 0; + self.dirty = true; + self.entries.drain().map(|(key, _)| key).collect() + } pub fn from_json(bytes: &[u8]) -> ThumbIndex { let mut i: ThumbIndex = serde_json::from_slice(bytes).unwrap_or_default(); i.total = i.entries.values().map(|e| e.0).sum(); @@ -278,6 +304,23 @@ mod tests { use super::*; use lightcraft_engine::Session; + #[test] + fn clearing_index_changes_disk_namespace_and_survives_restart() { + let key = lightcraft_preview::hash_bytes(b"thumbnail"); + let mut index = ThumbIndex::default(); + let old = index.cache_key(key); + assert_eq!(old, key.to_string()); + index.insert(&old, 100); + assert_eq!(index.invalidate(), vec![old.clone()]); + let new = index.cache_key(key); + assert_ne!(old, new); + index.insert(&new, 100); + let reopened = ThumbIndex::from_json(&index.to_json()); + assert_eq!(reopened.cache_key(key), new); + assert!(!reopened.contains(&old)); + assert!(reopened.contains(&new)); + } + #[test] fn wire_job_renders_like_the_engine() { let mut s = Session::with_demo(); @@ -304,6 +347,8 @@ mod tests { let hash = crate::store::content_hash(&bytes); let origin = Source::File { path: crate::store::original_path(&hash, "a.png") }; let job = WireJob { + cache_generation: 0, + source_identity: None, level: SourceLevel::Thumb, origin, max_edge: 512, @@ -328,6 +373,12 @@ mod tests { let r = core.render(&job, Some(&bytes)).unwrap(); assert_eq!(r.image.width, 32); assert_eq!(core.needs_original(&job), None, "decoded source is kept"); + let mut reloaded = job.clone(); + reloaded.source_identity = Some("changed content".into()); + assert_eq!(core.needs_original(&reloaded).as_deref(), Some(hash.as_str()), "content reload must not reuse an old decoded source"); + let mut refreshed = job.clone(); + refreshed.cache_generation = 1; + assert_eq!(core.needs_original(&refreshed).as_deref(), Some(hash.as_str()), "explicit refresh must not reuse the old decoded source"); } #[test] diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 50ae0f72..21e90f7d 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -155,6 +155,10 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option, + namespace: u64, + request_id: u64, + cache_generation: u64, id: u32, slot: Slot, photo: PhotoId, @@ -171,6 +175,7 @@ struct W { } struct Inner { + preview_generation: Option<(std::sync::Weak, u64)>, workers: Vec, done: Vec<(Slot, RenderResult, f64)>, next_id: u32, @@ -185,6 +190,22 @@ struct Inner { inline_this_frame: bool, } +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; + } + }); + } + } + self.preview_generation = Some((Arc::downgrade(cache), generation)); + } +} + /// The main thread's handle on the render workers. #[derive(Clone)] pub struct Workers(Rc>); @@ -193,6 +214,7 @@ 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 { let w = Workers(Rc::new(RefCell::new(Inner { + preview_generation: None, workers: Vec::new(), done: Vec::new(), next_id: 0, @@ -248,7 +270,16 @@ impl Workers { w.dead = true; w.worker.terminate(); if let Some(b) = w.busy.take() { - let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None, quick: None }; + let r = RenderResult { + request_id: b.request_id, + source_key: None, + photo: b.photo, + level: b.level, + key: b.key, + rendered: Err(msg), + loaded: None, + quick: None, + }; g.done.push((b.slot, r, 0.0)); } } @@ -284,9 +315,11 @@ impl Workers { }; if let Ok(r) = &rendered && let Some((cache, key)) = &b.cache + && cache.generation() == b.cache_generation + && b.namespace == g.index.namespace() { - cache.put(*key, Arc::new(r.image.clone())); - let hex = key.to_string(); + cache.put_at(b.cache_generation, *key, Arc::new(r.image.clone())); + let hex = b.disk_key.clone().unwrap_or_else(|| key.to_string()); let stored = get(&data, "stored").as_f64().unwrap_or(0.0) as u64; if get(&data, "cacheMiss").is_truthy() { g.index.remove(&hex); @@ -307,8 +340,31 @@ impl Workers { } } g.affinity.insert((b.photo, b.level), i); + // A cleared worker may finish writing after the initial deletion. Its old namespace + // is never reused, so removing this file cannot delete a replacement's cache entry. + if let Some((cache, _)) = &b.cache + && (cache.generation() != b.cache_generation || b.namespace != g.index.namespace()) + && let (Some(backend), Some(key)) = (g.backend.clone(), b.disk_key.clone()) + { + wasm_bindgen_futures::spawn_local(async move { + let _ = backend.remove(&thumb_storage_key(&key)).await; + }); + } g.remote_done += 1; - g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None, quick: None }, ms)); + g.done.push(( + b.slot, + RenderResult { + request_id: b.request_id, + source_key: None, + photo: b.photo, + level: b.level, + key: b.key, + rendered, + loaded: None, + quick: None, + }, + ms, + )); } /// The thumbnail index as JSON if it changed since the last call (the host saves it). @@ -334,13 +390,18 @@ impl RenderOffload for Workers { fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { let mut g = self.0.borrow_mut(); let g = &mut *g; + if let Some((cache, _)) = job.cache.as_ref().or(job.view_cache.as_ref()) { + g.observe_cache(cache, job.cache_generation); + } // rendered thumbnail in memory: no worker needed if let Some((cache, key)) = &job.cache - && let Some(img) = cache.get(*key) + && let Some(img) = cache.get_at(job.cache_generation, *key) { let image = Arc::unwrap_or_clone(img); let histogram = Histogram::of_srgb8(&image); let r = RenderResult { + request_id: job.request_id, + source_key: job.source_key, photo: job.photo, level: job.level, key: job.key, @@ -374,25 +435,45 @@ impl RenderOffload for Workers { let Some(i) = pick else { return Some(job) }; // decided here, so the next job for this photo follows even before this one finishes g.affinity.insert((job.photo, job.level), i); - let thumb_cached = job.cache.as_ref().is_some_and(|(_, k)| g.index.touch(&k.to_string())); - let wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}")); + let disk_key = job.cache.as_ref().map(|(_, key)| g.index.cache_key(*key)); + let thumb_cached = disk_key.as_ref().is_some_and(|key| g.index.touch(key)); + let mut wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}")); + // The namespace also changes when a new library's cache starts again at generation zero. + wire.cache_generation = g.index.namespace(); + wire.thumb = disk_key.clone(); let Ok(text) = serde_json::to_string(&wire) else { return Some(job) }; g.next_id = g.next_id.wrapping_add(1); let id = g.next_id; let msg = Object::new(); set(&msg, "id", &id.into()); set(&msg, "job", &text.into()); + let namespace = g.index.namespace(); let w = &mut g.workers[i]; if let Err(e) = w.worker.post_message(&msg) { log::warn!("render worker {i}: {e:?}"); return Some(job); } - w.busy = Some(Busy { id, slot, photo: job.photo, level: job.level, key: job.key, cache: job.cache }); + w.busy = Some(Busy { + disk_key, + namespace, + request_id: job.request_id, + cache_generation: job.cache_generation, + id, + slot, + photo: job.photo, + level: job.level, + key: job.key, + cache: job.cache, + }); None } 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()) { + g.observe_cache(&cache, cache.generation()); + } g.inline_this_frame = false; std::mem::take(&mut g.done) } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 84100638..2a0f5f26 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -991,6 +991,7 @@ pub fn specs() -> Vec { }), cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| { s.media.rendered.clear(); + s.media.clear_sources(); ok() }), ] diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 80e5eb43..df0e96e8 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -993,6 +993,9 @@ impl Session { let source = self.media.origin_ref(&p.source, level.max_edge()); let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64; let small = crate::media::RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: None, photo: id, level, source, @@ -1009,7 +1012,7 @@ impl Session { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), _ => None, }; - Some(crate::media::QuickJob { photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) }) + Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) }) } /// Use the system clock for import/edit times (native hosts). diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 86b346e7..313a0b2e 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -16,7 +16,8 @@ //! embedded JPEG for an unedited raw, else a thumbnail-level render — while the real render is //! prepared. Grid thumbnails of unedited raws likewise start from the embedded preview. -use std::sync::Arc; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Weak}; use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; @@ -25,12 +26,51 @@ use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; +const SETTINGS_HASH_ENTRIES: usize = 1024; + +#[derive(Default)] +struct SettingsHashes { + entries: HashMap, u64)>, + order: VecDeque, + #[cfg(test)] + computations: usize, +} + +impl SettingsHashes { + fn get(&mut self, id: PhotoId, settings: &Arc) -> u64 { + if let Some((old, hash)) = self.entries.get(&id) + && old.as_ptr() == Arc::as_ptr(settings) + { + return *hash; + } + let hash = settings.hash64(); + #[cfg(test)] + { + self.computations += 1; + } + if !self.entries.contains_key(&id) { + if self.entries.len() >= SETTINGS_HASH_ENTRIES + && let Some(oldest) = self.order.pop_front() + { + self.entries.remove(&oldest); + } + self.order.push_back(id); + } + self.entries.insert(id, (Arc::downgrade(settings), hash)); + hash + } +} + /// Bump when the pipeline's output changes, to invalidate cached thumbnails. pub const RENDER_CACHE_VERSION: u64 = 11; /// 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]; +pub fn thumb_bucket(long_edge: usize) -> usize { + THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512) +} + /// Memory budgets. const THUMB_SOURCE_BYTES: usize = 384 << 20; const RENDERED_MEM_BYTES: usize = 128 << 20; @@ -145,6 +185,7 @@ impl SourceRef { } pub struct MediaCache { + settings_hashes: SettingsHashes, /// Decoded thumbnail-level sources (LRU by bytes). thumbs: Lru, /// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]). @@ -175,6 +216,7 @@ impl Default for MediaCache { fn default() -> Self { let budget = crate::memory::cache_share(crate::memory::budget()); MediaCache { + settings_hashes: SettingsHashes::default(), thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), previews: Vec::new(), full: None, @@ -209,6 +251,7 @@ impl MediaCache { /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). pub fn clear_sources(&mut self) { + self.settings_hashes = SettingsHashes::default(); self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); self.previews.clear(); self.full = None; @@ -391,6 +434,11 @@ impl MediaCache { /// Everything needed to render one photo, detached from the session. #[derive(Clone)] pub struct RenderJob { + /// Assigned by a frontend to distinguish late completions, even for an identical render key. + pub request_id: u64, + pub cache_generation: u64, + /// Content identity for validating a decoded source returned after a reload. + pub source_key: Option, pub photo: PhotoId, pub level: SourceLevel, pub source: SourceRef, @@ -412,6 +460,8 @@ pub struct RenderJob { } pub struct RenderResult { + pub request_id: u64, + pub source_key: Option, pub photo: PhotoId, pub level: SourceLevel, pub key: u64, @@ -469,11 +519,13 @@ impl RenderJob { pub fn run(self) -> RenderResult { if let Some((cache, key)) = &self.cache - && let Some(img) = cache.get(*key) + && let Some(img) = cache.get_at(self.cache_generation, *key) { let image = Arc::unwrap_or_clone(img); let histogram = Histogram::of_srgb8(&image); return RenderResult { + request_id: self.request_id, + source_key: self.source_key, photo: self.photo, level: self.level, key: self.key, @@ -492,14 +544,16 @@ impl RenderJob { let gpu = self.cache.is_none(); let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); if let Some((cache, key)) = &self.cache { - cache.put(*key, Arc::new(rendered.image.clone())); + cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); } if let Some((cache, key)) = &self.view_cache && self.request.quality == Quality::Full { - cache.put_deferred(*key, Arc::new(rendered.image.clone())); + cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); } RenderResult { + request_id: self.request_id, + source_key: self.source_key, photo: self.photo, level: self.level, key: self.key, @@ -508,7 +562,16 @@ impl RenderJob { quick: None, } } - Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None, quick: None }, + Err(e) => RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Err(e), + loaded: None, + quick: None, + }, } } } @@ -548,6 +611,7 @@ pub enum QuickSource { /// renders, the embedded preview of an unedited raw, a thumbnail-level render. #[derive(Clone)] pub struct QuickJob { + pub request_id: u64, pub photo: PhotoId, pub key: u64, /// Cached renders to try first, in order: (cache, key). @@ -561,7 +625,17 @@ pub struct QuickJob { impl QuickJob { pub fn run(self) -> RenderResult { let (photo, key) = (self.photo, self.key); - let done = |rendered: Result, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick }; + let request_id = self.request_id; + let done = |rendered: Result, quick| RenderResult { + request_id, + source_key: None, + photo, + level: SourceLevel::Thumb, + key, + rendered, + loaded: None, + quick, + }; let rendered = |image: Rgba8| { let histogram = Histogram::of_srgb8(&image); Ok(Rendered { image, histogram, deep: None }) @@ -578,7 +652,7 @@ impl QuickJob { } if let Some(job) = self.small { let r = job.run(); - return RenderResult { key, quick: Some(QuickSource::Small), ..r }; + return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r }; } done(Err("no quick preview".into()), None) } @@ -617,7 +691,7 @@ impl crate::Session { /// 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 { - let b = THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(THUMB_SIZES[THUMB_SIZES.len() - 1]); + let b = thumb_bucket(long_edge); self.build_job(id, b, b, false, true, Some(b)) } @@ -638,8 +712,12 @@ impl crate::Session { // the photo id is part of the key: two photos with the same settings and size must not // share a result (a view slot showing photo A would otherwise look current for photo B) // …and so is the file's content: a file changed on disk (Reload) renders afresh - let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; - let key = settings.hash64() + let content_key = content_key(&p); + let source_key = Hasher128::new().str(&content_key).finish(); + let content = source_key.0 as u64; + // Temporary "before" settings must not replace the photo's cached develop hash. + let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) }; + let key = settings_hash ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) @@ -648,8 +726,8 @@ impl crate::Session { ^ content.rotate_left(17); let cache = thumb_bucket.map(|b| { let k = Hasher128::new() - .str(&content_key(&p)) - .u64(settings.hash64()) + .str(&content_key) + .u64(settings_hash) .u64(b as u64) .u64(RENDER_CACHE_VERSION) .u64(crate::camera_profiles::cache_key()) @@ -657,6 +735,9 @@ impl crate::Session { (self.media.rendered.clone(), k) }); Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(source_key), photo: id, level, source, @@ -704,6 +785,9 @@ impl crate::Session { let source = self.media.source_ref(&p, level); let ck = Self::variant_key(&p, settings, edge); Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), photo: id, level, source, @@ -746,6 +830,9 @@ impl crate::Session { let source = self.media.source_ref(&p, level); let ck = Self::variant_key(&p, &settings, edge); Some(RenderJob { + request_id: 0, + cache_generation: self.media.rendered.generation(), + source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), photo: id, level, source, @@ -799,7 +886,7 @@ impl crate::Session { let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish(); let key = h.0 as u64; let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge()))); - Some(QuickJob { photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) + Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) } /// A first grid thumbnail for an unedited raw: the cached render for `job` (the real @@ -811,6 +898,7 @@ impl crate::Session { let (path, l) = self.embedded_of(&p)?; let edge = job.request.max_w.max(job.request.max_h); Some(QuickJob { + request_id: 0, photo: job.photo, key: job.key, cached: job.cache.clone().into_iter().collect(), @@ -821,7 +909,9 @@ impl crate::Session { /// Accept a finished job's loaded source into the cache. pub fn accept(&mut self, r: &RenderResult) { - if let Some(src) = &r.loaded { + if let Some(src) = &r.loaded + && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) + { self.media.insert_source(r.photo, r.level, src.clone()); } } @@ -907,6 +997,101 @@ pub struct ProbeInfo { pub type FileProbe = Arc Result + Send + Sync>; +#[cfg(test)] +mod thumbnail_hash_tests { + use super::*; + use lightcraft_catalog::Op; + + fn session() -> crate::Session { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s + } + #[test] + fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() { + let mut s = session(); + for edge in [256, 384, 512, 256] { + let p = s.catalog.photo(PhotoId(1)).unwrap().clone(); + let b = thumb_bucket(edge); + // Independent original formula: never asks the new hash cache for expected values. + let settings = p.develop.hash64(); + let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; + let expected = + settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17); + let disk = Hasher128::new() + .str(&content_key(&p)) + .u64(settings) + .u64(b as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish(); + let job = s.thumb_job(p.id, edge).unwrap(); + assert_eq!(job.key, expected); + assert_eq!(job.cache.unwrap().1, disk); + } + s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.render_job(PhotoId(1), 256, 256, true, true).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 1); + let mut edit = DevelopSettings::default(); + edit.light.exposure = 1.0; + let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.thumb_job(PhotoId(1), 384).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 2); + s.catalog.apply(undo).unwrap(); + s.thumb_job(PhotoId(1), 256).unwrap(); + assert_eq!(s.media.settings_hashes.computations, 3); + } + #[test] + fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() { + let mut hashes = SettingsHashes::default(); + let shared = Arc::new(DevelopSettings::default()); + for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 { + assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64()); + } + assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES); + assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES); + assert_eq!(Arc::strong_count(&shared), 1); + let mut edit = (*shared).clone(); + edit.light.exposure = 1.0; + let edit = Arc::new(edit); + let a = PhotoId(10000); + let b = PhotoId(10001); + let initial = hashes.get(a, &shared); + assert_eq!(hashes.get(b, &shared), initial); + assert_ne!(hashes.get(a, &edit), initial); + assert_eq!(hashes.get(b, &shared), initial); + let mut s = session(); + s.thumb_job(PhotoId(1), 256).unwrap(); + s.media.clear_sources(); + assert!(s.media.settings_hashes.entries.is_empty()); + assert!(s.media.settings_hashes.order.is_empty()); + } + #[test] + fn source_from_before_content_reload_is_not_accepted() { + let mut s = session(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default())))); + let old = s.thumb_job(PhotoId(1), 256).unwrap().run(); + assert!(old.loaded.is_some()); + s.catalog + .apply(Op::SetContent { + id: PhotoId(1), + width: 6000, + height: 4000, + file_size: 99, + content_hash: Some("replacement".into()), + preview_only: None, + }) + .unwrap(); + s.media.forget(PhotoId(1)); + s.accept(&old); + assert_eq!(s.media.source_usage().0, 0); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 9518983c..50dc68e5 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -18,7 +18,7 @@ pub mod lru; pub mod pool; use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; pub use disk::{DiskCache, decode_jpeg, encode_jpeg}; pub use hash::{Hash128, Hasher128, hash_bytes}; @@ -28,6 +28,7 @@ pub use pool::JobPool; /// Rendered-thumbnail cache: memory LRU over an optional disk cache. pub struct PreviewCache { + generation: RwLock, mem: Mutex>>, disk: Option, } @@ -35,12 +36,12 @@ pub struct PreviewCache { 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 } + 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 { mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) } + 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> { @@ -48,6 +49,19 @@ impl PreviewCache { } pub fn get(&self, key: Hash128) -> Option> { + 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> { + 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()); } @@ -57,6 +71,14 @@ impl PreviewCache { } pub fn put(&self, key: Hash128, img: Arc) { + self.put_at(self.generation(), key, img); + } + + pub fn put_at(&self, generation: u64, key: Hash128, img: Arc) { + 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); } @@ -67,13 +89,26 @@ impl PreviewCache { /// 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, key: Hash128, img: Arc) { + self.put_deferred_at(self.generation(), key, img); + } + + pub fn put_deferred_at(self: &Arc, generation: u64, key: Hash128, img: Arc) { + 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); } @@ -88,6 +123,8 @@ impl PreviewCache { /// 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(); diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index ee5bd36f..ca4d39f8 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -4,6 +4,28 @@ use lightcraft_raster::Rgba8; use super::*; +#[test] +fn cleared_generation_cannot_read_or_repopulate_memory_or_disk() { + let dir = std::env::temp_dir().join(format!("lc-thumbnail-generation-{}-{}", std::process::id(), next_tick())); + let cache = Arc::new(PreviewCache::with_disk(1 << 20, &dir, 1 << 20)); + let key = hash_bytes(b"same-key"); + let old = cache.generation(); + let wrong = Arc::new(gradient(8, 8, 255)); + let correct = Arc::new(gradient(8, 8, 0)); + cache.put_at(old, key, wrong.clone()); + assert!(cache.get_at(old, key).is_some()); + cache.clear(); + assert!(cache.get(key).is_none()); + assert!(cache.get_at(old, key).is_none()); + cache.put_at(old, key, wrong.clone()); + cache.put_deferred_at(old, key, wrong); + assert!(cache.get(key).is_none()); + assert!(cache.disk().unwrap().get(key).is_none()); + cache.put_at(cache.generation(), key, correct.clone()); + assert_eq!(cache.get(key).unwrap().as_bytes(), correct.as_bytes()); + cache.clear(); +} + 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); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index e83fec8b..da9f46b6 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -437,7 +437,20 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: /// first gets a stand-in (its cached thumbnail, else its embedded camera preview), and its real /// render then follows in the background. pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) { + let bucket = lightcraft_engine::media::thumb_bucket(size); + if let Some(photo) = app.session.catalog.photo(id) + && app.renderer.thumb_current(photo, bucket, priority) + { + return; + } let Some(job) = app.session.thumb_job(id, size) else { return }; + #[cfg(test)] + { + app.renderer.thumb_jobs_built += 1; + } + if let Some(photo) = app.session.catalog.photo(id) { + app.renderer.remember_thumb(photo, bucket, job.key); + } let quick = if app.renderer.textures.contains_key(&Slot::Thumb(id)) { None } else { app.session.quick_thumb_job(&job) }; match quick { Some(q) => { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 3c22bfb8..6186b3b6 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -85,6 +85,12 @@ pub const VARIANT_TEXTURES: usize = 96; const VARIANT_PRIORITY: u32 = 6; pub struct Renderer { + #[cfg(test)] + pub(crate) thumb_jobs_built: usize, + request_ids: HashMap, + preview_generation: Option<(std::sync::Weak, u64)>, + /// Last thumbnail inputs; weak identities do not retain photo histories or force deep clones. + thumb_inputs: HashMap, usize, u64)>, pool: JobPool, /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. offload: Option>, @@ -127,8 +133,22 @@ const IDLE_TRIM: std::time::Duration = std::time::Duration::from_secs(3); impl Default for Renderer { fn default() -> Self { let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; + Self::with_worker_threads(threads) + } +} + +impl Renderer { + /// Explicit worker count for hosts and bounded validation runs (one to six on native). + /// Browser hosts continue to use inline jobs or their installed worker offload. + pub fn with_worker_threads(threads: usize) -> Self { + let threads = if cfg!(target_arch = "wasm32") { 0 } else { threads.clamp(1, 6) }; Renderer { + #[cfg(test)] + thumb_jobs_built: 0, + request_ids: HashMap::new(), + preview_generation: None, pool: JobPool::new(threads), + thumb_inputs: HashMap::new(), offload: None, queue: Vec::new(), seq: 0, @@ -151,6 +171,33 @@ impl Default for Renderer { } impl Renderer { + pub fn thumb_current(&self, photo: &Arc, bucket: usize, priority: u32) -> bool { + let Some((old, size, key)) = self.thumb_inputs.get(&photo.id) else { return false }; + old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::Thumb(photo.id), *key, priority) + } + + pub fn remember_thumb(&mut self, photo: &Arc, bucket: usize, key: u64) { + if self.thumb_inputs.len() >= 1024 && !self.thumb_inputs.contains_key(&photo.id) { + self.thumb_inputs.retain(|_, (p, _, _)| p.strong_count() > 0); + if self.thumb_inputs.len() >= 1024 + && let Some(id) = self.thumb_inputs.keys().next().copied() + { + self.thumb_inputs.remove(&id); + } + } + self.thumb_inputs.insert(photo.id, (Arc::downgrade(photo), bucket, key)); + } + + fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool { + // A matching old texture must not mask a different newer request (e.g. undo). + if let Some(&(k, p)) = self.pending.get(&slot) { + return k != key || (p != priority && self.is_queued(slot)); + } + if self.textures.get(&slot).is_some_and(|t| t.key == key) || self.failed.get(&slot).is_some_and(|f| f.0 == key && f.2 == self.catalog_rev) { + return false; + } + true + } /// Run jobs through `offload` from now on (instead of the job pool / inline). pub fn set_offload(&mut self, offload: Box) { self.offload = Some(offload); @@ -167,23 +214,17 @@ impl Renderer { } 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).is_some_and(|p| p.0 == key) + self.wanted(slot) == Some(key) } /// 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.textures.get(&slot).is_some_and(|t| t.key == job.key) - || self.failed.get(&slot).is_some_and(|f| f.0 == job.key && f.2 == self.catalog_rev) - { - return; - } - if let Some(&(key, prio)) = self.pending.get(&slot) - && key == job.key - && (prio == priority || !self.is_queued(slot)) - { + pub fn request(&mut self, slot: Slot, mut job: RenderJob, priority: u32) { + if !self.request_needed(slot, job.key, priority) { return; } self.pending.insert(slot, (job.key, priority)); + job.request_id = lightcraft_preview::next_tick(); + self.request_ids.insert(slot, job.request_id); // (an offload keeps its own per-view stage caches; the flag tells it to) let job = if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; @@ -205,24 +246,28 @@ impl Renderer { } /// Request a stand-in for `slot` (once per job key). - pub fn request_quick(&mut self, slot: Slot, job: QuickJob, priority: u32) { + pub fn request_quick(&mut self, slot: Slot, mut job: QuickJob, priority: u32) { if self.quick_tried.get(&slot) == Some(&job.key) { return; } self.quick_tried.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); + job.request_id = lightcraft_preview::next_tick(); + self.request_ids.insert(slot, job.request_id); let key = job.key; self.pool.submit(slot, key, priority, Box::new(move || job.run())); } /// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one /// replaces it while queued). Memory stays bounded by the engine's source caches. - pub fn prefetch(&mut self, slot: Slot, job: RenderJob, priority: u32) { + pub fn prefetch(&mut self, slot: Slot, mut job: RenderJob, priority: u32) { if self.prefetched.get(&slot) == Some(&job.key) { return; } self.prefetched.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); + job.request_id = lightcraft_preview::next_tick(); + self.request_ids.insert(slot, job.request_id); let key = job.key; self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); } @@ -230,6 +275,10 @@ impl Renderer { /// Is a request for `slot` queued or running? /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). pub fn forget_all(&mut self) { + self.request_ids.clear(); + self.preview_generation = None; + self.pool.reprioritize(|_, _| None); + self.thumb_inputs.clear(); self.textures.clear(); self.stages.clear(); self.quick_tried.clear(); @@ -311,11 +360,14 @@ impl Renderer { }); for s in dropped { self.pending.remove(&s); + self.request_ids.remove(&s); } + let request_ids = &mut self.request_ids; let pending = &mut self.pending; self.queue.retain(|q| match q.slot { Slot::Variant(k) if stale(used.get(&k)) => { pending.remove(&q.slot); + request_ids.remove(&q.slot); false } _ => true, @@ -346,6 +398,12 @@ impl Renderer { /// Collect finished jobs into textures. Returns true if anything changed. pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool { + let cache = &session.media.rendered; + let generation = cache.generation(); + if self.preview_generation.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation) { + self.forget_all(); + } + self.preview_generation = Some((Arc::downgrade(cache), generation)); self.catalog_rev = session.catalog.revision; self.frame += 1; // wasm: run one job per frame on this thread, timed with the host clock @@ -367,8 +425,13 @@ impl Renderer { self.dispatch(); let mut changed = false; for (mut slot, r, ms) in finished { - session.accept(&r); self.completed += 1; + // Ignore obsolete pixels, failures and decoded sources, even for the same render key. + if self.request_ids.get(&slot) != Some(&r.request_id) { + continue; + } + self.request_ids.remove(&slot); + session.accept(&r); if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { self.pending.remove(&slot); } @@ -488,6 +551,10 @@ impl Renderer { /// Drop the import review thumbnails (a new review, or the dialog closed). pub fn forget_imports(&mut self) { + self.pool.reprioritize(|s, p| if matches!(s, Slot::Import(_)) { None } else { Some(p) }); + self.queue.retain(|q| !matches!(q.slot, Slot::Import(_))); + self.pending.retain(|s, _| !matches!(s, Slot::Import(_))); + self.request_ids.retain(|s, _| !matches!(s, Slot::Import(_))); self.textures.retain(|s, _| !matches!(s, Slot::Import(_))); self.quick_tried.retain(|s, _| !matches!(s, Slot::Import(_))); } @@ -495,18 +562,22 @@ impl Renderer { /// 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, max: usize) { + self.thumb_inputs.retain(|id, _| keep.contains(id)); let dropped = self.pool.reprioritize(|s, p| match s { Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id) && p <= 11 => None, _ => Some(p), }); for s in dropped { self.pending.remove(&s); + self.request_ids.remove(&s); self.quick_tried.remove(&s); } + let request_ids = &mut self.request_ids; let pending = &mut self.pending; self.queue.retain(|q| match q.slot { Slot::Thumb(id) if !keep.contains(&id) && q.priority <= 10 => { pending.remove(&q.slot); + request_ids.remove(&q.slot); false } _ => true, @@ -519,3 +590,303 @@ impl Renderer { self.quick_tried.retain(|s, _| !matches!(s, Slot::ThumbQuick(id) if !keep.contains(id))); } } + +#[cfg(test)] +mod thumbnail_tests { + use super::*; + use lightcraft_catalog::{Catalog, Op, Photo, Source}; + + struct Blocked; + impl RenderOffload for Blocked { + fn try_start(&mut self, _: Slot, job: RenderJob) -> Option { + Some(job) + } + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + vec![] + } + } + fn photo() -> Arc { + Arc::new(Photo::new(PhotoId(1), Source::File { path: "audit.jpg".into() }, "audit.jpg", "JPEG", 6000, 4000, "2026-01-01")) + } + #[test] + fn snapshot_tracks_cow_edits_undo_reload_and_replacement() { + let mut catalog = Catalog::default(); + catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let old = catalog.photo(PhotoId(1)).unwrap().clone(); + let mut r = Renderer::default(); + r.remember_thumb(&old, 384, 77); + r.pending.insert(Slot::Thumb(PhotoId(1)), (77, 10)); + assert!(r.thumb_current(&old, 384, 10)); + assert!(!r.thumb_current(&old, 512, 10)); + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.light.exposure = 1.0; + let undo = catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "edit".into(), edited: None }).unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + catalog.apply(undo).unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + catalog + .apply(Op::SetContent { + id: PhotoId(1), + width: 6000, + height: 4000, + file_size: 99, + content_hash: Some("changed".into()), + preview_only: None, + }) + .unwrap(); + assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); + assert!(!r.thumb_current(&photo(), 384, 10)); + r.forget_all(); + assert!(r.thumb_inputs.is_empty()); + } + #[test] + fn queue_promotions_running_work_and_cancellation() { + let p = photo(); + let slot = Slot::Thumb(p.id); + let mut session = Session::new(); + session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap(); + let job = session.thumb_job(p.id, 384).unwrap(); + let key = job.key; + let mut r = Renderer::default(); + r.set_offload(Box::new(Blocked)); + r.remember_thumb(&p, 384, key); + r.request(slot, job, 5); + assert!(r.thumb_current(&p, 384, 5)); + assert!(!r.thumb_current(&p, 384, 10)); + r.request(slot, session.thumb_job(p.id, 384).unwrap(), 10); + assert_eq!(r.pending.get(&slot), Some(&(key, 10))); + r.queue.clear(); // running work: cannot reprioritize, just as Renderer::request + assert!(r.thumb_current(&p, 384, 5)); + r.pending.clear(); + assert!(!r.thumb_current(&p, 384, 10)); + r.evict_thumbs(&Default::default(), 600); + assert!(r.thumb_inputs.is_empty()); + } + #[test] + fn failures_retry_after_revision_and_quick_only_is_not_final() { + let p = photo(); + let slot = Slot::Thumb(p.id); + let mut r = Renderer::default(); + r.remember_thumb(&p, 384, 77); + r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); + assert!(!r.thumb_current(&p, 384, 10)); + r.failed.insert(slot, (77, "no decoder".into(), 0)); + assert!(r.thumb_current(&p, 384, 10)); + r.catalog_rev = 1; + assert!(!r.thumb_current(&p, 384, 10)); + } + + #[derive(Default)] + struct Work { + jobs: Vec<(Slot, RenderJob)>, + finished: Vec<(Slot, RenderResult, f64)>, + } + struct Manual(std::rc::Rc>); + impl RenderOffload for Manual { + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { + self.0.borrow_mut().jobs.push((slot, job)); + None + } + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + std::mem::take(&mut self.0.borrow_mut().finished) + } + } + fn fixture() -> (crate::LightcraftApp, std::rc::Rc>, egui::Context) { + let mut s = Session::new(); + s.media.file_loader = Some(Arc::new(|_, _| { + let mut image = lightcraft_raster::Rgb32f::new(8, 8); + image.data.fill([0.3, 0.2, 0.1]); + Ok((image, Default::default())) + })); + s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let mut app = crate::LightcraftApp::new(s, crate::Services::default()); + app.renderer.keep_pixels = true; + let work = std::rc::Rc::new(std::cell::RefCell::new(Work::default())); + app.renderer.set_offload(Box::new(Manual(work.clone()))); + let ctx = egui::Context::default(); + app.renderer.poll(&ctx, &mut app.session); + (app, work, ctx) + } + fn take_job(work: &std::rc::Rc>) -> (Slot, RenderJob) { + work.borrow_mut().jobs.pop().unwrap() + } + fn finish(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context, slot: Slot, result: RenderResult) { + work.borrow_mut().finished.push((slot, result, 0.0)); + app.renderer.poll(ctx, &mut app.session); + } + fn request(app: &mut crate::LightcraftApp) { + crate::panels::grid::request_thumb(app, PhotoId(1), 256, 10); + } + + #[test] + fn unchanged_ready_and_running_frames_build_no_jobs() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 1); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert!(work.borrow().jobs.is_empty()); + } + + #[test] + fn unchanged_queued_frames_build_no_jobs() { + let (mut app, _, _) = fixture(); + app.renderer.set_offload(Box::new(Blocked)); + request(&mut app); + for _ in 0..180 { + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1); + assert_eq!(app.renderer.queue.len(), 1); + } + + #[test] + fn closing_import_review_cancels_queued_and_running_identities() { + let (mut app, work, ctx) = fixture(); + let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); + app.renderer.request(Slot::Import(0), job.clone(), 10); + let (slot, running) = take_job(&work); + app.renderer.set_offload(Box::new(Blocked)); + app.renderer.request(Slot::Import(1), job, 10); + app.renderer.forget_imports(); + assert!(!app.renderer.is_pending(Slot::Import(0))); + assert!(!app.renderer.is_pending(Slot::Import(1))); + assert!(app.renderer.queue.is_empty()); + work.borrow_mut().finished.push((slot, running.run(), 0.0)); + app.renderer.set_offload(Box::new(Manual(work.clone()))); + app.renderer.poll(&ctx, &mut app.session); + assert!(!app.renderer.textures.contains_key(&slot)); + assert_eq!(app.session.media.source_usage().0, 0); + } + + #[test] + fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, old) = take_job(&work); + let old_result = old.run(); + app.renderer.forget_all(); + let photo = app.session.catalog.photo(PhotoId(1)).unwrap().as_ref().clone(); + app.session = lightcraft_engine::Session::new(); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + app.session.media.file_loader = Some(Arc::new(|_, _| { + let mut image = lightcraft_raster::Rgb32f::new(8, 8); + image.data.fill([0.1, 0.2, 0.9]); + Ok((image, Default::default())) + })); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + let (_, new) = take_job(&work); + assert_eq!(old_result.key, new.key); + assert_ne!(old_result.request_id, new.request_id); + let expected = new.clone().run().rendered.unwrap().image.as_bytes(); + finish(&mut app, &work, &ctx, slot, new.run()); + finish(&mut app, &work, &ctx, slot, old_result); + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected + ); + } + + #[test] + fn late_results_and_errors_cannot_replace_newest_pixels_or_sources() { + for order in [0, 1, 2] { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, old) = take_job(&work); + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 1.0; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + request(&mut app); + let (_, new) = take_job(&work); + let expected = new.clone().run().rendered.unwrap().image.as_bytes(); + if order != 0 { + finish(&mut app, &work, &ctx, slot, new.run()); + // An obsolete source and error must not poison the current successful request. + let mut stale = old.run(); + if order == 2 { + stale.rendered = Err("old failure".into()); + } + finish(&mut app, &work, &ctx, slot, stale); + } else { + finish(&mut app, &work, &ctx, slot, old.run()); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + assert_eq!(app.session.media.source_usage().0, 0); + finish(&mut app, &work, &ctx, slot, new.run()); + } + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected + ); + assert!(app.renderer.failure(slot).is_none()); + } + } + + #[test] + fn undo_to_existing_texture_supersedes_pending_edit() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, first) = take_job(&work); + let key = first.key; + finish(&mut app, &work, &ctx, slot, first.run()); + let original = app.renderer.thumb(PhotoId(1)).unwrap().pixels.clone().unwrap(); + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.light.exposure = 1.0; + let undo = + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); + request(&mut app); + let (_, edited) = take_job(&work); + app.session.catalog.apply(undo).unwrap(); + request(&mut app); + let (_, restored) = take_job(&work); + assert_eq!(restored.key, key); + finish(&mut app, &work, &ctx, slot, restored.run()); + finish(&mut app, &work, &ctx, slot, edited.run()); + assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); + } + + #[test] + fn explicit_clear_repairs_same_key_bad_pixels_and_rejects_old_same_key_job() { + let (mut app, work, ctx) = fixture(); + let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); + let expected = job.clone().run().rendered.unwrap().image; + let (cache, key) = job.cache.clone().unwrap(); + let mut bad = expected.clone(); + bad.data.fill([255, 0, 255, 255]); + cache.put(key, Arc::new(bad.clone())); + request(&mut app); + let (slot, corrupt) = take_job(&work); + finish(&mut app, &work, &ctx, slot, corrupt.run()); + assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels[0].to_array(), [255, 0, 255, 255]); + // An old job can have the SAME render key and stale decoded pixels. + let mut old = job.clone(); + old.source = lightcraft_engine::media::SourceRef::Loaded(Box::new(lightcraft_engine::media::DecodedSource::new( + Arc::new(lightcraft_raster::Rgb32f::new(8, 8)), + None, + ))); + app.renderer.textures.clear(); + app.renderer.request(slot, old, 10); + let (_, old) = take_job(&work); + app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + assert!(app.renderer.thumb(PhotoId(1)).is_none()); + request(&mut app); + let (_, repaired) = take_job(&work); + assert_eq!(repaired.key, job.key); + finish(&mut app, &work, &ctx, slot, repaired.run()); + finish(&mut app, &work, &ctx, slot, old.run()); + assert_eq!(cache.get(key).unwrap().as_bytes(), expected.as_bytes()); + assert_eq!( + app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels.iter().flat_map(|c| c.to_array()).collect::>(), + expected.as_bytes() + ); + } +}