1#![allow(unsafe_code)]
54
55use std::collections::HashMap;
56use std::ffi::CStr;
57use std::path::Path;
58use std::time::Duration;
59
60use ff_format::channel::ChannelLayout;
61use ff_format::chapter::ChapterInfo;
62use ff_format::codec::{AudioCodec, VideoCodec};
63use ff_format::color::{ColorPrimaries, ColorRange, ColorSpace};
64use ff_format::stream::{AudioStreamInfo, VideoStreamInfo};
65use ff_format::{MediaInfo, PixelFormat, Rational, SampleFormat};
66
67use crate::error::ProbeError;
68
69const AV_TIME_BASE: i64 = 1_000_000;
71
72pub fn open(path: impl AsRef<Path>) -> Result<MediaInfo, ProbeError> {
128 let path = path.as_ref();
129
130 if !path.exists() {
132 return Err(ProbeError::FileNotFound {
133 path: path.to_path_buf(),
134 });
135 }
136
137 let file_size = std::fs::metadata(path).map(|m| m.len())?;
139
140 let ctx = unsafe { ff_sys::avformat::open_input(path) }.map_err(|err_code| {
143 ProbeError::CannotOpen {
144 path: path.to_path_buf(),
145 reason: ff_sys::av_error_string(err_code),
146 }
147 })?;
148
149 if let Err(err_code) = unsafe { ff_sys::avformat::find_stream_info(ctx) } {
152 unsafe {
154 let mut ctx_ptr = ctx;
155 ff_sys::avformat::close_input(&raw mut ctx_ptr);
156 }
157 return Err(ProbeError::InvalidMedia {
158 path: path.to_path_buf(),
159 reason: ff_sys::av_error_string(err_code),
160 });
161 }
162
163 let (format, format_long_name, duration) = unsafe { extract_format_info(ctx) };
166
167 let bitrate = unsafe { calculate_container_bitrate(ctx, file_size, duration) };
170
171 let metadata = unsafe { extract_metadata(ctx) };
174
175 let video_streams = unsafe { extract_video_streams(ctx) };
178
179 let audio_streams = unsafe { extract_audio_streams(ctx) };
182
183 let chapters = unsafe { extract_chapters(ctx) };
186
187 unsafe {
190 let mut ctx_ptr = ctx;
191 ff_sys::avformat::close_input(&raw mut ctx_ptr);
192 }
193
194 let mut builder = MediaInfo::builder()
196 .path(path)
197 .format(format)
198 .duration(duration)
199 .file_size(file_size)
200 .video_streams(video_streams)
201 .audio_streams(audio_streams)
202 .chapters(chapters)
203 .metadata_map(metadata);
204
205 if let Some(name) = format_long_name {
206 builder = builder.format_long_name(name);
207 }
208
209 if let Some(bps) = bitrate {
210 builder = builder.bitrate(bps);
211 }
212
213 Ok(builder.build())
214}
215
216unsafe fn extract_format_info(
222 ctx: *mut ff_sys::AVFormatContext,
223) -> (String, Option<String>, Duration) {
224 unsafe {
226 let format = extract_format_name(ctx);
227 let format_long_name = extract_format_long_name(ctx);
228 let duration = extract_duration(ctx);
229
230 (format, format_long_name, duration)
231 }
232}
233
234unsafe fn extract_format_name(ctx: *mut ff_sys::AVFormatContext) -> String {
240 unsafe {
242 let iformat = (*ctx).iformat;
243 if iformat.is_null() {
244 return String::from("unknown");
245 }
246
247 let name_ptr = (*iformat).name;
248 if name_ptr.is_null() {
249 return String::from("unknown");
250 }
251
252 CStr::from_ptr(name_ptr).to_string_lossy().into_owned()
253 }
254}
255
256unsafe fn extract_format_long_name(ctx: *mut ff_sys::AVFormatContext) -> Option<String> {
262 unsafe {
264 let iformat = (*ctx).iformat;
265 if iformat.is_null() {
266 return None;
267 }
268
269 let long_name_ptr = (*iformat).long_name;
270 if long_name_ptr.is_null() {
271 return None;
272 }
273
274 Some(CStr::from_ptr(long_name_ptr).to_string_lossy().into_owned())
275 }
276}
277
278unsafe fn extract_duration(ctx: *mut ff_sys::AVFormatContext) -> Duration {
287 let duration_us = unsafe { (*ctx).duration };
289
290 if duration_us <= 0 {
293 return Duration::ZERO;
294 }
295
296 #[expect(clippy::cast_sign_loss, reason = "verified duration_us > 0")]
300 let secs = (duration_us / AV_TIME_BASE) as u64;
301 #[expect(clippy::cast_sign_loss, reason = "verified duration_us > 0")]
302 let micros = (duration_us % AV_TIME_BASE) as u32;
303
304 Duration::new(secs, micros * 1000)
305}
306
307unsafe fn calculate_container_bitrate(
328 ctx: *mut ff_sys::AVFormatContext,
329 file_size: u64,
330 duration: Duration,
331) -> Option<u64> {
332 let bitrate = unsafe { (*ctx).bit_rate };
334
335 if bitrate > 0 {
337 #[expect(clippy::cast_sign_loss, reason = "verified bitrate > 0")]
338 return Some(bitrate as u64);
339 }
340
341 let duration_secs = duration.as_secs_f64();
344 if duration_secs > 0.0 && file_size > 0 {
345 #[expect(
349 clippy::cast_precision_loss,
350 reason = "precision loss acceptable for file size; f64 handles up to 9 PB"
351 )]
352 let file_size_f64 = file_size as f64;
353
354 #[expect(
355 clippy::cast_possible_truncation,
356 reason = "bitrate values are bounded by practical file sizes"
357 )]
358 #[expect(
359 clippy::cast_sign_loss,
360 reason = "result is always positive since both operands are positive"
361 )]
362 let calculated_bitrate = (file_size_f64 * 8.0 / duration_secs) as u64;
363 Some(calculated_bitrate)
364 } else {
365 None
366 }
367}
368
369unsafe fn extract_metadata(ctx: *mut ff_sys::AVFormatContext) -> HashMap<String, String> {
387 let mut metadata = HashMap::new();
388
389 unsafe {
391 let dict = (*ctx).metadata;
392 if dict.is_null() {
393 return metadata;
394 }
395
396 let mut entry: *const ff_sys::AVDictionaryEntry = std::ptr::null();
399
400 let flags = ff_sys::AV_DICT_IGNORE_SUFFIX.cast_signed();
402
403 loop {
404 entry = ff_sys::av_dict_get(dict, c"".as_ptr(), entry, flags);
407
408 if entry.is_null() {
409 break;
410 }
411
412 let key_ptr = (*entry).key;
414 let value_ptr = (*entry).value;
415
416 if key_ptr.is_null() || value_ptr.is_null() {
417 continue;
418 }
419
420 let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
422 let value = CStr::from_ptr(value_ptr).to_string_lossy().into_owned();
423
424 metadata.insert(key, value);
425 }
426 }
427
428 metadata
429}
430
431unsafe fn extract_video_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<VideoStreamInfo> {
444 unsafe {
446 let nb_streams = (*ctx).nb_streams;
447 let streams_ptr = (*ctx).streams;
448
449 if streams_ptr.is_null() || nb_streams == 0 {
450 return Vec::new();
451 }
452
453 let mut video_streams = Vec::new();
454
455 for i in 0..nb_streams {
456 let stream = *streams_ptr.add(i as usize);
458 if stream.is_null() {
459 continue;
460 }
461
462 let codecpar = (*stream).codecpar;
463 if codecpar.is_null() {
464 continue;
465 }
466
467 if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_VIDEO {
469 continue;
470 }
471
472 let stream_info = extract_single_video_stream(stream, codecpar, i);
474 video_streams.push(stream_info);
475 }
476
477 video_streams
478 }
479}
480
481unsafe fn extract_single_video_stream(
487 stream: *mut ff_sys::AVStream,
488 codecpar: *mut ff_sys::AVCodecParameters,
489 index: u32,
490) -> VideoStreamInfo {
491 unsafe {
493 let codec_id = (*codecpar).codec_id;
495 let codec = map_video_codec(codec_id);
496 let codec_name = extract_codec_name(codec_id);
497
498 #[expect(clippy::cast_sign_loss, reason = "width/height are always positive")]
500 let width = (*codecpar).width as u32;
501 #[expect(clippy::cast_sign_loss, reason = "width/height are always positive")]
502 let height = (*codecpar).height as u32;
503
504 let pixel_format = map_pixel_format((*codecpar).format);
506
507 let frame_rate = extract_frame_rate(stream);
509
510 let bitrate = extract_stream_bitrate(codecpar);
512
513 let color_space = map_color_space((*codecpar).color_space);
515 let color_range = map_color_range((*codecpar).color_range);
516 let color_primaries = map_color_primaries((*codecpar).color_primaries);
517
518 let duration = extract_stream_duration(stream);
520
521 let frame_count = extract_frame_count(stream);
523
524 let mut builder = VideoStreamInfo::builder()
526 .index(index)
527 .codec(codec)
528 .codec_name(codec_name)
529 .width(width)
530 .height(height)
531 .pixel_format(pixel_format)
532 .frame_rate(frame_rate)
533 .color_space(color_space)
534 .color_range(color_range)
535 .color_primaries(color_primaries);
536
537 if let Some(d) = duration {
538 builder = builder.duration(d);
539 }
540
541 if let Some(b) = bitrate {
542 builder = builder.bitrate(b);
543 }
544
545 if let Some(c) = frame_count {
546 builder = builder.frame_count(c);
547 }
548
549 builder.build()
550 }
551}
552
553unsafe fn extract_codec_name(codec_id: ff_sys::AVCodecID) -> String {
559 let name_ptr = unsafe { ff_sys::avcodec_get_name(codec_id) };
561
562 if name_ptr.is_null() {
563 return String::from("unknown");
564 }
565
566 unsafe { CStr::from_ptr(name_ptr).to_string_lossy().into_owned() }
568}
569
570unsafe fn extract_frame_rate(stream: *mut ff_sys::AVStream) -> Rational {
579 unsafe {
581 let r_frame_rate = (*stream).r_frame_rate;
583 if r_frame_rate.den > 0 && r_frame_rate.num > 0 {
584 return Rational::new(r_frame_rate.num, r_frame_rate.den);
585 }
586
587 let avg_frame_rate = (*stream).avg_frame_rate;
589 if avg_frame_rate.den > 0 && avg_frame_rate.num > 0 {
590 return Rational::new(avg_frame_rate.num, avg_frame_rate.den);
591 }
592
593 {
595 log::warn!(
596 "frame_rate unavailable, falling back to 30fps \
597 r_frame_rate={}/{} avg_frame_rate={}/{} fallback=30/1",
598 r_frame_rate.num,
599 r_frame_rate.den,
600 avg_frame_rate.num,
601 avg_frame_rate.den
602 );
603 Rational::new(30, 1)
604 }
605 }
606}
607
608unsafe fn extract_stream_bitrate(codecpar: *mut ff_sys::AVCodecParameters) -> Option<u64> {
616 let bitrate = unsafe { (*codecpar).bit_rate };
618
619 if bitrate > 0 {
620 #[expect(clippy::cast_sign_loss, reason = "verified bitrate > 0")]
621 Some(bitrate as u64)
622 } else {
623 None
624 }
625}
626
627unsafe fn extract_stream_duration(stream: *mut ff_sys::AVStream) -> Option<Duration> {
635 unsafe {
637 let duration_pts = (*stream).duration;
638
639 if duration_pts <= 0 {
641 return None;
642 }
643
644 let time_base = (*stream).time_base;
646 if time_base.den == 0 {
647 return None;
648 }
649
650 #[expect(clippy::cast_precision_loss, reason = "media timestamps are bounded")]
654 let secs = (duration_pts as f64) * f64::from(time_base.num) / f64::from(time_base.den);
655
656 if secs > 0.0 {
657 Some(Duration::from_secs_f64(secs))
658 } else {
659 None
660 }
661 }
662}
663
664unsafe fn extract_frame_count(stream: *mut ff_sys::AVStream) -> Option<u64> {
672 let nb_frames = unsafe { (*stream).nb_frames };
674
675 if nb_frames > 0 {
676 #[expect(clippy::cast_sign_loss, reason = "verified nb_frames > 0")]
677 Some(nb_frames as u64)
678 } else {
679 None
680 }
681}
682
683fn map_video_codec(codec_id: ff_sys::AVCodecID) -> VideoCodec {
689 match codec_id {
690 ff_sys::AVCodecID_AV_CODEC_ID_H264 => VideoCodec::H264,
691 ff_sys::AVCodecID_AV_CODEC_ID_HEVC => VideoCodec::H265,
692 ff_sys::AVCodecID_AV_CODEC_ID_VP8 => VideoCodec::Vp8,
693 ff_sys::AVCodecID_AV_CODEC_ID_VP9 => VideoCodec::Vp9,
694 ff_sys::AVCodecID_AV_CODEC_ID_AV1 => VideoCodec::Av1,
695 ff_sys::AVCodecID_AV_CODEC_ID_PRORES => VideoCodec::ProRes,
696 ff_sys::AVCodecID_AV_CODEC_ID_MPEG4 => VideoCodec::Mpeg4,
697 ff_sys::AVCodecID_AV_CODEC_ID_MPEG2VIDEO => VideoCodec::Mpeg2,
698 ff_sys::AVCodecID_AV_CODEC_ID_MJPEG => VideoCodec::Mjpeg,
699 _ => {
700 log::warn!(
701 "video_codec has no mapping, using Unknown \
702 codec_id={codec_id}"
703 );
704 VideoCodec::Unknown
705 }
706 }
707}
708
709fn map_pixel_format(format: i32) -> PixelFormat {
711 #[expect(clippy::cast_sign_loss, reason = "AVPixelFormat values are positive")]
712 let format_u32 = format as u32;
713
714 match format_u32 {
715 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_RGB24 as u32 => PixelFormat::Rgb24,
716 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA as u32 => PixelFormat::Rgba,
717 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_BGR24 as u32 => PixelFormat::Bgr24,
718 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_BGRA as u32 => PixelFormat::Bgra,
719 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P as u32 => PixelFormat::Yuv420p,
720 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV422P as u32 => PixelFormat::Yuv422p,
721 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV444P as u32 => PixelFormat::Yuv444p,
722 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_NV12 as u32 => PixelFormat::Nv12,
723 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_NV21 as u32 => PixelFormat::Nv21,
724 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P10LE as u32 => PixelFormat::Yuv420p10le,
725 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_P010LE as u32 => PixelFormat::P010le,
726 x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_GRAY8 as u32 => PixelFormat::Gray8,
727 _ => {
728 log::warn!(
729 "pixel_format has no mapping, using Other \
730 format={format_u32}"
731 );
732 PixelFormat::Other(format_u32)
733 }
734 }
735}
736
737fn map_color_space(color_space: ff_sys::AVColorSpace) -> ColorSpace {
739 match color_space {
740 ff_sys::AVColorSpace_AVCOL_SPC_BT709 => ColorSpace::Bt709,
741 ff_sys::AVColorSpace_AVCOL_SPC_BT470BG | ff_sys::AVColorSpace_AVCOL_SPC_SMPTE170M => {
742 ColorSpace::Bt601
743 }
744 ff_sys::AVColorSpace_AVCOL_SPC_BT2020_NCL | ff_sys::AVColorSpace_AVCOL_SPC_BT2020_CL => {
745 ColorSpace::Bt2020
746 }
747 ff_sys::AVColorSpace_AVCOL_SPC_RGB => ColorSpace::Srgb,
748 _ => {
749 log::warn!(
750 "color_space has no mapping, using Unknown \
751 color_space={color_space}"
752 );
753 ColorSpace::Unknown
754 }
755 }
756}
757
758fn map_color_range(color_range: ff_sys::AVColorRange) -> ColorRange {
760 match color_range {
761 ff_sys::AVColorRange_AVCOL_RANGE_MPEG => ColorRange::Limited,
762 ff_sys::AVColorRange_AVCOL_RANGE_JPEG => ColorRange::Full,
763 _ => {
764 log::warn!(
765 "color_range has no mapping, using Unknown \
766 color_range={color_range}"
767 );
768 ColorRange::Unknown
769 }
770 }
771}
772
773fn map_color_primaries(color_primaries: ff_sys::AVColorPrimaries) -> ColorPrimaries {
775 match color_primaries {
776 ff_sys::AVColorPrimaries_AVCOL_PRI_BT709 => ColorPrimaries::Bt709,
777 ff_sys::AVColorPrimaries_AVCOL_PRI_BT470BG
778 | ff_sys::AVColorPrimaries_AVCOL_PRI_SMPTE170M => ColorPrimaries::Bt601,
779 ff_sys::AVColorPrimaries_AVCOL_PRI_BT2020 => ColorPrimaries::Bt2020,
780 _ => {
781 log::warn!(
782 "color_primaries has no mapping, using Unknown \
783 color_primaries={color_primaries}"
784 );
785 ColorPrimaries::Unknown
786 }
787 }
788}
789
790unsafe fn extract_audio_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<AudioStreamInfo> {
803 unsafe {
805 let nb_streams = (*ctx).nb_streams;
806 let streams_ptr = (*ctx).streams;
807
808 if streams_ptr.is_null() || nb_streams == 0 {
809 return Vec::new();
810 }
811
812 let mut audio_streams = Vec::new();
813
814 for i in 0..nb_streams {
815 let stream = *streams_ptr.add(i as usize);
817 if stream.is_null() {
818 continue;
819 }
820
821 let codecpar = (*stream).codecpar;
822 if codecpar.is_null() {
823 continue;
824 }
825
826 if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_AUDIO {
828 continue;
829 }
830
831 let stream_info = extract_single_audio_stream(stream, codecpar, i);
833 audio_streams.push(stream_info);
834 }
835
836 audio_streams
837 }
838}
839
840unsafe fn extract_single_audio_stream(
846 stream: *mut ff_sys::AVStream,
847 codecpar: *mut ff_sys::AVCodecParameters,
848 index: u32,
849) -> AudioStreamInfo {
850 unsafe {
852 let codec_id = (*codecpar).codec_id;
854 let codec = map_audio_codec(codec_id);
855 let codec_name = extract_codec_name(codec_id);
856
857 #[expect(clippy::cast_sign_loss, reason = "sample_rate is always positive")]
859 let sample_rate = (*codecpar).sample_rate as u32;
860
861 let channels = extract_channel_count(codecpar);
863
864 let channel_layout = extract_channel_layout(codecpar, channels);
866
867 let sample_format = map_sample_format((*codecpar).format);
869
870 let bitrate = extract_stream_bitrate(codecpar);
872
873 let duration = extract_stream_duration(stream);
875
876 let language = extract_language(stream);
878
879 let mut builder = AudioStreamInfo::builder()
881 .index(index)
882 .codec(codec)
883 .codec_name(codec_name)
884 .sample_rate(sample_rate)
885 .channels(channels)
886 .channel_layout(channel_layout)
887 .sample_format(sample_format);
888
889 if let Some(d) = duration {
890 builder = builder.duration(d);
891 }
892
893 if let Some(b) = bitrate {
894 builder = builder.bitrate(b);
895 }
896
897 if let Some(lang) = language {
898 builder = builder.language(lang);
899 }
900
901 builder.build()
902 }
903}
904
905unsafe fn extract_channel_count(codecpar: *mut ff_sys::AVCodecParameters) -> u32 {
916 #[expect(clippy::cast_sign_loss, reason = "channel count is always positive")]
919 let channels = unsafe { (*codecpar).ch_layout.nb_channels as u32 };
920
921 if channels > 0 {
923 channels
924 } else {
925 log::warn!(
926 "channel_count is 0 (uninitialized), falling back to mono \
927 fallback=1"
928 );
929 1
930 }
931}
932
933unsafe fn extract_channel_layout(
939 codecpar: *mut ff_sys::AVCodecParameters,
940 channels: u32,
941) -> ChannelLayout {
942 let ch_layout = unsafe { &(*codecpar).ch_layout };
945
946 if ch_layout.order == ff_sys::AVChannelOrder_AV_CHANNEL_ORDER_NATIVE {
949 let mask = unsafe { ch_layout.u.mask };
953 match mask {
954 0x4 => ChannelLayout::Mono,
956 0x3 => ChannelLayout::Stereo,
958 0x103 => ChannelLayout::Stereo2_1,
960 0x7 => ChannelLayout::Surround3_0,
962 0x33 => ChannelLayout::Quad,
964 0x37 => ChannelLayout::Surround5_0,
966 0x3F => ChannelLayout::Surround5_1,
968 0x13F => ChannelLayout::Surround6_1,
970 0x63F => ChannelLayout::Surround7_1,
972 _ => {
973 log::warn!(
974 "channel_layout mask has no mapping, deriving from channel count \
975 mask={mask} channels={channels}"
976 );
977 ChannelLayout::from_channels(channels)
978 }
979 }
980 } else {
981 log::warn!(
982 "channel_layout order is not NATIVE, deriving from channel count \
983 order={order} channels={channels}",
984 order = ch_layout.order
985 );
986 ChannelLayout::from_channels(channels)
987 }
988}
989
990unsafe fn extract_language(stream: *mut ff_sys::AVStream) -> Option<String> {
996 unsafe {
998 let metadata = (*stream).metadata;
999 if metadata.is_null() {
1000 return None;
1001 }
1002
1003 let key = c"language";
1005 let entry = ff_sys::av_dict_get(metadata, key.as_ptr(), std::ptr::null(), 0);
1006
1007 if entry.is_null() {
1008 return None;
1009 }
1010
1011 let value_ptr = (*entry).value;
1012 if value_ptr.is_null() {
1013 return None;
1014 }
1015
1016 Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1017 }
1018}
1019
1020fn map_audio_codec(codec_id: ff_sys::AVCodecID) -> AudioCodec {
1026 match codec_id {
1027 ff_sys::AVCodecID_AV_CODEC_ID_AAC => AudioCodec::Aac,
1028 ff_sys::AVCodecID_AV_CODEC_ID_MP3 => AudioCodec::Mp3,
1029 ff_sys::AVCodecID_AV_CODEC_ID_OPUS => AudioCodec::Opus,
1030 ff_sys::AVCodecID_AV_CODEC_ID_FLAC => AudioCodec::Flac,
1031 ff_sys::AVCodecID_AV_CODEC_ID_VORBIS => AudioCodec::Vorbis,
1032 ff_sys::AVCodecID_AV_CODEC_ID_AC3 => AudioCodec::Ac3,
1033 ff_sys::AVCodecID_AV_CODEC_ID_EAC3 => AudioCodec::Eac3,
1034 ff_sys::AVCodecID_AV_CODEC_ID_DTS => AudioCodec::Dts,
1035 ff_sys::AVCodecID_AV_CODEC_ID_ALAC => AudioCodec::Alac,
1036 ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16LE
1038 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16BE
1039 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S24LE
1040 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S24BE
1041 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S32LE
1042 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S32BE
1043 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32LE
1044 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32BE
1045 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F64LE
1046 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F64BE
1047 | ff_sys::AVCodecID_AV_CODEC_ID_PCM_U8 => AudioCodec::Pcm,
1048 _ => {
1049 log::warn!(
1050 "audio_codec has no mapping, using Unknown \
1051 codec_id={codec_id}"
1052 );
1053 AudioCodec::Unknown
1054 }
1055 }
1056}
1057
1058fn map_sample_format(format: i32) -> SampleFormat {
1060 #[expect(clippy::cast_sign_loss, reason = "AVSampleFormat values are positive")]
1061 let format_u32 = format as u32;
1062
1063 match format_u32 {
1064 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8 as u32 => SampleFormat::U8,
1066 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16 as u32 => SampleFormat::I16,
1067 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32 as u32 => SampleFormat::I32,
1068 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLT as u32 => SampleFormat::F32,
1069 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBL as u32 => SampleFormat::F64,
1070 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8P as u32 => SampleFormat::U8p,
1072 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16P as u32 => SampleFormat::I16p,
1073 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32P as u32 => SampleFormat::I32p,
1074 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLTP as u32 => SampleFormat::F32p,
1075 x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBLP as u32 => SampleFormat::F64p,
1076 _ => {
1078 log::warn!(
1079 "sample_format has no mapping, using Other \
1080 format={format_u32}"
1081 );
1082 SampleFormat::Other(format_u32)
1083 }
1084 }
1085}
1086
1087unsafe fn extract_chapters(ctx: *mut ff_sys::AVFormatContext) -> Vec<ChapterInfo> {
1097 unsafe {
1099 let nb_chapters = (*ctx).nb_chapters;
1100 let chapters_ptr = (*ctx).chapters;
1101
1102 if chapters_ptr.is_null() || nb_chapters == 0 {
1103 return Vec::new();
1104 }
1105
1106 let mut chapters = Vec::with_capacity(nb_chapters as usize);
1107
1108 for i in 0..nb_chapters {
1109 let chapter = *chapters_ptr.add(i as usize);
1111 if chapter.is_null() {
1112 continue;
1113 }
1114
1115 chapters.push(extract_single_chapter(chapter));
1116 }
1117
1118 chapters
1119 }
1120}
1121
1122unsafe fn extract_single_chapter(chapter: *mut ff_sys::AVChapter) -> ChapterInfo {
1128 unsafe {
1130 let id = (*chapter).id;
1131
1132 let av_tb = (*chapter).time_base;
1133 let time_base = if av_tb.den != 0 {
1134 Some(Rational::new(av_tb.num, av_tb.den))
1135 } else {
1136 log::warn!(
1137 "chapter time_base has zero denominator, treating as unknown \
1138 chapter_id={id} time_base_num={num} time_base_den=0",
1139 num = av_tb.num
1140 );
1141 None
1142 };
1143
1144 let (start, end) = if let Some(tb) = time_base {
1145 (
1146 pts_to_duration((*chapter).start, tb),
1147 pts_to_duration((*chapter).end, tb),
1148 )
1149 } else {
1150 (std::time::Duration::ZERO, std::time::Duration::ZERO)
1151 };
1152
1153 let title = extract_chapter_title((*chapter).metadata);
1154 let metadata = extract_chapter_metadata((*chapter).metadata);
1155
1156 let mut builder = ChapterInfo::builder().id(id).start(start).end(end);
1157
1158 if let Some(t) = title {
1159 builder = builder.title(t);
1160 }
1161 if let Some(tb) = time_base {
1162 builder = builder.time_base(tb);
1163 }
1164 if let Some(m) = metadata {
1165 builder = builder.metadata(m);
1166 }
1167
1168 builder.build()
1169 }
1170}
1171
1172fn pts_to_duration(pts: i64, time_base: Rational) -> std::time::Duration {
1176 if pts <= 0 {
1177 return std::time::Duration::ZERO;
1178 }
1179 #[expect(clippy::cast_precision_loss, reason = "media timestamps are bounded")]
1182 let secs = (pts as f64) * f64::from(time_base.num()) / f64::from(time_base.den());
1183 if secs > 0.0 {
1184 std::time::Duration::from_secs_f64(secs)
1185 } else {
1186 std::time::Duration::ZERO
1187 }
1188}
1189
1190unsafe fn extract_chapter_title(dict: *mut ff_sys::AVDictionary) -> Option<String> {
1198 unsafe {
1200 if dict.is_null() {
1201 return None;
1202 }
1203 let entry = ff_sys::av_dict_get(dict, c"title".as_ptr(), std::ptr::null(), 0);
1204 if entry.is_null() {
1205 return None;
1206 }
1207 let value_ptr = (*entry).value;
1208 if value_ptr.is_null() {
1209 return None;
1210 }
1211 Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1212 }
1213}
1214
1215unsafe fn extract_chapter_metadata(
1223 dict: *mut ff_sys::AVDictionary,
1224) -> Option<HashMap<String, String>> {
1225 unsafe {
1227 if dict.is_null() {
1228 return None;
1229 }
1230
1231 let mut map = HashMap::new();
1232 let mut entry: *const ff_sys::AVDictionaryEntry = std::ptr::null();
1233 let flags = ff_sys::AV_DICT_IGNORE_SUFFIX.cast_signed();
1234
1235 loop {
1236 entry = ff_sys::av_dict_get(dict, c"".as_ptr(), entry, flags);
1237 if entry.is_null() {
1238 break;
1239 }
1240
1241 let key_ptr = (*entry).key;
1242 let value_ptr = (*entry).value;
1243
1244 if key_ptr.is_null() || value_ptr.is_null() {
1245 continue;
1246 }
1247
1248 let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
1249 if key == "title" {
1250 continue;
1251 }
1252 let value = CStr::from_ptr(value_ptr).to_string_lossy().into_owned();
1253 map.insert(key, value);
1254 }
1255
1256 if map.is_empty() { None } else { Some(map) }
1257 }
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262 use super::*;
1263
1264 #[test]
1265 fn test_open_nonexistent_file() {
1266 let result = open("/nonexistent/path/to/video.mp4");
1267 assert!(result.is_err());
1268 match result {
1269 Err(ProbeError::FileNotFound { path }) => {
1270 assert!(path.to_string_lossy().contains("video.mp4"));
1271 }
1272 _ => panic!("Expected FileNotFound error"),
1273 }
1274 }
1275
1276 #[test]
1277 fn test_open_invalid_file() {
1278 let temp_dir = std::env::temp_dir();
1280 let temp_file = temp_dir.join("ff_probe_test_invalid.mp4");
1281 std::fs::write(&temp_file, b"not a valid video file").ok();
1282
1283 let result = open(&temp_file);
1284
1285 std::fs::remove_file(&temp_file).ok();
1287
1288 assert!(result.is_err());
1290 match result {
1291 Err(ProbeError::CannotOpen { .. }) | Err(ProbeError::InvalidMedia { .. }) => {}
1292 _ => panic!("Expected CannotOpen or InvalidMedia error"),
1293 }
1294 }
1295
1296 #[test]
1297 fn test_av_time_base_constant() {
1298 assert_eq!(AV_TIME_BASE, 1_000_000);
1300 }
1301
1302 #[test]
1307 fn pts_to_duration_should_convert_millisecond_timebase_correctly() {
1308 let tb = Rational::new(1, 1000);
1310 let dur = pts_to_duration(5000, tb);
1311 assert_eq!(dur, Duration::from_secs(5));
1312 }
1313
1314 #[test]
1315 fn pts_to_duration_should_convert_mpeg_ts_timebase_correctly() {
1316 let tb = Rational::new(1, 90000);
1318 let dur = pts_to_duration(90000, tb);
1319 assert!((dur.as_secs_f64() - 1.0).abs() < 1e-6);
1320 }
1321
1322 #[test]
1323 fn pts_to_duration_should_return_zero_for_zero_pts() {
1324 let tb = Rational::new(1, 1000);
1325 assert_eq!(pts_to_duration(0, tb), Duration::ZERO);
1326 }
1327
1328 #[test]
1329 fn pts_to_duration_should_return_zero_for_negative_pts() {
1330 let tb = Rational::new(1, 1000);
1331 assert_eq!(pts_to_duration(-1, tb), Duration::ZERO);
1332 }
1333
1334 #[test]
1335 fn test_duration_conversion() {
1336 let duration_us: i64 = 5_500_000; let secs = (duration_us / AV_TIME_BASE) as u64;
1339 let micros = (duration_us % AV_TIME_BASE) as u32;
1340 let duration = Duration::new(secs, micros * 1000);
1341
1342 assert_eq!(duration.as_secs(), 5);
1343 assert_eq!(duration.subsec_micros(), 500_000);
1344 }
1345
1346 #[test]
1351 fn test_map_video_codec_h264() {
1352 let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_H264);
1353 assert_eq!(codec, VideoCodec::H264);
1354 }
1355
1356 #[test]
1357 fn test_map_video_codec_hevc() {
1358 let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_HEVC);
1359 assert_eq!(codec, VideoCodec::H265);
1360 }
1361
1362 #[test]
1363 fn test_map_video_codec_vp9() {
1364 let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_VP9);
1365 assert_eq!(codec, VideoCodec::Vp9);
1366 }
1367
1368 #[test]
1369 fn test_map_video_codec_av1() {
1370 let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_AV1);
1371 assert_eq!(codec, VideoCodec::Av1);
1372 }
1373
1374 #[test]
1375 fn test_map_video_codec_unknown() {
1376 let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_THEORA);
1378 assert_eq!(codec, VideoCodec::Unknown);
1379 }
1380
1381 #[test]
1386 fn test_map_pixel_format_yuv420p() {
1387 let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P as i32);
1388 assert_eq!(format, PixelFormat::Yuv420p);
1389 }
1390
1391 #[test]
1392 fn test_map_pixel_format_rgba() {
1393 let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA as i32);
1394 assert_eq!(format, PixelFormat::Rgba);
1395 }
1396
1397 #[test]
1398 fn test_map_pixel_format_nv12() {
1399 let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_NV12 as i32);
1400 assert_eq!(format, PixelFormat::Nv12);
1401 }
1402
1403 #[test]
1404 fn test_map_pixel_format_yuv420p10le() {
1405 let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P10LE as i32);
1406 assert_eq!(format, PixelFormat::Yuv420p10le);
1407 }
1408
1409 #[test]
1410 fn test_map_pixel_format_unknown() {
1411 let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_PAL8 as i32);
1413 assert!(matches!(format, PixelFormat::Other(_)));
1414 }
1415
1416 #[test]
1421 fn test_map_color_space_bt709() {
1422 let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT709);
1423 assert_eq!(space, ColorSpace::Bt709);
1424 }
1425
1426 #[test]
1427 fn test_map_color_space_bt601() {
1428 let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT470BG);
1429 assert_eq!(space, ColorSpace::Bt601);
1430
1431 let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_SMPTE170M);
1432 assert_eq!(space, ColorSpace::Bt601);
1433 }
1434
1435 #[test]
1436 fn test_map_color_space_bt2020() {
1437 let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT2020_NCL);
1438 assert_eq!(space, ColorSpace::Bt2020);
1439
1440 let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT2020_CL);
1441 assert_eq!(space, ColorSpace::Bt2020);
1442 }
1443
1444 #[test]
1445 fn test_map_color_space_srgb() {
1446 let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_RGB);
1447 assert_eq!(space, ColorSpace::Srgb);
1448 }
1449
1450 #[test]
1451 fn test_map_color_space_unknown() {
1452 let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_UNSPECIFIED);
1453 assert_eq!(space, ColorSpace::Unknown);
1454 }
1455
1456 #[test]
1461 fn test_map_color_range_limited() {
1462 let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_MPEG);
1463 assert_eq!(range, ColorRange::Limited);
1464 }
1465
1466 #[test]
1467 fn test_map_color_range_full() {
1468 let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_JPEG);
1469 assert_eq!(range, ColorRange::Full);
1470 }
1471
1472 #[test]
1473 fn test_map_color_range_unknown() {
1474 let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_UNSPECIFIED);
1475 assert_eq!(range, ColorRange::Unknown);
1476 }
1477
1478 #[test]
1483 fn test_map_color_primaries_bt709() {
1484 let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT709);
1485 assert_eq!(primaries, ColorPrimaries::Bt709);
1486 }
1487
1488 #[test]
1489 fn test_map_color_primaries_bt601() {
1490 let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT470BG);
1491 assert_eq!(primaries, ColorPrimaries::Bt601);
1492
1493 let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_SMPTE170M);
1494 assert_eq!(primaries, ColorPrimaries::Bt601);
1495 }
1496
1497 #[test]
1498 fn test_map_color_primaries_bt2020() {
1499 let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT2020);
1500 assert_eq!(primaries, ColorPrimaries::Bt2020);
1501 }
1502
1503 #[test]
1504 fn test_map_color_primaries_unknown() {
1505 let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_UNSPECIFIED);
1506 assert_eq!(primaries, ColorPrimaries::Unknown);
1507 }
1508
1509 #[test]
1514 fn test_map_audio_codec_aac() {
1515 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_AAC);
1516 assert_eq!(codec, AudioCodec::Aac);
1517 }
1518
1519 #[test]
1520 fn test_map_audio_codec_mp3() {
1521 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_MP3);
1522 assert_eq!(codec, AudioCodec::Mp3);
1523 }
1524
1525 #[test]
1526 fn test_map_audio_codec_opus() {
1527 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_OPUS);
1528 assert_eq!(codec, AudioCodec::Opus);
1529 }
1530
1531 #[test]
1532 fn test_map_audio_codec_flac() {
1533 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_FLAC);
1534 assert_eq!(codec, AudioCodec::Flac);
1535 }
1536
1537 #[test]
1538 fn test_map_audio_codec_vorbis() {
1539 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_VORBIS);
1540 assert_eq!(codec, AudioCodec::Vorbis);
1541 }
1542
1543 #[test]
1544 fn test_map_audio_codec_ac3() {
1545 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_AC3);
1546 assert_eq!(codec, AudioCodec::Ac3);
1547 }
1548
1549 #[test]
1550 fn test_map_audio_codec_eac3() {
1551 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_EAC3);
1552 assert_eq!(codec, AudioCodec::Eac3);
1553 }
1554
1555 #[test]
1556 fn test_map_audio_codec_dts() {
1557 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_DTS);
1558 assert_eq!(codec, AudioCodec::Dts);
1559 }
1560
1561 #[test]
1562 fn test_map_audio_codec_alac() {
1563 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_ALAC);
1564 assert_eq!(codec, AudioCodec::Alac);
1565 }
1566
1567 #[test]
1568 fn test_map_audio_codec_pcm() {
1569 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16LE);
1571 assert_eq!(codec, AudioCodec::Pcm);
1572
1573 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32LE);
1574 assert_eq!(codec, AudioCodec::Pcm);
1575
1576 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_U8);
1577 assert_eq!(codec, AudioCodec::Pcm);
1578 }
1579
1580 #[test]
1581 fn test_map_audio_codec_unknown() {
1582 let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_WMAV2);
1584 assert_eq!(codec, AudioCodec::Unknown);
1585 }
1586
1587 #[test]
1592 fn test_map_sample_format_u8() {
1593 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8 as i32);
1594 assert_eq!(format, SampleFormat::U8);
1595 }
1596
1597 #[test]
1598 fn test_map_sample_format_i16() {
1599 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16 as i32);
1600 assert_eq!(format, SampleFormat::I16);
1601 }
1602
1603 #[test]
1604 fn test_map_sample_format_i32() {
1605 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32 as i32);
1606 assert_eq!(format, SampleFormat::I32);
1607 }
1608
1609 #[test]
1610 fn test_map_sample_format_f32() {
1611 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLT as i32);
1612 assert_eq!(format, SampleFormat::F32);
1613 }
1614
1615 #[test]
1616 fn test_map_sample_format_f64() {
1617 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBL as i32);
1618 assert_eq!(format, SampleFormat::F64);
1619 }
1620
1621 #[test]
1622 fn test_map_sample_format_planar() {
1623 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8P as i32);
1624 assert_eq!(format, SampleFormat::U8p);
1625
1626 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16P as i32);
1627 assert_eq!(format, SampleFormat::I16p);
1628
1629 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32P as i32);
1630 assert_eq!(format, SampleFormat::I32p);
1631
1632 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLTP as i32);
1633 assert_eq!(format, SampleFormat::F32p);
1634
1635 let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBLP as i32);
1636 assert_eq!(format, SampleFormat::F64p);
1637 }
1638
1639 #[test]
1640 fn test_map_sample_format_unknown() {
1641 let format = map_sample_format(999);
1643 assert!(matches!(format, SampleFormat::Other(_)));
1644 }
1645
1646 #[test]
1651 fn test_bitrate_fallback_calculation() {
1652 let file_size: u64 = 10_000_000;
1658 let duration = Duration::from_secs(10);
1659 let duration_secs = duration.as_secs_f64();
1660
1661 let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1662 assert_eq!(calculated_bitrate, 8_000_000);
1663 }
1664
1665 #[test]
1666 fn test_bitrate_fallback_with_subsecond_duration() {
1667 let file_size: u64 = 1_000_000;
1671 let duration = Duration::from_millis(500);
1672 let duration_secs = duration.as_secs_f64();
1673
1674 let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1675 assert_eq!(calculated_bitrate, 16_000_000);
1676 }
1677
1678 #[test]
1679 fn test_bitrate_zero_duration() {
1680 let duration = Duration::ZERO;
1682 let duration_secs = duration.as_secs_f64();
1683
1684 assert!(duration_secs == 0.0);
1686 }
1687
1688 #[test]
1689 fn test_bitrate_zero_file_size() {
1690 let file_size: u64 = 0;
1692 let duration = Duration::from_secs(10);
1693 let duration_secs = duration.as_secs_f64();
1694
1695 if duration_secs > 0.0 && file_size > 0 {
1696 let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1697 assert_eq!(calculated_bitrate, 0);
1698 } else {
1699 assert_eq!(file_size, 0);
1701 }
1702 }
1703
1704 #[test]
1705 fn test_bitrate_typical_video_file() {
1706 let file_size: u64 = 100_000_000;
1710 let duration = Duration::from_secs(300); let duration_secs = duration.as_secs_f64();
1712
1713 let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1714 assert_eq!(calculated_bitrate, 2_666_666);
1715 }
1716
1717 #[test]
1718 fn test_bitrate_high_quality_video() {
1719 let file_size: u64 = 5_000_000_000;
1723 let duration = Duration::from_secs(7200); let duration_secs = duration.as_secs_f64();
1725
1726 let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1727 assert_eq!(calculated_bitrate, 5_555_555);
1728 }
1729}