tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ main llimphi-image/src/lib.rs Rust · 541 líneas · 21.4 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · 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 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 mod perfil;
93pub use perfil::{Curva, Perfil};
94
95/// Una imagen decodificada **más de dónde vino**.
96///
97/// [`decode_bytes`] y [`load_path`] devuelven sólo los píxeles, que es lo que
98/// necesita quien va a pintarlos. Pero un editor no: `tullpu` tiene que poder
99/// abrir una foto Display P3, editarla, y **guardarla de vuelta en P3** en vez
100/// de degradar el archivo del usuario a sRGB por el solo hecho de haberlo
101/// abierto. Para eso hace falta saber en qué espacio venía, y eso es esto.
102pub struct Decodificada {
103    /// Los píxeles, ya en sRGB — idénticos a lo que devuelve [`decode_bytes`].
104    pub imagen: Image,
105    /// El perfil que traía el archivo, si era un matrix-shaper legible.
106    ///
107    /// `None` **no** quiere decir «era sRGB»: quiere decir que no había perfil,
108    /// o que era de los que este crate no entiende (CMYK, LUT). En los dos
109    /// casos los píxeles quedaron como venían y no hay nada a lo que volver.
110    pub origen: Option<Perfil>,
111    /// Si de verdad se recorrieron los píxeles. `false` con `origen: Some(…)`
112    /// es el caso corriente del archivo que ya venía en sRGB.
113    pub convertida: bool,
114    /// El ICC **crudo**, tal como venía en el archivo.
115    ///
116    /// Está separado de `origen` porque los dos sirven para cosas distintas y
117    /// hacen falta las dos: `origen` es el perfil ya interpretado, y con él se
118    /// convierten los píxeles de vuelta; esto son los bytes que hay que
119    /// **volver a empotrar en el archivo exportado**. Sin ellos, escribir los
120    /// píxeles en el espacio de origen deja el archivo *peor* que exportarlo en
121    /// sRGB: los números son de otro espacio y no hay nada que se lo diga a
122    /// quien lo abra, así que todo visor los lee como sRGB.
123    ///
124    /// Puede haber `icc: Some(…)` con `origen: None` — un perfil CMYK o de LUT,
125    /// que este crate no interpreta. En ese caso los píxeles no se tocaron y el
126    /// ICC tampoco sirve para re-empotrar: describe un espacio que no es el de
127    /// los píxeles que hay. Por eso el exportador tiene que mirar `origen`, no
128    /// esto, para decidir si hay a dónde volver.
129    pub icc: Option<Vec<u8>>,
130}
131
132impl Decodificada {
133    /// Lleva píxeles RGBA8 **de sRGB de vuelta al espacio de origen**, listos
134    /// para escribirlos junto al ICC del archivo. `false` si no hay a dónde
135    /// volver (sin perfil, o el perfil ya era sRGB) — y entonces lo que hay que
136    /// guardar es sRGB, tal cual está.
137    ///
138    /// Ojo con lo que esto **no** deshace: si al abrir se recortó a gamut sRGB
139    /// (ver [`perfil::convertir_desde_srgb`]), ese recorte ya pasó. Lo que
140    /// evita es que el archivo cambie de espacio sin que nadie lo pidiera.
141    pub fn devolver_al_origen(&self, rgba: &mut [u8]) -> bool {
142        match &self.origen {
143            Some(p) => perfil::convertir_desde_srgb(rgba, p),
144            None => false,
145        }
146    }
147}
148
149pub fn from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> Image {
150    let blob = Blob::new(Arc::new(rgba));
151    Image::new(ImageData {
152        data: blob,
153        format: ImageFormat::Rgba8,
154        alpha_type: ImageAlphaType::Alpha,
155        width: w,
156        height: h,
157    })
158}
159
160/// Decodifica **y lleva la imagen a sRGB** si trae un perfil ICC que diga otra
161/// cosa.
162///
163/// Se baja al decoder en vez de llamar a `reader.decode()` para poder
164/// preguntarle el perfil: `decode()` lo consume y lo tira. Sin esto, una foto
165/// Display P3 —lo que saca cualquier teléfono desde 2016— se pinta
166/// **sobresaturada**, porque sus rojos, que son más rojos que los de sRGB, se
167/// muestran como si fueran los de sRGB.
168fn decodificar_con_perfil<'a, R: std::io::BufRead + std::io::Seek + 'a>(
169    reader: image::ImageReader<R>,
170) -> Result<Decodificada, DecodeError> {
171    use image::ImageDecoder as _;
172    let mut decoder = reader
173        .into_decoder()
174        .map_err(|e| DecodeError::Decode(e.to_string()))?;
175    let icc = decoder.icc_profile().ok().flatten();
176    let img = image::DynamicImage::from_decoder(decoder)
177        .map_err(|e| DecodeError::Decode(e.to_string()))?;
178    let rgba = img.to_rgba8();
179    let (w, h) = (rgba.width(), rgba.height());
180    let mut datos = rgba.into_raw();
181    // Sin perfil se asume sRGB, que es la convención de la web y lo que se hacía
182    // antes. Con un perfil sRGB, `convertir_a_srgb` detecta que la matriz es la
183    // identidad y no recorre un solo píxel.
184    let origen = icc.as_deref().and_then(perfil::leer_icc);
185    let convertida = match &origen {
186        Some(p) => perfil::convertir_a_srgb(&mut datos, p),
187        None => false,
188    };
189    Ok(Decodificada { imagen: from_rgba8(datos, w, h), origen, convertida, icc })
190}
191
192/// Decodifica bytes a un `peniko::Image` listo para `View::image()`.
193/// El crate `image` adivina el formato por magic bytes (no por
194/// extensión) — apto para data URIs, descargas, blobs de DB.
195pub fn decode_bytes(bytes: &[u8]) -> Result<Image, DecodeError> {
196    decode_bytes_con_origen(bytes).map(|d| d.imagen)
197}
198
199/// Igual que [`decode_bytes`], pero además dice **de qué espacio de color
200/// venía**. Para editores que tienen que re-exportar al espacio de origen; el
201/// que sólo va a pintar usa [`decode_bytes`] y no se entera.
202pub fn decode_bytes_con_origen(bytes: &[u8]) -> Result<Decodificada, DecodeError> {
203    let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
204        .with_guessed_format()
205        .map_err(DecodeError::Io)?;
206    if reader.format().is_none() {
207        return Err(DecodeError::UnsupportedFormat);
208    }
209    decodificar_con_perfil(reader)
210}
211
212/// Lee un path, valida tamaño en disco, decodifica. El cap se compara
213/// contra el tamaño del archivo (NO la imagen decodificada, que en
214/// RGBA8 puede ser mucho mayor — un PNG 4K decomprimido ocupa ~64 MB).
215/// `max_bytes = 0` deshabilita el cap.
216pub fn load_path(path: &Path, max_bytes: u64) -> Result<Image, DecodeError> {
217    load_path_con_origen(path, max_bytes).map(|d| d.imagen)
218}
219
220/// Igual que [`load_path`], pero además dice de qué espacio de color venía.
221/// Ver [`Decodificada`].
222pub fn load_path_con_origen(path: &Path, max_bytes: u64) -> Result<Decodificada, DecodeError> {
223    if max_bytes > 0 {
224        let meta = std::fs::metadata(path).map_err(DecodeError::Io)?;
225        if meta.len() > max_bytes {
226            return Err(DecodeError::TooBig {
227                size_bytes: meta.len(),
228                max_bytes,
229            });
230        }
231    }
232    let reader = image::ImageReader::open(path)
233        .map_err(DecodeError::Io)?
234        .with_guessed_format()
235        .map_err(DecodeError::Io)?;
236    if reader.format().is_none() {
237        return Err(DecodeError::UnsupportedFormat);
238    }
239    decodificar_con_perfil(reader)
240}
241
242/// Descarga + caché de imágenes por URL (feature `net`). Síncrono (ureq):
243/// la idea es que la app lo llame desde un worker (`Handle::spawn` /
244/// `std::thread`), NO en el hilo de UI, y despache un `Msg` con la imagen
245/// decodificada. La [`ImageCache`] es `Clone + Send + Sync` (interna
246/// `Arc<Mutex<…>>`) para compartirse entre el hilo UI (lecturas) y los
247/// workers (descargas).
248#[cfg(feature = "net")]
249mod net {
250    use super::{decode_bytes, DecodeError, Image};
251    use std::collections::HashMap;
252    use std::io::Read;
253    use std::sync::{Arc, Mutex};
254
255    /// Errores de [`fetch_bytes`]/[`load_url`].
256    #[derive(Debug)]
257    pub enum FetchError {
258        /// La capa de red/HTTP falló (DNS, TLS, status no-2xx, timeout…).
259        Network(String),
260        /// El cuerpo descargado superó `max_bytes`.
261        TooBig { max_bytes: u64 },
262        /// Se descargó pero no se pudo decodificar como imagen.
263        Decode(DecodeError),
264    }
265
266    impl std::fmt::Display for FetchError {
267        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268            match self {
269                FetchError::Network(s) => write!(f, "red: {s}"),
270                FetchError::TooBig { max_bytes } => {
271                    write!(f, "descarga supera el cap de {max_bytes} bytes")
272                }
273                FetchError::Decode(e) => write!(f, "{e}"),
274            }
275        }
276    }
277
278    impl std::error::Error for FetchError {}
279
280    impl From<DecodeError> for FetchError {
281        fn from(e: DecodeError) -> Self {
282            FetchError::Decode(e)
283        }
284    }
285
286    /// Descarga los bytes crudos de una URL (bloqueante). `max_bytes = 0`
287    /// deshabilita el cap; si no, corta la lectura apenas se excede (no
288    /// bufferiza un cuerpo gigante antes de rechazarlo).
289    pub fn fetch_bytes(url: &str, max_bytes: u64) -> Result<Vec<u8>, FetchError> {
290        let resp = ureq::get(url)
291            .call()
292            .map_err(|e| FetchError::Network(e.to_string()))?;
293        let mut reader = resp.into_reader();
294        let mut buf = Vec::new();
295        if max_bytes > 0 {
296            // Leemos hasta max_bytes+1: si llega a +1 sabemos que se pasó.
297            reader
298                .by_ref()
299                .take(max_bytes + 1)
300                .read_to_end(&mut buf)
301                .map_err(|e| FetchError::Network(e.to_string()))?;
302            if buf.len() as u64 > max_bytes {
303                return Err(FetchError::TooBig { max_bytes });
304            }
305        } else {
306            reader
307                .read_to_end(&mut buf)
308                .map_err(|e| FetchError::Network(e.to_string()))?;
309        }
310        Ok(buf)
311    }
312
313    /// Descarga + decodifica una URL a `peniko::Image` (bloqueante). Sin
314    /// caché — para eso usar [`ImageCache::get_or_fetch`].
315    pub fn load_url(url: &str, max_bytes: u64) -> Result<Image, FetchError> {
316        let bytes = fetch_bytes(url, max_bytes)?;
317        Ok(decode_bytes(&bytes)?)
318    }
319
320    /// Techo de imágenes vivas en la caché. Sin cota, `get_or_fetch` acumulaba
321    /// toda `Image` descargada durante la sesión (p. ej. navegar resultados de
322    /// media-tube) sin evictar nunca — fuga monótona de RAM. `get` re-descarga
323    /// si una URL fue evictada, así que evictar la más vieja es transparente.
324    pub(crate) const CACHE_CAP: usize = 256;
325
326    /// Caché de imágenes por URL, compartible entre hilos. La `peniko::Image`
327    /// es barata de clonar (su `Blob` es `Arc`-backed), así que `get` devuelve
328    /// una copia lista para `View::image()`. Evicción FIFO al superar
329    /// `CACHE_CAP`: `order` lleva el orden de inserción de claves nuevas, en
330    /// paso con `inner`.
331    #[derive(Clone, Default)]
332    pub struct ImageCache {
333        inner: Arc<Mutex<HashMap<String, Image>>>,
334        order: Arc<Mutex<std::collections::VecDeque<String>>>,
335    }
336
337    impl ImageCache {
338        pub fn new() -> Self {
339            Self::default()
340        }
341
342        /// Imagen cacheada para esa URL, si ya se descargó. Barato — esto es
343        /// lo que la `view` consulta en el hilo UI cada frame.
344        pub fn get(&self, url: &str) -> Option<Image> {
345            self.inner.lock().ok()?.get(url).cloned()
346        }
347
348        /// `true` si la URL ya está en caché (sin clonar la imagen).
349        pub fn contains(&self, url: &str) -> bool {
350            self.inner
351                .lock()
352                .map(|m| m.contains_key(url))
353                .unwrap_or(false)
354        }
355
356        /// Inserta (o reemplaza) la imagen de una URL. Lo llama el worker tras
357        /// decodificar, o la app si ya tiene la imagen por otra vía. Evicta la
358        /// más vieja (FIFO) al superar `CACHE_CAP`.
359        pub fn insert(&self, url: impl Into<String>, img: Image) {
360            let url = url.into();
361            if let Ok(mut m) = self.inner.lock() {
362                let es_nueva = !m.contains_key(&url);
363                m.insert(url.clone(), img);
364                if es_nueva {
365                    if let Ok(mut order) = self.order.lock() {
366                        order.push_back(url);
367                        while m.len() > CACHE_CAP {
368                            match order.pop_front() {
369                                Some(vieja) => {
370                                    m.remove(&vieja);
371                                }
372                                None => break,
373                            }
374                        }
375                    }
376                }
377            }
378        }
379
380        /// Vacía la caché (p. ej. al cambiar de sesión/usuario).
381        pub fn clear(&self) {
382            if let Ok(mut m) = self.inner.lock() {
383                m.clear();
384            }
385            if let Ok(mut order) = self.order.lock() {
386                order.clear();
387            }
388        }
389
390        /// Cantidad de imágenes cacheadas.
391        pub fn len(&self) -> usize {
392            self.inner.lock().map(|m| m.len()).unwrap_or(0)
393        }
394
395        pub fn is_empty(&self) -> bool {
396            self.len() == 0
397        }
398
399        /// Devuelve la imagen cacheada o, si falta, la descarga+decodifica y la
400        /// cachea (bloqueante). **Llamar desde un worker**, no en el hilo UI.
401        /// El patrón típico: la `view` hace `cache.get(url)`; si es `None`,
402        /// dispara `Handle::spawn` que llama `get_or_fetch` y al volver
403        /// despacha un `Msg` para repintar.
404        pub fn get_or_fetch(&self, url: &str, max_bytes: u64) -> Result<Image, FetchError> {
405            if let Some(img) = self.get(url) {
406                return Ok(img);
407            }
408            let img = load_url(url, max_bytes)?;
409            self.insert(url.to_string(), img.clone());
410            Ok(img)
411        }
412    }
413}
414
415#[cfg(feature = "net")]
416pub use net::{fetch_bytes, load_url, FetchError, ImageCache};
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    /// Bytes PNG válido de 2×2 píxeles: rojo, verde, azul, blanco.
423    /// Generado con `image::RgbaImage` + encode a PNG.
424    fn png_2x2_bytes() -> Vec<u8> {
425        let mut buf = Vec::new();
426        let mut rgba = image::RgbaImage::new(2, 2);
427        rgba.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
428        rgba.put_pixel(1, 0, image::Rgba([0, 255, 0, 255]));
429        rgba.put_pixel(0, 1, image::Rgba([0, 0, 255, 255]));
430        rgba.put_pixel(1, 1, image::Rgba([255, 255, 255, 255]));
431        image::DynamicImage::ImageRgba8(rgba)
432            .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
433            .expect("encode png");
434        buf
435    }
436
437    #[cfg(feature = "net")]
438    #[test]
439    fn image_cache_evicta_fifo_al_superar_el_cap() {
440        // Inserta más del cap: las más viejas se evictan, las últimas viven.
441        let cache = ImageCache::new();
442        let total = net::CACHE_CAP + 10;
443        for i in 0..total {
444            cache.insert(format!("u{i}"), from_rgba8(vec![0u8; 4], 1, 1));
445        }
446        assert_eq!(cache.len(), net::CACHE_CAP, "la caché queda acotada al cap");
447        // Las primeras 10 (las más viejas) fueron evictadas.
448        assert!(cache.get("u0").is_none());
449        assert!(cache.get("u9").is_none());
450        // Las últimas siguen presentes.
451        assert!(cache.get(&format!("u{}", total - 1)).is_some());
452        // Reinsertar una clave existente no reordena ni evicta de más.
453        cache.insert(format!("u{}", total - 1), from_rgba8(vec![0u8; 4], 1, 1));
454        assert_eq!(cache.len(), net::CACHE_CAP);
455    }
456
457    #[test]
458    fn from_rgba8_arma_image_con_dimensiones_correctas() {
459        let img = from_rgba8(vec![0u8; 16], 2, 2); // 2x2x4 = 16 bytes
460        assert_eq!(img.image.width, 2);
461        assert_eq!(img.image.height, 2);
462        assert!(matches!(img.image.format, ImageFormat::Rgba8));
463    }
464
465    #[test]
466    fn decode_bytes_png_basico() {
467        let bytes = png_2x2_bytes();
468        let img = decode_bytes(&bytes).expect("decode ok");
469        assert_eq!(img.image.width, 2);
470        assert_eq!(img.image.height, 2);
471    }
472
473    #[test]
474    fn decode_bytes_invalido_devuelve_error() {
475        let bad = vec![0u8, 1, 2, 3, 4, 5];
476        let r = decode_bytes(&bad);
477        assert!(r.is_err());
478        // Magic no matchea ningún formato conocido → UnsupportedFormat.
479        match r.unwrap_err() {
480            DecodeError::UnsupportedFormat => {}
481            other => panic!("esperaba UnsupportedFormat, recibí: {other:?}"),
482        }
483    }
484
485    #[test]
486    fn load_path_respeta_cap_de_tamano() {
487        // Escribimos un PNG válido y lo cargamos con cap muy bajo.
488        let dir = std::env::temp_dir();
489        let path = dir.join("llimphi_image_test_cap.png");
490        let bytes = png_2x2_bytes();
491        std::fs::write(&path, &bytes).expect("write tmp png");
492        // Cap de 1 byte → demasiado grande.
493        let r = load_path(&path, 1);
494        match r {
495            Err(DecodeError::TooBig { size_bytes, max_bytes }) => {
496                assert!(size_bytes > 1);
497                assert_eq!(max_bytes, 1);
498            }
499            other => panic!("esperaba TooBig, recibí: {other:?}"),
500        }
501        // Cap 0 → deshabilitado, decodifica OK.
502        let img = load_path(&path, 0).expect("decode ok sin cap");
503        assert_eq!(img.image.width, 2);
504        assert_eq!(img.image.height, 2);
505        std::fs::remove_file(&path).ok();
506    }
507
508    #[test]
509    fn load_path_io_error_cuando_no_existe() {
510        let r = load_path(std::path::Path::new("/no/existe/probablemente.png"), 0);
511        match r {
512            Err(DecodeError::Io(_)) => {}
513            other => panic!("esperaba Io, recibí: {other:?}"),
514        }
515    }
516
517    /// La lógica de caché (sin red): insert / get / contains / clear sobre una
518    /// imagen sintética. La descarga real no se testea (requiere red).
519    #[cfg(feature = "net")]
520    #[test]
521    fn image_cache_insert_get_contains_clear() {
522        let cache = ImageCache::new();
523        assert!(cache.is_empty());
524        assert!(!cache.contains("http://x/a.png"));
525        assert!(cache.get("http://x/a.png").is_none());
526
527        let img = from_rgba8(vec![0u8; 16], 2, 2);
528        cache.insert("http://x/a.png", img);
529        assert!(cache.contains("http://x/a.png"));
530        assert_eq!(cache.len(), 1);
531        let got = cache.get("http://x/a.png").expect("cacheada");
532        assert_eq!(got.image.width, 2);
533
534        // Una segunda URL no colisiona.
535        cache.insert("http://x/b.png".to_string(), from_rgba8(vec![0u8; 4], 1, 1));
536        assert_eq!(cache.len(), 2);
537
538        cache.clear();
539        assert!(cache.is_empty());
540    }
541}