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