Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/image_cache.rs
StratoSDK / crates / strato-ui-core / src / image_cache.rs
1use anyhow::{anyhow, Result};
2use core::fmt;
3use itertools::Itertools;
4use std::{
5 collections::HashMap,
6 error,
7 hash::{DefaultHasher, Hash, Hasher},
8 rc::Rc,
9 sync::{Arc, LazyLock},
10};
11use strum_macros::EnumIter;
12 
13use crate::{
14 assets::asset_cache::{Asset, AssetCache, AssetSource, AssetState},
15 util::parse_u32,
16 Entity, SingletonEntity,
17};
18use image::{
19 codecs::{gif::GifDecoder, webp::WebPDecoder},
20 imageops::FilterType,
21 AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageFormat,
22};
23use parking_lot::{RwLock, RwLockUpgradableReadGuard};
24use pathfinder_geometry::vector::Vector2I;
25use resvg::{
26 tiny_skia::{self, IntSize},
27 usvg,
28};
29 
30const MIN_REFRESH_DELAY_MS: u32 = 50;
31 
32static SVG_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
33 let mut fontdb = usvg::fontdb::Database::new();
34 fontdb.load_system_fonts();
35 Arc::new(fontdb)
36});
37 
38pub fn prewarm_svg_font_db() {
39 LazyLock::force(&SVG_FONT_DB);
40}
41 
42#[derive(EnumIter, Debug)]
43pub enum CustomImageFormat {
44 Rgb,
45 Rgba,
46}
47 
48impl CustomImageFormat {
49 fn create_tag(&self) -> String {
50 match self {
51 CustomImageFormat::Rgb => "rgb".into(),
52 CustomImageFormat::Rgba => "rgba".into(),
53 }
54 }
55}
56 
57#[derive(Debug, Clone)]
58enum CustomHeaderParsingError {
59 ExpectedDataSizeMismatch {
60 expected_bytes: usize,
61 actual_bytes: usize,
62 },
63 InvalidCustomHeaderParam {
64 param_name: String,
65 value: String,
66 },
67 MissingCustomHeaderParam {
68 param_name: String,
69 },
70 MissingHeaderIdentifier,
71}
72 
73impl error::Error for CustomHeaderParsingError {}
74 
75impl fmt::Display for CustomHeaderParsingError {
76 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
77 match self {
78 Self::ExpectedDataSizeMismatch {
79 expected_bytes,
80 actual_bytes,
81 } => write!(
82 f,
83 "The custom image's expected bytes ({expected_bytes}) did not match the actual number of bytes ({actual_bytes})."
84 ),
85 Self::InvalidCustomHeaderParam { param_name, value } => write!(
86 f,
87 "Custom header had field {param_name} with an invalid value of {value}"
88 ),
89 Self::MissingCustomHeaderParam { param_name } => {
90 write!(f, "Custom header had {param_name} field missing")
91 }
92 Self::MissingHeaderIdentifier => {
93 write!(f, "Image did not contain the 'warp-img:' prefix.")
94 }
95 }
96 }
97}
98 
99#[derive(Debug, Clone)]
100pub enum CustomHeaderCreationError {
101 ExpectedDataSizeMismatch {
102 expected_bytes: usize,
103 actual_bytes: usize,
104 },
105}
106 
107#[derive(Debug)]
108pub struct CustomImageHeader {
109 pub width: u32,
110 pub height: u32,
111 pub image_format: CustomImageFormat,
112}
113 
114impl CustomImageHeader {
115 pub fn create_header(&self) -> String {
116 format!(
117 "warp-img:{}:{}:{}:",
118 self.image_format.create_tag(),
119 self.width,
120 self.height
121 )
122 }
123 
124 pub fn prepend_custom_header(
125 mut data: Vec<u8>,
126 width: u32,
127 height: u32,
128 image_format: CustomImageFormat,
129 ) -> Result<Vec<u8>, CustomHeaderCreationError> {
130 let bytes_per_pixel = match image_format {
131 CustomImageFormat::Rgb => 3,
132 CustomImageFormat::Rgba => 4,
133 };
134 let expected_byte_count = (bytes_per_pixel * width * height) as usize;
135 
136 if expected_byte_count != data.len() {
137 return Err(CustomHeaderCreationError::ExpectedDataSizeMismatch {
138 expected_bytes: expected_byte_count,
139 actual_bytes: data.len(),
140 });
141 }
142 
143 let custom_header = CustomImageHeader {
144 width,
145 height,
146 image_format,
147 };
148 
149 data.splice(
150 0..0,
151 custom_header.create_header().as_bytes().iter().copied(),
152 );
153 
154 Ok(data)
155 }
156 
157 fn try_from_bytes(data: &[u8]) -> Result<(CustomImageHeader, &[u8]), CustomHeaderParsingError> {
158 if !data.starts_with(b"warp-img:") {
159 return Err(CustomHeaderParsingError::MissingHeaderIdentifier);
160 }
161 
162 let data = match data
163 .iter()
164 .position(|&byte| byte == b':')
165 .map(|position| &data[position + 1..])
166 {
167 Some(data) => data,
168 None => return Err(CustomHeaderParsingError::MissingHeaderIdentifier),
169 };
170 
171 let (image_type, data) = match data
172 .iter()
173 .position(|&byte| byte == b':')
174 .map(|position| (&data[..position], &data[position + 1..]))
175 {
176 Some((image_type, data)) => (image_type, data),
177 None => {
178 return Err(CustomHeaderParsingError::MissingCustomHeaderParam {
179 param_name: "image_type".to_string(),
180 });
181 }
182 };
183 
184 let image_type = match image_type {
185 b"rgb" => CustomImageFormat::Rgb,
186 b"rgba" => CustomImageFormat::Rgba,
187 _ => {
188 return Err(CustomHeaderParsingError::InvalidCustomHeaderParam {
189 param_name: "image_type".to_string(),
190 value: std::str::from_utf8(image_type)
191 .unwrap_or("Unable to parse utf8")
192 .to_string(),
193 });
194 }
195 };
196 
197 let (width, data) = match data
198 .iter()
199 .position(|&byte| byte == b':')
200 .map(|position| (&data[..position], &data[position + 1..]))
201 {
202 Some((width, data)) => (width, data),
203 None => {
204 return Err(CustomHeaderParsingError::MissingCustomHeaderParam {
205 param_name: "width".to_string(),
206 });
207 }
208 };
209 
210 let width = match parse_u32(width) {
211 Some(width) => width,
212 None => {
213 return Err(CustomHeaderParsingError::InvalidCustomHeaderParam {
214 param_name: "width".to_string(),
215 value: std::str::from_utf8(width)
216 .unwrap_or("Unable to parse utf8")
217 .to_string(),
218 });
219 }
220 };
221 
222 let (height, data) = match data
223 .iter()
224 .position(|&byte| byte == b':')
225 .map(|position| (&data[..position], &data[position + 1..]))
226 {
227 Some((height, data)) => (height, data),
228 None => {
229 return Err(CustomHeaderParsingError::MissingCustomHeaderParam {
230 param_name: "height".to_string(),
231 });
232 }
233 };
234 
235 let height = match parse_u32(height) {
236 Some(height) => height,
237 None => {
238 return Err(CustomHeaderParsingError::InvalidCustomHeaderParam {
239 param_name: "height".to_string(),
240 value: std::str::from_utf8(height)
241 .unwrap_or("Unable to parse utf8")
242 .to_string(),
243 });
244 }
245 };
246 
247 let bytes_per_pixel = match image_type {
248 CustomImageFormat::Rgb => 3,
249 CustomImageFormat::Rgba => 4,
250 };
251 let expected_byte_count = (bytes_per_pixel * width * height) as usize;
252 
253 if expected_byte_count != data.len() {
254 return Err(CustomHeaderParsingError::ExpectedDataSizeMismatch {
255 expected_bytes: expected_byte_count,
256 actual_bytes: data.len(),
257 });
258 }
259 
260 Ok((
261 CustomImageHeader {
262 width,
263 height,
264 image_format: image_type,
265 },
266 data,
267 ))
268 }
269}
270 
271impl Asset for ImageType {
272 fn try_from_bytes(data: &[u8]) -> anyhow::Result<ImageType> {
273 // SVGs are not handled by the guess_format helper function, so we have to manually check
274 // if it's an SVG ourselves.
275 if data.first() == Some(&b'<') {
276 let options = usvg::Options {
277 fontdb: SVG_FONT_DB.clone(),
278 ..Default::default()
279 };
280 let svg = Rc::new(usvg::Tree::from_data(data, &options)?);
281 return Ok(ImageType::Svg { svg });
282 }
283 
284 if data.starts_with(b"warp-img:") {
285 let (custom_warp_header, data) = match CustomImageHeader::try_from_bytes(data) {
286 Ok((custom_warp_header, data)) => (custom_warp_header, data),
287 Err(err) => return Err(anyhow!(err.to_string())),
288 };
289 
290 let data = data.into();
291 let Some(img) = (match custom_warp_header.image_format {
292 CustomImageFormat::Rgb => {
293 let dynamic_image = ImageBuffer::from_raw(
294 custom_warp_header.width,
295 custom_warp_header.height,
296 data,
297 )
298 .map(DynamicImage::ImageRgb8);
299 dynamic_image.map(|dynamic_image| dynamic_image.into_rgba8())
300 }
301 CustomImageFormat::Rgba => {
302 let dynamic_image = ImageBuffer::from_raw(
303 custom_warp_header.width,
304 custom_warp_header.height,
305 data,
306 )
307 .map(DynamicImage::ImageRgba8);
308 dynamic_image.map(|dynamic_image| dynamic_image.into_rgba8())
309 }
310 }) else {
311 return Err(anyhow!(
312 "Could not convert custom warp image into approprate dynamic image."
313 ));
314 };
315 return Ok(ImageType::StaticBitmap {
316 image: Arc::new(StaticImage { img }),
317 });
318 }
319 
320 match image::guess_format(data) {
321 Ok(ImageFormat::Jpeg) => {
322 let img = image::ImageReader::with_format(
323 std::io::Cursor::new(data),
324 image::ImageFormat::Jpeg,
325 )
326 .decode()?
327 .into_rgba8();
328 Ok(ImageType::StaticBitmap {
329 image: Arc::new(StaticImage { img }),
330 })
331 }
332 Ok(ImageFormat::Png) => {
333 let img = image::ImageReader::with_format(
334 std::io::Cursor::new(data),
335 image::ImageFormat::Png,
336 )
337 .decode()?
338 .into_rgba8();
339 Ok(ImageType::StaticBitmap {
340 image: Arc::new(StaticImage { img }),
341 })
342 }
343 Ok(ImageFormat::WebP) => {
344 let decoder = WebPDecoder::new(std::io::Cursor::new(data))?;
345 if decoder.has_animation() {
346 let frames = decoder.into_frames().collect_frames()?;
347 Ok(ImageType::AnimatedBitmap {
348 image: Arc::new(AnimatedImage::from(frames)),
349 })
350 } else {
351 let img = DynamicImage::from_decoder(decoder)?.into_rgba8();
352 Ok(ImageType::StaticBitmap {
353 image: Arc::new(StaticImage { img }),
354 })
355 }
356 }
357 Ok(ImageFormat::Gif) => {
358 let decoder = GifDecoder::new(std::io::Cursor::new(data))?;
359 let frames = decoder.into_frames().collect_frames()?;
360 Ok(ImageType::AnimatedBitmap {
361 image: Arc::new(AnimatedImage::from(frames)),
362 })
363 }
364 _ => Ok(ImageType::Unrecognized),
365 }
366 }
367 
368 fn size_in_bytes(&self) -> usize {
369 match self {
370 ImageType::Svg { .. } => 0, // TODO: How do we calculate svg size in bytes?
371 ImageType::StaticBitmap { image } => image.rgba_bytes().len(),
372 ImageType::AnimatedBitmap { image } => image
373 .frames
374 .iter()
375 .map(|frame| frame.image.rgba_bytes().len())
376 .reduce(|acc, bytes| acc + bytes)
377 .unwrap_or(0),
378 ImageType::Unrecognized => 0,
379 }
380 }
381}
382 
383/// A reference to an image in the asset cache. Can be a static or animated image.
384#[derive(Clone)]
385pub enum Image {
386 Static(Arc<StaticImage>),
387 Animated(Arc<AnimatedImage>),
388}
389 
390/// A representation of an image in the asset cache.
391pub struct StaticImage {
392 /// The actual RGBA image data, stored as a vector of bytes.
393 img: image::RgbaImage,
394}
395 
396impl StaticImage {
397 pub fn size(&self) -> Vector2I {
398 Vector2I::new(self.width() as i32, self.height() as i32)
399 }
400 
401 pub fn width(&self) -> u32 {
402 self.img.width()
403 }
404 
405 pub fn height(&self) -> u32 {
406 self.img.height()
407 }
408 
409 pub fn rgba_bytes(&self) -> &[u8] {
410 self.img.as_raw().as_slice()
411 }
412}
413 
414/// A representation of a single frame in an animated image.
415pub struct AnimatedImageFrame {
416 // The static image representing the current frame of an animated image.
417 pub image: Arc<StaticImage>,
418 // Delay until the next frame in ms.
419 pub delay: u32,
420}
421 
422/// A representation of an animated image (e.g. gif) in the asset cache.
423pub struct AnimatedImage {
424 /// The frames of the animated image in sequential order.
425 pub frames: Vec<AnimatedImageFrame>,
426 /// Total duration of the animated image in ms.
427 pub duration: u32,
428}
429 
430#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
431pub enum AnimatedImageBehavior {
432 #[default]
433 FullAnimation,
434 FirstFramePreview,
435}
436 
437#[derive(Clone, Debug, Copy, Eq, Hash, PartialEq)]
438pub enum FitType {
439 /// Expands the image to fill the entire space while maintaining the aspect ratio.
440 Cover,
441 /// Resizes the image to maximum size that fully fits in the given bounds,
442 /// maintaining the aspect ratio.
443 Contain,
444 /// Stretches the image to fit the given bounds, ignoring the aspect ratio.
445 /// This should likely only be used with SVGs, and not all SVGs are designed
446 /// to be stretched.
447 Stretch,
448}
449 
450impl FitType {
451 pub fn should_retain_aspect_ratio(&self) -> bool {
452 match self {
453 FitType::Cover | FitType::Contain => true,
454 FitType::Stretch => false,
455 }
456 }
457}
458 
459#[derive(Clone)]
460pub enum ImageType {
461 Svg { svg: Rc<usvg::Tree> },
462 StaticBitmap { image: Arc<StaticImage> },
463 AnimatedBitmap { image: Arc<AnimatedImage> },
464 // TODO: other types
465 Unrecognized,
466}
467 
468impl ImageType {
469 /// Returns the size of the underlying asset.
470 pub fn image_size(&self) -> Option<Vector2I> {
471 match self {
472 ImageType::Svg { svg } => Some(Vector2I::new(
473 svg.size().width().round() as i32,
474 svg.size().height().round() as i32,
475 )),
476 ImageType::StaticBitmap { image } => Some(image.size()),
477 ImageType::AnimatedBitmap { image } => {
478 image.frames.first().map(|frame| frame.image.size())
479 }
480 ImageType::Unrecognized => None,
481 }
482 }
483 
484 fn type_str(&self) -> &'static str {
485 match self {
486 ImageType::Svg { .. } => "ImageType::Svg",
487 ImageType::StaticBitmap { .. } => "ImageType::StaticBitmap",
488 ImageType::AnimatedBitmap { .. } => "ImageType::AnimatedBitmap",
489 ImageType::Unrecognized => "ImageType::Unrecognized",
490 }
491 }
492}
493 
494#[derive(Clone, Copy, Debug)]
495pub enum CacheOption {
496 /// Only the specific used sizes are cached.
497 /// Best for situations when the image/asset is used with a fixed size.
498 /// Example: icons, theme picker previews.
499 BySize,
500 /// Only the original asset is cached.
501 /// Best for situations when the image/asset doesn't have a fixed size, and it may change
502 /// significantly on every window resize.
503 /// Example: background image.
504 Original,
505}
506 
507impl From<Vec<Frame>> for AnimatedImage {
508 fn from(value: Vec<Frame>) -> Self {
509 let mut duration = 0;
510 let frames = value
511 .into_iter()
512 .map(|frame| {
513 let (delay_numerator, delay_denominator) = frame.delay().numer_denom_ms();
514 let delay_ms = (delay_numerator / delay_denominator).max(MIN_REFRESH_DELAY_MS);
515 duration += delay_ms;
516 
517 AnimatedImageFrame {
518 image: Arc::new(StaticImage {
519 img: frame.into_buffer(),
520 }),
521 delay: delay_ms,
522 }
523 })
524 .collect_vec();
525 
526 AnimatedImage { frames, duration }
527 }
528}
529 
530fn resize_animated_image(
531 image: &AnimatedImage,
532 bounds: Vector2I,
533 fit_type: FitType,
534) -> AnimatedImage {
535 let resized_frames = image
536 .frames
537 .iter()
538 .map(|frame| AnimatedImageFrame {
539 image: Arc::new(StaticImage {
540 img: resize_image(&frame.image.img, bounds, fit_type),
541 }),
542 delay: frame.delay,
543 })
544 .collect_vec();
545 AnimatedImage {
546 frames: resized_frames,
547 duration: image.duration,
548 }
549}
550 
551fn svg_image(svg: &Rc<usvg::Tree>, bounds: Vector2I, fit_type: FitType) -> Result<Image> {
552 let svg_size = &svg.size();
553 
554 let svg_has_wider_ratio =
555 svg_size.width() / svg_size.height() > bounds.x() as f32 / bounds.y() as f32;
556 let fit = match (fit_type, svg_has_wider_ratio) {
557 (FitType::Contain, true) | (FitType::Cover, false) => FitTo::Width(bounds.x() as u32),
558 (FitType::Contain, false) | (FitType::Cover, true) => FitTo::Height(bounds.y() as u32),
559 (FitType::Stretch, _) => FitTo::Bounds(bounds.x() as u32, bounds.y() as u32),
560 };
561 let svg_size = svg_size.to_int_size();
562 let size = fit
563 .fit_to_size(svg_size)
564 .ok_or_else(|| anyhow!("Unable to fit SVG image to size"))?;
565 let transform = fit.fit_to_transform(svg_size);
566 
567 let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())
568 .ok_or_else(|| anyhow!("Could not could create pixmap for bounds {:?}", bounds))?;
569 resvg::render(svg.as_ref(), transform, &mut pixmap.as_mut());
570 
571 let img = image::RgbaImage::from_vec(pixmap.width(), pixmap.height(), pixmap.take()).ok_or_else(|| anyhow!("Failed to convert tiny_skia::Pixmap into image::ImageBuffer due to buffer size mismatch"))?;
572 
573 Ok(Image::Static(Arc::new(StaticImage { img })))
574}
575 
576fn resize_image(img: &image::RgbaImage, bounds: Vector2I, fit_type: FitType) -> image::RgbaImage {
577 // If the image dimensions match the target bounding box, return a simple
578 // copy of it.
579 if bounds.x() as u32 == img.width() && bounds.y() as u32 == img.height() {
580 return img.clone();
581 }
582 
583 let filter = FilterType::Triangle;
584 match fit_type {
585 // This logic is adapted from image::DynamicImage::resize_to_fill().
586 FitType::Cover => {
587 let nwidth = bounds.x() as u32;
588 let nheight = bounds.y() as u32;
589 
590 // Resize the image, maintaining aspect ratio, such that the
591 // smaller dimension equals its bounds.
592 let (iwidth, iheight) =
593 resize_dimensions(img.width(), img.height(), nwidth, nheight, FitType::Cover);
594 let mut intermediate =
595 DynamicImage::from(image::imageops::resize(img, iwidth, iheight, filter));
596 
597 // Based on the original and new aspect ratios, crop off the excess
598 // image data along either the vertical or horizontal axis.
599 let aspect_ratio = u64::from(iwidth) * u64::from(nheight);
600 let new_aspect_ratio = u64::from(nwidth) * u64::from(iheight);
601 let img = if new_aspect_ratio > aspect_ratio {
602 intermediate.crop(0, (iheight - nheight) / 2, nwidth, nheight)
603 } else {
604 intermediate.crop((iwidth - nwidth) / 2, 0, nwidth, nheight)
605 };
606 
607 img.into_rgba8()
608 }
609 fit_type => {
610 let (new_width, new_height) = resize_dimensions(
611 img.width(),
612 img.height(),
613 bounds.x() as u32,
614 bounds.y() as u32,
615 fit_type,
616 );
617 image::imageops::resize(img, new_width, new_height, filter)
618 }
619 }
620}
621 
622/// Calculates the width and height an image should be resized to.
623/// This preserves aspect ratio, and based on the `fit_type` parameter
624/// will either fill the dimensions to fit inside the smaller constraint
625/// (will overflow the specified bounds on one axis to preserve
626/// aspect ratio), or will shrink so that both dimensions are
627/// completely contained within the given `width` and `height`,
628/// with empty space on one axis, unless the fit_type is `Stretch`.
629///
630/// This is adapted from image::math::utils::resize_dimensions().
631pub fn resize_dimensions(
632 width: u32,
633 height: u32,
634 nwidth: u32,
635 nheight: u32,
636 fit_type: FitType,
637) -> (u32, u32) {
638 use std::cmp::max;
639 
640 let wratio = nwidth as f64 / width as f64;
641 let hratio = nheight as f64 / height as f64;
642 
643 let ratio = match fit_type {
644 FitType::Cover => f64::max(wratio, hratio),
645 FitType::Contain => {
646 // Resize the image, maintaining aspect ratio, such that the larger
647 // dimension equals its bounds.
648 f64::min(wratio, hratio)
649 }
650 // Stretch doesn't maintain the aspect ratio
651 FitType::Stretch => return (nwidth, nheight),
652 };
653 
654 let nw = max((width as f64 * ratio).round() as u64, 1);
655 let nh = max((height as f64 * ratio).round() as u64, 1);
656 
657 if nw > u64::from(u32::MAX) {
658 let ratio = u32::MAX as f64 / width as f64;
659 (u32::MAX, max((height as f64 * ratio).round() as u32, 1))
660 } else if nh > u64::from(u32::MAX) {
661 let ratio = u32::MAX as f64 / height as f64;
662 (max((width as f64 * ratio).round() as u32, 1), u32::MAX)
663 } else {
664 (nw as u32, nh as u32)
665 }
666}
667 
668impl ImageType {
669 /// Converts the ImageType to the Image structure.
670 /// Takes into account bounds, fit_type and whether to resize the image.
671 /// If resize is set to true, the image is first resized to either cover or contain fit within
672 /// the given bounds. Otherwise, the dimensions are ignored, and the image is converted with
673 /// its original size. In this case we may cache the image bytes to avoid repeated conversions.
674 fn to_image(
675 &self,
676 bounds: Vector2I,
677 fit_type: FitType,
678 resize: bool,
679 animated_image_behavior: AnimatedImageBehavior,
680 ) -> Result<Image> {
681 match self {
682 ImageType::Unrecognized => Err(anyhow!("Unrecognized image format.")),
683 ImageType::StaticBitmap { image } => {
684 if resize {
685 let img = resize_image(&image.img, bounds, fit_type);
686 Ok(Image::Static(Arc::new(StaticImage { img })))
687 } else {
688 Ok(Image::Static(image.clone()))
689 }
690 }
691 ImageType::AnimatedBitmap { image } => match animated_image_behavior {
692 AnimatedImageBehavior::FullAnimation => {
693 if resize {
694 Ok(Image::Animated(Arc::new(resize_animated_image(
695 image.as_ref(),
696 bounds,
697 fit_type,
698 ))))
699 } else {
700 Ok(Image::Animated(image.clone()))
701 }
702 }
703 AnimatedImageBehavior::FirstFramePreview => {
704 let first_frame = image
705 .frames
706 .first()
707 .ok_or_else(|| anyhow!("Animated image contained no frames"))?
708 .image
709 .clone();
710 if resize {
711 let img = resize_image(&first_frame.img, bounds, fit_type);
712 Ok(Image::Static(Arc::new(StaticImage { img })))
713 } else {
714 Ok(Image::Static(first_frame))
715 }
716 }
717 },
718 ImageType::Svg { svg } => svg_image(svg, bounds, fit_type),
719 }
720 }
721}
722 
723impl AnimatedImage {
724 /// Calculates the current frame of the animated image based on elapsed time and
725 /// returns a pointer to the image along with the remaining delay in the frame.
726 /// `elapsed` is the time in ms since the animated image started animating.
727 pub fn get_current_frame(&self, elapsed: u32) -> Result<(Arc<StaticImage>, u32)> {
728 if self.duration == 0 {
729 return Err(anyhow!(
730 "Animated image has duration 0, which is not supported"
731 ));
732 }
733 // Linear search for the correct frame, this can be optimized.
734 let elapsed = elapsed % self.duration;
735 let mut start = 0;
736 for frame in self.frames.iter() {
737 let end = start + frame.delay;
738 if elapsed >= start && elapsed < end {
739 let remaining_delay = end - elapsed;
740 return Ok((frame.image.clone(), remaining_delay));
741 }
742 start = end;
743 }
744 
745 // We should only reach here if self.frames is empty.
746 Err(anyhow!("No frame found for elapsed {}", elapsed))
747 }
748}
749 
750/// Image fit options used when rendering an SVG into a bitmap. `resvg` used to support this
751/// directly, however it was removed in version `0.34`.
752#[derive(Clone, Debug)]
753enum FitTo {
754 /// Scale to width, preserving aspect ratio.
755 Width(u32),
756 /// Scale to height, preserving aspect ratio.
757 Height(u32),
758 /// Stretch to fit the given bounds, ignoring the aspect ratio.
759 Bounds(u32, u32),
760}
761 
762impl FitTo {
763 /// Adjusts `size` based on the current value of `FitTo`.
764 /// Taken directly from `resvg`:
765 /// <https://github.com/RazrFalcon/resvg/blob/0c8a8cd0781d3025659f6de6158d605ca1b752f5/crates/resvg/src/main.rs#L418C8-L439>.
766 fn fit_to_size(&self, size: IntSize) -> Option<IntSize> {
767 match *self {
768 FitTo::Width(w) => size.scale_to_width(w),
769 FitTo::Height(h) => size.scale_to_height(h),
770 FitTo::Bounds(w, h) => Some(IntSize::from_wh(w, h)?),
771 }
772 }
773 
774 /// Returns a [`tiny_skia::Transform`] that would scale `size` to match the new fitted size
775 /// produced via [`FitTo::fit_to_size`].
776 /// Taken directly from `resvg`:
777 /// <https://github.com/RazrFalcon/resvg/blob/0c8a8cd0781d3025659f6de6158d605ca1b752f5/crates/resvg/src/main.rs#L418C8-L439>.
778 fn fit_to_transform(&self, size: IntSize) -> tiny_skia::Transform {
779 let original_size = size.to_size();
780 let fitted_size = match self.fit_to_size(size) {
781 Some(fitted_size) => fitted_size.to_size(),
782 None => return tiny_skia::Transform::default(),
783 };
784 tiny_skia::Transform::from_scale(
785 fitted_size.width() / original_size.width(),
786 fitted_size.height() / original_size.height(),
787 )
788 }
789}
790 
791#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
792struct RenderedImageCacheKey {
793 bounds: Vector2I,
794 animated_image_behavior: AnimatedImageBehavior,
795}
796 
797#[derive(Default)]
798pub struct ImageCache {
799 /// Map of images of any ImageType already scaled to a certain size.
800 /// Uses the hashed AssetSource and rendered-image properties as a key.
801 images: RwLock<HashMap<u64, HashMap<RenderedImageCacheKey, Rc<Image>>>>,
802}
803 
804impl ImageCache {
805 pub fn new() -> Self {
806 Self::default()
807 }
808 
809 pub fn evict_image(&self, asset_source: &AssetSource) {
810 let mut cache = self.images.write();
811 
812 let mut s = DefaultHasher::new();
813 asset_source.hash(&mut s);
814 let cache_key = s.finish();
815 
816 cache.remove(&cache_key);
817 }
818 
819 /// Removes a single cached size entry for an asset.
820 ///
821 /// When the removed `Rc<Image>` is the last strong holder of the inner
822 /// `Arc<StaticImage>`, that `Arc`'s strong count drops to zero. On the
823 /// next call to `TextureCache::end_frame()`, the corresponding GPU texture
824 /// will be evicted automatically via the `Weak<StaticImage>` it holds.
825 ///
826 /// `bounds` must match the resolved bounds used as the cache key inside
827 /// `image()` (i.e., after any `max_dimension` adjustment), not the
828 /// originally requested bounds.
829 // Called by the debounce eviction pass added in the main changeset.
830 /// TODO(APP-3877): remove `#[allow(dead_code)]` once the debounce eviction pass wires this up.
831 #[allow(dead_code)]
832 fn evict_size(
833 &self,
834 asset_source: &AssetSource,
835 bounds: Vector2I,
836 animated_image_behavior: AnimatedImageBehavior,
837 ) {
838 let mut s = DefaultHasher::new();
839 asset_source.hash(&mut s);
840 let cache_key = s.finish();
841 
842 let rendered_key = RenderedImageCacheKey {
843 bounds,
844 animated_image_behavior,
845 };
846 
847 let mut cache = self.images.write();
848 if let Some(inner_map) = cache.get_mut(&cache_key) {
849 inner_map.remove(&rendered_key);
850 if inner_map.is_empty() {
851 cache.remove(&cache_key);
852 }
853 }
854 }
855 
856 #[allow(clippy::too_many_arguments)]
857 pub fn image(
858 &self,
859 asset_source: AssetSource,
860 bounds: Vector2I,
861 fit_type: FitType,
862 animated_image_behavior: AnimatedImageBehavior,
863 cache_option: CacheOption,
864 max_dimension: Option<u32>,
865 asset_cache: &AssetCache,
866 ) -> AssetState<Image> {
867 let mut s = DefaultHasher::new();
868 asset_source.hash(&mut s);
869 let cache_key = s.finish();
870 
871 match asset_cache.load_asset::<ImageType>(asset_source) {
872 AssetState::Loading { handle } => AssetState::Loading { handle },
873 AssetState::Evicted => AssetState::Evicted,
874 AssetState::FailedToLoad(err) => AssetState::FailedToLoad(err),
875 AssetState::Loaded { data } => {
876 let (mut needs_resize, mut bounds) = match cache_option {
877 CacheOption::BySize => {
878 // Only store a resized copy of the source asset if a
879 // specific size was requested and it doesn't match the
880 // source asset's size.
881 let needs_resize = data.image_size() != Some(bounds);
882 (needs_resize, bounds)
883 }
884 CacheOption::Original => {
885 // If the caller requested that we cache the asset at
886 // its original size, set its size as the target
887 // bounds.
888 let Some(bounds) = data.image_size() else {
889 return AssetState::FailedToLoad(Rc::new(anyhow!(
890 "Requested CacheOption::Original for {}, which has no inherent size",
891 data.type_str()
892 )));
893 };
894 (false, bounds)
895 }
896 };
897 
898 // If we need to ensure the image isn't larger than a given
899 // size along either dimension, check if a resize is needed
900 // and update needs_resize and bounds accordingly.
901 if let Some(max_dimension) = max_dimension {
902 let width = bounds.x() as u32;
903 let height = bounds.y() as u32;
904 
905 if width > max_dimension || height > max_dimension {
906 needs_resize = true;
907 let (nwidth, nheight) = resize_dimensions(
908 width,
909 height,
910 max_dimension,
911 max_dimension,
912 fit_type,
913 );
914 bounds = Vector2I::new(nwidth as i32, nheight as i32);
915 }
916 }
917 
918 let rendered_image_cache_key = RenderedImageCacheKey {
919 bounds,
920 animated_image_behavior,
921 };
922 
923 // If it's already in the image cache at the target size,
924 // return it.
925 let cache = self.images.upgradable_read();
926 if let Some(inner_map) = cache.get(&cache_key) {
927 if let Some(image) = inner_map.get(&rendered_image_cache_key) {
928 return AssetState::Loaded {
929 data: image.clone(),
930 };
931 }
932 }
933 
934 // Otherwise, create the correctly-sized image struct and
935 // insert it into the cache (if necessary).
936 let image =
937 match data.to_image(bounds, fit_type, needs_resize, animated_image_behavior) {
938 Ok(image) => Rc::new(image),
939 Err(err) => return AssetState::FailedToLoad(Rc::new(err)),
940 };
941 if needs_resize {
942 let mut images_cache = RwLockUpgradableReadGuard::upgrade(cache);
943 images_cache
944 .entry(cache_key)
945 .or_default()
946 .insert(rendered_image_cache_key, image.clone());
947 }
948 
949 AssetState::Loaded { data: image }
950 }
951 }
952 }
953}
954 
955impl Entity for ImageCache {
956 type Event = ();
957}
958 
959impl SingletonEntity for ImageCache {}
960 
961#[cfg(test)]
962#[path = "image_cache_tests.rs"]
963mod tests;
964 
965#[cfg(test)]
966pub(crate) mod test_utils {
967 use super::*;
968 
969 /// Creates an `Arc<StaticImage>` with the given dimensions for use in unit tests.
970 pub(crate) fn make_static_image(width: u32, height: u32) -> Arc<StaticImage> {
971 Arc::new(StaticImage {
972 img: image::RgbaImage::new(width, height),
973 })
974 }
975}
976