rs_web/assets/
images.rs

1use anyhow::{Context, Result};
2use image::{DynamicImage, GenericImageView, imageops};
3use rayon::prelude::*;
4use std::fs;
5use std::path::Path;
6use webp::{Encoder, WebPMemory};
7
8/// Image optimization configuration
9pub struct ImageConfig {
10    pub quality: f32,
11    pub scale_factor: f64,
12}
13
14impl Default for ImageConfig {
15    fn default() -> Self {
16        Self {
17            quality: 85.0,
18            scale_factor: 1.0,
19        }
20    }
21}
22
23/// Optimize all images in a directory
24pub fn optimize_images<P: AsRef<Path>>(
25    input_dir: P,
26    output_dir: P,
27    config: &ImageConfig,
28) -> Result<()> {
29    let input_dir = input_dir.as_ref();
30    let output_dir = output_dir.as_ref();
31
32    if !input_dir.exists() {
33        return Ok(());
34    }
35
36    fs::create_dir_all(output_dir)?;
37
38    // Find all image files
39    let image_files: Vec<_> = fs::read_dir(input_dir)?
40        .filter_map(|entry| entry.ok())
41        .filter(|entry| {
42            let path = entry.path();
43            path.extension().is_some_and(|ext| {
44                let ext = ext.to_string_lossy().to_lowercase();
45                ext == "jpg" || ext == "jpeg" || ext == "png"
46            })
47        })
48        .collect();
49
50    // Process images
51    image_files.par_iter().try_for_each(|entry| {
52        let path = entry.path();
53        optimize_image_to_dir(&path, output_dir, config)
54    })?;
55
56    Ok(())
57}
58
59/// Optimize a single image (batch version - output to directory)
60fn optimize_image_to_dir(input_path: &Path, output_dir: &Path, config: &ImageConfig) -> Result<()> {
61    let img = image::open(input_path)
62        .with_context(|| format!("Failed to open image: {:?}", input_path))?;
63
64    let (w, h) = img.dimensions();
65
66    // Resize if scale factor is less than 1
67    let img = if config.scale_factor < 1.0 {
68        let new_w = (w as f64 * config.scale_factor) as u32;
69        let new_h = (h as f64 * config.scale_factor) as u32;
70        DynamicImage::ImageRgba8(imageops::resize(
71            &img,
72            new_w,
73            new_h,
74            imageops::FilterType::Triangle,
75        ))
76    } else {
77        img
78    };
79
80    // Get the file stem for output naming
81    let stem = input_path
82        .file_stem()
83        .and_then(|s| s.to_str())
84        .unwrap_or("image");
85
86    // Convert to WebP
87    let encoder = Encoder::from_image(&img)
88        .map_err(|e| anyhow::anyhow!("Failed to create WebP encoder: {}", e))?;
89    let webp: WebPMemory = encoder.encode(config.quality);
90
91    let webp_path = output_dir.join(format!("{}.webp", stem));
92    fs::write(&webp_path, &*webp)
93        .with_context(|| format!("Failed to write WebP: {:?}", webp_path))?;
94
95    // Also copy original (as fallback)
96    let file_name = input_path
97        .file_name()
98        .ok_or_else(|| anyhow::anyhow!("Invalid file path: {:?}", input_path))?;
99    let original_path = output_dir.join(file_name);
100    fs::copy(input_path, &original_path)
101        .with_context(|| format!("Failed to copy original: {:?}", original_path))?;
102
103    Ok(())
104}
105
106/// Optimize a single image to a specific output path (for incremental builds)
107pub fn optimize_single_image(
108    input_path: &Path,
109    output_path: &Path,
110    config: &ImageConfig,
111) -> Result<()> {
112    let img = image::open(input_path)
113        .with_context(|| format!("Failed to open image: {:?}", input_path))?;
114
115    let (w, h) = img.dimensions();
116
117    // Resize if scale factor is less than 1
118    let img = if config.scale_factor < 1.0 {
119        let new_w = (w as f64 * config.scale_factor) as u32;
120        let new_h = (h as f64 * config.scale_factor) as u32;
121        DynamicImage::ImageRgba8(imageops::resize(
122            &img,
123            new_w,
124            new_h,
125            imageops::FilterType::Triangle,
126        ))
127    } else {
128        img
129    };
130
131    // Get output directory
132    let output_dir = output_path
133        .parent()
134        .ok_or_else(|| anyhow::anyhow!("Invalid output path: {:?}", output_path))?;
135
136    // Get the file stem for WebP naming
137    let stem = output_path
138        .file_stem()
139        .and_then(|s| s.to_str())
140        .unwrap_or("image");
141
142    // Convert to WebP
143    let encoder = Encoder::from_image(&img)
144        .map_err(|e| anyhow::anyhow!("Failed to create WebP encoder: {}", e))?;
145    let webp: WebPMemory = encoder.encode(config.quality);
146
147    let webp_path = output_dir.join(format!("{}.webp", stem));
148    fs::write(&webp_path, &*webp)
149        .with_context(|| format!("Failed to write WebP: {:?}", webp_path))?;
150
151    // Also copy original (as fallback)
152    fs::copy(input_path, output_path)
153        .with_context(|| format!("Failed to copy original: {:?}", output_path))?;
154
155    Ok(())
156}