Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(assets): Adds caching to image transformation

+444 -39
+7
.sampo/changesets/cranky-runesinger-vipunen.md
··· 1 + --- 2 + packages: 3 + - maudit 4 + release: minor 5 + --- 6 + 7 + The data URI and average RGBA for thumbnails is now calculated lazily, as such the `average_rgba` and `data_uri` fields have been replaced by methods.
+7
.sampo/changesets/gallant-guardian-otso.md
··· 1 + --- 2 + packages: 3 + - maudit 4 + release: patch 5 + --- 6 + 7 + Added caching mechanism to placeholder and image transformation
+1
crates/maudit/src/assets.rs
··· 5 5 use std::{fs, path::PathBuf}; 6 6 7 7 mod image; 8 + pub mod image_cache; 8 9 mod script; 9 10 mod style; 10 11 pub use image::{Image, ImageFormat, ImageOptions};
+149 -34
crates/maudit/src/assets/image.rs
··· 1 1 use std::hash::Hash; 2 - use std::{path::PathBuf, sync::OnceLock}; 2 + use std::{path::PathBuf, sync::OnceLock, time::Instant}; 3 3 4 4 use base64::Engine; 5 5 use image::GenericImageView; 6 + use log::debug; 6 7 use thumbhash::{rgba_to_thumb_hash, thumb_hash_to_average_rgba, thumb_hash_to_rgba}; 7 8 9 + use super::image_cache::ImageCache; 8 10 use crate::assets::{Asset, InternalAsset}; 9 11 use crate::is_dev; 10 12 ··· 58 60 pub format: Option<ImageFormat>, 59 61 } 60 62 61 - #[derive(Clone, PartialEq, Eq)] 63 + #[derive(Clone)] 62 64 #[non_exhaustive] 63 65 pub struct Image { 64 66 pub path: PathBuf, ··· 77 79 } 78 80 } 79 81 82 + impl PartialEq for Image { 83 + fn eq(&self, other: &Self) -> bool { 84 + self.path == other.path 85 + && self.assets_dir == other.assets_dir 86 + && self.hash == other.hash 87 + && self.options == other.options 88 + } 89 + } 90 + 91 + impl Eq for Image {} 92 + 80 93 impl Image { 81 94 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 82 95 /// 83 96 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 84 97 pub fn placeholder(&self) -> &ImagePlaceholder { 85 98 self.__cache_placeholder 86 - .get_or_init(|| get_placeholder(&self.path).unwrap_or_default()) 99 + .get_or_init(|| get_placeholder(&self.path)) 87 100 } 88 101 } 89 102 90 - #[derive(Debug, Clone, PartialEq, Default, Eq)] 103 + #[derive(Debug)] 91 104 pub struct ImagePlaceholder { 92 105 pub thumbhash: Vec<u8>, 93 106 pub thumbhash_base64: String, 94 - pub average_rgba: Option<(u8, u8, u8, u8)>, 95 - pub data_uri: String, 107 + average_rgba_cache: OnceLock<Option<(u8, u8, u8, u8)>>, 108 + data_uri_cache: OnceLock<String>, 109 + } 110 + 111 + impl Clone for ImagePlaceholder { 112 + fn clone(&self) -> Self { 113 + Self { 114 + thumbhash: self.thumbhash.clone(), 115 + thumbhash_base64: self.thumbhash_base64.clone(), 116 + average_rgba_cache: OnceLock::new(), 117 + data_uri_cache: OnceLock::new(), 118 + } 119 + } 120 + } 121 + 122 + impl Default for ImagePlaceholder { 123 + fn default() -> Self { 124 + Self { 125 + thumbhash: Vec::new(), 126 + thumbhash_base64: String::new(), 127 + average_rgba_cache: OnceLock::new(), 128 + data_uri_cache: OnceLock::new(), 129 + } 130 + } 131 + } 132 + 133 + impl ImagePlaceholder { 134 + pub fn average_rgba(&self) -> Option<(u8, u8, u8, u8)> { 135 + *self.average_rgba_cache.get_or_init(|| { 136 + let start = Instant::now(); 137 + let result = thumb_hash_to_average_rgba(&self.thumbhash) 138 + .ok() 139 + .map(|(r, g, b, a)| { 140 + ( 141 + (r * 255.0) as u8, 142 + (g * 255.0) as u8, 143 + (b * 255.0) as u8, 144 + (a * 255.0) as u8, 145 + ) 146 + }); 147 + debug!("Average RGBA calculation took {:?}", start.elapsed()); 148 + result 149 + }) 150 + } 151 + 152 + pub fn data_uri(&self) -> &str { 153 + self.data_uri_cache.get_or_init(|| { 154 + let start = Instant::now(); 155 + 156 + let rgba_start = Instant::now(); 157 + let thumbhash_rgba = thumb_hash_to_rgba(&self.thumbhash).unwrap(); 158 + debug!( 159 + "ThumbHash to RGBA conversion took {:?}", 160 + rgba_start.elapsed() 161 + ); 162 + 163 + let png_start = Instant::now(); 164 + let thumbhash_png = thumbhash_to_png(&thumbhash_rgba); 165 + debug!("PNG generation took {:?}", png_start.elapsed()); 166 + 167 + let optimized_png = if is_dev() { 168 + thumbhash_png 169 + } else { 170 + let optimize_start = Instant::now(); 171 + let result = 172 + oxipng::optimize_from_memory(&thumbhash_png, &Default::default()).unwrap(); 173 + debug!("PNG optimization took {:?}", optimize_start.elapsed()); 174 + result 175 + }; 176 + 177 + let encode_start = Instant::now(); 178 + let base64 = base64::engine::general_purpose::STANDARD.encode(&optimized_png); 179 + let result = format!("data:image/png;base64,{}", base64); 180 + debug!("Data URI encoding took {:?}", encode_start.elapsed()); 181 + 182 + debug!("Total data URI generation took {:?}", start.elapsed()); 183 + result 184 + }) 185 + } 96 186 } 97 187 98 - fn get_placeholder(path: &PathBuf) -> Option<ImagePlaceholder> { 99 - let image = image::open(path).ok()?; 188 + fn get_placeholder(path: &PathBuf) -> ImagePlaceholder { 189 + // Check cache first 190 + if let Some(cached) = ImageCache::get_placeholder(path) { 191 + debug!("Using cached placeholder for {}", path.display()); 192 + let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 193 + return ImagePlaceholder { 194 + thumbhash: cached.thumbhash, 195 + thumbhash_base64, 196 + average_rgba_cache: OnceLock::new(), 197 + data_uri_cache: OnceLock::new(), 198 + }; 199 + } 200 + 201 + let total_start = Instant::now(); 202 + 203 + let load_start = Instant::now(); 204 + let image = image::open(path).ok().unwrap(); 100 205 let (width, height) = image.dimensions(); 101 206 let (width, height) = (width as usize, height as usize); 207 + debug!( 208 + "Image load took {:?} for {}", 209 + load_start.elapsed(), 210 + path.display() 211 + ); 102 212 103 213 // If width or height > 100, resize image down to max 100 104 214 let (width, height, rgba) = if width.max(height) > 100 { 215 + let resize_start = Instant::now(); 105 216 let scale = 100.0 / width.max(height) as f32; 106 217 let new_width = (width as f32 * scale).round() as usize; 107 218 let new_height = (height as f32 * scale).round() as usize; ··· 112 223 new_height as u32, 113 224 image::imageops::FilterType::Nearest, 114 225 ); 115 - (new_width, new_height, resized.into_raw()) 226 + let result = (new_width, new_height, resized.into_raw()); 227 + debug!( 228 + "Image resize took {:?} ({}x{} -> {}x{})", 229 + resize_start.elapsed(), 230 + width, 231 + height, 232 + new_width, 233 + new_height 234 + ); 235 + result 116 236 } else { 117 - (width, height, image.to_rgba8().into_raw()) 237 + let convert_start = Instant::now(); 238 + let result = (width, height, image.to_rgba8().into_raw()); 239 + debug!("Image RGBA conversion took {:?}", convert_start.elapsed()); 240 + result 118 241 }; 119 242 243 + let thumbhash_start = Instant::now(); 120 244 let thumb_hash = rgba_to_thumb_hash(width, height, &rgba); 121 - let average_rgba = thumb_hash_to_average_rgba(&thumb_hash) 122 - .ok() 123 - .map(|(r, g, b, a)| { 124 - ( 125 - (r * 255.0) as u8, 126 - (g * 255.0) as u8, 127 - (b * 255.0) as u8, 128 - (a * 255.0) as u8, 129 - ) 130 - }); 245 + debug!("ThumbHash generation took {:?}", thumbhash_start.elapsed()); 131 246 132 - let thumbhash_rgba = thumb_hash_to_rgba(&thumb_hash).ok().unwrap(); 133 - let thumbhash_png = thumbhash_to_png(&thumbhash_rgba); 134 - let optimized_png = if is_dev() { 135 - thumbhash_png 136 - } else { 137 - oxipng::optimize_from_memory(&thumbhash_png, &Default::default()).unwrap() 138 - }; 247 + let encode_start = Instant::now(); 248 + let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&thumb_hash); 249 + debug!("Base64 encoding took {:?}", encode_start.elapsed()); 139 250 140 - let base64 = base64::engine::general_purpose::STANDARD.encode(&optimized_png); 141 - let data_uri = format!("data:image/png;base64,{}", base64); 251 + debug!( 252 + "Total placeholder generation took {:?} for {}", 253 + total_start.elapsed(), 254 + path.display() 255 + ); 142 256 143 - let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&thumb_hash); 257 + // Cache the result 258 + ImageCache::cache_placeholder(path, thumb_hash.clone()); 144 259 145 - Some(ImagePlaceholder { 260 + ImagePlaceholder { 146 261 thumbhash: thumb_hash, 147 262 thumbhash_base64, 148 - average_rgba, 149 - data_uri, 150 - }) 263 + average_rgba_cache: OnceLock::new(), 264 + data_uri_cache: OnceLock::new(), 265 + } 151 266 } 152 267 153 268 /// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234
+247
crates/maudit/src/assets/image_cache.rs
··· 1 + use std::{ 2 + fs, 3 + path::{Path, PathBuf}, 4 + sync::{Mutex, OnceLock}, 5 + }; 6 + 7 + use base64::Engine; 8 + use log::debug; 9 + use rustc_hash::FxHashMap; 10 + 11 + pub const IMAGE_CACHE_DIR: &str = "target/maudit_cache/images"; 12 + pub const MANIFEST_VERSION: u32 = 1; 13 + 14 + #[derive(Debug, Clone)] 15 + pub struct PlaceholderCacheEntry { 16 + pub thumbhash: Vec<u8>, 17 + } 18 + 19 + #[derive(Debug, Clone)] 20 + pub struct TransformedImageCacheEntry { 21 + /// Path to the cached transformed image file 22 + pub cached_path: PathBuf, 23 + } 24 + 25 + #[derive(Debug, Default)] 26 + struct CacheManifest { 27 + /// Cache for placeholder data (thumbhash, etc.) 28 + placeholders: FxHashMap<PathBuf, PlaceholderCacheEntry>, 29 + /// Cache for transformed images (path + options -> cached file path) 30 + transformed: FxHashMap<String, TransformedImageCacheEntry>, 31 + } 32 + 33 + pub struct ImageCache { 34 + manifest: CacheManifest, 35 + cache_dir: PathBuf, 36 + manifest_path: PathBuf, 37 + } 38 + 39 + static CACHE: OnceLock<Mutex<ImageCache>> = OnceLock::new(); 40 + 41 + impl ImageCache { 42 + fn new() -> Self { 43 + let cache_dir = PathBuf::from(IMAGE_CACHE_DIR); 44 + let manifest_path = cache_dir.join("manifest"); 45 + 46 + // Create cache directory if it doesn't exist 47 + if let Err(e) = fs::create_dir_all(&cache_dir) { 48 + debug!("Failed to create cache directory: {}", e); 49 + } 50 + 51 + // Load existing manifest or create new one 52 + let manifest = if manifest_path.exists() { 53 + Self::load_manifest(&manifest_path).unwrap_or_default() 54 + } else { 55 + CacheManifest::default() 56 + }; 57 + 58 + debug!( 59 + "Image cache initialized with {} placeholders and {} transformed images", 60 + manifest.placeholders.len(), 61 + manifest.transformed.len() 62 + ); 63 + 64 + Self { 65 + manifest, 66 + cache_dir, 67 + manifest_path, 68 + } 69 + } 70 + 71 + fn load_manifest(path: &Path) -> Option<CacheManifest> { 72 + let content = fs::read_to_string(path).ok()?; 73 + let mut manifest = CacheManifest::default(); 74 + let mut found_version = None; 75 + 76 + let mut current_section = ""; 77 + for line in content.lines() { 78 + let line = line.trim(); 79 + if line.is_empty() || line.starts_with('#') { 80 + continue; 81 + } 82 + 83 + // Check for version line 84 + if line.starts_with("version = ") { 85 + if let Some(version_str) = line.strip_prefix("version = ") 86 + && let Ok(version) = version_str.parse::<u32>() 87 + { 88 + found_version = Some(version); 89 + } 90 + continue; 91 + } 92 + 93 + if line == "[placeholders]" { 94 + current_section = "placeholders"; 95 + continue; 96 + } else if line == "[transformed]" { 97 + current_section = "transformed"; 98 + continue; 99 + } 100 + 101 + match current_section { 102 + "placeholders" => { 103 + if let Some((path_str, thumbhash_b64)) = line.split_once('=') 104 + && let Ok(thumbhash) = 105 + base64::engine::general_purpose::STANDARD.decode(thumbhash_b64) 106 + { 107 + let entry = PlaceholderCacheEntry { thumbhash }; 108 + manifest.placeholders.insert(PathBuf::from(path_str), entry); 109 + } 110 + } 111 + "transformed" => { 112 + if let Some((cache_key, cached_path_str)) = line.split_once('=') { 113 + let entry = TransformedImageCacheEntry { 114 + cached_path: PathBuf::from(cached_path_str), 115 + }; 116 + manifest.transformed.insert(cache_key.to_string(), entry); 117 + } 118 + } 119 + _ => {} 120 + } 121 + } 122 + 123 + // Check version compatibility 124 + if let Some(version) = found_version { 125 + if version != MANIFEST_VERSION { 126 + debug!( 127 + "Manifest version mismatch: found {}, expected {}. Invalidating cache.", 128 + version, MANIFEST_VERSION 129 + ); 130 + // Delete the manifest file to invalidate the cache 131 + let _ = fs::remove_file(path); 132 + return None; 133 + } 134 + } else { 135 + debug!("No version found in manifest. Invalidating cache."); 136 + let _ = fs::remove_file(path); 137 + return None; 138 + } 139 + 140 + Some(manifest) 141 + } 142 + 143 + fn get() -> &'static Mutex<ImageCache> { 144 + CACHE.get_or_init(|| Mutex::new(ImageCache::new())) 145 + } 146 + 147 + fn save_manifest(&self) { 148 + let mut content = String::new(); 149 + content.push_str("# Maudit Image Cache Manifest\n"); 150 + content.push_str(&format!("version = {}\n\n", MANIFEST_VERSION)); 151 + 152 + // Write placeholders section 153 + content.push_str("[placeholders]\n"); 154 + for (path, entry) in &self.manifest.placeholders { 155 + let thumbhash_b64 = base64::engine::general_purpose::STANDARD.encode(&entry.thumbhash); 156 + content.push_str(&format!("{}={}\n", path.to_string_lossy(), thumbhash_b64)); 157 + } 158 + 159 + content.push_str("\n[transformed]\n"); 160 + for (cache_key, entry) in &self.manifest.transformed { 161 + content.push_str(&format!( 162 + "{}={}\n", 163 + cache_key, 164 + entry.cached_path.to_string_lossy() 165 + )); 166 + } 167 + 168 + if let Err(e) = fs::write(&self.manifest_path, content) { 169 + debug!("Failed to save cache manifest: {}", e); 170 + } 171 + } 172 + 173 + /// Get cached placeholder or None if not found 174 + pub fn get_placeholder(src_path: &Path) -> Option<PlaceholderCacheEntry> { 175 + let cache = Self::get().lock().ok()?; 176 + let entry = cache.manifest.placeholders.get(src_path)?; 177 + 178 + debug!("Placeholder cache hit for {}", src_path.display()); 179 + Some(entry.clone()) 180 + } 181 + 182 + /// Cache a placeholder 183 + pub fn cache_placeholder(src_path: &Path, thumbhash: Vec<u8>) { 184 + if let Ok(mut cache) = Self::get().lock() { 185 + let entry = PlaceholderCacheEntry { thumbhash }; 186 + 187 + cache 188 + .manifest 189 + .placeholders 190 + .insert(src_path.to_path_buf(), entry); 191 + cache.save_manifest(); 192 + debug!("Cached placeholder for {}", src_path.display()); 193 + } 194 + } 195 + 196 + /// Get cached transformed image path or None if not found 197 + pub fn get_transformed_image(final_filename: &str) -> Option<PathBuf> { 198 + let cache = Self::get().lock().ok()?; 199 + let entry = cache.manifest.transformed.get(final_filename)?; 200 + 201 + // Check if cached file still exists 202 + if !entry.cached_path.exists() { 203 + debug!( 204 + "Cached transformed image file missing: {}", 205 + entry.cached_path.display() 206 + ); 207 + return None; 208 + } 209 + 210 + debug!( 211 + "Transformed image cache hit for {} -> {}", 212 + final_filename, 213 + entry.cached_path.display() 214 + ); 215 + Some(entry.cached_path.clone()) 216 + } 217 + 218 + /// Cache a transformed image 219 + pub fn cache_transformed_image(final_filename: &str, cached_path: PathBuf) { 220 + if let Ok(mut cache) = Self::get().lock() { 221 + let entry = TransformedImageCacheEntry { 222 + cached_path: cached_path.clone(), 223 + }; 224 + 225 + cache 226 + .manifest 227 + .transformed 228 + .insert(final_filename.to_string(), entry); 229 + cache.save_manifest(); 230 + debug!( 231 + "Cached transformed image {} -> {}", 232 + final_filename, 233 + cached_path.display() 234 + ); 235 + } 236 + } 237 + 238 + /// Generate a cache path for a transformed image 239 + pub fn generate_cache_path(final_filename: &str) -> PathBuf { 240 + if let Ok(cache) = Self::get().lock() { 241 + cache.cache_dir.join(final_filename) 242 + } else { 243 + // Fallback path if cache is unavailable 244 + PathBuf::from(IMAGE_CACHE_DIR).join(final_filename) 245 + } 246 + } 247 + }
+33 -3
crates/maudit/src/build.rs
··· 12 12 13 13 use crate::{ 14 14 BuildOptions, BuildOutput, 15 - assets::{self}, 15 + assets::{ 16 + self, 17 + image_cache::{IMAGE_CACHE_DIR, ImageCache}, 18 + }, 19 + build::images::process_image, 16 20 content::{Content, ContentSources}, 17 21 errors::BuildError, 18 22 is_dev, ··· 24 28 }, 25 29 }; 26 30 use colored::{ColoredString, Colorize}; 27 - use log::{info, trace}; 31 + use log::{debug, info, trace}; 28 32 use oxc_sourcemap::SourceMap; 29 33 use rolldown::{ 30 34 Bundler, BundlerOptions, InputItem, ModuleType, ··· 425 429 if !build_pages_images.is_empty() { 426 430 print_title("processing images"); 427 431 432 + let _ = fs::create_dir_all(IMAGE_CACHE_DIR); 433 + 428 434 let start_time = Instant::now(); 429 435 build_pages_images.par_iter().for_each(|image| { 430 436 let start_process = Instant::now(); 431 437 let dest_path = assets_dir.join(image.final_file_name()); 438 + 432 439 if let Some(image_options) = &image.options { 433 - images::process_image(image, &dest_path, image_options); 440 + let final_filename = image.final_file_name(); 441 + 442 + // Check cache for transformed images 443 + if let Some(cached_path) = ImageCache::get_transformed_image(&final_filename) { 444 + // Copy from cache instead of processing 445 + if fs::copy(&cached_path, &dest_path).is_ok() { 446 + info!(target: "assets", "{} -> {} (from cache) {}", image.path().to_string_lossy(), dest_path.to_string_lossy().dimmed(), format_elapsed_time(start_process.elapsed(), &route_format_options).dimmed()); 447 + return; 448 + } 449 + } 450 + 451 + // Generate cache path for transformed image 452 + let cache_path = ImageCache::generate_cache_path(&final_filename); 453 + 454 + // Process image directly to cache 455 + process_image(image, &cache_path, image_options); 456 + 457 + // Copy from cache to destination 458 + if fs::copy(&cache_path, &dest_path).is_ok() { 459 + // Cache the processed image path 460 + ImageCache::cache_transformed_image(&final_filename, cache_path); 461 + } else { 462 + debug!("Failed to copy from cache {} to dest {}", cache_path.display(), dest_path.display()); 463 + } 434 464 } else if !dest_path.exists() { 435 465 // TODO: Check if copying should be done in this parallel iterator, I/O doesn't benefit from parallelism so having those tasks here might just be slowing processing 436 466 fs::copy(image.path(), &dest_path).unwrap_or_else(|e| {
-2
examples/image-processing/src/pages/index.rs
··· 17 17 }, 18 18 ); 19 19 20 - println!("Walrus placeholder ({:?})", walrus.placeholder()); 21 - 22 20 layout(html! { 23 21 (logo) 24 22 h1 { "Hello World" }