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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-14 08:45:22 +03:00
1 parent f6fc244430
commit 46dec40f4d
1 file changed
+31 -15
+31 -15
View File
@@ -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<Vec<u8>>, brightness: u8, contrast: u8) -> Arc<Vec<u8>> {
/// 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<Vec<u8>>,
brightness: u8,
contrast: u8,
) -> Arc<Vec<u8>> {
if brightness == 50 && contrast == 50 {
return Arc::clone(pixels);
}
type Key = (String, usize, u8, u8);
static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Vec<u8>>>>> = OnceLock::new();
type Entry = (Arc<Vec<u8>>, Arc<Vec<u8>>);
static CACHE: OnceLock<Mutex<HashMap<Key, Entry>>> = 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<Vec<u8>>, 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<DecodedImage> {
let path = path.trim();
if path.is_empty() {