1use crate::{
2 ImageDifference, ImageInfo, Policy, Resource, ResourceInventory,
3 filesystem::{contained_file, hash, write_new},
4 image_backend, optimizer,
5 resources::{bounded_read, inventory_with_options},
6};
7use anyhow::{Context, Result, ensure};
8use rayon::prelude::*;
9use serde::{Deserialize, Serialize};
10use std::{
11 collections::BTreeMap,
12 fs,
13 path::{Path, PathBuf},
14 sync::atomic::{AtomicUsize, Ordering},
15};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default, deny_unknown_fields)]
19pub struct AnalysisOptions {
20 pub qualities: Vec<u8>,
21 pub include_ignored: bool,
22 pub jobs: usize,
23 pub min_input_bytes: u64,
25 pub min_savings_bytes: u64,
26 pub probe_only: bool,
27 pub max_alpha_error: f32,
29 pub max_pixels: usize,
31 pub png_level: u8,
33 pub png_reductions: bool,
35}
36impl Default for AnalysisOptions {
37 fn default() -> Self {
38 Self {
39 qualities: vec![75, 85, 95],
40 include_ignored: false,
41 jobs: 2,
42 min_input_bytes: 0,
43 min_savings_bytes: 1,
44 probe_only: false,
45 max_alpha_error: 1.0 / 255.0 + 0.000001,
46 max_pixels: image_backend::DEFAULT_MAX_PIXELS,
47 png_level: Policy::default().png_level,
48 png_reductions: false,
49 }
50 }
51}
52impl AnalysisOptions {
53 pub(crate) fn validate(&self) -> Result<()> {
54 ensure!(
55 self.max_alpha_error.is_finite() && (0.0..=1.0).contains(&self.max_alpha_error),
56 "max_alpha_error must be 0..=1"
57 );
58 ensure!((1..=8).contains(&self.jobs), "jobs must be 1..=8");
59 ensure!(
60 (1..=image_backend::MAX_PIXELS_LIMIT).contains(&self.max_pixels),
61 "max_pixels must be 1..={}",
62 image_backend::MAX_PIXELS_LIMIT
63 );
64 self.png_policy().validate()?;
65 ensure!(
66 !self.qualities.is_empty()
67 && self.qualities.len() <= 8
68 && self.qualities.iter().all(|q| (1..=100).contains(q)),
69 "qualities must contain 1..=8 values in 1..=100"
70 );
71 let mut qualities = self.qualities.clone();
72 qualities.sort_unstable();
73 qualities.dedup();
74 ensure!(
75 qualities.len() == self.qualities.len(),
76 "duplicate quality values"
77 );
78 Ok(())
79 }
80
81 fn png_policy(&self) -> Policy {
82 Policy {
83 png_level: self.png_level,
84 reductions: self.png_reductions,
85 ..Policy::default()
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ImageCandidate {
92 pub format: String,
93 pub quality: Option<u8>,
94 pub lossy: bool,
95 pub bytes: u64,
96 pub savings_bytes: u64,
97 pub valid: bool,
98 pub rejection: Option<String>,
99 pub difference: Option<ImageDifference>,
100 pub artifact: Option<PathBuf>,
101 pub preview: Option<PathBuf>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ResourceAnalysis {
106 pub resource: Resource,
107 pub sha256: Option<String>,
108 pub image: Option<ImageInfo>,
109 pub status: String,
110 pub issues: Vec<String>,
111 pub candidates: Vec<ImageCandidate>,
112 pub smallest_candidate: Option<usize>,
114 pub original_preview: Option<PathBuf>,
115 pub original_artifact: Option<PathBuf>,
116}
117
118#[derive(Debug, Serialize, Deserialize)]
119pub struct AnalysisReport {
120 pub schema_version: u32,
121 pub root: PathBuf,
122 pub backend: String,
123 pub options: AnalysisOptions,
124 pub inventory: ResourceInventory,
125 pub resources: Vec<ResourceAnalysis>,
126 pub status_counts: BTreeMap<String, usize>,
127 pub potential_source_bytes_saved: u64,
128}
129
130pub fn analyze(
133 root: impl AsRef<Path>,
134 out: impl AsRef<Path>,
135 options: AnalysisOptions,
136) -> Result<AnalysisReport> {
137 analyze_with_progress(root, out, options, |_, _| {})
138}
139
140pub fn analyze_with_progress(
141 root: impl AsRef<Path>,
142 out: impl AsRef<Path>,
143 options: AnalysisOptions,
144 progress: impl Fn(usize, usize) + Sync,
145) -> Result<AnalysisReport> {
146 options.validate()?;
147 if !options.probe_only && image_backend::image_backend_available() {
148 image_backend::check_encoders()?;
149 }
150 let inventory = inventory_with_options(
151 root,
152 crate::ScanOptions {
153 include_ignored: options.include_ignored,
154 },
155 )?;
156 let out = out.as_ref();
157 let parent = fs::canonicalize(
158 out.parent()
159 .filter(|p| !p.as_os_str().is_empty())
160 .unwrap_or(Path::new(".")),
161 )?;
162 let out = parent.join(out.file_name().context("output directory has no name")?);
163 ensure!(
164 !out.starts_with(&inventory.root),
165 "analysis output must be outside the scanned project"
166 );
167 fs::create_dir(&out).context("analysis output must be a new directory")?;
168 fs::create_dir(out.join("candidates"))?;
169 fs::create_dir(out.join("previews"))?;
170 fs::create_dir(out.join("originals"))?;
171 let pool = rayon::ThreadPoolBuilder::new()
172 .num_threads(options.jobs)
173 .build()?;
174 let complete = AtomicUsize::new(0);
175 let resources = pool.install(|| {
176 inventory
177 .assets
178 .par_iter()
179 .enumerate()
180 .map(|(index, resource)| {
181 #[cfg(target_os = "macos")]
182 let result = objc2::rc::autoreleasepool(|_| {
183 analyze_resource(resource, index, &inventory.root, &out, &options)
184 });
185 #[cfg(not(target_os = "macos"))]
186 let result = analyze_resource(resource, index, &inventory.root, &out, &options);
187 let done = complete.fetch_add(1, Ordering::Relaxed) + 1;
188 progress(done, inventory.assets.len());
189 result
190 })
191 .collect::<Vec<_>>()
192 });
193 let mut status_counts = BTreeMap::new();
194 let mut savings = 0;
195 for resource in &resources {
196 *status_counts.entry(resource.status.clone()).or_insert(0) += 1;
197 if let Some(index) = resource.smallest_candidate {
198 savings += resource.candidates[index].savings_bytes;
199 }
200 }
201 let report = AnalysisReport {
202 schema_version: 1,
203 root: inventory.root.clone(),
204 backend: if image_backend::image_backend_available() {
205 "Apple ImageIO + CoreGraphics sRGB float comparison"
206 } else {
207 "Portable PNG lossless; JPEG/HEIC require macOS"
208 }
209 .into(),
210 options,
211 inventory,
212 resources,
213 status_counts,
214 potential_source_bytes_saved: savings,
215 };
216 write_new(
217 &out.join("analysis.json"),
218 &serde_json::to_vec_pretty(&report)?,
219 )?;
220 write_new(
221 &out.join("report.html"),
222 crate::report::render_html(&report)?.as_bytes(),
223 )?;
224 Ok(report)
225}
226
227fn analyze_resource(
228 resource: &Resource,
229 index: usize,
230 root: &Path,
231 out: &Path,
232 options: &AnalysisOptions,
233) -> ResourceAnalysis {
234 let mut result = ResourceAnalysis {
235 resource: resource.clone(),
236 sha256: None,
237 image: None,
238 status: "inventory_only".into(),
239 issues: vec![],
240 candidates: vec![],
241 smallest_candidate: None,
242 original_preview: None,
243 original_artifact: None,
244 };
245 if resource.kind != "image" {
246 result.issues.push(format!(
247 "{}_optimization_backend_not_implemented",
248 resource.kind
249 ));
250 return result;
251 }
252 let attempt = (|| -> Result<()> {
253 let path = contained_file(root, &resource.path)?;
254 let original = bounded_read(&path)?;
255 result.sha256 = Some(hash(&original));
256 let decoded = image_backend::decode(&original, options.max_pixels)?;
257 result.image = Some(decoded.info.clone());
258 result.status = "inspected".into();
259 if decoded.info.frames != 1 {
260 result.issues.push("multiple_frames_not_transcoded".into());
261 return Ok(());
262 }
263 if let Some(reason) = &resource.conversion_exclusion {
264 result.issues.push(reason.clone());
265 return Ok(());
266 }
267 if options.probe_only {
268 return Ok(());
269 }
270 if original.len() < options.min_input_bytes as usize {
271 result.issues.push("below_explicit_input_threshold".into());
272 return Ok(());
273 }
274 let targets = if !image_backend::image_backend_available() {
275 vec![]
276 } else if decoded.info.has_transparent_pixels {
277 vec!["heic"]
278 } else {
279 vec!["jpeg", "heic"]
280 };
281 let mut trials: Vec<(&str, Option<u8>)> = targets
282 .iter()
283 .flat_map(|format| {
284 options
285 .qualities
286 .iter()
287 .map(move |quality| (*format, Some(*quality)))
288 })
289 .collect();
290 if resource.format == "png" {
291 trials.insert(0, ("png", None));
292 }
293 for (format, quality) in trials {
294 let mut trial = ImageCandidate {
295 format: format.into(),
296 quality,
297 lossy: quality.is_some(),
298 bytes: 0,
299 savings_bytes: 0,
300 valid: false,
301 rejection: None,
302 difference: None,
303 artifact: None,
304 preview: None,
305 };
306 let encoded = match quality {
307 Some(quality) => image_backend::encode(&original, format, quality),
308 None => optimizer::optimize(&original, &options.png_policy()),
309 };
310 let checked = (|| -> Result<()> {
311 let bytes = encoded?;
312 trial.bytes = bytes.len() as u64;
313 trial.savings_bytes = (original.len() as u64).saturating_sub(trial.bytes);
314 let candidate = image_backend::decode(&bytes, options.max_pixels)?;
315 let difference = image_backend::compare(&decoded, &candidate)?;
316 trial.difference = Some(difference.clone());
317 ensure!(
318 difference.max_alpha_error <= options.max_alpha_error,
319 "alpha_error_exceeds_policy"
320 );
321 ensure!(
322 decoded.info.has_transparent_pixels == candidate.info.has_transparent_pixels,
323 "transparency_presence_changed"
324 );
325 trial.valid = true;
326 if trial.savings_bytes >= options.min_savings_bytes
327 && trial.bytes < original.len() as u64
328 {
329 let stem = format!("{index}-{format}-{}", quality.unwrap_or(0));
330 let artifact = PathBuf::from(format!("candidates/{stem}.{format}"));
331 let preview = PathBuf::from(format!("previews/{stem}.png"));
332 write_new(&out.join(&artifact), &bytes)?;
333 write_new(&out.join(&preview), &image_backend::preview(&candidate)?)?;
334 trial.artifact = Some(artifact);
335 trial.preview = Some(preview);
336 }
337 Ok(())
338 })();
339 if let Err(error) = checked {
340 trial.valid = false;
341 trial.rejection = Some(format!("{error:#}"));
342 }
343 result.candidates.push(trial);
344 }
345 result.smallest_candidate = result
346 .candidates
347 .iter()
348 .enumerate()
349 .filter(|(_, candidate)| candidate.valid && candidate.artifact.is_some())
350 .min_by_key(|(_, candidate)| candidate.bytes)
351 .map(|(index, _)| index);
352 if result.smallest_candidate.is_some() {
353 let preview = PathBuf::from(format!("previews/{index}-original.png"));
354 write_new(&out.join(&preview), &image_backend::preview(&decoded)?)?;
355 result.original_preview = Some(preview);
356 let artifact = PathBuf::from(format!("originals/{index}.{}", resource.format));
357 write_new(&out.join(&artifact), &original)?;
358 result.original_artifact = Some(artifact);
359 result.status = "candidates_available".into();
360 }
361 ensure!(
363 hash(&bounded_read(&path)?)
364 == *result.sha256.as_ref().context("source hash missing")?,
365 "source_changed_during_analysis"
366 );
367 Ok(())
368 })();
369 if let Err(error) = attempt {
370 result.status = "failed".into();
371 result.issues.push(format!("{error:#}"));
372 result.smallest_candidate = None;
373 }
374 result
375}