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 pub min_input_bytes: u64,
24 pub min_savings_bytes: u64,
25 pub probe_only: bool,
26 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 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
105pub 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 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('&', "&")
344 .replace('<', "<")
345 .replace('>', ">")
346 .replace('"', """)
347 .replace('\'', "'")
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}