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    /// Largest decoded image analyzed; each pixel costs 16 bytes per decode.
30    pub max_pixels: usize,
31    /// oxipng effort for the lossless PNG candidate.
32    pub png_level: u8,
33    /// Allow lossless PNG color-type, bit-depth and palette reductions.
34    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    /// A size winner, not an assertion of acceptable visual quality.
113    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
130/// Read-only analysis of all inventoried resources, with lossy candidates staged
131/// solely for review. This report is deliberately not an executable apply plan.
132pub 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        // Report a moving source instead of presenting stale candidate estimates.
362        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}