Skip to main content

viser_ffmpeg/
probe.rs

1use serde::{Deserialize, Serialize};
2use tokio::process::Command;
3
4use crate::ffprobe_path;
5
6/// Parsed result of an `ffprobe` run: container format plus all streams.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ProbeResult {
9    /// Container-level format information.
10    pub format: FormatInfo,
11    /// All streams (video, audio, subtitle) found in the file.
12    pub streams: Vec<StreamInfo>,
13}
14
15/// Container-level metadata reported by ffprobe.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct FormatInfo {
18    /// Probed file path.
19    pub filename: String,
20    /// Short container format name (e.g. `"mov,mp4,m4a,..."`).
21    pub format_name: String,
22    /// Human-readable container format name.
23    pub format_long_name: String,
24    /// Total duration in seconds.
25    pub duration: f64, // seconds
26    /// File size in bytes.
27    pub size: i64, // bytes
28    /// Overall bitrate in bits per second.
29    pub bit_rate: i64, // bits/sec
30    /// ffprobe's confidence score for the detected format.
31    pub probe_score: i32,
32}
33
34/// Per-stream metadata reported by ffprobe.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct StreamInfo {
37    /// Stream index within the container.
38    pub index: i32,
39    /// Short codec name (e.g. `"h264"`).
40    pub codec_name: String,
41    /// Human-readable codec name.
42    pub codec_long_name: String,
43    /// Stream type: `"video"`, `"audio"`, or `"subtitle"`.
44    pub codec_type: String, // "video", "audio", "subtitle"
45    /// Codec profile (e.g. `"High"`).
46    pub profile: String,
47    /// Pixel width (video).
48    pub width: i32,
49    /// Pixel height (video).
50    pub height: i32,
51    /// Pixel format (e.g. `"yuv420p"`).
52    pub pix_fmt: String,
53    /// Codec level.
54    pub level: i32,
55    /// Field/scan order (e.g. `"progressive"`).
56    pub field_order: String,
57    /// Color range (e.g. `"tv"`/`"pc"`).
58    pub color_range: String,
59    /// Color matrix / space.
60    pub color_space: String,
61    /// Color transfer characteristics (e.g. `"smpte2084"` for PQ).
62    pub color_transfer: String,
63    /// Color primaries (e.g. `"bt2020"`).
64    pub color_primaries: String,
65    /// Stream duration in seconds.
66    pub duration: f64, // seconds
67    /// Stream bitrate in bits per second.
68    pub bit_rate: i64, // bits/sec
69    /// Number of frames, when known.
70    pub nb_frames: i32,
71    /// Raw frame rate as a rational string (e.g. `"25/1"`).
72    pub r_frame_rate: String, // e.g. "25/1"
73    /// Average frame rate as a rational string (e.g. `"25/1"`).
74    pub avg_frame_rate: String, // e.g. "25/1"
75    /// Audio sample rate in Hz.
76    pub sample_rate: i32, // audio
77    /// Audio channel count.
78    pub channels: i32, // audio
79    /// Audio channel layout (e.g. `"stereo"`).
80    pub channel_layout: String, // audio
81    /// Bits per raw sample (used to detect high-bit-depth/HDR content).
82    pub bits_per_raw_sample: i32,
83}
84
85impl StreamInfo {
86    /// Returns an error unless this is a video stream with positive dimensions.
87    pub fn validate(&self) -> anyhow::Result<()> {
88        if self.codec_type != "video" {
89            anyhow::bail!("not a video stream (type={})", self.codec_type);
90        }
91        if self.width <= 0 || self.height <= 0 {
92            anyhow::bail!("invalid dimensions: {}x{}", self.width, self.height);
93        }
94        Ok(())
95    }
96
97    /// Frame rate parsed from r_frame_rate (e.g. "50/1" -> 50.0).
98    pub fn fps(&self) -> f64 {
99        parse_rational(&self.r_frame_rate)
100    }
101
102    /// Returns `"WIDTHxHEIGHT"`, or an empty string if dimensions are unknown.
103    pub fn resolution_str(&self) -> String {
104        if self.width == 0 || self.height == 0 {
105            String::new()
106        } else {
107            format!("{}x{}", self.width, self.height)
108        }
109    }
110
111    /// Detects HDR type from color metadata: `"PQ"`, `"HLG"`, `"BT.2020"`, or `None` for SDR.
112    ///
113    /// Detection order:
114    /// 1. `color_transfer` → `"smpte2084"` → `"PQ"`
115    /// 2. `color_transfer` → `"arib-std-b67"` → `"HLG"`
116    /// 3. BT.2020 colour primaries (or colour-space) + high bit depth → `"BT.2020"`
117    ///
118    /// ffprobe ≥ 8.0 does not always populate `color_primaries` or `bits_per_raw_sample`
119    /// as top-level fields for HEVC/Matroska; the `color_space` field and pixel-format
120    /// bit-depth are used as fallbacks.
121    pub fn hdr_kind(&self) -> Option<&'static str> {
122        let transfer = self.color_transfer.to_ascii_lowercase();
123        if transfer == "smpte2084" {
124            return Some("PQ");
125        }
126        if transfer == "arib-std-b67" {
127            return Some("HLG");
128        }
129
130        let high_bit_depth = self.bits_per_raw_sample >= 10
131            || self.pix_fmt.contains("10")
132            || self.pix_fmt.contains("12")
133            || self.pix_fmt.contains("16");
134
135        // color_primaries is the canonical field; when absent (ffprobe ≥ 8.0 for some
136        // containers) fall back to color_space which carries equivalent information.
137        let primaries_or_space_bt2020 = self.color_primaries.eq_ignore_ascii_case("bt2020")
138            || self.color_space.contains("bt2020");
139        if primaries_or_space_bt2020 && high_bit_depth {
140            return Some("BT.2020");
141        }
142
143        None
144    }
145
146    /// Returns `true` if the stream carries HDR color metadata.
147    pub fn is_hdr(&self) -> bool {
148        self.hdr_kind().is_some()
149    }
150}
151
152impl FormatInfo {
153    /// Container duration as a `Duration`.
154    pub fn duration_secs(&self) -> std::time::Duration {
155        std::time::Duration::from_secs_f64(self.duration)
156    }
157}
158
159impl ProbeResult {
160    /// First video stream, if any.
161    pub fn video_stream(&self) -> Option<&StreamInfo> {
162        self.streams.iter().find(|s| s.codec_type == "video")
163    }
164
165    /// First audio stream, if any.
166    pub fn audio_stream(&self) -> Option<&StreamInfo> {
167        self.streams.iter().find(|s| s.codec_type == "audio")
168    }
169}
170
171/// Runs ffprobe on the given file and returns parsed results.
172pub async fn probe(path: &str) -> anyhow::Result<ProbeResult> {
173    let args = ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", path];
174
175    let output = Command::new(ffprobe_path())
176        .args(args)
177        .stderr(std::process::Stdio::piped())
178        .output()
179        .await?;
180
181    if !output.status.success() {
182        let stderr = String::from_utf8_lossy(&output.stderr);
183        anyhow::bail!("ffprobe failed for {path}: {stderr}");
184    }
185
186    let raw: ProbeJsonRaw = serde_json::from_slice(&output.stdout)
187        .map_err(|e| anyhow::anyhow!("failed to parse ffprobe output: {e}"))?;
188
189    Ok(convert_probe(raw))
190}
191
192// Raw ffprobe JSON — numbers come as strings
193#[derive(Deserialize)]
194struct ProbeJsonRaw {
195    format: ProbeFormatRaw,
196    streams: Vec<ProbeStreamRaw>,
197}
198
199#[derive(Deserialize)]
200struct ProbeFormatRaw {
201    #[serde(default)]
202    filename: String,
203    #[serde(default)]
204    format_name: String,
205    #[serde(default)]
206    format_long_name: String,
207    #[serde(default)]
208    duration: String,
209    #[serde(default)]
210    size: String,
211    #[serde(default)]
212    bit_rate: String,
213    #[serde(default)]
214    probe_score: i32,
215}
216
217#[derive(Deserialize)]
218struct ProbeStreamRaw {
219    #[serde(default)]
220    index: i32,
221    #[serde(default)]
222    codec_name: String,
223    #[serde(default)]
224    codec_long_name: String,
225    #[serde(default)]
226    codec_type: String,
227    #[serde(default)]
228    profile: String,
229    #[serde(default)]
230    width: i32,
231    #[serde(default)]
232    height: i32,
233    #[serde(default)]
234    pix_fmt: String,
235    #[serde(default)]
236    level: i32,
237    #[serde(default)]
238    field_order: String,
239    #[serde(default)]
240    color_range: String,
241    #[serde(default)]
242    color_space: String,
243    #[serde(default)]
244    color_transfer: String,
245    #[serde(default)]
246    color_primaries: String,
247    #[serde(default)]
248    duration: String,
249    #[serde(default)]
250    bit_rate: String,
251    #[serde(default)]
252    nb_frames: String,
253    #[serde(default)]
254    r_frame_rate: String,
255    #[serde(default)]
256    avg_frame_rate: String,
257    #[serde(default)]
258    sample_rate: String,
259    #[serde(default)]
260    channels: i32,
261    #[serde(default)]
262    channel_layout: String,
263    #[serde(default)]
264    bits_per_raw_sample: String,
265}
266
267fn convert_probe(raw: ProbeJsonRaw) -> ProbeResult {
268    let format = FormatInfo {
269        filename: raw.format.filename,
270        format_name: raw.format.format_name,
271        format_long_name: raw.format.format_long_name,
272        duration: raw.format.duration.parse().unwrap_or(0.0),
273        size: raw.format.size.parse().unwrap_or(0),
274        bit_rate: raw.format.bit_rate.parse().unwrap_or(0),
275        probe_score: raw.format.probe_score,
276    };
277
278    let streams = raw
279        .streams
280        .into_iter()
281        .map(|s| StreamInfo {
282            index: s.index,
283            codec_name: s.codec_name,
284            codec_long_name: s.codec_long_name,
285            codec_type: s.codec_type,
286            profile: s.profile,
287            width: s.width,
288            height: s.height,
289            pix_fmt: s.pix_fmt,
290            level: s.level,
291            field_order: s.field_order,
292            color_range: s.color_range,
293            color_space: s.color_space,
294            color_transfer: s.color_transfer,
295            color_primaries: s.color_primaries,
296            duration: s.duration.parse().unwrap_or(0.0),
297            bit_rate: s.bit_rate.parse().unwrap_or(0),
298            nb_frames: s.nb_frames.parse().unwrap_or(0),
299            r_frame_rate: s.r_frame_rate,
300            avg_frame_rate: s.avg_frame_rate,
301            sample_rate: s.sample_rate.parse().unwrap_or(0),
302            channels: s.channels,
303            channel_layout: s.channel_layout,
304            bits_per_raw_sample: s.bits_per_raw_sample.parse().unwrap_or(0),
305        })
306        .collect();
307
308    ProbeResult { format, streams }
309}
310
311fn parse_rational(s: &str) -> f64 {
312    if let Some((num_s, den_s)) = s.split_once('/') {
313        let num: f64 = num_s.parse().unwrap_or(0.0);
314        let den: f64 = den_s.parse().unwrap_or(0.0);
315        if den != 0.0 { num / den } else { 0.0 }
316    } else {
317        s.parse().unwrap_or(0.0)
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn video_stream() -> StreamInfo {
326        StreamInfo {
327            index: 0,
328            codec_name: "h264".into(),
329            codec_long_name: String::new(),
330            codec_type: "video".into(),
331            profile: String::new(),
332            width: 1920,
333            height: 1080,
334            pix_fmt: "yuv420p".into(),
335            level: 0,
336            field_order: String::new(),
337            color_range: String::new(),
338            color_space: String::new(),
339            color_transfer: String::new(),
340            color_primaries: String::new(),
341            duration: 0.0,
342            bit_rate: 0,
343            nb_frames: 0,
344            r_frame_rate: "24/1".into(),
345            avg_frame_rate: "24/1".into(),
346            sample_rate: 0,
347            channels: 0,
348            channel_layout: String::new(),
349            bits_per_raw_sample: 8,
350        }
351    }
352
353    fn audio_stream() -> StreamInfo {
354        StreamInfo {
355            index: 1,
356            codec_type: "audio".into(),
357            codec_name: "aac".into(),
358            sample_rate: 48000,
359            channels: 2,
360            channel_layout: "stereo".into(),
361            ..video_stream()
362        }
363    }
364
365    // ── HDR detection ──
366    #[test]
367    fn test_hdr_kind_detects_pq() {
368        let mut stream = video_stream();
369        stream.color_transfer = "smpte2084".into();
370        assert_eq!(stream.hdr_kind(), Some("PQ"));
371        assert!(stream.is_hdr());
372    }
373
374    #[test]
375    fn test_hdr_kind_detects_hlg() {
376        let mut stream = video_stream();
377        stream.color_transfer = "arib-std-b67".into();
378        assert_eq!(stream.hdr_kind(), Some("HLG"));
379    }
380
381    #[test]
382    fn test_hdr_kind_detects_bt2020_high_bit_depth() {
383        let mut stream = video_stream();
384        stream.color_primaries = "bt2020".into();
385        stream.pix_fmt = "yuv420p10le".into();
386        assert_eq!(stream.hdr_kind(), Some("BT.2020"));
387    }
388
389    #[test]
390    fn test_hdr_kind_ignores_sdr() {
391        assert_eq!(video_stream().hdr_kind(), None);
392    }
393
394    #[test]
395    fn test_hdr_kind_case_insensitive_pq() {
396        let mut stream = video_stream();
397        stream.color_transfer = "SMPTE2084".into();
398        assert_eq!(stream.hdr_kind(), Some("PQ"));
399    }
400
401    #[test]
402    fn test_hdr_kind_case_insensitive_hlg() {
403        let mut stream = video_stream();
404        stream.color_transfer = "ARIB-STD-B67".into();
405        assert_eq!(stream.hdr_kind(), Some("HLG"));
406    }
407
408    #[test]
409    fn test_hdr_kind_case_insensitive_primaries() {
410        let mut stream = video_stream();
411        stream.color_primaries = "BT2020".into();
412        stream.pix_fmt = "yuv420p10le".into();
413        assert_eq!(stream.hdr_kind(), Some("BT.2020"));
414    }
415
416    #[test]
417    fn test_hdr_kind_pq_takes_priority_over_bt2020() {
418        let mut stream = video_stream();
419        stream.color_transfer = "smpte2084".into();
420        stream.color_primaries = "bt2020".into();
421        stream.pix_fmt = "yuv420p10le".into();
422        assert_eq!(stream.hdr_kind(), Some("PQ"));
423    }
424
425    #[test]
426    fn test_hdr_bt2020_no_high_bit_depth_not_hdr() {
427        let mut stream = video_stream();
428        stream.color_primaries = "bt2020".into();
429        stream.pix_fmt = "yuv420p".into();
430        stream.bits_per_raw_sample = 8;
431        assert_eq!(stream.hdr_kind(), None);
432    }
433
434    #[test]
435    fn test_hdr_16bit_pix_fmt_detected() {
436        let mut stream = video_stream();
437        stream.color_primaries = "bt2020".into();
438        stream.pix_fmt = "yuv420p16le".into();
439        assert_eq!(stream.hdr_kind(), Some("BT.2020"));
440    }
441
442    #[test]
443    fn test_hdr_12bit_pix_fmt_detected() {
444        let mut stream = video_stream();
445        stream.color_primaries = "bt2020".into();
446        stream.pix_fmt = "yuv420p12le".into();
447        assert_eq!(stream.hdr_kind(), Some("BT.2020"));
448    }
449
450    #[test]
451    fn test_hdr_bits_per_raw_sample_10() {
452        let mut stream = video_stream();
453        stream.color_primaries = "bt2020".into();
454        stream.bits_per_raw_sample = 10;
455        assert_eq!(stream.hdr_kind(), Some("BT.2020"));
456    }
457
458    #[test]
459    fn test_is_hdr_false_for_sdr() {
460        assert!(!video_stream().is_hdr());
461    }
462
463    #[test]
464    fn test_hdr_color_space_fallback_without_primaries() {
465        // ffprobe >= 8.0 may omit color_primaries but report color_space
466        let mut stream = video_stream();
467        stream.color_primaries = String::new();
468        stream.color_space = "bt2020nc".into();
469        stream.pix_fmt = "yuv420p10le".into();
470        assert_eq!(stream.hdr_kind(), Some("BT.2020"));
471    }
472
473    #[test]
474    fn test_hdr_color_space_fallback_bt2020c() {
475        let mut stream = video_stream();
476        stream.color_primaries = String::new();
477        stream.color_space = "bt2020c".into();
478        stream.pix_fmt = "yuv420p10le".into();
479        assert_eq!(stream.hdr_kind(), Some("BT.2020"));
480    }
481
482    #[test]
483    fn test_hdr_color_space_sdr_8bit_not_hdr() {
484        let mut stream = video_stream();
485        stream.color_primaries = String::new();
486        stream.color_space = "bt2020nc".into();
487        stream.pix_fmt = "yuv420p".into();
488        stream.bits_per_raw_sample = 8;
489        assert_eq!(stream.hdr_kind(), None);
490    }
491
492    // ── Stream validation ──
493    #[test]
494    fn test_validate_video_stream_ok() {
495        assert!(video_stream().validate().is_ok());
496    }
497
498    #[test]
499    fn test_validate_audio_stream_fails() {
500        assert!(audio_stream().validate().is_err());
501    }
502
503    #[test]
504    fn test_validate_zero_width_fails() {
505        let mut stream = video_stream();
506        stream.width = 0;
507        assert!(stream.validate().is_err());
508    }
509
510    #[test]
511    fn test_validate_zero_height_fails() {
512        let mut stream = video_stream();
513        stream.height = 0;
514        assert!(stream.validate().is_err());
515    }
516
517    #[test]
518    fn test_validate_negative_dimensions_fails() {
519        let mut stream = video_stream();
520        stream.width = -1;
521        assert!(stream.validate().is_err());
522    }
523
524    // ── Frame rate parsing ──
525    #[test]
526    fn test_fps_standard() {
527        let mut stream = video_stream();
528        stream.r_frame_rate = "24/1".into();
529        assert!((stream.fps() - 24.0).abs() < 1e-9);
530    }
531
532    #[test]
533    fn test_fps_ntsc() {
534        let mut stream = video_stream();
535        stream.r_frame_rate = "30000/1001".into();
536        assert!((stream.fps() - 29.97).abs() < 0.1);
537    }
538
539    #[test]
540    fn test_fps_pal() {
541        let mut stream = video_stream();
542        stream.r_frame_rate = "25/1".into();
543        assert!((stream.fps() - 25.0).abs() < 1e-9);
544    }
545
546    #[test]
547    fn test_fps_60fps() {
548        let mut stream = video_stream();
549        stream.r_frame_rate = "60/1".into();
550        assert!((stream.fps() - 60.0).abs() < 1e-9);
551    }
552
553    #[test]
554    fn test_fps_high_frame_rate() {
555        let mut stream = video_stream();
556        stream.r_frame_rate = "120/1".into();
557        assert!((stream.fps() - 120.0).abs() < 1e-9);
558    }
559
560    // ── parse_rational edge cases ──
561    #[test]
562    fn test_parse_rational_division_by_zero() {
563        assert!((parse_rational("24/0") - 0.0).abs() < 1e-9);
564    }
565
566    #[test]
567    fn test_parse_rational_no_slash() {
568        assert!((parse_rational("30") - 30.0).abs() < 1e-9);
569    }
570
571    #[test]
572    fn test_parse_rational_empty_string() {
573        assert!((parse_rational("") - 0.0).abs() < 1e-9);
574    }
575
576    #[test]
577    fn test_parse_rational_bogus() {
578        assert!((parse_rational("abc") - 0.0).abs() < 1e-9);
579    }
580
581    #[test]
582    fn test_parse_rational_negative_numerator() {
583        let v = parse_rational("-24/1001");
584        assert!(v < 0.0, "negative rational should be negative, got {v}");
585        assert!(v > -0.05, "expected > -0.05, got {v}");
586    }
587
588    #[test]
589    fn test_fps_empty_r_frame_rate() {
590        let mut stream = video_stream();
591        stream.r_frame_rate = String::new();
592        assert!((stream.fps() - 0.0).abs() < 1e-9);
593    }
594
595    // ── Resolution string ──
596    #[test]
597    fn test_resolution_str_standard() {
598        let mut stream = video_stream();
599        stream.width = 3840;
600        stream.height = 2160;
601        assert_eq!(stream.resolution_str(), "3840x2160");
602    }
603
604    #[test]
605    fn test_resolution_str_zero_dimensions() {
606        let mut stream = video_stream();
607        stream.width = 0;
608        stream.height = 0;
609        assert_eq!(stream.resolution_str(), "");
610    }
611
612    // ── Video/audio stream finders ──
613    #[test]
614    fn test_probe_result_video_stream() {
615        let result = ProbeResult {
616            format: FormatInfo {
617                filename: "test.mp4".into(),
618                format_name: "mov,mp4".into(),
619                format_long_name: String::new(),
620                duration: 10.0,
621                size: 5000000,
622                bit_rate: 4000000,
623                probe_score: 100,
624            },
625            streams: vec![video_stream(), audio_stream()],
626        };
627        assert!(result.video_stream().is_some());
628        assert_eq!(result.video_stream().unwrap().codec_type, "video");
629    }
630
631    #[test]
632    fn test_probe_result_audio_stream() {
633        let result = ProbeResult {
634            format: FormatInfo {
635                filename: "test.mp4".into(),
636                format_name: "mov,mp4".into(),
637                format_long_name: String::new(),
638                duration: 10.0,
639                size: 5000000,
640                bit_rate: 4000000,
641                probe_score: 100,
642            },
643            streams: vec![video_stream(), audio_stream()],
644        };
645        assert!(result.audio_stream().is_some());
646        assert_eq!(result.audio_stream().unwrap().codec_type, "audio");
647    }
648
649    #[test]
650    fn test_probe_result_audio_only_no_video() {
651        let result = ProbeResult {
652            format: FormatInfo {
653                filename: "audio.aac".into(),
654                format_name: "aac".into(),
655                format_long_name: String::new(),
656                duration: 180.0,
657                size: 500000,
658                bit_rate: 128000,
659                probe_score: 100,
660            },
661            streams: vec![audio_stream()],
662        };
663        assert!(result.video_stream().is_none());
664    }
665
666    #[test]
667    fn test_probe_result_multiple_video_streams_finds_first() {
668        let mut stream1 = video_stream();
669        stream1.index = 0;
670        let mut stream2 = video_stream();
671        stream2.index = 1;
672        let result = ProbeResult {
673            format: FormatInfo {
674                filename: "multi.mp4".into(),
675                format_name: "mov,mp4".into(),
676                format_long_name: String::new(),
677                duration: 10.0,
678                size: 5000000,
679                bit_rate: 4000000,
680                probe_score: 100,
681            },
682            streams: vec![stream1.clone(), stream2],
683        };
684        let found = result.video_stream().unwrap();
685        assert_eq!(found.index, 0);
686    }
687
688    // ── Format duration ──
689    #[test]
690    fn test_format_duration_secs() {
691        let format = FormatInfo {
692            filename: "test.mp4".into(),
693            format_name: "mov,mp4".into(),
694            format_long_name: String::new(),
695            duration: 123.456,
696            size: 0,
697            bit_rate: 0,
698            probe_score: 100,
699        };
700        let d = format.duration_secs();
701        assert!((d.as_secs_f64() - 123.456).abs() < 0.001);
702    }
703
704    // ── convert_probe edge cases ──
705    #[test]
706    fn test_convert_probe_empty_streams() {
707        let raw = serde_json::from_str::<serde_json::Value>(
708            r#"{
709            "format": {
710                "filename": "test.mp4",
711                "format_name": "mov,mp4",
712                "format_long_name": "QuickTime / MOV",
713                "duration": "10.500",
714                "size": "1000000",
715                "bit_rate": "800000",
716                "probe_score": 100
717            },
718            "streams": []
719        }"#,
720        )
721        .unwrap();
722        let raw_probe: ProbeJsonRaw = serde_json::from_value(raw).unwrap();
723        let result = convert_probe(raw_probe);
724        assert!((result.format.duration - 10.5).abs() < 1e-9);
725        assert_eq!(result.streams.len(), 0);
726    }
727
728    #[test]
729    fn test_convert_probe_missing_optional_fields() {
730        let raw = serde_json::from_str::<serde_json::Value>(
731            r#"{
732            "format": {},
733            "streams": [
734                {
735                    "codec_type": "video",
736                    "r_frame_rate": "30/1",
737                    "avg_frame_rate": "30/1"
738                }
739            ]
740        }"#,
741        )
742        .unwrap();
743        let raw_probe: ProbeJsonRaw = serde_json::from_value(raw).unwrap();
744        let result = convert_probe(raw_probe);
745        assert_eq!(result.format.duration, 0.0);
746        assert_eq!(result.format.size, 0);
747        assert_eq!(result.format.bit_rate, 0);
748        assert_eq!(result.streams[0].width, 0);
749        assert_eq!(result.streams[0].height, 0);
750    }
751
752    #[test]
753    fn test_convert_probe_bogus_numeric_strings() {
754        let raw = serde_json::from_str::<serde_json::Value>(
755            r#"{
756            "format": {
757                "duration": "not_a_number",
758                "size": "",
759                "bit_rate": "also_bogus"
760            },
761            "streams": [{
762                "codec_type": "video",
763                "nb_frames": "bogus",
764                "r_frame_rate": "abc",
765                "avg_frame_rate": "def"
766            }]
767        }"#,
768        )
769        .unwrap();
770        let raw_probe: ProbeJsonRaw = serde_json::from_value(raw).unwrap();
771        let result = convert_probe(raw_probe);
772        assert_eq!(result.format.duration, 0.0);
773        assert_eq!(result.format.size, 0);
774        assert_eq!(result.format.bit_rate, 0);
775        assert_eq!(result.streams[0].nb_frames, 0);
776    }
777
778    #[test]
779    fn test_convert_probe_multiple_streams_mixed_types() {
780        let raw = serde_json::from_str::<serde_json::Value>(r#"{
781            "format": {"duration": "60.0"},
782            "streams": [
783                {"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080,
784                 "r_frame_rate": "24/1", "avg_frame_rate": "24/1"},
785                {"index": 1, "codec_type": "audio", "codec_name": "aac", "sample_rate": "48000", "channels": 2,
786                 "r_frame_rate": "0/0", "avg_frame_rate": "0/0"},
787                {"index": 2, "codec_type": "subtitle", "codec_name": "mov_text",
788                 "r_frame_rate": "0/0", "avg_frame_rate": "0/0"}
789            ]
790        }"#).unwrap();
791        let raw_probe: ProbeJsonRaw = serde_json::from_value(raw).unwrap();
792        let result = convert_probe(raw_probe);
793        assert_eq!(result.streams.len(), 3);
794        assert_eq!(result.streams[0].codec_type, "video");
795        assert_eq!(result.streams[1].codec_type, "audio");
796        assert_eq!(result.streams[2].codec_type, "subtitle");
797        // audio stream parsed correctly
798        assert_eq!(result.streams[1].sample_rate, 48000);
799        assert_eq!(result.streams[1].channels, 2);
800    }
801
802    #[test]
803    fn test_convert_probe_fractional_fps() {
804        let raw = serde_json::from_str::<serde_json::Value>(
805            r#"{
806            "format": {},
807            "streams": [{
808                "codec_type": "video",
809                "r_frame_rate": "30000/1001",
810                "avg_frame_rate": "30000/1001"
811            }]
812        }"#,
813        )
814        .unwrap();
815        let raw_probe: ProbeJsonRaw = serde_json::from_value(raw).unwrap();
816        let result = convert_probe(raw_probe);
817        let fps = result.streams[0].fps();
818        assert!(fps > 29.0 && fps < 30.0);
819    }
820
821    // ── ProbeResult serde roundtrip ──
822    #[test]
823    fn test_probe_result_serde_roundtrip() {
824        let result = ProbeResult {
825            format: FormatInfo {
826                filename: "sintel_trailer.mp4".into(),
827                format_name: "mov,mp4,m4a,3gp,3g2,mj2".into(),
828                format_long_name: "QuickTime / MOV".into(),
829                duration: 52.0,
830                size: 23976340,
831                bit_rate: 3688667,
832                probe_score: 100,
833            },
834            streams: vec![video_stream(), audio_stream()],
835        };
836        let json = serde_json::to_string(&result).unwrap();
837        let back: ProbeResult = serde_json::from_str(&json).unwrap();
838        assert_eq!(back.format.filename, result.format.filename);
839        assert!((back.format.duration - result.format.duration).abs() < 1e-9);
840        assert_eq!(back.streams.len(), 2);
841        assert_eq!(back.streams[0].codec_type, "video");
842        assert_eq!(back.streams[1].codec_type, "audio");
843    }
844
845    // ── Stream info defaults ──
846    #[test]
847    fn test_stream_info_resolution_str_empty_for_zero() {
848        let stream = StreamInfo { width: 0, height: 720, ..video_stream() };
849        assert_eq!(stream.resolution_str(), "");
850    }
851
852    #[test]
853    fn test_stream_info_hdr_pq_case_variation() {
854        let mut stream = video_stream();
855        stream.color_transfer = "SmPtE2084".into();
856        assert_eq!(stream.hdr_kind(), Some("PQ"));
857    }
858
859    // ── Validation message for non-video streams ──
860    #[test]
861    fn test_validate_subtitle_stream_fails() {
862        let mut stream = video_stream();
863        stream.codec_type = "subtitle".into();
864        let err = stream.validate().unwrap_err();
865        assert!(err.to_string().contains("not a video stream"));
866    }
867
868    #[test]
869    fn test_validate_data_stream_fails() {
870        let mut stream = video_stream();
871        stream.codec_type = "data".into();
872        assert!(stream.validate().is_err());
873    }
874}