1use crate::{
2 ImageDifference, ImageInfo, Policy, Resource, ResourceInventory,
3 analyze_image::{self, Context as ImageContext},
4 cache::Cache,
5 filesystem::{contained_file, hash, write_new},
6 image_backend,
7 resources::{bounded_read, inventory_with_options},
8 timings::{Phase, Timings},
9};
10use anyhow::{Context, Result, ensure};
11use serde::{Deserialize, Serialize};
12use std::{
13 collections::{BTreeMap, HashMap},
14 fs,
15 path::{Path, PathBuf},
16 sync::{
17 Arc, Condvar, Mutex, OnceLock,
18 atomic::{AtomicBool, AtomicUsize, Ordering},
19 },
20 time::Instant,
21};
22
23pub(crate) const WARNINGS: [&str; 3] = [
26 "alpha_error_exceeds_policy",
27 "transparency_presence_changed",
28 "quality_below_policy",
29];
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(default, deny_unknown_fields)]
33pub struct AnalysisOptions {
34 pub qualities: Vec<u8>,
36 pub include_ignored: bool,
37 pub jobs: usize,
39 pub min_input_bytes: u64,
41 pub min_savings_bytes: u64,
42 pub probe_only: bool,
43 pub max_alpha_error: f32,
45 pub min_score: f64,
48 pub max_pixels: usize,
50 pub png_level: u8,
52 pub png_reductions: bool,
54 pub webp: bool,
56 pub android_min_sdk: Option<u32>,
58 pub cache_dir: Option<PathBuf>,
61}
62impl Default for AnalysisOptions {
63 fn default() -> Self {
64 Self {
65 qualities: vec![75, 85, 95],
66 include_ignored: false,
67 jobs: 0,
68 min_input_bytes: 0,
69 min_savings_bytes: 1,
70 probe_only: false,
71 max_alpha_error: 1.0 / 255.0 + 0.000001,
72 min_score: 80.0,
73 max_pixels: image_backend::DEFAULT_MAX_PIXELS,
74 png_level: Policy::default().png_level,
75 png_reductions: false,
76 webp: false,
77 android_min_sdk: None,
78 cache_dir: None,
79 }
80 }
81}
82impl AnalysisOptions {
83 pub(crate) fn validate(&self) -> Result<()> {
84 ensure!(
85 self.max_alpha_error.is_finite() && (0.0..=1.0).contains(&self.max_alpha_error),
86 "max_alpha_error must be 0..=1"
87 );
88 ensure!(
89 self.min_score.is_finite() && (0.0..=100.0).contains(&self.min_score),
90 "min_score must be 0..=100"
91 );
92 ensure!(
93 self.jobs <= 16,
94 "jobs must be 0..=16 (0 selects automatically)"
95 );
96 ensure!(
97 self.android_min_sdk.is_none_or(|v| (1..=99).contains(&v)),
98 "android_min_sdk must be 1..=99"
99 );
100 ensure!(
101 (1..=image_backend::MAX_PIXELS_LIMIT).contains(&self.max_pixels),
102 "max_pixels must be 1..={}",
103 image_backend::MAX_PIXELS_LIMIT
104 );
105 self.png_policy().validate()?;
106 ensure!(
107 !self.qualities.is_empty()
108 && self.qualities.len() <= 8
109 && self.qualities.iter().all(|q| (1..=100).contains(q)),
110 "qualities must contain 1..=8 values in 1..=100"
111 );
112 let mut qualities = self.qualities.clone();
113 qualities.sort_unstable();
114 qualities.dedup();
115 ensure!(
116 qualities.len() == self.qualities.len(),
117 "duplicate quality values"
118 );
119 Ok(())
120 }
121
122 pub(crate) fn png_policy(&self) -> Policy {
123 Policy {
124 png_level: self.png_level,
125 reductions: self.png_reductions,
126 ..Policy::default()
127 }
128 }
129
130 pub(crate) fn worker_count(&self) -> usize {
131 if self.jobs > 0 {
132 return self.jobs;
133 }
134 std::thread::available_parallelism()
135 .map_or(2, |n| n.get())
136 .clamp(1, 8)
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ImageCandidate {
142 pub format: String,
143 pub quality: Option<u8>,
144 pub lossy: bool,
145 pub bytes: u64,
146 pub savings_bytes: u64,
147 pub valid: bool,
148 pub rejection: Option<String>,
149 pub difference: Option<ImageDifference>,
150 pub artifact: Option<PathBuf>,
151 pub preview: Option<PathBuf>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub sha256: Option<String>,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub warnings: Vec<String>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub notes: Vec<String>,
162}
163
164impl ImageCandidate {
165 pub(crate) fn is_warning(&self) -> bool {
167 !self.valid
168 && self.lossy
169 && self.artifact.is_some()
170 && self
171 .rejection
172 .as_deref()
173 .is_some_and(|r| WARNINGS.contains(&r))
174 }
175
176 pub(crate) fn required_warnings(&self) -> Vec<String> {
178 if !self.is_warning() {
179 vec![]
180 } else if self.warnings.is_empty() {
181 self.rejection.iter().cloned().collect()
183 } else {
184 self.warnings.clone()
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ResourceAnalysis {
191 pub resource: Resource,
192 pub sha256: Option<String>,
193 pub image: Option<ImageInfo>,
194 pub status: String,
197 pub issues: Vec<String>,
198 pub candidates: Vec<ImageCandidate>,
199 pub smallest_candidate: Option<usize>,
201 pub original_preview: Option<PathBuf>,
202 pub original_artifact: Option<PathBuf>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub media: Option<crate::media::MediaInfo>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub fingerprint: Option<crate::similarity::Fingerprint>,
209}
210
211impl ResourceAnalysis {
212 pub(crate) fn new(resource: &Resource, status: &str) -> Self {
213 Self {
214 resource: resource.clone(),
215 sha256: None,
216 image: None,
217 status: status.into(),
218 issues: vec![],
219 candidates: vec![],
220 smallest_candidate: None,
221 original_preview: None,
222 original_artifact: None,
223 fingerprint: None,
224 media: None,
225 }
226 }
227
228 pub(crate) fn recommended_savings(&self) -> u64 {
229 self.smallest_candidate
230 .and_then(|i| self.candidates.get(i))
231 .filter(|c| c.valid && c.artifact.is_some())
232 .map_or(0, |c| c.savings_bytes)
233 }
234}
235
236#[derive(Debug, Serialize, Deserialize)]
237pub struct AnalysisReport {
238 pub schema_version: u32,
239 pub root: PathBuf,
240 pub backend: String,
241 pub options: AnalysisOptions,
242 pub inventory: ResourceInventory,
243 pub resources: Vec<ResourceAnalysis>,
244 pub status_counts: BTreeMap<String, usize>,
245 pub potential_source_bytes_saved: u64,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub similar_groups: Vec<crate::similarity::SimilarGroup>,
251 #[serde(default)]
253 pub cancelled: bool,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub performance: Option<Performance>,
256}
257
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
259pub struct Performance {
260 pub wall_seconds: f64,
261 pub first_result_seconds: Option<f64>,
262 pub workers: usize,
263 pub cache_hits: usize,
264 pub duplicate_reuses: usize,
265 pub phase_seconds: BTreeMap<String, f64>,
267}
268
269#[derive(Clone, Default)]
271pub struct AnalysisControl(Arc<AtomicBool>);
272impl AnalysisControl {
273 pub fn cancel(&self) {
274 self.0.store(true, Ordering::SeqCst);
275 }
276 pub fn is_cancelled(&self) -> bool {
277 self.0.load(Ordering::SeqCst)
278 }
279}
280
281pub(crate) struct PixelBudget {
284 capacity: usize,
285 available: Mutex<usize>,
286 released: Condvar,
287}
288pub(crate) struct PixelLease<'a>(&'a PixelBudget, usize);
289impl PixelBudget {
290 pub fn new(capacity: usize) -> Self {
291 Self {
292 capacity,
293 available: Mutex::new(capacity),
294 released: Condvar::new(),
295 }
296 }
297 pub fn acquire(&self, pixels: usize) -> PixelLease<'_> {
298 let wanted = pixels.clamp(1, self.capacity);
299 let mut available = self.available.lock().unwrap_or_else(|e| e.into_inner());
300 while *available < wanted {
301 available = self
302 .released
303 .wait(available)
304 .unwrap_or_else(|e| e.into_inner());
305 }
306 *available -= wanted;
307 PixelLease(self, wanted)
308 }
309}
310impl Drop for PixelLease<'_> {
311 fn drop(&mut self) {
312 *self.0.available.lock().unwrap_or_else(|e| e.into_inner()) += self.1;
313 self.0.released.notify_all();
314 }
315}
316
317pub fn analyze(
320 root: impl AsRef<Path>,
321 out: impl AsRef<Path>,
322 options: AnalysisOptions,
323) -> Result<AnalysisReport> {
324 analyze_with_progress(root, out, options, |_, _| {})
325}
326
327pub fn analyze_with_progress(
328 root: impl AsRef<Path>,
329 out: impl AsRef<Path>,
330 options: AnalysisOptions,
331 progress: impl Fn(usize, usize) + Sync,
332) -> Result<AnalysisReport> {
333 analyze_with_observer(
334 root,
335 out,
336 options,
337 &AnalysisControl::default(),
338 |_, _, done, total| progress(done, total),
339 )
340}
341
342type Shared = Arc<OnceLock<(usize, ResourceAnalysis)>>;
343
344pub(crate) fn analyze_with_observer(
345 root: impl AsRef<Path>,
346 out: impl AsRef<Path>,
347 options: AnalysisOptions,
348 control: &AnalysisControl,
349 progress: impl Fn(usize, &ResourceAnalysis, usize, usize) + Sync,
350) -> Result<AnalysisReport> {
351 let started = Instant::now();
352 options.validate()?;
353 if !options.probe_only && image_backend::image_backend_available() {
354 image_backend::check_encoders()?;
355 }
356 let timings = Timings::default();
357 let inventory = timings.time(Phase::Scan, || {
358 inventory_with_options(
359 root,
360 crate::ScanOptions {
361 include_ignored: options.include_ignored,
362 },
363 )
364 })?;
365 let out = out.as_ref();
366 let parent = fs::canonicalize(
367 out.parent()
368 .filter(|p| !p.as_os_str().is_empty())
369 .unwrap_or(Path::new(".")),
370 )?;
371 let out = parent.join(out.file_name().context("output directory has no name")?);
372 ensure!(
373 !out.starts_with(&inventory.root),
374 "analysis output must be outside the scanned project"
375 );
376 fs::create_dir(&out).context("analysis output must be a new directory")?;
377 for folder in ["candidates", "previews", "originals"] {
378 fs::create_dir(out.join(folder))?;
379 }
380 let cache = options
381 .cache_dir
382 .as_ref()
383 .filter(|_| !options.probe_only)
384 .and_then(|directory| Cache::open(directory).ok());
385 let min_sdk = options
386 .android_min_sdk
387 .or(inventory.android_min_sdk.as_ref().map(|sdk| sdk.level));
388 let workers = options.worker_count();
389 let budget = PixelBudget::new(options.max_pixels);
390 let context = ImageContext {
391 root: &inventory.root,
392 out: &out,
393 options: &options,
394 min_sdk,
395 timings: &timings,
396 control,
397 budget: &budget,
398 };
399 let total = inventory.assets.len();
400 let complete = AtomicUsize::new(0);
401 let cache_hits = AtomicUsize::new(0);
402 let duplicate_reuses = AtomicUsize::new(0);
403 let first_result = OnceLock::new();
404 let in_flight: Mutex<HashMap<String, Shared>> = Mutex::new(HashMap::new());
405 let finish = |index: usize, result: ResourceAnalysis| {
406 if result.status == "candidates_available" {
407 first_result.get_or_init(|| started.elapsed().as_secs_f64());
408 }
409 let done = complete.fetch_add(1, Ordering::Relaxed) + 1;
410 progress(index, &result, done, total);
411 (index, result)
412 };
413
414 let (mut work, settled): (Vec<usize>, Vec<usize>) = (0..total).partition(|&index| {
418 let resource = &inventory.assets[index];
419 resource.support == "optimizable"
420 || (resource.kind == "image" && options.probe_only)
421 || (is_media(resource) && crate::media::ffprobe_available())
422 });
423 work.sort_by_key(|&index| std::cmp::Reverse(inventory.assets[index].bytes));
424 let mut indexed: Vec<(usize, ResourceAnalysis)> = settled
425 .into_iter()
426 .map(|index| finish(index, settled_row(&inventory.assets[index])))
427 .collect();
428
429 let analyze_one = |index: usize| -> ResourceAnalysis {
430 let resource = &inventory.assets[index];
431 if control.is_cancelled() {
432 return ResourceAnalysis::new(resource, "not_analyzed");
433 }
434 if is_media(resource) {
435 let mut row = settled_row(resource);
438 if let Ok(path) = contained_file(&inventory.root, &resource.path) {
439 row.media = timings.time(Phase::Decode, || crate::media::probe(&path));
440 }
441 return row;
442 }
443 let read = timings.time(Phase::Hash, || {
444 contained_file(&inventory.root, &resource.path)
445 .and_then(|path| bounded_read(&path))
446 .map(|bytes| {
447 let digest = hash(&bytes);
448 (bytes, digest)
449 })
450 });
451 let (bytes, digest) = match read {
452 Ok(read) => read,
453 Err(error) => {
454 let mut failed = ResourceAnalysis::new(resource, "failed");
455 failed.issues.push(format!("{error:#}"));
456 return failed;
457 }
458 };
459 let compute = |own_index: usize| {
460 #[cfg(target_os = "macos")]
461 {
462 objc2::rc::autoreleasepool(|_| {
463 analyze_image::analyze(&context, resource, own_index, &bytes, &digest)
464 })
465 }
466 #[cfg(not(target_os = "macos"))]
467 {
468 analyze_image::analyze(&context, resource, own_index, &bytes, &digest)
469 }
470 };
471 if options.probe_only {
472 return compute(index);
473 }
474 let key = match Cache::key(
475 &digest,
476 &analyze_image::policy_key(resource, min_sdk),
477 &options,
478 ) {
479 Ok(key) => key,
480 Err(_) => return compute(index),
481 };
482 let slot = in_flight
484 .lock()
485 .unwrap_or_else(|e| e.into_inner())
486 .entry(key.clone())
487 .or_default()
488 .clone();
489 let mut computed_here = false;
490 let (owner, shared) = slot.get_or_init(|| {
491 computed_here = true;
492 if let Some(cache) = &cache
493 && let Some(mut hit) = timings.time(Phase::Cache, || cache.load(&key, &out, index))
494 && restore_original_artifact(&mut hit, &out, index, &bytes, resource).is_ok()
495 {
496 cache_hits.fetch_add(1, Ordering::Relaxed);
497 hit.resource = resource.clone();
498 return (index, hit);
499 }
500 let result = compute(index);
501 if let Some(cache) = &cache
502 && matches!(result.status.as_str(), "candidates_available" | "inspected")
503 {
504 let _ = timings.time(Phase::Cache, || cache.store(&key, &result, &out, index));
505 }
506 (index, result)
507 });
508 if computed_here || *owner == index {
509 return shared.clone();
510 }
511 if matches!(shared.status.as_str(), "failed" | "not_analyzed") {
512 return compute(index);
513 }
514 duplicate_reuses.fetch_add(1, Ordering::Relaxed);
515 let mut reused = shared.clone();
516 reused.resource = resource.clone();
517 reused
518 };
519 let next = AtomicUsize::new(0);
524 let finished = Mutex::new(Vec::with_capacity(work.len()));
525 std::thread::scope(|scope| {
526 for _ in 0..workers.min(work.len()).max(1) {
527 scope.spawn(|| {
528 while let Some(&index) = work.get(next.fetch_add(1, Ordering::Relaxed)) {
529 let done = finish(index, analyze_one(index));
530 finished
531 .lock()
532 .unwrap_or_else(|e| e.into_inner())
533 .push(done);
534 }
535 });
536 }
537 });
538 indexed.extend(finished.into_inner().unwrap_or_else(|e| e.into_inner()));
539 indexed.sort_by_key(|(index, _)| *index);
540 let resources: Vec<_> = indexed.into_iter().map(|(_, result)| result).collect();
541 let mut status_counts = BTreeMap::new();
542 let mut savings = 0;
543 for resource in &resources {
544 *status_counts.entry(resource.status.clone()).or_insert(0) += 1;
545 savings += resource.recommended_savings();
546 }
547 if let Some(cache) = &cache {
548 let _ = cache.prune(crate::cache::DEFAULT_MAX_BYTES);
549 }
550 let similar_groups = crate::similarity::group(&resources);
551 let resources: Vec<_> = resources
553 .into_iter()
554 .map(|resource| ResourceAnalysis {
555 fingerprint: None,
556 ..resource
557 })
558 .collect();
559 let mut report = AnalysisReport {
560 schema_version: 2,
561 root: inventory.root.clone(),
562 backend: if image_backend::image_backend_available() {
563 "Apple ImageIO + CoreGraphics sRGB float comparison; bundled oxipng and libwebp"
564 } else {
565 "Portable PNG and WebP (bundled oxipng and libwebp); JPEG/HEIC require macOS"
566 }
567 .into(),
568 options,
569 inventory,
570 resources,
571 status_counts,
572 potential_source_bytes_saved: savings,
573 similar_groups,
574 cancelled: control.is_cancelled(),
575 performance: None,
576 };
577 let html = timings.time(Phase::Report, || crate::report::render_html(&report))?;
578 report.performance = Some(Performance {
579 wall_seconds: started.elapsed().as_secs_f64(),
580 first_result_seconds: first_result.get().copied(),
581 workers,
582 cache_hits: cache_hits.load(Ordering::Relaxed),
583 duplicate_reuses: duplicate_reuses.load(Ordering::Relaxed),
584 phase_seconds: timings.snapshot(),
585 });
586 write_new(&out.join("analysis.json"), &serde_json::to_vec(&report)?)?;
587 write_new(&out.join("report.html"), html.as_bytes())?;
588 Ok(report)
589}
590
591fn is_media(resource: &Resource) -> bool {
592 matches!(resource.kind.as_str(), "audio" | "video") && resource.conversion_exclusion.is_none()
593}
594
595fn settled_row(resource: &Resource) -> ResourceAnalysis {
597 if let Some(reason) = &resource.conversion_exclusion {
598 let mut row = ResourceAnalysis::new(resource, "excluded");
599 row.issues.push(reason.clone());
600 return row;
601 }
602 let mut row = ResourceAnalysis::new(resource, "unsupported");
603 if is_media(resource) && !crate::media::ffprobe_available() {
604 row.issues.push("ffprobe_not_installed".into());
605 }
606 row.issues.push(if resource.kind == "image" {
607 format!("{}_decoding_requires_macos_imageio", resource.format)
608 } else {
609 format!("{}_optimization_backend_not_implemented", resource.kind)
610 });
611 row
612}
613
614fn restore_original_artifact(
616 hit: &mut ResourceAnalysis,
617 out: &Path,
618 index: usize,
619 bytes: &[u8],
620 resource: &Resource,
621) -> Result<()> {
622 if hit.status == "candidates_available" {
623 let artifact = PathBuf::from(format!("originals/{index}.{}", resource.format));
624 crate::filesystem::write_artifact(&out.join(&artifact), bytes)?;
625 hit.original_artifact = Some(artifact);
626 }
627 Ok(())
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn pixel_budget_serializes_oversized_work_without_deadlock() {
636 let budget = PixelBudget::new(100);
637 let peak = AtomicUsize::new(0);
638 let current = AtomicUsize::new(0);
639 std::thread::scope(|scope| {
640 for _ in 0..6 {
641 scope.spawn(|| {
642 let _lease = budget.acquire(1_000);
644 let now = current.fetch_add(1, Ordering::SeqCst) + 1;
645 peak.fetch_max(now, Ordering::SeqCst);
646 std::thread::sleep(std::time::Duration::from_millis(5));
647 current.fetch_sub(1, Ordering::SeqCst);
648 });
649 }
650 });
651 assert_eq!(peak.load(Ordering::SeqCst), 1);
652 let _a = budget.acquire(60);
653 let _b = budget.acquire(40);
654 }
655
656 #[test]
657 fn options_reject_out_of_range_policy_values() {
658 for options in [
659 AnalysisOptions {
660 min_score: 101.0,
661 ..Default::default()
662 },
663 AnalysisOptions {
664 min_score: f64::NAN,
665 ..Default::default()
666 },
667 AnalysisOptions {
668 jobs: 17,
669 ..Default::default()
670 },
671 AnalysisOptions {
672 android_min_sdk: Some(0),
673 ..Default::default()
674 },
675 ] {
676 assert!(options.validate().is_err());
677 }
678 assert!(AnalysisOptions::default().validate().is_ok());
679 assert!((1..=8).contains(&AnalysisOptions::default().worker_count()));
680 }
681}