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}