1use crate::{
7 ImageCandidate, ResourceAnalysis,
8 analysis::WARNINGS,
9 filesystem::hash,
10 review::{Approvals, Review},
11};
12use anyhow::{Result, ensure};
13use serde::{Deserialize, Serialize};
14use std::{
15 path::PathBuf,
16 sync::{
17 Mutex,
18 atomic::{AtomicBool, Ordering},
19 },
20};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default, deny_unknown_fields)]
26pub struct BatchPolicy {
27 pub lossless: bool,
28 pub lossy: bool,
29 pub cross_format: bool,
31 pub min_score: Option<f64>,
33 pub formats: Vec<String>,
35 pub accept_warnings: Vec<String>,
37 pub resources: Option<Vec<usize>>,
39}
40
41impl Default for BatchPolicy {
42 fn default() -> Self {
43 Self {
44 lossless: true,
45 lossy: false,
46 cross_format: false,
47 min_score: None,
48 formats: vec![],
49 accept_warnings: vec![],
50 resources: None,
51 }
52 }
53}
54
55impl BatchPolicy {
56 pub fn validate(&self) -> Result<()> {
57 ensure!(
58 self.lossless || self.lossy,
59 "batch policy selects neither lossless nor lossy candidates"
60 );
61 ensure!(
62 self.min_score
63 .is_none_or(|s| s.is_finite() && (0.0..=100.0).contains(&s)),
64 "min_score must be 0..=100"
65 );
66 for warning in &self.accept_warnings {
67 ensure!(
68 WARNINGS.contains(&warning.as_str()),
69 "unknown warning kind: {warning}"
70 );
71 }
72 ensure!(
73 self.accept_warnings.is_empty() || self.lossy,
74 "warning candidates are lossy; enable lossy candidates to accept warnings"
75 );
76 Ok(())
77 }
78
79 fn approvals(&self) -> Approvals {
80 Approvals {
81 lossy: self.lossy,
82 warnings: self.accept_warnings.clone(),
83 }
84 }
85
86 fn allows(&self, resource: &ResourceAnalysis, candidate: &ImageCandidate) -> bool {
87 let accepted_warning = candidate.is_warning()
88 && candidate
89 .required_warnings()
90 .iter()
91 .all(|w| self.accept_warnings.contains(w));
92 let crossing =
93 candidate.format != resource.resource.format || resource.resource.extension_mismatch;
94 candidate.artifact.is_some()
95 && (candidate.valid || accepted_warning)
96 && (if candidate.lossy {
97 self.lossy
98 } else {
99 self.lossless
100 })
101 && (self.cross_format || !crossing)
102 && (!crossing || resource.resource.format_lock.is_none())
103 && !(crossing
107 && resource
108 .resource
109 .android
110 .as_ref()
111 .is_some_and(|a| a.area == "assets"))
112 && (self.formats.is_empty() || self.formats.contains(&candidate.format))
113 && (!candidate.lossy
114 || self.min_score.is_none_or(|floor| {
115 candidate
116 .difference
117 .as_ref()
118 .and_then(|d| d.ssimulacra2)
119 .is_some_and(|score| score >= floor)
120 }))
121 }
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct BatchItem {
126 pub resource: usize,
127 pub candidate: usize,
128 pub path: PathBuf,
129 pub format: String,
130 pub quality: Option<u8>,
131 pub lossy: bool,
132 pub warning: Option<String>,
133 pub original_bytes: u64,
134 pub savings_bytes: u64,
135}
136
137#[derive(Debug, Clone, Serialize)]
138pub struct BatchPlan {
139 pub policy: BatchPolicy,
140 pub items: Vec<BatchItem>,
141 pub savings_bytes: u64,
142 pub lossy_items: usize,
143 pub warning_items: usize,
144 pub cross_format_items: usize,
145 pub token: String,
147}
148
149pub(crate) fn plan(review: &Review, policy: &BatchPolicy) -> Result<BatchPlan> {
152 policy.validate()?;
153 let states = review.states();
154 let mut items = Vec::new();
155 for (index, resource) in review.report.resources.iter().enumerate() {
156 if policy
157 .resources
158 .as_ref()
159 .is_some_and(|r| !r.contains(&index))
160 || resource.status != "candidates_available"
161 || resource.resource.conversion_exclusion.is_some()
162 || states
163 .get(index.to_string())
164 .is_some_and(|s| s["state"] != "original")
165 {
166 continue;
167 }
168 let Some((candidate_index, candidate)) = resource
169 .candidates
170 .iter()
171 .enumerate()
172 .filter(|(_, c)| policy.allows(resource, c))
173 .min_by_key(|(_, c)| c.bytes)
174 else {
175 continue;
176 };
177 items.push(BatchItem {
178 resource: index,
179 candidate: candidate_index,
180 path: resource.resource.path.clone(),
181 format: candidate.format.clone(),
182 quality: candidate.quality,
183 lossy: candidate.lossy,
184 warning: candidate
185 .rejection
186 .clone()
187 .filter(|_| candidate.is_warning()),
188 original_bytes: resource.resource.bytes,
189 savings_bytes: candidate.savings_bytes,
190 });
191 }
192 items.sort_by(|a, b| {
194 b.savings_bytes
195 .cmp(&a.savings_bytes)
196 .then(a.resource.cmp(&b.resource))
197 });
198 let selection: Vec<_> = items.iter().map(|i| (i.resource, i.candidate)).collect();
199 let token = hash(&serde_json::to_vec(&(policy, &selection))?);
200 Ok(BatchPlan {
201 policy: policy.clone(),
202 savings_bytes: items.iter().map(|i| i.savings_bytes).sum(),
203 lossy_items: items.iter().filter(|i| i.lossy).count(),
204 warning_items: items.iter().filter(|i| i.warning.is_some()).count(),
205 cross_format_items: items
206 .iter()
207 .filter(|i| {
208 let r = &review.report.resources[i.resource].resource;
209 i.format != r.format || r.extension_mismatch
210 })
211 .count(),
212 items,
213 token,
214 })
215}
216
217#[derive(Debug, Clone, Serialize)]
218pub struct BatchOutcome {
219 pub resource: usize,
220 pub path: PathBuf,
221 pub outcome: &'static str,
223 pub error: Option<String>,
224 pub savings_bytes: u64,
225}
226
227#[derive(Debug, Clone, Default, Serialize)]
228pub struct BatchStatus {
229 pub running: bool,
230 pub cancelled: bool,
231 pub total: usize,
232 pub outcomes: Vec<BatchOutcome>,
233 pub applied: usize,
234 pub failed: usize,
235 pub savings_bytes: u64,
236}
237
238pub(crate) fn run(
241 review: &Review,
242 plan: &BatchPlan,
243 status: &Mutex<BatchStatus>,
244 cancel: &AtomicBool,
245) {
246 {
247 let mut status = status.lock().unwrap_or_else(|e| e.into_inner());
248 *status = BatchStatus {
249 running: true,
250 total: plan.items.len(),
251 ..Default::default()
252 };
253 }
254 let approvals = plan.policy.approvals();
255 for item in &plan.items {
256 let (outcome, error) = if cancel.load(Ordering::SeqCst) {
257 ("cancelled", None)
258 } else {
259 match review.apply_with_warnings(item.resource, item.candidate, &approvals, None, false)
260 {
261 Ok(()) => ("applied", None),
262 Err(error) => ("failed", Some(format!("{error:#}"))),
263 }
264 };
265 let mut status = status.lock().unwrap_or_else(|e| e.into_inner());
266 match outcome {
267 "applied" => {
268 status.applied += 1;
269 status.savings_bytes += item.savings_bytes;
270 }
271 "failed" => status.failed += 1,
272 _ => status.cancelled = true,
273 }
274 status.outcomes.push(BatchOutcome {
275 resource: item.resource,
276 path: item.path.clone(),
277 outcome,
278 error,
279 savings_bytes: if outcome == "applied" {
280 item.savings_bytes
281 } else {
282 0
283 },
284 });
285 }
286 status.lock().unwrap_or_else(|e| e.into_inner()).running = false;
287}
288
289pub(crate) fn restore_many(
294 review: &Review,
295 resources: Option<&[usize]>,
296 cancel: &AtomicBool,
297) -> BatchStatus {
298 let selected = resources.map(|indexes| {
299 indexes
300 .iter()
301 .copied()
302 .collect::<std::collections::BTreeSet<_>>()
303 });
304 let mut pending: Vec<usize> = review
305 .states()
306 .as_object()
307 .into_iter()
308 .flatten()
309 .filter_map(|(index, state)| {
310 let index = index.parse().ok()?;
311 (state["state"] != "original"
312 && selected
313 .as_ref()
314 .is_none_or(|indexes| indexes.contains(&index)))
315 .then_some(index)
316 })
317 .collect();
318 pending.sort_unstable();
319 let mut status = BatchStatus {
320 total: pending.len(),
321 ..Default::default()
322 };
323 let mut last_errors = std::collections::BTreeMap::new();
324 loop {
325 let before = pending.len();
326 pending.retain(|&index| {
327 if cancel.load(Ordering::SeqCst) {
328 return true;
329 }
330 match review.restore(index) {
331 Ok(()) => {
332 status.applied += 1;
333 status.outcomes.push(BatchOutcome {
334 resource: index,
335 path: review.report.resources[index].resource.path.clone(),
336 outcome: "applied",
337 error: None,
338 savings_bytes: 0,
339 });
340 false
341 }
342 Err(error) => {
343 last_errors.insert(index, format!("{error:#}"));
344 true
345 }
346 }
347 });
348 if pending.is_empty() || pending.len() == before || cancel.load(Ordering::SeqCst) {
349 break;
350 }
351 }
352 status.cancelled = cancel.load(Ordering::SeqCst);
353 for index in pending {
354 status.failed += 1;
355 status.outcomes.push(BatchOutcome {
356 resource: index,
357 path: review.report.resources[index].resource.path.clone(),
358 outcome: if status.cancelled {
359 "cancelled"
360 } else {
361 "failed"
362 },
363 error: last_errors.remove(&index),
364 savings_bytes: 0,
365 });
366 }
367 status
368}
369
370pub(crate) fn restore_all(review: &Review, cancel: &AtomicBool) -> BatchStatus {
371 restore_many(review, None, cancel)
372}
373
374pub fn plan_report(
376 directory: impl AsRef<std::path::Path>,
377 policy: &BatchPolicy,
378) -> Result<BatchPlan> {
379 plan(&Review::open(directory.as_ref())?, policy)
380}
381
382pub fn apply_report(
386 directory: impl AsRef<std::path::Path>,
387 policy: &BatchPolicy,
388) -> Result<BatchStatus> {
389 let review = Review::open(directory.as_ref())?;
390 let plan = plan(&review, policy)?;
391 let status = Mutex::new(BatchStatus::default());
392 run(&review, &plan, &status, &AtomicBool::new(false));
393 Ok(status.into_inner().unwrap_or_else(|e| e.into_inner()))
394}
395
396pub fn restore_report(directory: impl AsRef<std::path::Path>) -> Result<BatchStatus> {
398 let review = Review::open(directory.as_ref())?;
399 Ok(restore_all(&review, &AtomicBool::new(false)))
400}