1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::metrics::{MetricResult, PerceptionLevel};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CodecResult {
17 pub codec_id: String,
19
20 pub codec_version: String,
22
23 pub quality: f64,
25
26 pub file_size: usize,
28
29 pub bits_per_pixel: f64,
31
32 #[serde(with = "duration_millis")]
34 pub encode_time: Duration,
35
36 #[serde(with = "duration_millis_option")]
38 pub decode_time: Option<Duration>,
39
40 pub metrics: MetricResult,
42
43 pub perception: Option<PerceptionLevel>,
45
46 pub cached_path: Option<PathBuf>,
48
49 #[serde(default)]
51 pub codec_params: HashMap<String, String>,
52}
53
54impl CodecResult {
55 #[must_use]
57 pub fn compression_ratio(&self, original_size: usize) -> f64 {
58 if self.file_size == 0 {
59 0.0
60 } else {
61 original_size as f64 / self.file_size as f64
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ImageReport {
69 pub name: String,
71
72 pub source_path: Option<PathBuf>,
74
75 pub width: u32,
77 pub height: u32,
78
79 pub uncompressed_size: usize,
81
82 pub results: Vec<CodecResult>,
84
85 #[serde(with = "chrono_serde")]
87 pub timestamp: chrono::DateTime<chrono::Utc>,
88}
89
90impl ImageReport {
91 #[must_use]
93 pub fn new(name: String, width: u32, height: u32) -> Self {
94 Self {
95 name,
96 source_path: None,
97 width,
98 height,
99 uncompressed_size: (width as usize) * (height as usize) * 3,
100 results: Vec::new(),
101 timestamp: chrono::Utc::now(),
102 }
103 }
104
105 pub fn results_for_codec(&self, codec_id: &str) -> impl Iterator<Item = &CodecResult> {
107 self.results.iter().filter(move |r| r.codec_id == codec_id)
108 }
109
110 #[must_use]
112 pub fn best_at_size(&self, max_bytes: usize) -> Option<&CodecResult> {
113 self.results
114 .iter()
115 .filter(|r| r.file_size <= max_bytes)
116 .max_by(|a, b| {
117 let a_quality = a.metrics.dssim.map(|d| -d).unwrap_or(f64::NEG_INFINITY);
119 let b_quality = b.metrics.dssim.map(|d| -d).unwrap_or(f64::NEG_INFINITY);
120 a_quality
121 .partial_cmp(&b_quality)
122 .unwrap_or(std::cmp::Ordering::Equal)
123 })
124 }
125
126 #[must_use]
128 pub fn smallest_at_quality(&self, max_dssim: f64) -> Option<&CodecResult> {
129 self.results
130 .iter()
131 .filter(|r| r.metrics.dssim.map_or(false, |d| d <= max_dssim))
132 .min_by_key(|r| r.file_size)
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct CorpusReport {
139 pub name: String,
141
142 pub images: Vec<ImageReport>,
144
145 #[serde(with = "chrono_serde")]
147 pub timestamp: chrono::DateTime<chrono::Utc>,
148
149 pub config_summary: String,
151}
152
153impl CorpusReport {
154 #[must_use]
156 pub fn new(name: String) -> Self {
157 Self {
158 name,
159 images: Vec::new(),
160 timestamp: chrono::Utc::now(),
161 config_summary: String::new(),
162 }
163 }
164
165 #[must_use]
167 pub fn total_results(&self) -> usize {
168 self.images.iter().map(|img| img.results.len()).sum()
169 }
170
171 #[must_use]
173 pub fn codec_ids(&self) -> Vec<String> {
174 let mut ids: Vec<String> = self
175 .images
176 .iter()
177 .flat_map(|img| img.results.iter().map(|r| r.codec_id.clone()))
178 .collect();
179 ids.sort();
180 ids.dedup();
181 ids
182 }
183}
184
185mod duration_millis {
187 use serde::{Deserialize, Deserializer, Serialize, Serializer};
188 use std::time::Duration;
189
190 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
191 where
192 S: Serializer,
193 {
194 duration.as_millis().serialize(serializer)
195 }
196
197 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
198 where
199 D: Deserializer<'de>,
200 {
201 let millis = u64::deserialize(deserializer)?;
202 Ok(Duration::from_millis(millis))
203 }
204}
205
206mod duration_millis_option {
207 use serde::{Deserialize, Deserializer, Serialize, Serializer};
208 use std::time::Duration;
209
210 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
211 where
212 S: Serializer,
213 {
214 duration.map(|d| d.as_millis()).serialize(serializer)
215 }
216
217 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
218 where
219 D: Deserializer<'de>,
220 {
221 let millis: Option<u64> = Option::deserialize(deserializer)?;
222 Ok(millis.map(Duration::from_millis))
223 }
224}
225
226mod chrono_serde {
227 use chrono::{DateTime, Utc};
228 use serde::{Deserialize, Deserializer, Serialize, Serializer};
229
230 pub fn serialize<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
231 where
232 S: Serializer,
233 {
234 dt.to_rfc3339().serialize(serializer)
235 }
236
237 pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
238 where
239 D: Deserializer<'de>,
240 {
241 let s = String::deserialize(deserializer)?;
242 DateTime::parse_from_rfc3339(&s)
243 .map(|dt| dt.with_timezone(&Utc))
244 .map_err(serde::de::Error::custom)
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_image_report_new() {
254 let report = ImageReport::new("test.png".to_string(), 1920, 1080);
255 assert_eq!(report.name, "test.png");
256 assert_eq!(report.width, 1920);
257 assert_eq!(report.height, 1080);
258 assert_eq!(report.uncompressed_size, 1920 * 1080 * 3);
259 }
260
261 #[test]
262 fn test_codec_result_compression_ratio() {
263 let result = CodecResult {
264 codec_id: "test".to_string(),
265 codec_version: "1.0".to_string(),
266 quality: 80.0,
267 file_size: 1000,
268 bits_per_pixel: 0.5,
269 encode_time: Duration::from_millis(100),
270 decode_time: None,
271 metrics: MetricResult::default(),
272 perception: None,
273 cached_path: None,
274 codec_params: HashMap::new(),
275 };
276
277 assert!((result.compression_ratio(10000) - 10.0).abs() < 0.001);
278 }
279
280 #[test]
281 fn test_corpus_report_codec_ids() {
282 let mut report = CorpusReport::new("test".to_string());
283 let mut img = ImageReport::new("img1.png".to_string(), 100, 100);
284 img.results.push(CodecResult {
285 codec_id: "mozjpeg".to_string(),
286 codec_version: "4.0".to_string(),
287 quality: 80.0,
288 file_size: 1000,
289 bits_per_pixel: 0.8,
290 encode_time: Duration::from_millis(50),
291 decode_time: None,
292 metrics: MetricResult::default(),
293 perception: None,
294 cached_path: None,
295 codec_params: HashMap::new(),
296 });
297 img.results.push(CodecResult {
298 codec_id: "webp".to_string(),
299 codec_version: "1.0".to_string(),
300 quality: 80.0,
301 file_size: 900,
302 bits_per_pixel: 0.72,
303 encode_time: Duration::from_millis(60),
304 decode_time: None,
305 metrics: MetricResult::default(),
306 perception: None,
307 cached_path: None,
308 codec_params: HashMap::new(),
309 });
310 report.images.push(img);
311
312 let ids = report.codec_ids();
313 assert_eq!(ids.len(), 2);
314 assert!(ids.contains(&"mozjpeg".to_string()));
315 assert!(ids.contains(&"webp".to_string()));
316 }
317}