1use anyhow::{Context, Result};
2use image::{DynamicImage, GenericImageView, imageops};
3use rayon::prelude::*;
4use std::fs;
5use std::path::Path;
6use webp::{Encoder, WebPMemory};
7
8pub 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
23pub 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 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 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
59fn 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 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 let stem = input_path
82 .file_stem()
83 .and_then(|s| s.to_str())
84 .unwrap_or("image");
85
86 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 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
106pub 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 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 let output_dir = output_path
133 .parent()
134 .ok_or_else(|| anyhow::anyhow!("Invalid output path: {:?}", output_path))?;
135
136 let stem = output_path
138 .file_stem()
139 .and_then(|s| s.to_str())
140 .unwrap_or("image");
141
142 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 fs::copy(input_path, output_path)
153 .with_context(|| format!("Failed to copy original: {:?}", output_path))?;
154
155 Ok(())
156}