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>
This commit is contained in:
radialmonster 2026-10-07 08:35:17 -04:00
parent f73aedcc94
commit f2f7a1cb26
9 changed files with 807 additions and 43 deletions

View File

@ -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<String>,
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<String, (u64, u64)>,
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<String> {
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]

View File

@ -155,6 +155,10 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
// main side
struct Busy {
disk_key: Option<String>,
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<PreviewCache>, u64)>,
workers: Vec<W>,
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<PreviewCache>, 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<RefCell<Inner>>);
@ -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<Backend>, 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<RenderJob> {
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)
}

View File

@ -991,6 +991,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
s.media.rendered.clear();
s.media.clear_sources();
ok()
}),
]

View File

@ -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).

View File

@ -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<PhotoId, (Weak<DevelopSettings>, u64)>,
order: VecDeque<PhotoId>,
#[cfg(test)]
computations: usize,
}
impl SettingsHashes {
fn get(&mut self, id: PhotoId, settings: &Arc<DevelopSettings>) -> 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<PhotoId, DecodedSource>,
/// 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<Hash128>,
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<Hash128>,
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<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
let request_id = self.request_id;
let done = |rendered: Result<Rendered, String>, 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<RenderJob> {
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<dyn Fn(&str) -> Result<ProbeInfo, String> + 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::*;

View File

@ -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<u64>,
mem: Mutex<Lru<Hash128, Arc<Rgba8>>>,
disk: Option<DiskCache>,
}
@ -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<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());
}
@ -57,6 +71,14 @@ impl PreviewCache {
}
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);
}
@ -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<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);
}
@ -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();

View File

@ -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);

View File

@ -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) => {

View File

@ -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<Slot, u64>,
preview_generation: Option<(std::sync::Weak<lightcraft_preview::PreviewCache>, u64)>,
/// Last thumbnail inputs; weak identities do not retain photo histories or force deep clones.
thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64)>,
pool: JobPool<Slot, RenderResult>,
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
offload: Option<Box<dyn RenderOffload>>,
@ -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::<Slot, RenderResult>::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<lightcraft_catalog::Photo>, 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<lightcraft_catalog::Photo>, 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<dyn RenderOffload>) {
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<PhotoId>, 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<RenderJob> {
Some(job)
}
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
vec![]
}
}
fn photo() -> Arc<Photo> {
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<std::cell::RefCell<Work>>);
impl RenderOffload for Manual {
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob> {
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<std::cell::RefCell<Work>>, 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<std::cell::RefCell<Work>>) -> (Slot, RenderJob) {
work.borrow_mut().jobs.pop().unwrap()
}
fn finish(app: &mut crate::LightcraftApp, work: &std::rc::Rc<std::cell::RefCell<Work>>, 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::<Vec<_>>(),
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::<Vec<_>>(),
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::<Vec<_>>(),
expected.as_bytes()
);
}
}