···11+---
22+packages:
33+ - maudit
44+release: minor
55+---
66+77+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
···11+---
22+packages:
33+ - maudit
44+release: patch
55+---
66+77+Added caching mechanism to placeholder and image transformation
+1
crates/maudit/src/assets.rs
···55use std::{fs, path::PathBuf};
6677mod image;
88+pub mod image_cache;
89mod script;
910mod style;
1011pub use image::{Image, ImageFormat, ImageOptions};
+149-34
crates/maudit/src/assets/image.rs
···11use std::hash::Hash;
22-use std::{path::PathBuf, sync::OnceLock};
22+use std::{path::PathBuf, sync::OnceLock, time::Instant};
3344use base64::Engine;
55use image::GenericImageView;
66+use log::debug;
67use thumbhash::{rgba_to_thumb_hash, thumb_hash_to_average_rgba, thumb_hash_to_rgba};
7899+use super::image_cache::ImageCache;
810use crate::assets::{Asset, InternalAsset};
911use crate::is_dev;
1012···5860 pub format: Option<ImageFormat>,
5961}
60626161-#[derive(Clone, PartialEq, Eq)]
6363+#[derive(Clone)]
6264#[non_exhaustive]
6365pub struct Image {
6466 pub path: PathBuf,
···7779 }
7880}
79818282+impl PartialEq for Image {
8383+ fn eq(&self, other: &Self) -> bool {
8484+ self.path == other.path
8585+ && self.assets_dir == other.assets_dir
8686+ && self.hash == other.hash
8787+ && self.options == other.options
8888+ }
8989+}
9090+9191+impl Eq for Image {}
9292+8093impl Image {
8194 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques.
8295 ///
8396 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image.
8497 pub fn placeholder(&self) -> &ImagePlaceholder {
8598 self.__cache_placeholder
8686- .get_or_init(|| get_placeholder(&self.path).unwrap_or_default())
9999+ .get_or_init(|| get_placeholder(&self.path))
87100 }
88101}
891029090-#[derive(Debug, Clone, PartialEq, Default, Eq)]
103103+#[derive(Debug)]
91104pub struct ImagePlaceholder {
92105 pub thumbhash: Vec<u8>,
93106 pub thumbhash_base64: String,
9494- pub average_rgba: Option<(u8, u8, u8, u8)>,
9595- pub data_uri: String,
107107+ average_rgba_cache: OnceLock<Option<(u8, u8, u8, u8)>>,
108108+ data_uri_cache: OnceLock<String>,
109109+}
110110+111111+impl Clone for ImagePlaceholder {
112112+ fn clone(&self) -> Self {
113113+ Self {
114114+ thumbhash: self.thumbhash.clone(),
115115+ thumbhash_base64: self.thumbhash_base64.clone(),
116116+ average_rgba_cache: OnceLock::new(),
117117+ data_uri_cache: OnceLock::new(),
118118+ }
119119+ }
120120+}
121121+122122+impl Default for ImagePlaceholder {
123123+ fn default() -> Self {
124124+ Self {
125125+ thumbhash: Vec::new(),
126126+ thumbhash_base64: String::new(),
127127+ average_rgba_cache: OnceLock::new(),
128128+ data_uri_cache: OnceLock::new(),
129129+ }
130130+ }
131131+}
132132+133133+impl ImagePlaceholder {
134134+ pub fn average_rgba(&self) -> Option<(u8, u8, u8, u8)> {
135135+ *self.average_rgba_cache.get_or_init(|| {
136136+ let start = Instant::now();
137137+ let result = thumb_hash_to_average_rgba(&self.thumbhash)
138138+ .ok()
139139+ .map(|(r, g, b, a)| {
140140+ (
141141+ (r * 255.0) as u8,
142142+ (g * 255.0) as u8,
143143+ (b * 255.0) as u8,
144144+ (a * 255.0) as u8,
145145+ )
146146+ });
147147+ debug!("Average RGBA calculation took {:?}", start.elapsed());
148148+ result
149149+ })
150150+ }
151151+152152+ pub fn data_uri(&self) -> &str {
153153+ self.data_uri_cache.get_or_init(|| {
154154+ let start = Instant::now();
155155+156156+ let rgba_start = Instant::now();
157157+ let thumbhash_rgba = thumb_hash_to_rgba(&self.thumbhash).unwrap();
158158+ debug!(
159159+ "ThumbHash to RGBA conversion took {:?}",
160160+ rgba_start.elapsed()
161161+ );
162162+163163+ let png_start = Instant::now();
164164+ let thumbhash_png = thumbhash_to_png(&thumbhash_rgba);
165165+ debug!("PNG generation took {:?}", png_start.elapsed());
166166+167167+ let optimized_png = if is_dev() {
168168+ thumbhash_png
169169+ } else {
170170+ let optimize_start = Instant::now();
171171+ let result =
172172+ oxipng::optimize_from_memory(&thumbhash_png, &Default::default()).unwrap();
173173+ debug!("PNG optimization took {:?}", optimize_start.elapsed());
174174+ result
175175+ };
176176+177177+ let encode_start = Instant::now();
178178+ let base64 = base64::engine::general_purpose::STANDARD.encode(&optimized_png);
179179+ let result = format!("data:image/png;base64,{}", base64);
180180+ debug!("Data URI encoding took {:?}", encode_start.elapsed());
181181+182182+ debug!("Total data URI generation took {:?}", start.elapsed());
183183+ result
184184+ })
185185+ }
96186}
971879898-fn get_placeholder(path: &PathBuf) -> Option<ImagePlaceholder> {
9999- let image = image::open(path).ok()?;
188188+fn get_placeholder(path: &PathBuf) -> ImagePlaceholder {
189189+ // Check cache first
190190+ if let Some(cached) = ImageCache::get_placeholder(path) {
191191+ debug!("Using cached placeholder for {}", path.display());
192192+ let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash);
193193+ return ImagePlaceholder {
194194+ thumbhash: cached.thumbhash,
195195+ thumbhash_base64,
196196+ average_rgba_cache: OnceLock::new(),
197197+ data_uri_cache: OnceLock::new(),
198198+ };
199199+ }
200200+201201+ let total_start = Instant::now();
202202+203203+ let load_start = Instant::now();
204204+ let image = image::open(path).ok().unwrap();
100205 let (width, height) = image.dimensions();
101206 let (width, height) = (width as usize, height as usize);
207207+ debug!(
208208+ "Image load took {:?} for {}",
209209+ load_start.elapsed(),
210210+ path.display()
211211+ );
102212103213 // If width or height > 100, resize image down to max 100
104214 let (width, height, rgba) = if width.max(height) > 100 {
215215+ let resize_start = Instant::now();
105216 let scale = 100.0 / width.max(height) as f32;
106217 let new_width = (width as f32 * scale).round() as usize;
107218 let new_height = (height as f32 * scale).round() as usize;
···112223 new_height as u32,
113224 image::imageops::FilterType::Nearest,
114225 );
115115- (new_width, new_height, resized.into_raw())
226226+ let result = (new_width, new_height, resized.into_raw());
227227+ debug!(
228228+ "Image resize took {:?} ({}x{} -> {}x{})",
229229+ resize_start.elapsed(),
230230+ width,
231231+ height,
232232+ new_width,
233233+ new_height
234234+ );
235235+ result
116236 } else {
117117- (width, height, image.to_rgba8().into_raw())
237237+ let convert_start = Instant::now();
238238+ let result = (width, height, image.to_rgba8().into_raw());
239239+ debug!("Image RGBA conversion took {:?}", convert_start.elapsed());
240240+ result
118241 };
119242243243+ let thumbhash_start = Instant::now();
120244 let thumb_hash = rgba_to_thumb_hash(width, height, &rgba);
121121- let average_rgba = thumb_hash_to_average_rgba(&thumb_hash)
122122- .ok()
123123- .map(|(r, g, b, a)| {
124124- (
125125- (r * 255.0) as u8,
126126- (g * 255.0) as u8,
127127- (b * 255.0) as u8,
128128- (a * 255.0) as u8,
129129- )
130130- });
245245+ debug!("ThumbHash generation took {:?}", thumbhash_start.elapsed());
131246132132- let thumbhash_rgba = thumb_hash_to_rgba(&thumb_hash).ok().unwrap();
133133- let thumbhash_png = thumbhash_to_png(&thumbhash_rgba);
134134- let optimized_png = if is_dev() {
135135- thumbhash_png
136136- } else {
137137- oxipng::optimize_from_memory(&thumbhash_png, &Default::default()).unwrap()
138138- };
247247+ let encode_start = Instant::now();
248248+ let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&thumb_hash);
249249+ debug!("Base64 encoding took {:?}", encode_start.elapsed());
139250140140- let base64 = base64::engine::general_purpose::STANDARD.encode(&optimized_png);
141141- let data_uri = format!("data:image/png;base64,{}", base64);
251251+ debug!(
252252+ "Total placeholder generation took {:?} for {}",
253253+ total_start.elapsed(),
254254+ path.display()
255255+ );
142256143143- let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&thumb_hash);
257257+ // Cache the result
258258+ ImageCache::cache_placeholder(path, thumb_hash.clone());
144259145145- Some(ImagePlaceholder {
260260+ ImagePlaceholder {
146261 thumbhash: thumb_hash,
147262 thumbhash_base64,
148148- average_rgba,
149149- data_uri,
150150- })
263263+ average_rgba_cache: OnceLock::new(),
264264+ data_uri_cache: OnceLock::new(),
265265+ }
151266}
152267153268/// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234
+247
crates/maudit/src/assets/image_cache.rs
···11+use std::{
22+ fs,
33+ path::{Path, PathBuf},
44+ sync::{Mutex, OnceLock},
55+};
66+77+use base64::Engine;
88+use log::debug;
99+use rustc_hash::FxHashMap;
1010+1111+pub const IMAGE_CACHE_DIR: &str = "target/maudit_cache/images";
1212+pub const MANIFEST_VERSION: u32 = 1;
1313+1414+#[derive(Debug, Clone)]
1515+pub struct PlaceholderCacheEntry {
1616+ pub thumbhash: Vec<u8>,
1717+}
1818+1919+#[derive(Debug, Clone)]
2020+pub struct TransformedImageCacheEntry {
2121+ /// Path to the cached transformed image file
2222+ pub cached_path: PathBuf,
2323+}
2424+2525+#[derive(Debug, Default)]
2626+struct CacheManifest {
2727+ /// Cache for placeholder data (thumbhash, etc.)
2828+ placeholders: FxHashMap<PathBuf, PlaceholderCacheEntry>,
2929+ /// Cache for transformed images (path + options -> cached file path)
3030+ transformed: FxHashMap<String, TransformedImageCacheEntry>,
3131+}
3232+3333+pub struct ImageCache {
3434+ manifest: CacheManifest,
3535+ cache_dir: PathBuf,
3636+ manifest_path: PathBuf,
3737+}
3838+3939+static CACHE: OnceLock<Mutex<ImageCache>> = OnceLock::new();
4040+4141+impl ImageCache {
4242+ fn new() -> Self {
4343+ let cache_dir = PathBuf::from(IMAGE_CACHE_DIR);
4444+ let manifest_path = cache_dir.join("manifest");
4545+4646+ // Create cache directory if it doesn't exist
4747+ if let Err(e) = fs::create_dir_all(&cache_dir) {
4848+ debug!("Failed to create cache directory: {}", e);
4949+ }
5050+5151+ // Load existing manifest or create new one
5252+ let manifest = if manifest_path.exists() {
5353+ Self::load_manifest(&manifest_path).unwrap_or_default()
5454+ } else {
5555+ CacheManifest::default()
5656+ };
5757+5858+ debug!(
5959+ "Image cache initialized with {} placeholders and {} transformed images",
6060+ manifest.placeholders.len(),
6161+ manifest.transformed.len()
6262+ );
6363+6464+ Self {
6565+ manifest,
6666+ cache_dir,
6767+ manifest_path,
6868+ }
6969+ }
7070+7171+ fn load_manifest(path: &Path) -> Option<CacheManifest> {
7272+ let content = fs::read_to_string(path).ok()?;
7373+ let mut manifest = CacheManifest::default();
7474+ let mut found_version = None;
7575+7676+ let mut current_section = "";
7777+ for line in content.lines() {
7878+ let line = line.trim();
7979+ if line.is_empty() || line.starts_with('#') {
8080+ continue;
8181+ }
8282+8383+ // Check for version line
8484+ if line.starts_with("version = ") {
8585+ if let Some(version_str) = line.strip_prefix("version = ")
8686+ && let Ok(version) = version_str.parse::<u32>()
8787+ {
8888+ found_version = Some(version);
8989+ }
9090+ continue;
9191+ }
9292+9393+ if line == "[placeholders]" {
9494+ current_section = "placeholders";
9595+ continue;
9696+ } else if line == "[transformed]" {
9797+ current_section = "transformed";
9898+ continue;
9999+ }
100100+101101+ match current_section {
102102+ "placeholders" => {
103103+ if let Some((path_str, thumbhash_b64)) = line.split_once('=')
104104+ && let Ok(thumbhash) =
105105+ base64::engine::general_purpose::STANDARD.decode(thumbhash_b64)
106106+ {
107107+ let entry = PlaceholderCacheEntry { thumbhash };
108108+ manifest.placeholders.insert(PathBuf::from(path_str), entry);
109109+ }
110110+ }
111111+ "transformed" => {
112112+ if let Some((cache_key, cached_path_str)) = line.split_once('=') {
113113+ let entry = TransformedImageCacheEntry {
114114+ cached_path: PathBuf::from(cached_path_str),
115115+ };
116116+ manifest.transformed.insert(cache_key.to_string(), entry);
117117+ }
118118+ }
119119+ _ => {}
120120+ }
121121+ }
122122+123123+ // Check version compatibility
124124+ if let Some(version) = found_version {
125125+ if version != MANIFEST_VERSION {
126126+ debug!(
127127+ "Manifest version mismatch: found {}, expected {}. Invalidating cache.",
128128+ version, MANIFEST_VERSION
129129+ );
130130+ // Delete the manifest file to invalidate the cache
131131+ let _ = fs::remove_file(path);
132132+ return None;
133133+ }
134134+ } else {
135135+ debug!("No version found in manifest. Invalidating cache.");
136136+ let _ = fs::remove_file(path);
137137+ return None;
138138+ }
139139+140140+ Some(manifest)
141141+ }
142142+143143+ fn get() -> &'static Mutex<ImageCache> {
144144+ CACHE.get_or_init(|| Mutex::new(ImageCache::new()))
145145+ }
146146+147147+ fn save_manifest(&self) {
148148+ let mut content = String::new();
149149+ content.push_str("# Maudit Image Cache Manifest\n");
150150+ content.push_str(&format!("version = {}\n\n", MANIFEST_VERSION));
151151+152152+ // Write placeholders section
153153+ content.push_str("[placeholders]\n");
154154+ for (path, entry) in &self.manifest.placeholders {
155155+ let thumbhash_b64 = base64::engine::general_purpose::STANDARD.encode(&entry.thumbhash);
156156+ content.push_str(&format!("{}={}\n", path.to_string_lossy(), thumbhash_b64));
157157+ }
158158+159159+ content.push_str("\n[transformed]\n");
160160+ for (cache_key, entry) in &self.manifest.transformed {
161161+ content.push_str(&format!(
162162+ "{}={}\n",
163163+ cache_key,
164164+ entry.cached_path.to_string_lossy()
165165+ ));
166166+ }
167167+168168+ if let Err(e) = fs::write(&self.manifest_path, content) {
169169+ debug!("Failed to save cache manifest: {}", e);
170170+ }
171171+ }
172172+173173+ /// Get cached placeholder or None if not found
174174+ pub fn get_placeholder(src_path: &Path) -> Option<PlaceholderCacheEntry> {
175175+ let cache = Self::get().lock().ok()?;
176176+ let entry = cache.manifest.placeholders.get(src_path)?;
177177+178178+ debug!("Placeholder cache hit for {}", src_path.display());
179179+ Some(entry.clone())
180180+ }
181181+182182+ /// Cache a placeholder
183183+ pub fn cache_placeholder(src_path: &Path, thumbhash: Vec<u8>) {
184184+ if let Ok(mut cache) = Self::get().lock() {
185185+ let entry = PlaceholderCacheEntry { thumbhash };
186186+187187+ cache
188188+ .manifest
189189+ .placeholders
190190+ .insert(src_path.to_path_buf(), entry);
191191+ cache.save_manifest();
192192+ debug!("Cached placeholder for {}", src_path.display());
193193+ }
194194+ }
195195+196196+ /// Get cached transformed image path or None if not found
197197+ pub fn get_transformed_image(final_filename: &str) -> Option<PathBuf> {
198198+ let cache = Self::get().lock().ok()?;
199199+ let entry = cache.manifest.transformed.get(final_filename)?;
200200+201201+ // Check if cached file still exists
202202+ if !entry.cached_path.exists() {
203203+ debug!(
204204+ "Cached transformed image file missing: {}",
205205+ entry.cached_path.display()
206206+ );
207207+ return None;
208208+ }
209209+210210+ debug!(
211211+ "Transformed image cache hit for {} -> {}",
212212+ final_filename,
213213+ entry.cached_path.display()
214214+ );
215215+ Some(entry.cached_path.clone())
216216+ }
217217+218218+ /// Cache a transformed image
219219+ pub fn cache_transformed_image(final_filename: &str, cached_path: PathBuf) {
220220+ if let Ok(mut cache) = Self::get().lock() {
221221+ let entry = TransformedImageCacheEntry {
222222+ cached_path: cached_path.clone(),
223223+ };
224224+225225+ cache
226226+ .manifest
227227+ .transformed
228228+ .insert(final_filename.to_string(), entry);
229229+ cache.save_manifest();
230230+ debug!(
231231+ "Cached transformed image {} -> {}",
232232+ final_filename,
233233+ cached_path.display()
234234+ );
235235+ }
236236+ }
237237+238238+ /// Generate a cache path for a transformed image
239239+ pub fn generate_cache_path(final_filename: &str) -> PathBuf {
240240+ if let Ok(cache) = Self::get().lock() {
241241+ cache.cache_dir.join(final_filename)
242242+ } else {
243243+ // Fallback path if cache is unavailable
244244+ PathBuf::from(IMAGE_CACHE_DIR).join(final_filename)
245245+ }
246246+ }
247247+}
+33-3
crates/maudit/src/build.rs
···12121313use crate::{
1414 BuildOptions, BuildOutput,
1515- assets::{self},
1515+ assets::{
1616+ self,
1717+ image_cache::{IMAGE_CACHE_DIR, ImageCache},
1818+ },
1919+ build::images::process_image,
1620 content::{Content, ContentSources},
1721 errors::BuildError,
1822 is_dev,
···2428 },
2529};
2630use colored::{ColoredString, Colorize};
2727-use log::{info, trace};
3131+use log::{debug, info, trace};
2832use oxc_sourcemap::SourceMap;
2933use rolldown::{
3034 Bundler, BundlerOptions, InputItem, ModuleType,
···425429 if !build_pages_images.is_empty() {
426430 print_title("processing images");
427431432432+ let _ = fs::create_dir_all(IMAGE_CACHE_DIR);
433433+428434 let start_time = Instant::now();
429435 build_pages_images.par_iter().for_each(|image| {
430436 let start_process = Instant::now();
431437 let dest_path = assets_dir.join(image.final_file_name());
438438+432439 if let Some(image_options) = &image.options {
433433- images::process_image(image, &dest_path, image_options);
440440+ let final_filename = image.final_file_name();
441441+442442+ // Check cache for transformed images
443443+ if let Some(cached_path) = ImageCache::get_transformed_image(&final_filename) {
444444+ // Copy from cache instead of processing
445445+ if fs::copy(&cached_path, &dest_path).is_ok() {
446446+ 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());
447447+ return;
448448+ }
449449+ }
450450+451451+ // Generate cache path for transformed image
452452+ let cache_path = ImageCache::generate_cache_path(&final_filename);
453453+454454+ // Process image directly to cache
455455+ process_image(image, &cache_path, image_options);
456456+457457+ // Copy from cache to destination
458458+ if fs::copy(&cache_path, &dest_path).is_ok() {
459459+ // Cache the processed image path
460460+ ImageCache::cache_transformed_image(&final_filename, cache_path);
461461+ } else {
462462+ debug!("Failed to copy from cache {} to dest {}", cache_path.display(), dest_path.display());
463463+ }
434464 } else if !dest_path.exists() {
435465 // 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
436466 fs::copy(image.path(), &dest_path).unwrap_or_else(|e| {