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