Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,975 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user