976 lines
34 KiB
Rust
976 lines
34 KiB
Rust
use anyhow::{anyhow, Result};
|
|
use core::fmt;
|
|
use itertools::Itertools;
|
|
use std::{
|
|
collections::HashMap,
|
|
error,
|
|
hash::{DefaultHasher, Hash, Hasher},
|
|
rc::Rc,
|
|
sync::{Arc, LazyLock},
|
|
};
|
|
use strum_macros::EnumIter;
|
|
|
|
use crate::{
|
|
assets::asset_cache::{Asset, AssetCache, AssetSource, AssetState},
|
|
util::parse_u32,
|
|
Entity, SingletonEntity,
|
|
};
|
|
use image::{
|
|
codecs::{gif::GifDecoder, webp::WebPDecoder},
|
|
imageops::FilterType,
|
|
AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageFormat,
|
|
};
|
|
use parking_lot::{RwLock, RwLockUpgradableReadGuard};
|
|
use pathfinder_geometry::vector::Vector2I;
|
|
use resvg::{
|
|
tiny_skia::{self, IntSize},
|
|
usvg,
|
|
};
|
|
|
|
const MIN_REFRESH_DELAY_MS: u32 = 50;
|
|
|
|
static SVG_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
|
|
let mut fontdb = usvg::fontdb::Database::new();
|
|
fontdb.load_system_fonts();
|
|
Arc::new(fontdb)
|
|
});
|
|
|
|
pub fn prewarm_svg_font_db() {
|
|
LazyLock::force(&SVG_FONT_DB);
|
|
}
|
|
|
|
#[derive(EnumIter, Debug)]
|
|
pub enum CustomImageFormat {
|
|
Rgb,
|
|
Rgba,
|
|
}
|
|
|
|
impl CustomImageFormat {
|
|
fn create_tag(&self) -> String {
|
|
match self {
|
|
CustomImageFormat::Rgb => "rgb".into(),
|
|
CustomImageFormat::Rgba => "rgba".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum CustomHeaderParsingError {
|
|
ExpectedDataSizeMismatch {
|
|
expected_bytes: usize,
|
|
actual_bytes: usize,
|
|
},
|
|
InvalidCustomHeaderParam {
|
|
param_name: String,
|
|
value: String,
|
|
},
|
|
MissingCustomHeaderParam {
|
|
param_name: String,
|
|
},
|
|
MissingHeaderIdentifier,
|
|
}
|
|
|
|
impl error::Error for CustomHeaderParsingError {}
|
|
|
|
impl fmt::Display for CustomHeaderParsingError {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
match self {
|
|
Self::ExpectedDataSizeMismatch {
|
|
expected_bytes,
|
|
actual_bytes,
|
|
} => write!(
|
|
f,
|
|
"The custom image's expected bytes ({expected_bytes}) did not match the actual number of bytes ({actual_bytes})."
|
|
),
|
|
Self::InvalidCustomHeaderParam { param_name, value } => write!(
|
|
f,
|
|
"Custom header had field {param_name} with an invalid value of {value}"
|
|
),
|
|
Self::MissingCustomHeaderParam { param_name } => {
|
|
write!(f, "Custom header had {param_name} field missing")
|
|
}
|
|
Self::MissingHeaderIdentifier => {
|
|
write!(f, "Image did not contain the 'warp-img:' prefix.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum CustomHeaderCreationError {
|
|
ExpectedDataSizeMismatch {
|
|
expected_bytes: usize,
|
|
actual_bytes: usize,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CustomImageHeader {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub image_format: CustomImageFormat,
|
|
}
|
|
|
|
impl CustomImageHeader {
|
|
pub fn create_header(&self) -> String {
|
|
format!(
|
|
"warp-img:{}:{}:{}:",
|
|
self.image_format.create_tag(),
|
|
self.width,
|
|
self.height
|
|
)
|
|
}
|
|
|
|
pub fn prepend_custom_header(
|
|
mut data: Vec<u8>,
|
|
width: u32,
|
|
height: u32,
|
|
image_format: CustomImageFormat,
|
|
) -> Result<Vec<u8>, CustomHeaderCreationError> {
|
|
let bytes_per_pixel = match image_format {
|
|
CustomImageFormat::Rgb => 3,
|
|
CustomImageFormat::Rgba => 4,
|
|
};
|
|
let expected_byte_count = (bytes_per_pixel * width * height) as usize;
|
|
|
|
if expected_byte_count != data.len() {
|
|
return Err(CustomHeaderCreationError::ExpectedDataSizeMismatch {
|
|
expected_bytes: expected_byte_count,
|
|
actual_bytes: data.len(),
|
|
});
|
|
}
|
|
|
|
let custom_header = CustomImageHeader {
|
|
width,
|
|
height,
|
|
image_format,
|
|
};
|
|
|
|
data.splice(
|
|
0..0,
|
|
custom_header.create_header().as_bytes().iter().copied(),
|
|
);
|
|
|
|
Ok(data)
|
|
}
|
|
|
|
fn try_from_bytes(data: &[u8]) -> Result<(CustomImageHeader, &[u8]), CustomHeaderParsingError> {
|
|
if !data.starts_with(b"warp-img:") {
|
|
return Err(CustomHeaderParsingError::MissingHeaderIdentifier);
|
|
}
|
|
|
|
let data = match data
|
|
.iter()
|
|
.position(|&byte| byte == b':')
|
|
.map(|position| &data[position + 1..])
|
|
{
|
|
Some(data) => data,
|
|
None => return Err(CustomHeaderParsingError::MissingHeaderIdentifier),
|
|
};
|
|
|
|
let (image_type, data) = match data
|
|
.iter()
|
|
.position(|&byte| byte == b':')
|
|
.map(|position| (&data[..position], &data[position + 1..]))
|
|
{
|
|
Some((image_type, data)) => (image_type, data),
|
|
None => {
|
|
return Err(CustomHeaderParsingError::MissingCustomHeaderParam {
|
|
param_name: "image_type".to_string(),
|
|
});
|
|
}
|
|
};
|
|
|
|
let image_type = match image_type {
|
|
b"rgb" => CustomImageFormat::Rgb,
|
|
b"rgba" => CustomImageFormat::Rgba,
|
|
_ => {
|
|
return Err(CustomHeaderParsingError::InvalidCustomHeaderParam {
|
|
param_name: "image_type".to_string(),
|
|
value: std::str::from_utf8(image_type)
|
|
.unwrap_or("Unable to parse utf8")
|
|
.to_string(),
|
|
});
|
|
}
|
|
};
|
|
|
|
let (width, data) = match data
|
|
.iter()
|
|
.position(|&byte| byte == b':')
|
|
.map(|position| (&data[..position], &data[position + 1..]))
|
|
{
|
|
Some((width, data)) => (width, data),
|
|
None => {
|
|
return Err(CustomHeaderParsingError::MissingCustomHeaderParam {
|
|
param_name: "width".to_string(),
|
|
});
|
|
}
|
|
};
|
|
|
|
let width = match parse_u32(width) {
|
|
Some(width) => width,
|
|
None => {
|
|
return Err(CustomHeaderParsingError::InvalidCustomHeaderParam {
|
|
param_name: "width".to_string(),
|
|
value: std::str::from_utf8(width)
|
|
.unwrap_or("Unable to parse utf8")
|
|
.to_string(),
|
|
});
|
|
}
|
|
};
|
|
|
|
let (height, data) = match data
|
|
.iter()
|
|
.position(|&byte| byte == b':')
|
|
.map(|position| (&data[..position], &data[position + 1..]))
|
|
{
|
|
Some((height, data)) => (height, data),
|
|
None => {
|
|
return Err(CustomHeaderParsingError::MissingCustomHeaderParam {
|
|
param_name: "height".to_string(),
|
|
});
|
|
}
|
|
};
|
|
|
|
let height = match parse_u32(height) {
|
|
Some(height) => height,
|
|
None => {
|
|
return Err(CustomHeaderParsingError::InvalidCustomHeaderParam {
|
|
param_name: "height".to_string(),
|
|
value: std::str::from_utf8(height)
|
|
.unwrap_or("Unable to parse utf8")
|
|
.to_string(),
|
|
});
|
|
}
|
|
};
|
|
|
|
let bytes_per_pixel = match image_type {
|
|
CustomImageFormat::Rgb => 3,
|
|
CustomImageFormat::Rgba => 4,
|
|
};
|
|
let expected_byte_count = (bytes_per_pixel * width * height) as usize;
|
|
|
|
if expected_byte_count != data.len() {
|
|
return Err(CustomHeaderParsingError::ExpectedDataSizeMismatch {
|
|
expected_bytes: expected_byte_count,
|
|
actual_bytes: data.len(),
|
|
});
|
|
}
|
|
|
|
Ok((
|
|
CustomImageHeader {
|
|
width,
|
|
height,
|
|
image_format: image_type,
|
|
},
|
|
data,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl Asset for ImageType {
|
|
fn try_from_bytes(data: &[u8]) -> anyhow::Result<ImageType> {
|
|
// SVGs are not handled by the guess_format helper function, so we have to manually check
|
|
// if it's an SVG ourselves.
|
|
if data.first() == Some(&b'<') {
|
|
let options = usvg::Options {
|
|
fontdb: SVG_FONT_DB.clone(),
|
|
..Default::default()
|
|
};
|
|
let svg = Rc::new(usvg::Tree::from_data(data, &options)?);
|
|
return Ok(ImageType::Svg { svg });
|
|
}
|
|
|
|
if data.starts_with(b"warp-img:") {
|
|
let (custom_warp_header, data) = match CustomImageHeader::try_from_bytes(data) {
|
|
Ok((custom_warp_header, data)) => (custom_warp_header, data),
|
|
Err(err) => return Err(anyhow!(err.to_string())),
|
|
};
|
|
|
|
let data = data.into();
|
|
let Some(img) = (match custom_warp_header.image_format {
|
|
CustomImageFormat::Rgb => {
|
|
let dynamic_image = ImageBuffer::from_raw(
|
|
custom_warp_header.width,
|
|
custom_warp_header.height,
|
|
data,
|
|
)
|
|
.map(DynamicImage::ImageRgb8);
|
|
dynamic_image.map(|dynamic_image| dynamic_image.into_rgba8())
|
|
}
|
|
CustomImageFormat::Rgba => {
|
|
let dynamic_image = ImageBuffer::from_raw(
|
|
custom_warp_header.width,
|
|
custom_warp_header.height,
|
|
data,
|
|
)
|
|
.map(DynamicImage::ImageRgba8);
|
|
dynamic_image.map(|dynamic_image| dynamic_image.into_rgba8())
|
|
}
|
|
}) else {
|
|
return Err(anyhow!(
|
|
"Could not convert custom warp image into approprate dynamic image."
|
|
));
|
|
};
|
|
return Ok(ImageType::StaticBitmap {
|
|
image: Arc::new(StaticImage { img }),
|
|
});
|
|
}
|
|
|
|
match image::guess_format(data) {
|
|
Ok(ImageFormat::Jpeg) => {
|
|
let img = image::ImageReader::with_format(
|
|
std::io::Cursor::new(data),
|
|
image::ImageFormat::Jpeg,
|
|
)
|
|
.decode()?
|
|
.into_rgba8();
|
|
Ok(ImageType::StaticBitmap {
|
|
image: Arc::new(StaticImage { img }),
|
|
})
|
|
}
|
|
Ok(ImageFormat::Png) => {
|
|
let img = image::ImageReader::with_format(
|
|
std::io::Cursor::new(data),
|
|
image::ImageFormat::Png,
|
|
)
|
|
.decode()?
|
|
.into_rgba8();
|
|
Ok(ImageType::StaticBitmap {
|
|
image: Arc::new(StaticImage { img }),
|
|
})
|
|
}
|
|
Ok(ImageFormat::WebP) => {
|
|
let decoder = WebPDecoder::new(std::io::Cursor::new(data))?;
|
|
if decoder.has_animation() {
|
|
let frames = decoder.into_frames().collect_frames()?;
|
|
Ok(ImageType::AnimatedBitmap {
|
|
image: Arc::new(AnimatedImage::from(frames)),
|
|
})
|
|
} else {
|
|
let img = DynamicImage::from_decoder(decoder)?.into_rgba8();
|
|
Ok(ImageType::StaticBitmap {
|
|
image: Arc::new(StaticImage { img }),
|
|
})
|
|
}
|
|
}
|
|
Ok(ImageFormat::Gif) => {
|
|
let decoder = GifDecoder::new(std::io::Cursor::new(data))?;
|
|
let frames = decoder.into_frames().collect_frames()?;
|
|
Ok(ImageType::AnimatedBitmap {
|
|
image: Arc::new(AnimatedImage::from(frames)),
|
|
})
|
|
}
|
|
_ => Ok(ImageType::Unrecognized),
|
|
}
|
|
}
|
|
|
|
fn size_in_bytes(&self) -> usize {
|
|
match self {
|
|
ImageType::Svg { .. } => 0, // TODO: How do we calculate svg size in bytes?
|
|
ImageType::StaticBitmap { image } => image.rgba_bytes().len(),
|
|
ImageType::AnimatedBitmap { image } => image
|
|
.frames
|
|
.iter()
|
|
.map(|frame| frame.image.rgba_bytes().len())
|
|
.reduce(|acc, bytes| acc + bytes)
|
|
.unwrap_or(0),
|
|
ImageType::Unrecognized => 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A reference to an image in the asset cache. Can be a static or animated image.
|
|
#[derive(Clone)]
|
|
pub enum Image {
|
|
Static(Arc<StaticImage>),
|
|
Animated(Arc<AnimatedImage>),
|
|
}
|
|
|
|
/// A representation of an image in the asset cache.
|
|
pub struct StaticImage {
|
|
/// The actual RGBA image data, stored as a vector of bytes.
|
|
img: image::RgbaImage,
|
|
}
|
|
|
|
impl StaticImage {
|
|
pub fn size(&self) -> Vector2I {
|
|
Vector2I::new(self.width() as i32, self.height() as i32)
|
|
}
|
|
|
|
pub fn width(&self) -> u32 {
|
|
self.img.width()
|
|
}
|
|
|
|
pub fn height(&self) -> u32 {
|
|
self.img.height()
|
|
}
|
|
|
|
pub fn rgba_bytes(&self) -> &[u8] {
|
|
self.img.as_raw().as_slice()
|
|
}
|
|
}
|
|
|
|
/// A representation of a single frame in an animated image.
|
|
pub struct AnimatedImageFrame {
|
|
// The static image representing the current frame of an animated image.
|
|
pub image: Arc<StaticImage>,
|
|
// Delay until the next frame in ms.
|
|
pub delay: u32,
|
|
}
|
|
|
|
/// A representation of an animated image (e.g. gif) in the asset cache.
|
|
pub struct AnimatedImage {
|
|
/// The frames of the animated image in sequential order.
|
|
pub frames: Vec<AnimatedImageFrame>,
|
|
/// Total duration of the animated image in ms.
|
|
pub duration: u32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
|
|
pub enum AnimatedImageBehavior {
|
|
#[default]
|
|
FullAnimation,
|
|
FirstFramePreview,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Copy, Eq, Hash, PartialEq)]
|
|
pub enum FitType {
|
|
/// Expands the image to fill the entire space while maintaining the aspect ratio.
|
|
Cover,
|
|
/// Resizes the image to maximum size that fully fits in the given bounds,
|
|
/// maintaining the aspect ratio.
|
|
Contain,
|
|
/// Stretches the image to fit the given bounds, ignoring the aspect ratio.
|
|
/// This should likely only be used with SVGs, and not all SVGs are designed
|
|
/// to be stretched.
|
|
Stretch,
|
|
}
|
|
|
|
impl FitType {
|
|
pub fn should_retain_aspect_ratio(&self) -> bool {
|
|
match self {
|
|
FitType::Cover | FitType::Contain => true,
|
|
FitType::Stretch => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub enum ImageType {
|
|
Svg { svg: Rc<usvg::Tree> },
|
|
StaticBitmap { image: Arc<StaticImage> },
|
|
AnimatedBitmap { image: Arc<AnimatedImage> },
|
|
// TODO: other types
|
|
Unrecognized,
|
|
}
|
|
|
|
impl ImageType {
|
|
/// Returns the size of the underlying asset.
|
|
pub fn image_size(&self) -> Option<Vector2I> {
|
|
match self {
|
|
ImageType::Svg { svg } => Some(Vector2I::new(
|
|
svg.size().width().round() as i32,
|
|
svg.size().height().round() as i32,
|
|
)),
|
|
ImageType::StaticBitmap { image } => Some(image.size()),
|
|
ImageType::AnimatedBitmap { image } => {
|
|
image.frames.first().map(|frame| frame.image.size())
|
|
}
|
|
ImageType::Unrecognized => None,
|
|
}
|
|
}
|
|
|
|
fn type_str(&self) -> &'static str {
|
|
match self {
|
|
ImageType::Svg { .. } => "ImageType::Svg",
|
|
ImageType::StaticBitmap { .. } => "ImageType::StaticBitmap",
|
|
ImageType::AnimatedBitmap { .. } => "ImageType::AnimatedBitmap",
|
|
ImageType::Unrecognized => "ImageType::Unrecognized",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum CacheOption {
|
|
/// Only the specific used sizes are cached.
|
|
/// Best for situations when the image/asset is used with a fixed size.
|
|
/// Example: icons, theme picker previews.
|
|
BySize,
|
|
/// Only the original asset is cached.
|
|
/// Best for situations when the image/asset doesn't have a fixed size, and it may change
|
|
/// significantly on every window resize.
|
|
/// Example: background image.
|
|
Original,
|
|
}
|
|
|
|
impl From<Vec<Frame>> for AnimatedImage {
|
|
fn from(value: Vec<Frame>) -> Self {
|
|
let mut duration = 0;
|
|
let frames = value
|
|
.into_iter()
|
|
.map(|frame| {
|
|
let (delay_numerator, delay_denominator) = frame.delay().numer_denom_ms();
|
|
let delay_ms = (delay_numerator / delay_denominator).max(MIN_REFRESH_DELAY_MS);
|
|
duration += delay_ms;
|
|
|
|
AnimatedImageFrame {
|
|
image: Arc::new(StaticImage {
|
|
img: frame.into_buffer(),
|
|
}),
|
|
delay: delay_ms,
|
|
}
|
|
})
|
|
.collect_vec();
|
|
|
|
AnimatedImage { frames, duration }
|
|
}
|
|
}
|
|
|
|
fn resize_animated_image(
|
|
image: &AnimatedImage,
|
|
bounds: Vector2I,
|
|
fit_type: FitType,
|
|
) -> AnimatedImage {
|
|
let resized_frames = image
|
|
.frames
|
|
.iter()
|
|
.map(|frame| AnimatedImageFrame {
|
|
image: Arc::new(StaticImage {
|
|
img: resize_image(&frame.image.img, bounds, fit_type),
|
|
}),
|
|
delay: frame.delay,
|
|
})
|
|
.collect_vec();
|
|
AnimatedImage {
|
|
frames: resized_frames,
|
|
duration: image.duration,
|
|
}
|
|
}
|
|
|
|
fn svg_image(svg: &Rc<usvg::Tree>, bounds: Vector2I, fit_type: FitType) -> Result<Image> {
|
|
let svg_size = &svg.size();
|
|
|
|
let svg_has_wider_ratio =
|
|
svg_size.width() / svg_size.height() > bounds.x() as f32 / bounds.y() as f32;
|
|
let fit = match (fit_type, svg_has_wider_ratio) {
|
|
(FitType::Contain, true) | (FitType::Cover, false) => FitTo::Width(bounds.x() as u32),
|
|
(FitType::Contain, false) | (FitType::Cover, true) => FitTo::Height(bounds.y() as u32),
|
|
(FitType::Stretch, _) => FitTo::Bounds(bounds.x() as u32, bounds.y() as u32),
|
|
};
|
|
let svg_size = svg_size.to_int_size();
|
|
let size = fit
|
|
.fit_to_size(svg_size)
|
|
.ok_or_else(|| anyhow!("Unable to fit SVG image to size"))?;
|
|
let transform = fit.fit_to_transform(svg_size);
|
|
|
|
let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())
|
|
.ok_or_else(|| anyhow!("Could not could create pixmap for bounds {:?}", bounds))?;
|
|
resvg::render(svg.as_ref(), transform, &mut pixmap.as_mut());
|
|
|
|
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"))?;
|
|
|
|
Ok(Image::Static(Arc::new(StaticImage { img })))
|
|
}
|
|
|
|
fn resize_image(img: &image::RgbaImage, bounds: Vector2I, fit_type: FitType) -> image::RgbaImage {
|
|
// If the image dimensions match the target bounding box, return a simple
|
|
// copy of it.
|
|
if bounds.x() as u32 == img.width() && bounds.y() as u32 == img.height() {
|
|
return img.clone();
|
|
}
|
|
|
|
let filter = FilterType::Triangle;
|
|
match fit_type {
|
|
// This logic is adapted from image::DynamicImage::resize_to_fill().
|
|
FitType::Cover => {
|
|
let nwidth = bounds.x() as u32;
|
|
let nheight = bounds.y() as u32;
|
|
|
|
// Resize the image, maintaining aspect ratio, such that the
|
|
// smaller dimension equals its bounds.
|
|
let (iwidth, iheight) =
|
|
resize_dimensions(img.width(), img.height(), nwidth, nheight, FitType::Cover);
|
|
let mut intermediate =
|
|
DynamicImage::from(image::imageops::resize(img, iwidth, iheight, filter));
|
|
|
|
// Based on the original and new aspect ratios, crop off the excess
|
|
// image data along either the vertical or horizontal axis.
|
|
let aspect_ratio = u64::from(iwidth) * u64::from(nheight);
|
|
let new_aspect_ratio = u64::from(nwidth) * u64::from(iheight);
|
|
let img = if new_aspect_ratio > aspect_ratio {
|
|
intermediate.crop(0, (iheight - nheight) / 2, nwidth, nheight)
|
|
} else {
|
|
intermediate.crop((iwidth - nwidth) / 2, 0, nwidth, nheight)
|
|
};
|
|
|
|
img.into_rgba8()
|
|
}
|
|
fit_type => {
|
|
let (new_width, new_height) = resize_dimensions(
|
|
img.width(),
|
|
img.height(),
|
|
bounds.x() as u32,
|
|
bounds.y() as u32,
|
|
fit_type,
|
|
);
|
|
image::imageops::resize(img, new_width, new_height, filter)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Calculates the width and height an image should be resized to.
|
|
/// This preserves aspect ratio, and based on the `fit_type` parameter
|
|
/// will either fill the dimensions to fit inside the smaller constraint
|
|
/// (will overflow the specified bounds on one axis to preserve
|
|
/// aspect ratio), or will shrink so that both dimensions are
|
|
/// completely contained within the given `width` and `height`,
|
|
/// with empty space on one axis, unless the fit_type is `Stretch`.
|
|
///
|
|
/// This is adapted from image::math::utils::resize_dimensions().
|
|
pub fn resize_dimensions(
|
|
width: u32,
|
|
height: u32,
|
|
nwidth: u32,
|
|
nheight: u32,
|
|
fit_type: FitType,
|
|
) -> (u32, u32) {
|
|
use std::cmp::max;
|
|
|
|
let wratio = nwidth as f64 / width as f64;
|
|
let hratio = nheight as f64 / height as f64;
|
|
|
|
let ratio = match fit_type {
|
|
FitType::Cover => f64::max(wratio, hratio),
|
|
FitType::Contain => {
|
|
// Resize the image, maintaining aspect ratio, such that the larger
|
|
// dimension equals its bounds.
|
|
f64::min(wratio, hratio)
|
|
}
|
|
// Stretch doesn't maintain the aspect ratio
|
|
FitType::Stretch => return (nwidth, nheight),
|
|
};
|
|
|
|
let nw = max((width as f64 * ratio).round() as u64, 1);
|
|
let nh = max((height as f64 * ratio).round() as u64, 1);
|
|
|
|
if nw > u64::from(u32::MAX) {
|
|
let ratio = u32::MAX as f64 / width as f64;
|
|
(u32::MAX, max((height as f64 * ratio).round() as u32, 1))
|
|
} else if nh > u64::from(u32::MAX) {
|
|
let ratio = u32::MAX as f64 / height as f64;
|
|
(max((width as f64 * ratio).round() as u32, 1), u32::MAX)
|
|
} else {
|
|
(nw as u32, nh as u32)
|
|
}
|
|
}
|
|
|
|
impl ImageType {
|
|
/// Converts the ImageType to the Image structure.
|
|
/// Takes into account bounds, fit_type and whether to resize the image.
|
|
/// If resize is set to true, the image is first resized to either cover or contain fit within
|
|
/// the given bounds. Otherwise, the dimensions are ignored, and the image is converted with
|
|
/// its original size. In this case we may cache the image bytes to avoid repeated conversions.
|
|
fn to_image(
|
|
&self,
|
|
bounds: Vector2I,
|
|
fit_type: FitType,
|
|
resize: bool,
|
|
animated_image_behavior: AnimatedImageBehavior,
|
|
) -> Result<Image> {
|
|
match self {
|
|
ImageType::Unrecognized => Err(anyhow!("Unrecognized image format.")),
|
|
ImageType::StaticBitmap { image } => {
|
|
if resize {
|
|
let img = resize_image(&image.img, bounds, fit_type);
|
|
Ok(Image::Static(Arc::new(StaticImage { img })))
|
|
} else {
|
|
Ok(Image::Static(image.clone()))
|
|
}
|
|
}
|
|
ImageType::AnimatedBitmap { image } => match animated_image_behavior {
|
|
AnimatedImageBehavior::FullAnimation => {
|
|
if resize {
|
|
Ok(Image::Animated(Arc::new(resize_animated_image(
|
|
image.as_ref(),
|
|
bounds,
|
|
fit_type,
|
|
))))
|
|
} else {
|
|
Ok(Image::Animated(image.clone()))
|
|
}
|
|
}
|
|
AnimatedImageBehavior::FirstFramePreview => {
|
|
let first_frame = image
|
|
.frames
|
|
.first()
|
|
.ok_or_else(|| anyhow!("Animated image contained no frames"))?
|
|
.image
|
|
.clone();
|
|
if resize {
|
|
let img = resize_image(&first_frame.img, bounds, fit_type);
|
|
Ok(Image::Static(Arc::new(StaticImage { img })))
|
|
} else {
|
|
Ok(Image::Static(first_frame))
|
|
}
|
|
}
|
|
},
|
|
ImageType::Svg { svg } => svg_image(svg, bounds, fit_type),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AnimatedImage {
|
|
/// Calculates the current frame of the animated image based on elapsed time and
|
|
/// returns a pointer to the image along with the remaining delay in the frame.
|
|
/// `elapsed` is the time in ms since the animated image started animating.
|
|
pub fn get_current_frame(&self, elapsed: u32) -> Result<(Arc<StaticImage>, u32)> {
|
|
if self.duration == 0 {
|
|
return Err(anyhow!(
|
|
"Animated image has duration 0, which is not supported"
|
|
));
|
|
}
|
|
// Linear search for the correct frame, this can be optimized.
|
|
let elapsed = elapsed % self.duration;
|
|
let mut start = 0;
|
|
for frame in self.frames.iter() {
|
|
let end = start + frame.delay;
|
|
if elapsed >= start && elapsed < end {
|
|
let remaining_delay = end - elapsed;
|
|
return Ok((frame.image.clone(), remaining_delay));
|
|
}
|
|
start = end;
|
|
}
|
|
|
|
// We should only reach here if self.frames is empty.
|
|
Err(anyhow!("No frame found for elapsed {}", elapsed))
|
|
}
|
|
}
|
|
|
|
/// Image fit options used when rendering an SVG into a bitmap. `resvg` used to support this
|
|
/// directly, however it was removed in version `0.34`.
|
|
#[derive(Clone, Debug)]
|
|
enum FitTo {
|
|
/// Scale to width, preserving aspect ratio.
|
|
Width(u32),
|
|
/// Scale to height, preserving aspect ratio.
|
|
Height(u32),
|
|
/// Stretch to fit the given bounds, ignoring the aspect ratio.
|
|
Bounds(u32, u32),
|
|
}
|
|
|
|
impl FitTo {
|
|
/// Adjusts `size` based on the current value of `FitTo`.
|
|
/// Taken directly from `resvg`:
|
|
/// <https://github.com/RazrFalcon/resvg/blob/0c8a8cd0781d3025659f6de6158d605ca1b752f5/crates/resvg/src/main.rs#L418C8-L439>.
|
|
fn fit_to_size(&self, size: IntSize) -> Option<IntSize> {
|
|
match *self {
|
|
FitTo::Width(w) => size.scale_to_width(w),
|
|
FitTo::Height(h) => size.scale_to_height(h),
|
|
FitTo::Bounds(w, h) => Some(IntSize::from_wh(w, h)?),
|
|
}
|
|
}
|
|
|
|
/// Returns a [`tiny_skia::Transform`] that would scale `size` to match the new fitted size
|
|
/// produced via [`FitTo::fit_to_size`].
|
|
/// Taken directly from `resvg`:
|
|
/// <https://github.com/RazrFalcon/resvg/blob/0c8a8cd0781d3025659f6de6158d605ca1b752f5/crates/resvg/src/main.rs#L418C8-L439>.
|
|
fn fit_to_transform(&self, size: IntSize) -> tiny_skia::Transform {
|
|
let original_size = size.to_size();
|
|
let fitted_size = match self.fit_to_size(size) {
|
|
Some(fitted_size) => fitted_size.to_size(),
|
|
None => return tiny_skia::Transform::default(),
|
|
};
|
|
tiny_skia::Transform::from_scale(
|
|
fitted_size.width() / original_size.width(),
|
|
fitted_size.height() / original_size.height(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
struct RenderedImageCacheKey {
|
|
bounds: Vector2I,
|
|
animated_image_behavior: AnimatedImageBehavior,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct ImageCache {
|
|
/// Map of images of any ImageType already scaled to a certain size.
|
|
/// Uses the hashed AssetSource and rendered-image properties as a key.
|
|
images: RwLock<HashMap<u64, HashMap<RenderedImageCacheKey, Rc<Image>>>>,
|
|
}
|
|
|
|
impl ImageCache {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn evict_image(&self, asset_source: &AssetSource) {
|
|
let mut cache = self.images.write();
|
|
|
|
let mut s = DefaultHasher::new();
|
|
asset_source.hash(&mut s);
|
|
let cache_key = s.finish();
|
|
|
|
cache.remove(&cache_key);
|
|
}
|
|
|
|
/// Removes a single cached size entry for an asset.
|
|
///
|
|
/// When the removed `Rc<Image>` is the last strong holder of the inner
|
|
/// `Arc<StaticImage>`, that `Arc`'s strong count drops to zero. On the
|
|
/// next call to `TextureCache::end_frame()`, the corresponding GPU texture
|
|
/// will be evicted automatically via the `Weak<StaticImage>` it holds.
|
|
///
|
|
/// `bounds` must match the resolved bounds used as the cache key inside
|
|
/// `image()` (i.e., after any `max_dimension` adjustment), not the
|
|
/// originally requested bounds.
|
|
// Called by the debounce eviction pass added in the main changeset.
|
|
/// TODO(APP-3877): remove `#[allow(dead_code)]` once the debounce eviction pass wires this up.
|
|
#[allow(dead_code)]
|
|
fn evict_size(
|
|
&self,
|
|
asset_source: &AssetSource,
|
|
bounds: Vector2I,
|
|
animated_image_behavior: AnimatedImageBehavior,
|
|
) {
|
|
let mut s = DefaultHasher::new();
|
|
asset_source.hash(&mut s);
|
|
let cache_key = s.finish();
|
|
|
|
let rendered_key = RenderedImageCacheKey {
|
|
bounds,
|
|
animated_image_behavior,
|
|
};
|
|
|
|
let mut cache = self.images.write();
|
|
if let Some(inner_map) = cache.get_mut(&cache_key) {
|
|
inner_map.remove(&rendered_key);
|
|
if inner_map.is_empty() {
|
|
cache.remove(&cache_key);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn image(
|
|
&self,
|
|
asset_source: AssetSource,
|
|
bounds: Vector2I,
|
|
fit_type: FitType,
|
|
animated_image_behavior: AnimatedImageBehavior,
|
|
cache_option: CacheOption,
|
|
max_dimension: Option<u32>,
|
|
asset_cache: &AssetCache,
|
|
) -> AssetState<Image> {
|
|
let mut s = DefaultHasher::new();
|
|
asset_source.hash(&mut s);
|
|
let cache_key = s.finish();
|
|
|
|
match asset_cache.load_asset::<ImageType>(asset_source) {
|
|
AssetState::Loading { handle } => AssetState::Loading { handle },
|
|
AssetState::Evicted => AssetState::Evicted,
|
|
AssetState::FailedToLoad(err) => AssetState::FailedToLoad(err),
|
|
AssetState::Loaded { data } => {
|
|
let (mut needs_resize, mut bounds) = match cache_option {
|
|
CacheOption::BySize => {
|
|
// Only store a resized copy of the source asset if a
|
|
// specific size was requested and it doesn't match the
|
|
// source asset's size.
|
|
let needs_resize = data.image_size() != Some(bounds);
|
|
(needs_resize, bounds)
|
|
}
|
|
CacheOption::Original => {
|
|
// If the caller requested that we cache the asset at
|
|
// its original size, set its size as the target
|
|
// bounds.
|
|
let Some(bounds) = data.image_size() else {
|
|
return AssetState::FailedToLoad(Rc::new(anyhow!(
|
|
"Requested CacheOption::Original for {}, which has no inherent size",
|
|
data.type_str()
|
|
)));
|
|
};
|
|
(false, bounds)
|
|
}
|
|
};
|
|
|
|
// If we need to ensure the image isn't larger than a given
|
|
// size along either dimension, check if a resize is needed
|
|
// and update needs_resize and bounds accordingly.
|
|
if let Some(max_dimension) = max_dimension {
|
|
let width = bounds.x() as u32;
|
|
let height = bounds.y() as u32;
|
|
|
|
if width > max_dimension || height > max_dimension {
|
|
needs_resize = true;
|
|
let (nwidth, nheight) = resize_dimensions(
|
|
width,
|
|
height,
|
|
max_dimension,
|
|
max_dimension,
|
|
fit_type,
|
|
);
|
|
bounds = Vector2I::new(nwidth as i32, nheight as i32);
|
|
}
|
|
}
|
|
|
|
let rendered_image_cache_key = RenderedImageCacheKey {
|
|
bounds,
|
|
animated_image_behavior,
|
|
};
|
|
|
|
// If it's already in the image cache at the target size,
|
|
// return it.
|
|
let cache = self.images.upgradable_read();
|
|
if let Some(inner_map) = cache.get(&cache_key) {
|
|
if let Some(image) = inner_map.get(&rendered_image_cache_key) {
|
|
return AssetState::Loaded {
|
|
data: image.clone(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Otherwise, create the correctly-sized image struct and
|
|
// insert it into the cache (if necessary).
|
|
let image =
|
|
match data.to_image(bounds, fit_type, needs_resize, animated_image_behavior) {
|
|
Ok(image) => Rc::new(image),
|
|
Err(err) => return AssetState::FailedToLoad(Rc::new(err)),
|
|
};
|
|
if needs_resize {
|
|
let mut images_cache = RwLockUpgradableReadGuard::upgrade(cache);
|
|
images_cache
|
|
.entry(cache_key)
|
|
.or_default()
|
|
.insert(rendered_image_cache_key, image.clone());
|
|
}
|
|
|
|
AssetState::Loaded { data: image }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Entity for ImageCache {
|
|
type Event = ();
|
|
}
|
|
|
|
impl SingletonEntity for ImageCache {}
|
|
|
|
#[cfg(test)]
|
|
#[path = "image_cache_tests.rs"]
|
|
mod tests;
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod test_utils {
|
|
use super::*;
|
|
|
|
/// Creates an `Arc<StaticImage>` with the given dimensions for use in unit tests.
|
|
pub(crate) fn make_static_image(width: u32, height: u32) -> Arc<StaticImage> {
|
|
Arc::new(StaticImage {
|
|
img: image::RgbaImage::new(width, height),
|
|
})
|
|
}
|
|
}
|