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}