diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 1195420fa..17b95b7b2 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -252,7 +252,12 @@ impl ImageModel { Some(Self { render_instance: None, file_path: img.file_path.clone(), - pixels: adjusted_image_pixels(&img.file_path, &decoded.pixels, img.brightness, img.contrast), + pixels: adjusted_image_pixels( + &img.file_path, + &decoded.pixels, + img.brightness, + img.contrast, + ), width: decoded.width, height: decoded.height, opacity, @@ -650,15 +655,6 @@ pub fn clear_image_cache() { } } -/// Resolve an image reference to decoded pixels, memoised per path. Handles a -/// local file and an `http`/`https` URL (fetched synchronously natively; on -/// the web the first resolve kicks a background fetch and returns `None` — -/// the placeholder shows until the fetched bytes land and the poll -/// re-resolves). Returns `None` for anything that can't be shown (missing -/// file, offline, decode error, or a fetch still in flight on the web build). -/// The result — including a `None` — is cached so the raster loader and the -/// unresolved-reference placeholder agree on one answer without fetching -/// twice. /// Brightness and contrast (0–100, 50 = unchanged) as one lookup table per /// colour channel. Measured against the reference's display of a gradient: /// brightness is a power curve x^g with g = ((100 − B) / B)^0.72 (0 is black, @@ -668,7 +664,10 @@ pub fn clear_image_cache() { pub(crate) fn brightness_contrast_lut(brightness: u8, contrast: u8) -> [u8; 256] { // ponytail: the 0.72 exponent is a least-squares fit of seven measured // levels (within about 10/255); refine with more reference samples. - let curve = |v: u8| -> f64 { (f64::from(100 - v.min(100)) / f64::from(v.min(100).max(1))).powf(0.72) }; + let curve = |v: u8| -> f64 { + let v = v.min(100); + (f64::from(100 - v) / f64::from(v.max(1))).powf(0.72) + }; let mut lut = [0u8; 256]; for (i, out) in lut.iter_mut().enumerate() { let mut x = i as f64 / 255.0; @@ -692,15 +691,23 @@ pub(crate) fn brightness_contrast_lut(brightness: u8, contrast: u8) -> [u8; 256] /// The decoded raster with the entity's brightness and contrast applied, /// memoised per source and setting (the GPU keeps textures per pixel buffer). -fn adjusted_image_pixels(path: &str, pixels: &Arc>, brightness: u8, contrast: u8) -> Arc> { +/// An entry holds its source buffer, so the address in its key cannot be +/// handed to another decode (a reloaded file) while the entry lives. +fn adjusted_image_pixels( + path: &str, + pixels: &Arc>, + brightness: u8, + contrast: u8, +) -> Arc> { if brightness == 50 && contrast == 50 { return Arc::clone(pixels); } type Key = (String, usize, u8, u8); - static CACHE: OnceLock>>>> = OnceLock::new(); + type Entry = (Arc>, Arc>); + static CACHE: OnceLock>> = OnceLock::new(); let key = (path.to_string(), Arc::as_ptr(pixels) as usize, brightness, contrast); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Some(hit) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(&key) { + if let Some((_, hit)) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(&key) { return Arc::clone(hit); } let lut = brightness_contrast_lut(brightness, contrast); @@ -716,10 +723,19 @@ fn adjusted_image_pixels(path: &str, pixels: &Arc>, brightness: u8, cont if cache.len() > 64 { cache.clear(); } - cache.insert(key, Arc::clone(&out)); + cache.insert(key, (Arc::clone(pixels), Arc::clone(&out))); out } +/// Resolve an image reference to decoded pixels, memoised per path. Handles a +/// local file and an `http`/`https` URL (fetched synchronously natively; on +/// the web the first resolve kicks a background fetch and returns `None` — +/// the placeholder shows until the fetched bytes land and the poll +/// re-resolves). Returns `None` for anything that can't be shown (missing +/// file, offline, decode error, or a fetch still in flight on the web build). +/// The result — including a `None` — is cached so the raster loader and the +/// unresolved-reference placeholder agree on one answer without fetching +/// twice. pub fn resolve_image(path: &str) -> Option { let path = path.trim(); if path.is_empty() {