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},
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 jobs: usize,
22    /// All sizes are included by default, unlike the legacy lossless plan.
23    pub min_input_bytes: u64,
24    pub min_savings_bytes: u64,
25    pub probe_only: bool,
26    /// Lossy HEIC may quantize alpha. 0 requires exact alpha samples.
27    pub max_alpha_error: f32,
28}
29impl Default for AnalysisOptions {
30    fn default() -> Self {
31        Self {
32            qualities: vec![75, 85, 95],
33            jobs: 2,
34            min_input_bytes: 0,
35            min_savings_bytes: 1,
36            probe_only: false,
37            max_alpha_error: 1.0 / 255.0 + 0.000001,
38        }
39    }
40}
41impl AnalysisOptions {
42    fn validate(&self) -> Result<()> {
43        ensure!(
44            self.max_alpha_error.is_finite() && (0.0..=1.0).contains(&self.max_alpha_error),
45            "max_alpha_error must be 0..=1"
46        );
47        ensure!((1..=8).contains(&self.jobs), "jobs must be 1..=8");
48        ensure!(
49            !self.qualities.is_empty()
50                && self.qualities.len() <= 8
51                && self.qualities.iter().all(|q| (1..=100).contains(q)),
52            "qualities must contain 1..=8 values in 1..=100"
53        );
54        let mut qualities = self.qualities.clone();
55        qualities.sort_unstable();
56        qualities.dedup();
57        ensure!(
58            qualities.len() == self.qualities.len(),
59            "duplicate quality values"
60        );
61        Ok(())
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ImageCandidate {
67    pub format: String,
68    pub quality: Option<u8>,
69    pub lossy: bool,
70    pub bytes: u64,
71    pub savings_bytes: u64,
72    pub valid: bool,
73    pub rejection: Option<String>,
74    pub difference: Option<ImageDifference>,
75    pub artifact: Option<PathBuf>,
76    pub preview: Option<PathBuf>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ResourceAnalysis {
81    pub resource: Resource,
82    pub sha256: Option<String>,
83    pub image: Option<ImageInfo>,
84    pub status: String,
85    pub issues: Vec<String>,
86    pub candidates: Vec<ImageCandidate>,
87    /// A size winner, not an assertion of acceptable visual quality.
88    pub smallest_candidate: Option<usize>,
89    pub original_preview: Option<PathBuf>,
90    pub original_artifact: Option<PathBuf>,
91}
92
93#[derive(Debug, Serialize, Deserialize)]
94pub struct AnalysisReport {
95    pub schema_version: u32,
96    pub root: PathBuf,
97    pub backend: String,
98    pub options: AnalysisOptions,
99    pub inventory: ResourceInventory,
100    pub resources: Vec<ResourceAnalysis>,
101    pub status_counts: BTreeMap<String, usize>,
102    pub potential_source_bytes_saved: u64,
103}
104
105/// Read-only analysis of all inventoried resources, with lossy candidates staged
106/// solely for review. This report is deliberately not an executable apply plan.
107pub fn analyze(
108    root: impl AsRef<Path>,
109    out: impl AsRef<Path>,
110    options: AnalysisOptions,
111) -> Result<AnalysisReport> {
112    analyze_with_progress(root, out, options, |_, _| {})
113}
114
115pub fn analyze_with_progress(
116    root: impl AsRef<Path>,
117    out: impl AsRef<Path>,
118    options: AnalysisOptions,
119    progress: impl Fn(usize, usize) + Sync,
120) -> Result<AnalysisReport> {
121    options.validate()?;
122    if !options.probe_only {
123        image_backend::check_encoders()?;
124    }
125    let inventory = inventory(root)?;
126    let out = out.as_ref();
127    let parent = fs::canonicalize(
128        out.parent()
129            .filter(|p| !p.as_os_str().is_empty())
130            .unwrap_or(Path::new(".")),
131    )?;
132    let out = parent.join(out.file_name().context("output directory has no name")?);
133    ensure!(
134        !out.starts_with(&inventory.root),
135        "analysis output must be outside the scanned project"
136    );
137    fs::create_dir(&out).context("analysis output must be a new directory")?;
138    fs::create_dir(out.join("candidates"))?;
139    fs::create_dir(out.join("previews"))?;
140    fs::create_dir(out.join("originals"))?;
141    let pool = rayon::ThreadPoolBuilder::new()
142        .num_threads(options.jobs)
143        .build()?;
144    let complete = AtomicUsize::new(0);
145    let resources = pool.install(|| {
146        inventory
147            .assets
148            .par_iter()
149            .enumerate()
150            .map(|(index, resource)| {
151                #[cfg(target_os = "macos")]
152                let result = objc2::rc::autoreleasepool(|_| {
153                    analyze_resource(resource, index, &inventory.root, &out, &options)
154                });
155                #[cfg(not(target_os = "macos"))]
156                let result = analyze_resource(resource, index, &inventory.root, &out, &options);
157                let done = complete.fetch_add(1, Ordering::Relaxed) + 1;
158                progress(done, inventory.assets.len());
159                result
160            })
161            .collect::<Vec<_>>()
162    });
163    let mut status_counts = BTreeMap::new();
164    let mut savings = 0;
165    for resource in &resources {
166        *status_counts.entry(resource.status.clone()).or_insert(0) += 1;
167        if let Some(index) = resource.smallest_candidate {
168            savings += resource.candidates[index].savings_bytes;
169        }
170    }
171    let report = AnalysisReport {
172        schema_version: 1,
173        root: inventory.root.clone(),
174        backend: if image_backend::image_backend_available() {
175            "Apple ImageIO + CoreGraphics sRGB float comparison"
176        } else {
177            "ImageIO unavailable on this platform"
178        }
179        .into(),
180        options,
181        inventory,
182        resources,
183        status_counts,
184        potential_source_bytes_saved: savings,
185    };
186    write_new(
187        &out.join("analysis.json"),
188        &serde_json::to_vec_pretty(&report)?,
189    )?;
190    write_new(&out.join("report.html"), render_html(&report).as_bytes())?;
191    Ok(report)
192}
193
194fn analyze_resource(
195    resource: &Resource,
196    index: usize,
197    root: &Path,
198    out: &Path,
199    options: &AnalysisOptions,
200) -> ResourceAnalysis {
201    let mut result = ResourceAnalysis {
202        resource: resource.clone(),
203        sha256: None,
204        image: None,
205        status: "inventory_only".into(),
206        issues: vec![],
207        candidates: vec![],
208        smallest_candidate: None,
209        original_preview: None,
210        original_artifact: None,
211    };
212    if resource.kind != "image" {
213        result.issues.push(format!(
214            "{}_optimization_backend_not_implemented",
215            resource.kind
216        ));
217        return result;
218    }
219    let attempt = (|| -> Result<()> {
220        let path = contained_file(root, &resource.path)?;
221        let original = bounded_read(&path)?;
222        result.sha256 = Some(hash(&original));
223        let decoded = image_backend::decode(&original)?;
224        result.image = Some(decoded.info.clone());
225        result.status = "inspected".into();
226        if decoded.info.frames != 1 {
227            result.issues.push("multiple_frames_not_transcoded".into());
228            return Ok(());
229        }
230        if let Some(reason) = &resource.conversion_exclusion {
231            result.issues.push(reason.clone());
232            return Ok(());
233        }
234        if options.probe_only {
235            return Ok(());
236        }
237        if original.len() < options.min_input_bytes as usize {
238            result.issues.push("below_explicit_input_threshold".into());
239            return Ok(());
240        }
241        let targets = if decoded.info.has_transparent_pixels {
242            vec!["heic"]
243        } else {
244            vec!["jpeg", "heic"]
245        };
246        let mut trials: Vec<(&str, Option<u8>)> = targets
247            .iter()
248            .flat_map(|format| {
249                options
250                    .qualities
251                    .iter()
252                    .map(move |quality| (*format, Some(*quality)))
253            })
254            .collect();
255        if resource.format == "png" {
256            trials.insert(0, ("png", None));
257        }
258        for (format, quality) in trials {
259            let mut trial = ImageCandidate {
260                format: format.into(),
261                quality,
262                lossy: quality.is_some(),
263                bytes: 0,
264                savings_bytes: 0,
265                valid: false,
266                rejection: None,
267                difference: None,
268                artifact: None,
269                preview: None,
270            };
271            let encoded = match quality {
272                Some(quality) => image_backend::encode(&original, format, quality),
273                None => optimizer::optimize(&original, &Policy::default()),
274            };
275            let checked = (|| -> Result<()> {
276                let bytes = encoded?;
277                trial.bytes = bytes.len() as u64;
278                trial.savings_bytes = (original.len() as u64).saturating_sub(trial.bytes);
279                let candidate = image_backend::decode(&bytes)?;
280                let difference = image_backend::compare(&decoded, &candidate)?;
281                trial.difference = Some(difference.clone());
282                ensure!(
283                    difference.max_alpha_error <= options.max_alpha_error,
284                    "alpha_error_exceeds_policy"
285                );
286                ensure!(
287                    decoded.info.has_transparent_pixels == candidate.info.has_transparent_pixels,
288                    "transparency_presence_changed"
289                );
290                trial.valid = true;
291                if trial.savings_bytes >= options.min_savings_bytes
292                    && trial.bytes < original.len() as u64
293                {
294                    let stem = format!("{index}-{format}-{}", quality.unwrap_or(0));
295                    let artifact = PathBuf::from(format!("candidates/{stem}.{format}"));
296                    let preview = PathBuf::from(format!("previews/{stem}.png"));
297                    write_new(&out.join(&artifact), &bytes)?;
298                    write_new(&out.join(&preview), &image_backend::preview(&candidate)?)?;
299                    trial.artifact = Some(artifact);
300                    trial.preview = Some(preview);
301                }
302                Ok(())
303            })();
304            if let Err(error) = checked {
305                trial.valid = false;
306                trial.rejection = Some(format!("{error:#}"));
307            }
308            result.candidates.push(trial);
309        }
310        result.smallest_candidate = result
311            .candidates
312            .iter()
313            .enumerate()
314            .filter(|(_, candidate)| candidate.valid && candidate.artifact.is_some())
315            .min_by_key(|(_, candidate)| candidate.bytes)
316            .map(|(index, _)| index);
317        if result.smallest_candidate.is_some() {
318            let preview = PathBuf::from(format!("previews/{index}-original.png"));
319            write_new(&out.join(&preview), &image_backend::preview(&decoded)?)?;
320            result.original_preview = Some(preview);
321            let artifact = PathBuf::from(format!("originals/{index}.{}", resource.format));
322            write_new(&out.join(&artifact), &original)?;
323            result.original_artifact = Some(artifact);
324            result.status = "candidates_available".into();
325        }
326        // Report a moving source instead of presenting stale candidate estimates.
327        ensure!(
328            hash(&bounded_read(&path)?)
329                == *result.sha256.as_ref().context("source hash missing")?,
330            "source_changed_during_analysis"
331        );
332        Ok(())
333    })();
334    if let Err(error) = attempt {
335        result.status = "failed".into();
336        result.issues.push(format!("{error:#}"));
337        result.smallest_candidate = None;
338    }
339    result
340}
341
342fn escaped(text: &str) -> String {
343    text.replace('&', "&amp;")
344        .replace('<', "&lt;")
345        .replace('>', "&gt;")
346        .replace('"', "&quot;")
347        .replace('\'', "&#39;")
348}
349
350fn render_html(report: &AnalysisReport) -> String {
351    use std::fmt::Write;
352    let mut html = String::from(
353        "<!doctype html><html lang=zh-CN><meta charset=utf-8><title>resopt 资源分析</title><style>body{font:15px system-ui;margin:32px;line-height:1.6;color:#222}table{border-collapse:collapse;width:100%}td,th{padding:8px;border-bottom:1px solid #ddd;text-align:left}img{max-width:256px;max-height:256px;background:repeating-conic-gradient(#ddd 0% 25%,#fff 0% 50%) 0/16px 16px}section{margin:24px 0;padding:16px;border:1px solid #ddd}code{word-break:break-all}.variants{display:flex;flex-wrap:wrap;gap:20px}figure{margin:0;max-width:280px}small{color:#666}</style><h1>resopt 资源分析</h1><p>此报告只生成候选,不修改项目。JPEG/HEIC 为有损编码;质量数值不是节省比例。体积最小不代表画质最佳。预览缩略图仅供初筛,正式选用前应检查原尺寸候选。MAE/PSNR 在统一 sRGB 预乘 Alpha 像素上计算,不是视觉验收。</p>",
354    );
355    let _ = write!(
356        html,
357        "<p>资源文件:{} · 有候选:{} · 按每个文件最小候选估算可节省 {} 字节(源文件体积)</p>",
358        report.resources.len(),
359        report
360            .status_counts
361            .get("candidates_available")
362            .unwrap_or(&0),
363        report.potential_source_bytes_saved
364    );
365    html.push_str("<p>不支持优化的音视频、动效、字体、压缩包等仍列入清单;不会解包或改写。详情见同目录 analysis.json。编译包体与构建目标归属未测量。</p>");
366    for resource in &report.resources {
367        let _ = write!(
368            html,
369            "<section><code>{}</code><p>{} · {} bytes · {}</p>",
370            escaped(&resource.resource.path.to_string_lossy()),
371            escaped(&resource.resource.format),
372            resource.resource.bytes,
373            escaped(&resource.status)
374        );
375        if let Some(info) = &resource.image {
376            let _ = write!(
377                html,
378                "<p>{}×{} · {} 帧 · 透明像素 {} · JPEG {}</p>",
379                info.width,
380                info.height,
381                info.frames,
382                info.transparent_pixels,
383                if info.has_transparent_pixels {
384                    "禁止"
385                } else {
386                    "可比较"
387                }
388            );
389        }
390        if !resource.issues.is_empty() {
391            let _ = write!(
392                html,
393                "<small>{}</small>",
394                escaped(&resource.issues.join("; "))
395            );
396        }
397        html.push_str("<div class=variants>");
398        if let (Some(preview), Some(artifact)) =
399            (&resource.original_preview, &resource.original_artifact)
400        {
401            let _ = write!(
402                html,
403                "<figure><a href=\"{}\"><img loading=lazy src=\"{}\"></a><figcaption>原图(缩略图,点击查看原文件)</figcaption></figure>",
404                escaped(&artifact.to_string_lossy()),
405                escaped(&preview.to_string_lossy())
406            );
407        }
408        for candidate in &resource.candidates {
409            if let (Some(preview), Some(artifact)) = (&candidate.preview, &candidate.artifact) {
410                let _ = write!(
411                    html,
412                    "<figure><a href=\"{}\"><img loading=lazy src=\"{}\"></a><figcaption>{} · 质量 {} · {} bytes<br>节省 {} bytes</figcaption></figure>",
413                    escaped(&artifact.to_string_lossy()),
414                    escaped(&preview.to_string_lossy()),
415                    escaped(&candidate.format),
416                    candidate.quality.map_or("无损".into(), |q| q.to_string()),
417                    candidate.bytes,
418                    candidate.savings_bytes
419                );
420            }
421        }
422        html.push_str("</div><table><tr><th>格式 / 质量</th><th>字节</th><th>RGB MAE</th><th>PSNR dB</th><th>Alpha 最大误差</th><th>结果</th></tr>");
423        for candidate in &resource.candidates {
424            let (mae, psnr) =
425                candidate
426                    .difference
427                    .as_ref()
428                    .map_or(("—".into(), "—".into()), |d| {
429                        (
430                            format!("{:.3}", d.rgb_mae_255),
431                            d.psnr_db.map_or("∞".into(), |p| format!("{p:.2}")),
432                        )
433                    });
434            let alpha = candidate
435                .difference
436                .as_ref()
437                .map_or("—".into(), |d| format!("{:.6}", d.max_alpha_error));
438            let _ = write!(
439                html,
440                "<tr><td>{} / {}</td><td>{}</td><td>{mae}</td><td>{psnr}</td><td>{alpha}</td><td>{}</td></tr>",
441                escaped(&candidate.format),
442                candidate.quality.map_or("无损".into(), |q| q.to_string()),
443                candidate.bytes,
444                escaped(candidate.rejection.as_deref().unwrap_or(
445                    if candidate.artifact.is_some() {
446                        "可审阅"
447                    } else {
448                        "无足够体积收益"
449                    }
450                ))
451            );
452        }
453        html.push_str("</table></section>");
454    }
455    html.push_str("</html>");
456    html
457}