use std::fmt::Display;
use std::hash::Hash;
use std::{path::PathBuf, sync::OnceLock, time::Instant};
use base64::Engine;
use image::{GenericImageView, image_dimensions};
use log::debug;
use thumbhash::{rgba_to_thumb_hash, thumb_hash_to_average_rgba, thumb_hash_to_rgba};
use crate::assets::image_cache::ImageCache;
use crate::assets::{RouteAssetsOptions, make_filename, make_final_path, make_final_url};
use crate::is_dev;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ImageFormat {
Png,
Jpeg,
WebP,
Avif,
Gif,
}
impl ImageFormat {
pub fn extension(&self) -> &'static str {
match self {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
ImageFormat::WebP => "webp",
ImageFormat::Avif => "avif",
ImageFormat::Gif => "gif",
}
}
pub(crate) fn to_hash_value(&self) -> u32 {
match self {
ImageFormat::Png => 1,
ImageFormat::Jpeg => 2,
ImageFormat::WebP => 3,
ImageFormat::Gif => 4,
ImageFormat::Avif => 5,
}
}
}
impl From<ImageFormat> for image::ImageFormat {
fn from(val: ImageFormat) -> Self {
match val {
ImageFormat::Png => image::ImageFormat::Png,
ImageFormat::Jpeg => image::ImageFormat::Jpeg,
ImageFormat::WebP => image::ImageFormat::WebP,
ImageFormat::Avif => image::ImageFormat::Avif,
ImageFormat::Gif => image::ImageFormat::Gif,
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ImageOptions {
pub width: Option<u32>,
pub height: Option<u32>,
pub format: Option<ImageFormat>,
}
#[derive(Clone, Debug)]
pub struct Image {
pub path: PathBuf,
pub(crate) hash: String,
pub(crate) options: Option<ImageOptions>,
pub(crate) filename: PathBuf,
pub(crate) url: String,
pub(crate) build_path: PathBuf,
pub(crate) cache: Option<ImageCache>,
}
impl Hash for Image {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.path.hash(state);
self.hash.hash(state);
self.options.hash(state);
self.filename.hash(state);
self.url.hash(state);
self.build_path.hash(state);
}
}
impl PartialEq for Image {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
&& self.hash == other.hash
&& self.options == other.options
&& self.filename == other.filename
&& self.url == other.url
&& self.build_path == other.build_path
}
}
impl Eq for Image {}
impl Image {
pub fn new(
path: PathBuf,
image_options: Option<ImageOptions>,
hash: String,
route_assets_options: &RouteAssetsOptions,
cache: Option<ImageCache>,
) -> Self {
let filename = make_filename(
&path,
&hash,
image_options
.as_ref()
.and_then(|opts| opts.format.as_ref().map(|f| f.extension().into()))
.or_else(|| {
path.extension()
.and_then(|ext| ext.to_str())
.map(|s| s.to_lowercase())
})
.as_deref(),
);
let build_path = make_final_path(&route_assets_options.output_assets_dir, &filename);
let url = make_final_url(&route_assets_options.assets_dir, &filename);
Self {
path,
hash,
options: image_options.clone(),
filename,
url,
build_path,
cache,
}
}
pub fn placeholder(&self) -> Result<ImagePlaceholder, crate::errors::AssetError> {
get_placeholder(&self.path, self.cache.as_ref())
}
pub fn dimensions(&self) -> (u32, u32) {
image_dimensions(&self.path).unwrap_or((0, 0))
}
}
#[derive(Debug)]
pub struct ImagePlaceholder {
pub thumbhash: Vec<u8>,
pub thumbhash_base64: String,
average_rgba_cache: OnceLock<Option<(u8, u8, u8, u8)>>,
data_uri_cache: OnceLock<String>,
}
impl Clone for ImagePlaceholder {
fn clone(&self) -> Self {
Self {
thumbhash: self.thumbhash.clone(),
thumbhash_base64: self.thumbhash_base64.clone(),
average_rgba_cache: OnceLock::new(),
data_uri_cache: OnceLock::new(),
}
}
}
impl Default for ImagePlaceholder {
fn default() -> Self {
Self {
thumbhash: Vec::new(),
thumbhash_base64: String::new(),
average_rgba_cache: OnceLock::new(),
data_uri_cache: OnceLock::new(),
}
}
}
impl ImagePlaceholder {
pub fn new(thumbhash: Vec<u8>, thumbhash_base64: String) -> Self {
Self {
thumbhash,
thumbhash_base64,
average_rgba_cache: OnceLock::new(),
data_uri_cache: OnceLock::new(),
}
}
pub fn average_rgba(&self) -> Option<(u8, u8, u8, u8)> {
*self.average_rgba_cache.get_or_init(|| {
let start = Instant::now();
let result = thumb_hash_to_average_rgba(&self.thumbhash)
.ok()
.map(|(r, g, b, a)| {
(
(r * 255.0) as u8,
(g * 255.0) as u8,
(b * 255.0) as u8,
(a * 255.0) as u8,
)
});
debug!("Average RGBA calculation took {:?}", start.elapsed());
result
})
}
pub fn data_uri(&self) -> &str {
self.data_uri_cache.get_or_init(|| {
let start = Instant::now();
let rgba_start = Instant::now();
let thumbhash_rgba = thumb_hash_to_rgba(&self.thumbhash).unwrap();
debug!(
"ThumbHash to RGBA conversion took {:?}",
rgba_start.elapsed()
);
let png_start = Instant::now();
let thumbhash_png = thumbhash_to_png(&thumbhash_rgba);
debug!("PNG generation took {:?}", png_start.elapsed());
let optimized_png = if is_dev() {
thumbhash_png
} else {
let optimize_start = Instant::now();
let result =
oxipng::optimize_from_memory(&thumbhash_png, &Default::default()).unwrap();
debug!("PNG optimization took {:?}", optimize_start.elapsed());
result
};
let encode_start = Instant::now();
let base64 = base64::engine::general_purpose::STANDARD.encode(&optimized_png);
let result = format!("data:image/png;base64,{}", base64);
debug!("Data URI encoding took {:?}", encode_start.elapsed());
debug!("Total data URI generation took {:?}", start.elapsed());
result
})
}
}
fn get_placeholder(
path: &PathBuf,
cache: Option<&ImageCache>,
) -> Result<ImagePlaceholder, crate::errors::AssetError> {
if let Some(cache) = cache
&& let Some(cached) = cache.get_placeholder(path)
{
debug!("Using cached placeholder for {}", path.display());
let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash);
return Ok(ImagePlaceholder::new(cached.thumbhash, thumbhash_base64));
}
let total_start = Instant::now();
let load_start = Instant::now();
let image = image::open(path).map_err(|e| crate::errors::AssetError::ImageLoadFailed {
path: path.clone(),
source: e,
})?;
let (width, height) = image.dimensions();
let (width, height) = (width as usize, height as usize);
debug!(
"Image load took {:?} for {}",
load_start.elapsed(),
path.display()
);
let (width, height, rgba) = if width.max(height) > 100 {
let resize_start = Instant::now();
let scale = 100.0 / width.max(height) as f32;
let new_width = (width as f32 * scale).round() as usize;
let new_height = (height as f32 * scale).round() as usize;
let resized = image::imageops::resize(
&image,
new_width as u32,
new_height as u32,
image::imageops::FilterType::Nearest,
);
let result = (new_width, new_height, resized.into_raw());
debug!(
"Image resize took {:?} ({}x{} -> {}x{})",
resize_start.elapsed(),
width,
height,
new_width,
new_height
);
result
} else {
let convert_start = Instant::now();
let result = (width, height, image.to_rgba8().into_raw());
debug!("Image RGBA conversion took {:?}", convert_start.elapsed());
result
};
let thumbhash_start = Instant::now();
let thumb_hash = rgba_to_thumb_hash(width, height, &rgba);
debug!("ThumbHash generation took {:?}", thumbhash_start.elapsed());
let encode_start = Instant::now();
let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&thumb_hash);
debug!("Base64 encoding took {:?}", encode_start.elapsed());
debug!(
"Total placeholder generation took {:?} for {}",
total_start.elapsed(),
path.display()
);
if let Some(cache) = cache {
cache.cache_placeholder(path, thumb_hash.clone());
}
Ok(ImagePlaceholder::new(thumb_hash, thumbhash_base64))
}
fn thumbhash_to_png(thumbhash_rgba: &(usize, usize, Vec<u8>)) -> Vec<u8> {
let w = thumbhash_rgba.0 as u32;
let h = thumbhash_rgba.1 as u32;
let rgba = &thumbhash_rgba.2;
let row = w * 4 + 1;
let idat = 6 + h * (5 + row);
let mut bytes = vec![
137,
80,
78,
71,
13,
10,
26,
10,
0,
0,
0,
13,
73,
72,
68,
82,
0,
0,
(w >> 8) as u8,
(w & 255) as u8,
0,
0,
(h >> 8) as u8,
(h & 255) as u8,
8,
6,
0,
0,
0,
0,
0,
0,
0,
(idat >> 24) as u8,
((idat >> 16) & 255) as u8,
((idat >> 8) & 255) as u8,
(idat & 255) as u8,
73,
68,
65,
84,
120,
1,
];
let table = [
0u32, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948,
3988292384, 4027552580, 3604390888, 3412177804, 2607071920, 2262029012, 2685067896,
3183342108,
];
let mut a = 1u32;
let mut b = 0u32;
let mut i = 0usize;
let mut end = (row - 1) as usize;
for y in 0..h {
let filter_type = if y + 1 < h { 0 } else { 1 };
bytes.extend_from_slice(&[
filter_type,
(row & 255) as u8,
(row >> 8) as u8,
(!row & 255) as u8,
((row >> 8) ^ 255) as u8,
0,
]);
b = (b + a) % 65521;
while i < end {
let u = rgba[i];
bytes.push(u);
a = (a + u as u32) % 65521;
b = (b + a) % 65521;
i += 1;
}
end += (row - 1) as usize;
}
bytes.extend_from_slice(&[
(b >> 8) as u8,
(b & 255) as u8,
(a >> 8) as u8,
(a & 255) as u8,
0,
0,
0,
0,
0,
0,
0,
0,
73,
69,
78,
68,
174,
66,
96,
130,
]);
let ranges = [(12usize, 29usize), (37usize, 41 + idat as usize)];
for (start, end_pos) in ranges {
let mut c = !0u32;
for &byte in &bytes[start..end_pos] {
c ^= byte as u32;
c = (c >> 4) ^ table[(c & 15) as usize];
c = (c >> 4) ^ table[(c & 15) as usize];
}
c = !c;
let mut end_idx = end_pos;
bytes[end_idx] = (c >> 24) as u8;
end_idx += 1;
bytes[end_idx] = ((c >> 16) & 255) as u8;
end_idx += 1;
bytes[end_idx] = ((c >> 8) & 255) as u8;
end_idx += 1;
bytes[end_idx] = (c & 255) as u8;
}
bytes
}
pub trait RenderWithAlt {
fn render(&self, alt: &str) -> RenderedImage;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedImage(String);
impl From<String> for RenderedImage {
fn from(value: String) -> Self {
RenderedImage(value)
}
}
impl Display for RenderedImage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl RenderWithAlt for Image {
fn render(&self, alt: &str) -> RenderedImage {
let (width, height) = self.dimensions();
let width_attr = if width > 0 {
format!(r#" width="{width}""#)
} else {
String::new()
};
let height_attr = if height > 0 {
format!(r#" height="{height}""#)
} else {
String::new()
};
format!(
r#"<img src="{src}"{width_attr}{height_attr} loading="lazy" decoding="async" alt="{alt}"/>"#,
src = self.url,
width_attr = width_attr,
height_attr = height_attr,
alt = alt
).into()
}
}
#[cfg(test)]
mod tests {
use crate::errors::AssetError;
use super::*;
use std::{error::Error, path::PathBuf};
#[test]
fn test_placeholder_with_missing_file() {
let nonexistent_path = PathBuf::from("/this/file/does/not/exist.png");
let result = get_placeholder(&nonexistent_path, None);
assert!(result.is_err());
if let Err(AssetError::ImageLoadFailed { path, .. }) = result {
assert_eq!(path, nonexistent_path);
} else {
panic!("Expected ImageLoadFailed error");
}
}
#[test]
fn test_placeholder_with_valid_image() {
let temp_dir = tempfile::tempdir().unwrap();
let image_path = temp_dir.path().join("test.png");
let img = image::ImageBuffer::<image::Rgba<u8>, _>::from_fn(1, 1, |_x, _y| {
image::Rgba([255, 0, 0, 255])
});
img.save(&image_path).unwrap();
let result = get_placeholder(&image_path, None);
if let Err(e) = &result {
eprintln!("get_placeholder failed: {:?}", e.source());
}
assert!(result.is_ok());
let placeholder = result.unwrap();
assert!(!placeholder.thumbhash.is_empty());
assert!(!placeholder.thumbhash_base64.is_empty());
}
}