Skip to main content

viser_complexity/
lib.rs

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,    // normalized entropy (0-1)
10    pub temporal: f64,   // inter-frame luma difference (0-255)
11    pub dct_energy: f64, // average DCT coefficient energy
12}
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, // combined 0-100 complexity score
24}
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
47/// Extracts per-frame complexity metrics and aggregates them into segments.
48pub 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); // temporal 30*3.33=99.9, spatial (1-0.5)*200=100
273        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); // 100 - 30
309
310        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); // 120 - 40
313    }
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        // If YLOW > YHIGH, dct_energy should clamp to 0
330        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        // Evenly spaced frames with a gap
375        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); // seg 0 has frame[0], seg 1 has frame[1]
378    }
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}