From 46dec40f4d17583265af0bbe64957f99c909a347 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 14 Oct 2026 08:45:22 +0300 Subject: [PATCH] fix(image): adjusted rasters keep their source alive; doc back on resolve_image The brightness/contrast cache keyed entries by the source buffer's address without holding it, so a reloaded image whose new buffer landed at the freed address could be shown with the old file's pixels. Each entry now holds its source. The new functions had also been inserted between resolve_image's doc comment and the function, merging that text into the lookup table's documentation; it is back where it belongs. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/image_model.rs | 46 +++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 15 deletions(-) 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() {