1use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::time::Duration;
12use tokio::process::Command;
13use viser_ffmpeg::{ffmpeg_path, probe};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum SceneClass {
18 Black,
20 Static,
22 Detailed,
24 Motion,
26 Complex,
28}
29
30impl fmt::Display for SceneClass {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 match self {
33 SceneClass::Black => write!(f, "black"),
34 SceneClass::Static => write!(f, "static"),
35 SceneClass::Detailed => write!(f, "detailed"),
36 SceneClass::Motion => write!(f, "motion"),
37 SceneClass::Complex => write!(f, "complex"),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FrameComplexity {
45 pub pts: Duration,
47 pub spatial: f64, pub temporal: f64, pub dct_energy: f64, }
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SegmentComplexity {
58 pub start: Duration,
60 pub end: Duration,
62 pub duration: Duration,
64 pub avg_spatial: f64,
66 pub avg_temporal: f64,
68 pub max_spatial: f64,
70 pub max_temporal: f64,
72 pub score: f64, pub scene_class: SceneClass,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Profile {
81 pub frames: Vec<FrameComplexity>,
83 pub segments: Vec<SegmentComplexity>,
85 pub avg_spatial: f64,
87 pub avg_temporal: f64,
89 pub overall_score: f64,
91}
92
93#[derive(Debug, Clone)]
95pub struct AnalyzeOpts {
96 pub segment_duration: Duration,
98 pub subsample: i32,
100}
101
102impl Default for AnalyzeOpts {
103 fn default() -> Self {
104 Self { segment_duration: Duration::from_secs(2), subsample: 1 }
105 }
106}
107
108pub async fn analyze(path: &str, opts: AnalyzeOpts) -> anyhow::Result<Profile> {
110 let seg_dur = if opts.segment_duration.is_zero() {
111 Duration::from_secs(2)
112 } else {
113 opts.segment_duration
114 };
115 let subsample = if opts.subsample <= 0 { 1 } else { opts.subsample };
116
117 let probe_result = probe(path).await?;
118 let total_duration = Duration::from_secs_f64(probe_result.format.duration);
119
120 let select_filter =
121 if subsample > 1 { format!("select='not(mod(n\\,{subsample}))',") } else { String::new() };
122
123 let filter = format!("{select_filter}entropy,signalstats,metadata=mode=print:file=-");
124 let args = ["-i", path, "-vf", &filter, "-f", "null", "-"];
125
126 let output = Command::new(ffmpeg_path())
127 .args(args)
128 .stdout(std::process::Stdio::piped())
129 .stderr(std::process::Stdio::piped())
130 .output()
131 .await?;
132
133 if !output.status.success() {
134 let stderr = String::from_utf8_lossy(&output.stderr);
135 anyhow::bail!("complexity analysis failed: {stderr}");
136 }
137
138 let stdout = String::from_utf8_lossy(&output.stdout);
139 let frames = parse_complexity_output(&stdout);
140
141 if frames.is_empty() {
142 anyhow::bail!("no frames analyzed");
143 }
144
145 let segments = aggregate_segments(&frames, total_duration, seg_dur);
146
147 let n = frames.len() as f64;
148 let avg_spatial: f64 = frames.iter().map(|f| f.spatial).sum::<f64>() / n;
149 let avg_temporal: f64 = frames.iter().map(|f| f.temporal).sum::<f64>() / n;
150 let overall_score = compute_score(avg_spatial, avg_temporal);
151
152 Ok(Profile { frames, segments, avg_spatial, avg_temporal, overall_score })
153}
154
155fn parse_complexity_output(output: &str) -> Vec<FrameComplexity> {
156 let mut frames = Vec::new();
157 let mut current =
158 FrameComplexity { pts: Duration::ZERO, spatial: 0.0, temporal: 0.0, dct_energy: 0.0 };
159 let mut has_pts = false;
160
161 for line in output.lines() {
162 if line.starts_with("frame:") {
163 if has_pts {
164 frames.push(current.clone());
165 }
166 current = FrameComplexity {
167 pts: Duration::ZERO,
168 spatial: 0.0,
169 temporal: 0.0,
170 dct_energy: 0.0,
171 };
172 has_pts = false;
173
174 if let Some(pts_time) = extract_field(line, "pts_time:") {
175 if let Ok(seconds) = pts_time.parse::<f64>() {
176 current.pts = Duration::from_secs_f64(seconds);
177 has_pts = true;
178 }
179 }
180 continue;
181 }
182
183 if let Some(val) = line.strip_prefix("lavfi.entropy.normalized_entropy.normal.Y=") {
184 current.spatial = val.parse().unwrap_or(0.0);
185 }
186 if let Some(val) = line.strip_prefix("lavfi.signalstats.YDIF=") {
187 current.temporal = val.parse().unwrap_or(0.0);
188 }
189 if let Some(val) = line.strip_prefix("lavfi.signalstats.YHIGH=") {
190 current.dct_energy = val.parse().unwrap_or(0.0);
191 }
192 if let Some(val) = line.strip_prefix("lavfi.signalstats.YLOW=") {
193 let y_low: f64 = val.parse().unwrap_or(0.0);
194 current.dct_energy -= y_low;
195 if current.dct_energy < 0.0 {
196 current.dct_energy = 0.0;
197 }
198 }
199 }
200
201 if has_pts {
202 frames.push(current);
203 }
204
205 frames
206}
207
208fn extract_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
209 let idx = line.find(key)?;
210 let rest = &line[idx + key.len()..];
211 let rest = rest.trim_start();
212 let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
213 Some(&rest[..end])
214}
215
216fn aggregate_segments(
217 frames: &[FrameComplexity],
218 total_duration: Duration,
219 seg_duration: Duration,
220) -> Vec<SegmentComplexity> {
221 let mut segments = Vec::new();
222 let mut seg_start = Duration::ZERO;
223
224 while seg_start < total_duration {
225 let seg_end = (seg_start + seg_duration).min(total_duration);
226
227 let seg_frames: Vec<&FrameComplexity> =
228 frames.iter().filter(|f| f.pts >= seg_start && f.pts < seg_end).collect();
229
230 if !seg_frames.is_empty() {
231 let spatial: Vec<f64> = seg_frames.iter().map(|f| f.spatial).collect();
232 let temporal: Vec<f64> = seg_frames.iter().map(|f| f.temporal).collect();
233 let dct: Vec<f64> = seg_frames.iter().map(|f| f.dct_energy).collect();
234
235 let avg_dct = mean(&dct);
236 let avg_s = mean(&spatial);
237 let avg_t = mean(&temporal);
238
239 let scene_class = classify_scene(avg_s, avg_t, avg_dct);
240
241 segments.push(SegmentComplexity {
242 start: seg_start,
243 end: seg_end,
244 duration: seg_end - seg_start,
245 avg_spatial: avg_s,
246 avg_temporal: avg_t,
247 max_spatial: max_val(&spatial),
248 max_temporal: max_val(&temporal),
249 score: compute_score_with_dct(avg_s, avg_t, avg_dct),
250 scene_class,
251 });
252 }
253
254 seg_start = seg_end;
255 }
256
257 segments
258}
259
260fn compute_score(spatial: f64, temporal: f64) -> f64 {
261 let spatial_norm = ((spatial - 0.5) * 200.0).clamp(0.0, 100.0);
262 let temporal_norm = (temporal * 3.33).min(100.0);
263 spatial_norm * 0.6 + temporal_norm * 0.4
264}
265
266fn compute_score_with_dct(spatial: f64, temporal: f64, dct_energy: f64) -> f64 {
267 let spatial_norm = ((spatial - 0.5) * 200.0).clamp(0.0, 100.0);
268 let temporal_norm = (temporal * 3.33).min(100.0);
269 let dct_norm = (dct_energy * 0.5).min(100.0);
270 spatial_norm * 0.4 + dct_norm * 0.3 + temporal_norm * 0.3
271}
272
273pub fn classify_scene(spatial: f64, temporal: f64, dct_energy: f64) -> SceneClass {
275 let s = spatial;
276 let t = temporal;
277 let d = dct_energy;
278
279 if t < 1.0 && s <= 0.3 && d < 5.0 {
281 return SceneClass::Black;
282 }
283
284 if t > 8.0 && s < 0.65 {
286 return SceneClass::Motion;
287 }
288
289 if s > 0.7 && t > 5.0 {
291 return SceneClass::Complex;
292 }
293
294 if s >= 0.65 && t <= 5.0 {
296 return SceneClass::Detailed;
297 }
298
299 SceneClass::Static
301}
302
303fn mean(vals: &[f64]) -> f64 {
304 if vals.is_empty() {
305 return 0.0;
306 }
307 vals.iter().sum::<f64>() / vals.len() as f64
308}
309
310fn max_val(vals: &[f64]) -> f64 {
311 vals.iter().copied().fold(f64::NEG_INFINITY, f64::max)
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316pub enum ContentType {
317 Natural,
319 Screen,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ScreenContentDetection {
326 pub content_type: ContentType,
328 pub confidence: f64, pub reason: String,
332}
333
334pub fn detect_screen_content(profile: &Profile) -> ScreenContentDetection {
341 if profile.frames.is_empty() {
342 return ScreenContentDetection {
343 content_type: ContentType::Natural,
344 confidence: 0.0,
345 reason: "no frames analyzed".into(),
346 };
347 }
348
349 let static_fraction = profile.frames.iter().filter(|f| f.temporal < 1.5).count() as f64
351 / profile.frames.len() as f64;
352
353 let has_sharp_edges = profile.avg_spatial > 0.75;
354 let is_mostly_static = static_fraction > 0.8;
355 let high_dct_low_temporal = profile.avg_temporal < 2.0
356 && (profile.segments.iter().any(|s| s.avg_spatial > 0.7) || profile.avg_spatial > 0.7);
357
358 let score = if has_sharp_edges && is_mostly_static {
359 90.0
361 } else if high_dct_low_temporal {
362 70.0
364 } else if is_mostly_static && profile.avg_spatial > 0.6 {
365 50.0
367 } else if static_fraction > 0.6 && profile.avg_temporal < 3.0 {
368 30.0
370 } else {
371 0.0
372 };
373
374 let content_type = if score >= 50.0 { ContentType::Screen } else { ContentType::Natural };
375 let reason = if score >= 90.0 {
376 format!(
377 "sharp edges (spatial={:.2}) + mostly static ({:.0}% frames) — classic screen content",
378 profile.avg_spatial,
379 static_fraction * 100.0
380 )
381 } else if score >= 70.0 {
382 format!(
383 "high spatial/DCT energy (spatial={:.2}) with low temporal ({:.1}) — likely screen content",
384 profile.avg_spatial, profile.avg_temporal
385 )
386 } else if score >= 50.0 {
387 format!(
388 "mostly static ({:.0}% frames) with moderate spatial ({:.2}) — possible screen content",
389 static_fraction * 100.0,
390 profile.avg_spatial
391 )
392 } else if score >= 30.0 {
393 format!("leaning static ({:.0}% frames)", static_fraction * 100.0)
394 } else {
395 "natural video content detected".into()
396 };
397
398 ScreenContentDetection { content_type, confidence: score, reason }
399}
400
401#[cfg(test)]
402mod screen_tests {
403 use super::*;
404
405 fn mk_frame(pts_secs: f64, spatial: f64, temporal: f64, dct: f64) -> FrameComplexity {
406 FrameComplexity {
407 pts: Duration::from_secs_f64(pts_secs),
408 spatial,
409 temporal,
410 dct_energy: dct,
411 }
412 }
413
414 #[test]
415 fn test_detect_screen_content_slides() {
416 let frames: Vec<_> =
418 (0..100).map(|i| mk_frame(i as f64 * 0.04, 0.85, 0.2, 100.0)).collect();
419 let profile = Profile {
420 frames: frames.clone(),
421 segments: vec![],
422 avg_spatial: 0.85,
423 avg_temporal: 0.2,
424 overall_score: 0.0,
425 };
426 let detection = detect_screen_content(&profile);
427 assert_eq!(detection.content_type, ContentType::Screen);
428 assert!(detection.confidence >= 90.0);
429 }
430
431 #[test]
432 fn test_detect_screen_content_natural_video() {
433 let frames: Vec<_> = (0..100).map(|i| mk_frame(i as f64 * 0.04, 0.5, 10.0, 50.0)).collect();
434 let profile = Profile {
435 frames,
436 segments: vec![],
437 avg_spatial: 0.5,
438 avg_temporal: 10.0,
439 overall_score: 0.0,
440 };
441 let detection = detect_screen_content(&profile);
442 assert_eq!(detection.content_type, ContentType::Natural);
443 assert_eq!(detection.confidence, 0.0);
444 }
445
446 #[test]
447 fn test_detect_screen_content_empty() {
448 let profile = Profile {
449 frames: vec![],
450 segments: vec![],
451 avg_spatial: 0.0,
452 avg_temporal: 0.0,
453 overall_score: 0.0,
454 };
455 let detection = detect_screen_content(&profile);
456 assert_eq!(detection.content_type, ContentType::Natural);
457 assert_eq!(detection.confidence, 0.0);
458 }
459
460 #[test]
461 fn test_detect_screen_content_code_capture() {
462 let frames: Vec<_> = (0..100).map(|i| mk_frame(i as f64 * 0.04, 0.78, 1.5, 80.0)).collect();
464 let profile = Profile {
465 frames: frames.clone(),
466 segments: vec![],
467 avg_spatial: 0.78,
468 avg_temporal: 1.5,
469 overall_score: 0.0,
470 };
471 let detection = detect_screen_content(&profile);
472 assert_eq!(detection.content_type, ContentType::Screen);
473 assert!(detection.confidence >= 70.0, "expected >= 70, got {}", detection.confidence);
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 fn frame(pts_secs: f64, spatial: f64, temporal: f64, dct: f64) -> FrameComplexity {
482 FrameComplexity {
483 pts: Duration::from_secs_f64(pts_secs),
484 spatial,
485 temporal,
486 dct_energy: dct,
487 }
488 }
489
490 #[test]
491 fn test_mean_empty() {
492 assert!((mean(&[]) - 0.0).abs() < 1e-9);
493 }
494
495 #[test]
496 fn test_mean_single() {
497 assert!((mean(&[42.0]) - 42.0).abs() < 1e-9);
498 }
499
500 #[test]
501 fn test_mean_multiple() {
502 assert!((mean(&[1.0, 2.0, 3.0]) - 2.0).abs() < 1e-9);
503 }
504
505 #[test]
506 fn test_max_val_empty_negative_inf() {
507 assert!(max_val(&[]).is_infinite() && max_val(&[]).is_sign_negative());
508 }
509
510 #[test]
511 fn test_max_val() {
512 assert!((max_val(&[1.0, 5.0, 3.0]) - 5.0).abs() < 1e-9);
513 }
514
515 #[test]
516 fn test_compute_score_bounds() {
517 let s = compute_score(0.5, 0.0);
518 assert!((0.0..=100.0).contains(&s));
519 }
520
521 #[test]
522 fn test_compute_score_zero_input() {
523 let s = compute_score(0.0, 0.0);
524 assert!(s >= 0.0);
525 }
526
527 #[test]
528 fn test_compute_score_high_input() {
529 let s = compute_score(1.0, 30.0); assert!(s <= 100.0);
531 assert!(s > 50.0);
532 }
533
534 #[test]
535 fn test_compute_score_with_dct() {
536 let s = compute_score_with_dct(0.5, 0.0, 0.0);
537 assert!(s >= 0.0);
538 }
539
540 #[test]
541 fn test_parse_complexity_output_empty() {
542 let frames = parse_complexity_output("");
543 assert!(frames.is_empty());
544 }
545
546 #[test]
547 fn test_parse_complexity_output_basic() {
548 let output = "\
549frame: 1 pts_time:0.000
550lavfi.entropy.normalized_entropy.normal.Y=0.6
551lavfi.signalstats.YDIF=2.5
552lavfi.signalstats.YHIGH=100.0
553lavfi.signalstats.YLOW=30.0
554frame: 2 pts_time:1.000
555lavfi.entropy.normalized_entropy.normal.Y=0.7
556lavfi.signalstats.YDIF=3.0
557lavfi.signalstats.YHIGH=120.0
558lavfi.signalstats.YLOW=40.0
559";
560 let frames = parse_complexity_output(output);
561 assert_eq!(frames.len(), 2);
562
563 assert!((frames[0].spatial - 0.6).abs() < 1e-9);
564 assert!((frames[0].temporal - 2.5).abs() < 1e-9);
565 assert!((frames[0].dct_energy - 70.0).abs() < 1e-9); assert!((frames[1].spatial - 0.7).abs() < 1e-9);
568 assert!((frames[1].temporal - 3.0).abs() < 1e-9);
569 assert!((frames[1].dct_energy - 80.0).abs() < 1e-9); }
571
572 #[test]
573 fn test_parse_complexity_output_handles_partial_data() {
574 let output = "\
575frame: 1 pts_time:0.000
576lavfi.entropy.normalized_entropy.normal.Y=0.5
577frame: 2 pts_time:1.000
578lavfi.signalstats.YDIF=1.0
579";
580 let frames = parse_complexity_output(output);
581 assert_eq!(frames.len(), 2);
582 }
583
584 #[test]
585 fn test_parse_complexity_output_negative_dct() {
586 let output = "\
588frame: 1 pts_time:0.000
589lavfi.signalstats.YHIGH=30.0
590lavfi.signalstats.YLOW=50.0
591";
592 let frames = parse_complexity_output(output);
593 assert!((frames[0].dct_energy - 0.0).abs() < 1e-9);
594 }
595
596 #[test]
597 fn test_aggregate_segments_single_segment() {
598 let frames = vec![
599 frame(0.0, 0.5, 1.0, 10.0),
600 frame(0.5, 0.6, 2.0, 20.0),
601 frame(1.0, 0.7, 3.0, 30.0),
602 ];
603 let segs = aggregate_segments(&frames, Duration::from_secs(2), Duration::from_secs(2));
604 assert_eq!(segs.len(), 1);
605 assert!((segs[0].avg_spatial - 0.6).abs() < 0.01);
606 assert!((segs[0].avg_temporal - 2.0).abs() < 0.01);
607 assert!((segs[0].max_spatial - 0.7).abs() < 1e-9);
608 assert_eq!(segs[0].start, Duration::ZERO);
609 assert_eq!(segs[0].end, Duration::from_secs(2));
610 }
611
612 #[test]
613 fn test_aggregate_segments_multiple() {
614 let frames = vec![
615 frame(0.0, 0.4, 1.0, 5.0),
616 frame(0.5, 0.5, 1.5, 6.0),
617 frame(1.0, 0.6, 2.0, 7.0),
618 frame(1.5, 0.7, 2.5, 8.0),
619 frame(2.0, 0.8, 3.0, 9.0),
620 frame(2.5, 0.9, 3.5, 10.0),
621 ];
622 let segs = aggregate_segments(&frames, Duration::from_secs(3), Duration::from_secs(1));
623 assert_eq!(segs.len(), 3);
624 assert_eq!(segs[0].start, Duration::from_secs(0));
625 assert_eq!(segs[1].start, Duration::from_secs(1));
626 assert_eq!(segs[2].start, Duration::from_secs(2));
627 }
628
629 #[test]
630 fn test_aggregate_segments_empty_bucket() {
631 let frames = vec![frame(0.0, 0.5, 1.0, 5.0), frame(3.0, 0.8, 3.0, 10.0)];
633 let segs = aggregate_segments(&frames, Duration::from_secs(4), Duration::from_secs(2));
634 assert_eq!(segs.len(), 2); }
636
637 #[test]
638 fn test_classify_black() {
639 assert_eq!(classify_scene(0.2, 0.5, 2.0), SceneClass::Black);
640 }
641
642 #[test]
643 fn test_classify_static() {
644 assert_eq!(classify_scene(0.4, 1.5, 10.0), SceneClass::Static);
645 }
646
647 #[test]
648 fn test_classify_detailed() {
649 assert_eq!(classify_scene(0.7, 3.0, 50.0), SceneClass::Detailed);
650 }
651
652 #[test]
653 fn test_classify_motion() {
654 assert_eq!(classify_scene(0.4, 10.0, 30.0), SceneClass::Motion);
655 }
656
657 #[test]
658 fn test_classify_complex() {
659 assert_eq!(classify_scene(0.8, 6.0, 60.0), SceneClass::Complex);
660 }
661
662 #[test]
663 fn test_classify_edges() {
664 assert_eq!(classify_scene(0.65, 5.0, 30.0), SceneClass::Detailed);
666 assert_eq!(classify_scene(0.6, 9.0, 20.0), SceneClass::Motion);
667 }
668
669 #[test]
670 fn test_scene_class_display() {
671 assert_eq!(SceneClass::Black.to_string(), "black");
672 assert_eq!(SceneClass::Static.to_string(), "static");
673 assert_eq!(SceneClass::Detailed.to_string(), "detailed");
674 assert_eq!(SceneClass::Motion.to_string(), "motion");
675 assert_eq!(SceneClass::Complex.to_string(), "complex");
676 }
677
678 #[test]
679 fn test_segment_has_scene_class() {
680 let frames = vec![FrameComplexity {
681 pts: Duration::from_secs_f64(0.0),
682 spatial: 0.2,
683 temporal: 0.5,
684 dct_energy: 2.0,
685 }];
686 let segs = aggregate_segments(&frames, Duration::from_secs(2), Duration::from_secs(2));
687 assert_eq!(segs.len(), 1);
688 assert_eq!(segs[0].scene_class, SceneClass::Black);
689 }
690
691 #[test]
692 fn test_analyze_opts_default() {
693 let opts = AnalyzeOpts::default();
694 assert_eq!(opts.segment_duration, Duration::from_secs(2));
695 assert_eq!(opts.subsample, 1);
696 }
697}