Skip to main content

resopt/
analysis.rs

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    /// All sizes are included by default, unlike the legacy lossless plan.
24    pub min_input_bytes: u64,
25    pub min_savings_bytes: u64,
26    pub probe_only: bool,
27    /// Lossy HEIC may quantize alpha. 0 requires exact alpha samples.
28    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    /// A size winner, not an assertion of acceptable visual quality.
90    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
107/// Read-only analysis of all inventoried resources, with lossy candidates staged
108/// solely for review. This report is deliberately not an executable apply plan.
109pub 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        // Report a moving source instead of presenting stale candidate estimates.
337        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}