tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-image/src/lib.rs Rust · 449 lines · 16.9 KB History Who touched what raw

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-image` — decode pipeline para `peniko::Image`.
2//!
3//! Hoy el caller que quiere mostrar una imagen con `View::image()` hace:
4//!
5//! ```ignore
6//! let reader = ImageReader::open(path)?.with_guessed_format()?;
7//! let img = reader.decode()?;
8//! let rgba = img.to_rgba8();
9//! let (w, h) = (rgba.width(), rgba.height());
10//! let blob = Blob::from(rgba.into_raw());
11//! let pen = Image::new(blob, ImageFormat::Rgba8, w, h);
12//! ```
13//!
14//! Cinco líneas + dos llamadas a `?` por cada caller (nahual viewer,
15//! mirada wallpaper, viewer de gallería, etc.). Este crate las
16//! encapsula en dos helpers:
17//!
18//! - [`decode_bytes`] — toma `&[u8]` y devuelve `Result<peniko::Image>`.
19//! - [`load_path`] — toma `&Path` + `max_bytes` cap y devuelve
20//!   `Result<peniko::Image>`, con guardia de tamaño en disco (las apps
21//!   no quieren leer un .iso de 4 GB pensando que es una imagen).
22//!
23//! Con la feature `net` (opt-in, ureq síncrono) se suman descarga + caché por
24//! URL: [`fetch_bytes`]/[`load_url`] (bloqueantes, para correr en un worker) y
25//! [`ImageCache`] (`Clone + Send + Sync`) que la `view` consulta en el hilo UI
26//! mientras un `Handle::spawn` la puebla. El crate base queda sin dep de red.
27//!
28//! `image` (del crate `image`) se construye con `to_rgba8` siempre — la
29//! conversión necesaria para `peniko::ImageFormat::Rgba8`. Para imagen
30//! ya en `rgba8` en memoria (sin necesidad de decodificación), ver
31//! [`from_rgba8`].
32//!
33//! Formatos: los que active la feature del crate `image` upstream (en
34//! el workspace, hoy: PNG, JPEG, WEBP). Otros se pueden habilitar
35//! agregando la feature; el helper no lo limita.
36
37#![forbid(unsafe_code)]
38
39use std::path::Path;
40use std::sync::Arc;
41
42pub use llimphi_raster::peniko::{Blob, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat};
43
44/// Errores que puede devolver el decode pipeline. Mantenemos el detalle
45/// upstream (`String` con el mensaje del crate `image` o de IO) para no
46/// perder información al cruzar el seam, pero clasificado para que el
47/// caller decida si mostrar diferenciado (`TooBig` ≠ `Decode`).
48#[derive(Debug)]
49pub enum DecodeError {
50    /// IO error leyendo el path (no existe, sin permisos, etc.).
51    Io(std::io::Error),
52    /// El archivo supera el cap `max_bytes` pasado a [`load_path`].
53    TooBig {
54        size_bytes: u64,
55        max_bytes: u64,
56    },
57    /// El reader no reconoce el formato (extensión + magic bytes no
58    /// matchean ninguno activo).
59    UnsupportedFormat,
60    /// El decoder falló (archivo corrupto, formato malo).
61    Decode(String),
62}
63
64impl std::fmt::Display for DecodeError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            DecodeError::Io(e) => write!(f, "IO: {e}"),
68            DecodeError::TooBig { size_bytes, max_bytes } => {
69                write!(f, "archivo demasiado grande: {size_bytes} bytes (cap: {max_bytes})")
70            }
71            DecodeError::UnsupportedFormat => f.write_str("formato no soportado"),
72            DecodeError::Decode(s) => write!(f, "decode: {s}"),
73        }
74    }
75}
76
77impl std::error::Error for DecodeError {}
78
79impl From<std::io::Error> for DecodeError {
80    fn from(e: std::io::Error) -> Self {
81        DecodeError::Io(e)
82    }
83}
84
85/// Construye un `peniko::Image` directamente desde bytes RGBA8 ya
86/// decodificados. No invoca el crate `image`. Útil para imágenes
87/// sintéticas o pre-decodificadas.
88///
89/// El `rgba` debe ser exactamente `w * h * 4` bytes (4 canales). Si
90/// no, el render mostrará basura — el helper no valida porque el
91/// constructor de `peniko::Image` tampoco; queda al caller.
92pub fn from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> Image {
93    let blob = Blob::new(Arc::new(rgba));
94    Image::new(ImageData {
95        data: blob,
96        format: ImageFormat::Rgba8,
97        alpha_type: ImageAlphaType::Alpha,
98        width: w,
99        height: h,
100    })
101}
102
103/// Decodifica bytes a un `peniko::Image` listo para `View::image()`.
104/// El crate `image` adivina el formato por magic bytes (no por
105/// extensión) — apto para data URIs, descargas, blobs de DB.
106pub fn decode_bytes(bytes: &[u8]) -> Result<Image, DecodeError> {
107    let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
108        .with_guessed_format()
109        .map_err(DecodeError::Io)?;
110    if reader.format().is_none() {
111        return Err(DecodeError::UnsupportedFormat);
112    }
113    let img = reader
114        .decode()
115        .map_err(|e| DecodeError::Decode(e.to_string()))?;
116    let rgba = img.to_rgba8();
117    let (w, h) = (rgba.width(), rgba.height());
118    Ok(from_rgba8(rgba.into_raw(), w, h))
119}
120
121/// Lee un path, valida tamaño en disco, decodifica. El cap se compara
122/// contra el tamaño del archivo (NO la imagen decodificada, que en
123/// RGBA8 puede ser mucho mayor — un PNG 4K decomprimido ocupa ~64 MB).
124/// `max_bytes = 0` deshabilita el cap.
125pub fn load_path(path: &Path, max_bytes: u64) -> Result<Image, DecodeError> {
126    if max_bytes > 0 {
127        let meta = std::fs::metadata(path).map_err(DecodeError::Io)?;
128        if meta.len() > max_bytes {
129            return Err(DecodeError::TooBig {
130                size_bytes: meta.len(),
131                max_bytes,
132            });
133        }
134    }
135    let reader = image::ImageReader::open(path)
136        .map_err(DecodeError::Io)?
137        .with_guessed_format()
138        .map_err(DecodeError::Io)?;
139    if reader.format().is_none() {
140        return Err(DecodeError::UnsupportedFormat);
141    }
142    let img = reader
143        .decode()
144        .map_err(|e| DecodeError::Decode(e.to_string()))?;
145    let rgba = img.to_rgba8();
146    let (w, h) = (rgba.width(), rgba.height());
147    Ok(from_rgba8(rgba.into_raw(), w, h))
148}
149
150/// Descarga + caché de imágenes por URL (feature `net`). Síncrono (ureq):
151/// la idea es que la app lo llame desde un worker (`Handle::spawn` /
152/// `std::thread`), NO en el hilo de UI, y despache un `Msg` con la imagen
153/// decodificada. La [`ImageCache`] es `Clone + Send + Sync` (interna
154/// `Arc<Mutex<…>>`) para compartirse entre el hilo UI (lecturas) y los
155/// workers (descargas).
156#[cfg(feature = "net")]
157mod net {
158    use super::{decode_bytes, DecodeError, Image};
159    use std::collections::HashMap;
160    use std::io::Read;
161    use std::sync::{Arc, Mutex};
162
163    /// Errores de [`fetch_bytes`]/[`load_url`].
164    #[derive(Debug)]
165    pub enum FetchError {
166        /// La capa de red/HTTP falló (DNS, TLS, status no-2xx, timeout…).
167        Network(String),
168        /// El cuerpo descargado superó `max_bytes`.
169        TooBig { max_bytes: u64 },
170        /// Se descargó pero no se pudo decodificar como imagen.
171        Decode(DecodeError),
172    }
173
174    impl std::fmt::Display for FetchError {
175        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176            match self {
177                FetchError::Network(s) => write!(f, "red: {s}"),
178                FetchError::TooBig { max_bytes } => {
179                    write!(f, "descarga supera el cap de {max_bytes} bytes")
180                }
181                FetchError::Decode(e) => write!(f, "{e}"),
182            }
183        }
184    }
185
186    impl std::error::Error for FetchError {}
187
188    impl From<DecodeError> for FetchError {
189        fn from(e: DecodeError) -> Self {
190            FetchError::Decode(e)
191        }
192    }
193
194    /// Descarga los bytes crudos de una URL (bloqueante). `max_bytes = 0`
195    /// deshabilita el cap; si no, corta la lectura apenas se excede (no
196    /// bufferiza un cuerpo gigante antes de rechazarlo).
197    pub fn fetch_bytes(url: &str, max_bytes: u64) -> Result<Vec<u8>, FetchError> {
198        let resp = ureq::get(url)
199            .call()
200            .map_err(|e| FetchError::Network(e.to_string()))?;
201        let mut reader = resp.into_reader();
202        let mut buf = Vec::new();
203        if max_bytes > 0 {
204            // Leemos hasta max_bytes+1: si llega a +1 sabemos que se pasó.
205            reader
206                .by_ref()
207                .take(max_bytes + 1)
208                .read_to_end(&mut buf)
209                .map_err(|e| FetchError::Network(e.to_string()))?;
210            if buf.len() as u64 > max_bytes {
211                return Err(FetchError::TooBig { max_bytes });
212            }
213        } else {
214            reader
215                .read_to_end(&mut buf)
216                .map_err(|e| FetchError::Network(e.to_string()))?;
217        }
218        Ok(buf)
219    }
220
221    /// Descarga + decodifica una URL a `peniko::Image` (bloqueante). Sin
222    /// caché — para eso usar [`ImageCache::get_or_fetch`].
223    pub fn load_url(url: &str, max_bytes: u64) -> Result<Image, FetchError> {
224        let bytes = fetch_bytes(url, max_bytes)?;
225        Ok(decode_bytes(&bytes)?)
226    }
227
228    /// Techo de imágenes vivas en la caché. Sin cota, `get_or_fetch` acumulaba
229    /// toda `Image` descargada durante la sesión (p. ej. navegar resultados de
230    /// media-tube) sin evictar nunca — fuga monótona de RAM. `get` re-descarga
231    /// si una URL fue evictada, así que evictar la más vieja es transparente.
232    pub(crate) const CACHE_CAP: usize = 256;
233
234    /// Caché de imágenes por URL, compartible entre hilos. La `peniko::Image`
235    /// es barata de clonar (su `Blob` es `Arc`-backed), así que `get` devuelve
236    /// una copia lista para `View::image()`. Evicción FIFO al superar
237    /// `CACHE_CAP`: `order` lleva el orden de inserción de claves nuevas, en
238    /// paso con `inner`.
239    #[derive(Clone, Default)]
240    pub struct ImageCache {
241        inner: Arc<Mutex<HashMap<String, Image>>>,
242        order: Arc<Mutex<std::collections::VecDeque<String>>>,
243    }
244
245    impl ImageCache {
246        pub fn new() -> Self {
247            Self::default()
248        }
249
250        /// Imagen cacheada para esa URL, si ya se descargó. Barato — esto es
251        /// lo que la `view` consulta en el hilo UI cada frame.
252        pub fn get(&self, url: &str) -> Option<Image> {
253            self.inner.lock().ok()?.get(url).cloned()
254        }
255
256        /// `true` si la URL ya está en caché (sin clonar la imagen).
257        pub fn contains(&self, url: &str) -> bool {
258            self.inner
259                .lock()
260                .map(|m| m.contains_key(url))
261                .unwrap_or(false)
262        }
263
264        /// Inserta (o reemplaza) la imagen de una URL. Lo llama el worker tras
265        /// decodificar, o la app si ya tiene la imagen por otra vía. Evicta la
266        /// más vieja (FIFO) al superar `CACHE_CAP`.
267        pub fn insert(&self, url: impl Into<String>, img: Image) {
268            let url = url.into();
269            if let Ok(mut m) = self.inner.lock() {
270                let es_nueva = !m.contains_key(&url);
271                m.insert(url.clone(), img);
272                if es_nueva {
273                    if let Ok(mut order) = self.order.lock() {
274                        order.push_back(url);
275                        while m.len() > CACHE_CAP {
276                            match order.pop_front() {
277                                Some(vieja) => {
278                                    m.remove(&vieja);
279                                }
280                                None => break,
281                            }
282                        }
283                    }
284                }
285            }
286        }
287
288        /// Vacía la caché (p. ej. al cambiar de sesión/usuario).
289        pub fn clear(&self) {
290            if let Ok(mut m) = self.inner.lock() {
291                m.clear();
292            }
293            if let Ok(mut order) = self.order.lock() {
294                order.clear();
295            }
296        }
297
298        /// Cantidad de imágenes cacheadas.
299        pub fn len(&self) -> usize {
300            self.inner.lock().map(|m| m.len()).unwrap_or(0)
301        }
302
303        pub fn is_empty(&self) -> bool {
304            self.len() == 0
305        }
306
307        /// Devuelve la imagen cacheada o, si falta, la descarga+decodifica y la
308        /// cachea (bloqueante). **Llamar desde un worker**, no en el hilo UI.
309        /// El patrón típico: la `view` hace `cache.get(url)`; si es `None`,
310        /// dispara `Handle::spawn` que llama `get_or_fetch` y al volver
311        /// despacha un `Msg` para repintar.
312        pub fn get_or_fetch(&self, url: &str, max_bytes: u64) -> Result<Image, FetchError> {
313            if let Some(img) = self.get(url) {
314                return Ok(img);
315            }
316            let img = load_url(url, max_bytes)?;
317            self.insert(url.to_string(), img.clone());
318            Ok(img)
319        }
320    }
321}
322
323#[cfg(feature = "net")]
324pub use net::{fetch_bytes, load_url, FetchError, ImageCache};
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    /// Bytes PNG válido de 2×2 píxeles: rojo, verde, azul, blanco.
331    /// Generado con `image::RgbaImage` + encode a PNG.
332    fn png_2x2_bytes() -> Vec<u8> {
333        let mut buf = Vec::new();
334        let mut rgba = image::RgbaImage::new(2, 2);
335        rgba.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
336        rgba.put_pixel(1, 0, image::Rgba([0, 255, 0, 255]));
337        rgba.put_pixel(0, 1, image::Rgba([0, 0, 255, 255]));
338        rgba.put_pixel(1, 1, image::Rgba([255, 255, 255, 255]));
339        image::DynamicImage::ImageRgba8(rgba)
340            .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
341            .expect("encode png");
342        buf
343    }
344
345    #[cfg(feature = "net")]
346    #[test]
347    fn image_cache_evicta_fifo_al_superar_el_cap() {
348        // Inserta más del cap: las más viejas se evictan, las últimas viven.
349        let cache = ImageCache::new();
350        let total = net::CACHE_CAP + 10;
351        for i in 0..total {
352            cache.insert(format!("u{i}"), from_rgba8(vec![0u8; 4], 1, 1));
353        }
354        assert_eq!(cache.len(), net::CACHE_CAP, "la caché queda acotada al cap");
355        // Las primeras 10 (las más viejas) fueron evictadas.
356        assert!(cache.get("u0").is_none());
357        assert!(cache.get("u9").is_none());
358        // Las últimas siguen presentes.
359        assert!(cache.get(&format!("u{}", total - 1)).is_some());
360        // Reinsertar una clave existente no reordena ni evicta de más.
361        cache.insert(format!("u{}", total - 1), from_rgba8(vec![0u8; 4], 1, 1));
362        assert_eq!(cache.len(), net::CACHE_CAP);
363    }
364
365    #[test]
366    fn from_rgba8_arma_image_con_dimensiones_correctas() {
367        let img = from_rgba8(vec![0u8; 16], 2, 2); // 2x2x4 = 16 bytes
368        assert_eq!(img.image.width, 2);
369        assert_eq!(img.image.height, 2);
370        assert!(matches!(img.image.format, ImageFormat::Rgba8));
371    }
372
373    #[test]
374    fn decode_bytes_png_basico() {
375        let bytes = png_2x2_bytes();
376        let img = decode_bytes(&bytes).expect("decode ok");
377        assert_eq!(img.image.width, 2);
378        assert_eq!(img.image.height, 2);
379    }
380
381    #[test]
382    fn decode_bytes_invalido_devuelve_error() {
383        let bad = vec![0u8, 1, 2, 3, 4, 5];
384        let r = decode_bytes(&bad);
385        assert!(r.is_err());
386        // Magic no matchea ningún formato conocido → UnsupportedFormat.
387        match r.unwrap_err() {
388            DecodeError::UnsupportedFormat => {}
389            other => panic!("esperaba UnsupportedFormat, recibí: {other:?}"),
390        }
391    }
392
393    #[test]
394    fn load_path_respeta_cap_de_tamano() {
395        // Escribimos un PNG válido y lo cargamos con cap muy bajo.
396        let dir = std::env::temp_dir();
397        let path = dir.join("llimphi_image_test_cap.png");
398        let bytes = png_2x2_bytes();
399        std::fs::write(&path, &bytes).expect("write tmp png");
400        // Cap de 1 byte → demasiado grande.
401        let r = load_path(&path, 1);
402        match r {
403            Err(DecodeError::TooBig { size_bytes, max_bytes }) => {
404                assert!(size_bytes > 1);
405                assert_eq!(max_bytes, 1);
406            }
407            other => panic!("esperaba TooBig, recibí: {other:?}"),
408        }
409        // Cap 0 → deshabilitado, decodifica OK.
410        let img = load_path(&path, 0).expect("decode ok sin cap");
411        assert_eq!(img.image.width, 2);
412        assert_eq!(img.image.height, 2);
413        std::fs::remove_file(&path).ok();
414    }
415
416    #[test]
417    fn load_path_io_error_cuando_no_existe() {
418        let r = load_path(std::path::Path::new("/no/existe/probablemente.png"), 0);
419        match r {
420            Err(DecodeError::Io(_)) => {}
421            other => panic!("esperaba Io, recibí: {other:?}"),
422        }
423    }
424
425    /// La lógica de caché (sin red): insert / get / contains / clear sobre una
426    /// imagen sintética. La descarga real no se testea (requiere red).
427    #[cfg(feature = "net")]
428    #[test]
429    fn image_cache_insert_get_contains_clear() {
430        let cache = ImageCache::new();
431        assert!(cache.is_empty());
432        assert!(!cache.contains("http://x/a.png"));
433        assert!(cache.get("http://x/a.png").is_none());
434
435        let img = from_rgba8(vec![0u8; 16], 2, 2);
436        cache.insert("http://x/a.png", img);
437        assert!(cache.contains("http://x/a.png"));
438        assert_eq!(cache.len(), 1);
439        let got = cache.get("http://x/a.png").expect("cacheada");
440        assert_eq!(got.image.width, 2);
441
442        // Una segunda URL no colisiona.
443        cache.insert("http://x/b.png".to_string(), from_rgba8(vec![0u8; 4], 1, 1));
444        assert_eq!(cache.len(), 2);
445
446        cache.clear();
447        assert!(cache.is_empty());
448    }
449}