1use serde::{Deserialize, Serialize};
2use std::time::Duration;
3use tokio::process::Command;
4use viser_ffmpeg::{ffmpeg_path, probe};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct FrameComplexity {
8 pub pts: Duration,
9 pub spatial: f64, pub temporal: f64, pub dct_energy: f64, }
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SegmentComplexity {
16 pub start: Duration,
17 pub end: Duration,
18 pub duration: Duration,
19 pub avg_spatial: f64,
20 pub avg_temporal: f64,
21 pub max_spatial: f64,
22 pub max_temporal: f64,
23 pub score: f64, }
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Profile {
28 pub frames: Vec<FrameComplexity>,
29 pub segments: Vec<SegmentComplexity>,
30 pub avg_spatial: f64,
31 pub avg_temporal: f64,
32 pub overall_score: f64,
33}
34
35#[derive(Debug, Clone)]
36pub struct AnalyzeOpts {
37 pub segment_duration: Duration,
38 pub subsample: i32,
39}
40
41impl Default for AnalyzeOpts {
42 fn default() -> Self {
43 Self { segment_duration: Duration::from_secs(2), subsample: 1 }
44 }
45}
46
47pub async fn analyze(path: &str, opts: AnalyzeOpts) -> anyhow::Result<Profile> {
49 let seg_dur = if opts.segment_duration.is_zero() {
50 Duration::from_secs(2)
51 } else {
52 opts.segment_duration
53 };
54 let subsample = if opts.subsample <= 0 { 1 } else { opts.subsample };
55
56 let probe_result = probe(path).await?;
57 let total_duration = Duration::from_secs_f64(probe_result.format.duration);
58
59 let select_filter =
60 if subsample > 1 { format!("select='not(mod(n\\,{subsample}))',") } else { String::new() };
61
62 let filter = format!("{select_filter}entropy,signalstats,metadata=mode=print:file=-");
63 let args = ["-i", path, "-vf", &filter, "-f", "null", "-"];
64
65 let output = Command::new(ffmpeg_path())
66 .args(args)
67 .stdout(std::process::Stdio::piped())
68 .stderr(std::process::Stdio::piped())
69 .output()
70 .await?;
71
72 if !output.status.success() {
73 let stderr = String::from_utf8_lossy(&output.stderr);
74 anyhow::bail!("complexity analysis failed: {stderr}");
75 }
76
77 let stdout = String::from_utf8_lossy(&output.stdout);
78 let frames = parse_complexity_output(&stdout);
79
80 if frames.is_empty() {
81 anyhow::bail!("no frames analyzed");
82 }
83
84 let segments = aggregate_segments(&frames, total_duration, seg_dur);
85
86 let n = frames.len() as f64;
87 let avg_spatial: f64 = frames.iter().map(|f| f.spatial).sum::<f64>() / n;
88 let avg_temporal: f64 = frames.iter().map(|f| f.temporal).sum::<f64>() / n;
89 let overall_score = compute_score(avg_spatial, avg_temporal);
90
91 Ok(Profile { frames, segments, avg_spatial, avg_temporal, overall_score })
92}
93
94fn parse_complexity_output(output: &str) -> Vec<FrameComplexity> {
95 let mut frames = Vec::new();
96 let mut current =
97 FrameComplexity { pts: Duration::ZERO, spatial: 0.0, temporal: 0.0, dct_energy: 0.0 };
98 let mut has_pts = false;
99
100 for line in output.lines() {
101 if line.starts_with("frame:") {
102 if has_pts {
103 frames.push(current.clone());
104 }
105 current = FrameComplexity {
106 pts: Duration::ZERO,
107 spatial: 0.0,
108 temporal: 0.0,
109 dct_energy: 0.0,
110 };
111 has_pts = false;
112
113 if let Some(pts_time) = extract_field(line, "pts_time:") {
114 if let Ok(seconds) = pts_time.parse::<f64>() {
115 current.pts = Duration::from_secs_f64(seconds);
116 has_pts = true;
117 }
118 }
119 continue;
120 }
121
122 if let Some(val) = line.strip_prefix("lavfi.entropy.normalized_entropy.normal.Y=") {
123 current.spatial = val.parse().unwrap_or(0.0);
124 }
125 if let Some(val) = line.strip_prefix("lavfi.signalstats.YDIF=") {
126 current.temporal = val.parse().unwrap_or(0.0);
127 }
128 if let Some(val) = line.strip_prefix("lavfi.signalstats.YHIGH=") {
129 current.dct_energy = val.parse().unwrap_or(0.0);
130 }
131 if let Some(val) = line.strip_prefix("lavfi.signalstats.YLOW=") {
132 let y_low: f64 = val.parse().unwrap_or(0.0);
133 current.dct_energy -= y_low;
134 if current.dct_energy < 0.0 {
135 current.dct_energy = 0.0;
136 }
137 }
138 }
139
140 if has_pts {
141 frames.push(current);
142 }
143
144 frames
145}
146
147fn extract_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
148 let idx = line.find(key)?;
149 let rest = &line[idx + key.len()..];
150 let rest = rest.trim_start();
151 let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
152 Some(&rest[..end])
153}
154
155fn aggregate_segments(
156 frames: &[FrameComplexity],
157 total_duration: Duration,
158 seg_duration: Duration,
159) -> Vec<SegmentComplexity> {
160 let mut segments = Vec::new();
161 let mut seg_start = Duration::ZERO;
162
163 while seg_start < total_duration {
164 let seg_end = (seg_start + seg_duration).min(total_duration);
165
166 let seg_frames: Vec<&FrameComplexity> =
167 frames.iter().filter(|f| f.pts >= seg_start && f.pts < seg_end).collect();
168
169 if !seg_frames.is_empty() {
170 let spatial: Vec<f64> = seg_frames.iter().map(|f| f.spatial).collect();
171 let temporal: Vec<f64> = seg_frames.iter().map(|f| f.temporal).collect();
172 let dct: Vec<f64> = seg_frames.iter().map(|f| f.dct_energy).collect();
173
174 let avg_dct = mean(&dct);
175 let avg_s = mean(&spatial);
176 let avg_t = mean(&temporal);
177
178 segments.push(SegmentComplexity {
179 start: seg_start,
180 end: seg_end,
181 duration: seg_end - seg_start,
182 avg_spatial: avg_s,
183 avg_temporal: avg_t,
184 max_spatial: max_val(&spatial),
185 max_temporal: max_val(&temporal),
186 score: compute_score_with_dct(avg_s, avg_t, avg_dct),
187 });
188 }
189
190 seg_start = seg_end;
191 }
192
193 segments
194}
195
196fn compute_score(spatial: f64, temporal: f64) -> f64 {
197 let spatial_norm = ((spatial - 0.5) * 200.0).clamp(0.0, 100.0);
198 let temporal_norm = (temporal * 3.33).min(100.0);
199 spatial_norm * 0.6 + temporal_norm * 0.4
200}
201
202fn compute_score_with_dct(spatial: f64, temporal: f64, dct_energy: f64) -> f64 {
203 let spatial_norm = ((spatial - 0.5) * 200.0).clamp(0.0, 100.0);
204 let temporal_norm = (temporal * 3.33).min(100.0);
205 let dct_norm = (dct_energy * 0.5).min(100.0);
206 spatial_norm * 0.4 + dct_norm * 0.3 + temporal_norm * 0.3
207}
208
209fn mean(vals: &[f64]) -> f64 {
210 if vals.is_empty() {
211 return 0.0;
212 }
213 vals.iter().sum::<f64>() / vals.len() as f64
214}
215
216fn max_val(vals: &[f64]) -> f64 {
217 vals.iter().copied().fold(f64::NEG_INFINITY, f64::max)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn frame(pts_secs: f64, spatial: f64, temporal: f64, dct: f64) -> FrameComplexity {
225 FrameComplexity {
226 pts: Duration::from_secs_f64(pts_secs),
227 spatial,
228 temporal,
229 dct_energy: dct,
230 }
231 }
232
233 #[test]
234 fn test_mean_empty() {
235 assert!((mean(&[]) - 0.0).abs() < 1e-9);
236 }
237
238 #[test]
239 fn test_mean_single() {
240 assert!((mean(&[42.0]) - 42.0).abs() < 1e-9);
241 }
242
243 #[test]
244 fn test_mean_multiple() {
245 assert!((mean(&[1.0, 2.0, 3.0]) - 2.0).abs() < 1e-9);
246 }
247
248 #[test]
249 fn test_max_val_empty_negative_inf() {
250 assert!(max_val(&[]).is_infinite() && max_val(&[]).is_sign_negative());
251 }
252
253 #[test]
254 fn test_max_val() {
255 assert!((max_val(&[1.0, 5.0, 3.0]) - 5.0).abs() < 1e-9);
256 }
257
258 #[test]
259 fn test_compute_score_bounds() {
260 let s = compute_score(0.5, 0.0);
261 assert!(s >= 0.0 && s <= 100.0);
262 }
263
264 #[test]
265 fn test_compute_score_zero_input() {
266 let s = compute_score(0.0, 0.0);
267 assert!(s >= 0.0);
268 }
269
270 #[test]
271 fn test_compute_score_high_input() {
272 let s = compute_score(1.0, 30.0); assert!(s <= 100.0);
274 assert!(s > 50.0);
275 }
276
277 #[test]
278 fn test_compute_score_with_dct() {
279 let s = compute_score_with_dct(0.5, 0.0, 0.0);
280 assert!(s >= 0.0);
281 }
282
283 #[test]
284 fn test_parse_complexity_output_empty() {
285 let frames = parse_complexity_output("");
286 assert!(frames.is_empty());
287 }
288
289 #[test]
290 fn test_parse_complexity_output_basic() {
291 let output = "\
292frame: 1 pts_time:0.000
293lavfi.entropy.normalized_entropy.normal.Y=0.6
294lavfi.signalstats.YDIF=2.5
295lavfi.signalstats.YHIGH=100.0
296lavfi.signalstats.YLOW=30.0
297frame: 2 pts_time:1.000
298lavfi.entropy.normalized_entropy.normal.Y=0.7
299lavfi.signalstats.YDIF=3.0
300lavfi.signalstats.YHIGH=120.0
301lavfi.signalstats.YLOW=40.0
302";
303 let frames = parse_complexity_output(output);
304 assert_eq!(frames.len(), 2);
305
306 assert!((frames[0].spatial - 0.6).abs() < 1e-9);
307 assert!((frames[0].temporal - 2.5).abs() < 1e-9);
308 assert!((frames[0].dct_energy - 70.0).abs() < 1e-9); assert!((frames[1].spatial - 0.7).abs() < 1e-9);
311 assert!((frames[1].temporal - 3.0).abs() < 1e-9);
312 assert!((frames[1].dct_energy - 80.0).abs() < 1e-9); }
314
315 #[test]
316 fn test_parse_complexity_output_handles_partial_data() {
317 let output = "\
318frame: 1 pts_time:0.000
319lavfi.entropy.normalized_entropy.normal.Y=0.5
320frame: 2 pts_time:1.000
321lavfi.signalstats.YDIF=1.0
322";
323 let frames = parse_complexity_output(output);
324 assert_eq!(frames.len(), 2);
325 }
326
327 #[test]
328 fn test_parse_complexity_output_negative_dct() {
329 let output = "\
331frame: 1 pts_time:0.000
332lavfi.signalstats.YHIGH=30.0
333lavfi.signalstats.YLOW=50.0
334";
335 let frames = parse_complexity_output(output);
336 assert!((frames[0].dct_energy - 0.0).abs() < 1e-9);
337 }
338
339 #[test]
340 fn test_aggregate_segments_single_segment() {
341 let frames = vec![
342 frame(0.0, 0.5, 1.0, 10.0),
343 frame(0.5, 0.6, 2.0, 20.0),
344 frame(1.0, 0.7, 3.0, 30.0),
345 ];
346 let segs = aggregate_segments(&frames, Duration::from_secs(2), Duration::from_secs(2));
347 assert_eq!(segs.len(), 1);
348 assert!((segs[0].avg_spatial - 0.6).abs() < 0.01);
349 assert!((segs[0].avg_temporal - 2.0).abs() < 0.01);
350 assert!((segs[0].max_spatial - 0.7).abs() < 1e-9);
351 assert_eq!(segs[0].start, Duration::ZERO);
352 assert_eq!(segs[0].end, Duration::from_secs(2));
353 }
354
355 #[test]
356 fn test_aggregate_segments_multiple() {
357 let frames = vec![
358 frame(0.0, 0.4, 1.0, 5.0),
359 frame(0.5, 0.5, 1.5, 6.0),
360 frame(1.0, 0.6, 2.0, 7.0),
361 frame(1.5, 0.7, 2.5, 8.0),
362 frame(2.0, 0.8, 3.0, 9.0),
363 frame(2.5, 0.9, 3.5, 10.0),
364 ];
365 let segs = aggregate_segments(&frames, Duration::from_secs(3), Duration::from_secs(1));
366 assert_eq!(segs.len(), 3);
367 assert_eq!(segs[0].start, Duration::from_secs(0));
368 assert_eq!(segs[1].start, Duration::from_secs(1));
369 assert_eq!(segs[2].start, Duration::from_secs(2));
370 }
371
372 #[test]
373 fn test_aggregate_segments_empty_bucket() {
374 let frames = vec![frame(0.0, 0.5, 1.0, 5.0), frame(3.0, 0.8, 3.0, 10.0)];
376 let segs = aggregate_segments(&frames, Duration::from_secs(4), Duration::from_secs(2));
377 assert_eq!(segs.len(), 2); }
379
380 #[test]
381 fn test_analyze_opts_default() {
382 let opts = AnalyzeOpts::default();
383 assert_eq!(opts.segment_duration, Duration::from_secs(2));
384 assert_eq!(opts.subsample, 1);
385 }
386}