1use serde::{Deserialize, Serialize};
2use tokio::process::Command;
3
4use crate::ffprobe_path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ProbeResult {
9 pub format: FormatInfo,
11 pub streams: Vec<StreamInfo>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct FormatInfo {
18 pub filename: String,
20 pub format_name: String,
22 pub format_long_name: String,
24 pub duration: f64, pub size: i64, pub bit_rate: i64, pub probe_score: i32,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct StreamInfo {
37 pub index: i32,
39 pub codec_name: String,
41 pub codec_long_name: String,
43 pub codec_type: String, pub profile: String,
47 pub width: i32,
49 pub height: i32,
51 pub pix_fmt: String,
53 pub level: i32,
55 pub field_order: String,
57 pub color_range: String,
59 pub color_space: String,
61 pub color_transfer: String,
63 pub color_primaries: String,
65 pub duration: f64, pub bit_rate: i64, pub nb_frames: i32,
71 pub r_frame_rate: String, pub avg_frame_rate: String, pub sample_rate: i32, pub channels: i32, pub channel_layout: String, pub bits_per_raw_sample: i32,
83}
84
85impl StreamInfo {
86 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 pub fn fps(&self) -> f64 {
99 parse_rational(&self.r_frame_rate)
100 }
101
102 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 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 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 pub fn is_hdr(&self) -> bool {
148 self.hdr_kind().is_some()
149 }
150}
151
152impl FormatInfo {
153 pub fn duration_secs(&self) -> std::time::Duration {
155 std::time::Duration::from_secs_f64(self.duration)
156 }
157}
158
159impl ProbeResult {
160 pub fn video_stream(&self) -> Option<&StreamInfo> {
162 self.streams.iter().find(|s| s.codec_type == "video")
163 }
164
165 pub fn audio_stream(&self) -> Option<&StreamInfo> {
167 self.streams.iter().find(|s| s.codec_type == "audio")
168 }
169}
170
171pub 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#[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}