Skip to main content

viser_complexity/
lib.rs

1//! Spatial, temporal, and DCT energy complexity analysis for video.
2//!
3//! Extracts per-frame complexity metrics (entropy, inter-frame difference, DCT energy)
4//! via FFmpeg, aggregates them into segments with scene classification, and computes an
5//! overall complexity score. Also detects screen content versus natural video.
6//!
7//! Part of the `viser` video-encoding-optimizer workspace.
8
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::time::Duration;
12use tokio::process::Command;
13use viser_ffmpeg::{ffmpeg_path, probe};
14
15/// Classified scene type based on spatial/temporal complexity.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum SceneClass {
18    /// Black / fade / freeze frame (near-zero motion and detail)
19    Black,
20    /// Static / talking-heads (low spatial, low temporal)
21    Static,
22    /// Detailed / landscape (high spatial, low temporal)
23    Detailed,
24    /// Motion / action (low spatial, high temporal)
25    Motion,
26    /// Complex / crowd / nature (high spatial, high temporal)
27    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/// Complexity metrics for a single frame.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FrameComplexity {
45    /// Presentation timestamp of the frame.
46    pub pts: Duration,
47    /// Spatial complexity: normalized luma entropy (0-1).
48    pub spatial: f64, // normalized entropy (0-1)
49    /// Temporal complexity: inter-frame luma difference (0-255).
50    pub temporal: f64, // inter-frame luma difference (0-255)
51    /// Average DCT coefficient energy.
52    pub dct_energy: f64, // average DCT coefficient energy
53}
54
55/// Aggregated complexity metrics over a time segment.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SegmentComplexity {
58    /// Segment start time.
59    pub start: Duration,
60    /// Segment end time.
61    pub end: Duration,
62    /// Segment length (`end - start`).
63    pub duration: Duration,
64    /// Mean spatial complexity over the segment.
65    pub avg_spatial: f64,
66    /// Mean temporal complexity over the segment.
67    pub avg_temporal: f64,
68    /// Maximum spatial complexity in the segment.
69    pub max_spatial: f64,
70    /// Maximum temporal complexity in the segment.
71    pub max_temporal: f64,
72    /// Combined complexity score (0-100).
73    pub score: f64, // combined 0-100 complexity score
74    /// Classified scene type for the segment.
75    pub scene_class: SceneClass,
76}
77
78/// Full complexity profile of a video: per-frame metrics, segment aggregates, and overall score.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Profile {
81    /// Per-frame complexity metrics.
82    pub frames: Vec<FrameComplexity>,
83    /// Per-segment aggregated metrics.
84    pub segments: Vec<SegmentComplexity>,
85    /// Mean spatial complexity across all frames.
86    pub avg_spatial: f64,
87    /// Mean temporal complexity across all frames.
88    pub avg_temporal: f64,
89    /// Overall complexity score (0-100).
90    pub overall_score: f64,
91}
92
93/// Options controlling complexity analysis.
94#[derive(Debug, Clone)]
95pub struct AnalyzeOpts {
96    /// Duration of each aggregation segment.
97    pub segment_duration: Duration,
98    /// Analyze every Nth frame; values <= 0 are treated as 1.
99    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
108/// Extracts per-frame complexity metrics and aggregates them into segments.
109pub 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
273/// Classify a segment by its spatial/temporal complexity profile.
274pub 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    // Black / fade: near-zero motion, entropy, and detail
280    if t < 1.0 && s <= 0.3 && d < 5.0 {
281        return SceneClass::Black;
282    }
283
284    // Motion / action: high inter-frame difference, moderate detail
285    if t > 8.0 && s < 0.65 {
286        return SceneClass::Motion;
287    }
288
289    // Complex / crowd / nature: high texture + high motion
290    if s > 0.7 && t > 5.0 {
291        return SceneClass::Complex;
292    }
293
294    // Detailed / landscape: high entropy, low motion
295    if s >= 0.65 && t <= 5.0 {
296        return SceneClass::Detailed;
297    }
298
299    // Static / talking-heads: low motion, moderate entropy
300    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/// Content type classification result.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316pub enum ContentType {
317    /// Natural video content (film, sports, etc.)
318    Natural,
319    /// Screen content (slides, code, UI, screencasts)
320    Screen,
321}
322
323/// Result of screen-content detection.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ScreenContentDetection {
326    /// Detected content type (natural vs. screen).
327    pub content_type: ContentType,
328    /// Confidence score for the classification (0-100).
329    pub confidence: f64, // 0-100
330    /// Human-readable explanation of the decision.
331    pub reason: String,
332}
333
334/// Detects whether a video is screen content based on complexity profile heuristics.
335///
336/// Screen content signatures:
337/// - Very high spatial complexity (sharp edges, text)
338/// - Very low temporal complexity (static or near-static)
339/// - High fraction of frames with minimal temporal change
340pub 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    // Screen content heuristics
350    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        // Classic slide/UI: sharp edges, barely moves
360        90.0
361    } else if high_dct_low_temporal {
362        // Code/screenshot: high DCT energy but low motion
363        70.0
364    } else if is_mostly_static && profile.avg_spatial > 0.6 {
365        // Mostly static with moderate edges
366        50.0
367    } else if static_fraction > 0.6 && profile.avg_temporal < 3.0 {
368        // Leaning static
369        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        // All frames have sharp edges, no motion
417        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        // High spatial, low temporal, moderate DCT
463        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); // temporal 30*3.33=99.9, spatial (1-0.5)*200=100
530        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); // 100 - 30
566
567        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); // 120 - 40
570    }
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        // If YLOW > YHIGH, dct_energy should clamp to 0
587        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        // Evenly spaced frames with a gap
632        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); // seg 0 has frame[0], seg 1 has frame[1]
635    }
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        // Boundary conditions
665        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}