Skip to main content

ff_probe/
info.rs

1//! Media file information extraction.
2//!
3//! This module provides the [`open`] function for extracting metadata from media files
4//! using `FFmpeg`. It creates a [`MediaInfo`] struct containing all relevant information
5//! about the media file, including container format, duration, file size, and stream details.
6//!
7//! # Examples
8//!
9//! ## Basic Usage
10//!
11//! ```no_run
12//! use ff_probe::open;
13//!
14//! fn main() -> Result<(), Box<dyn std::error::Error>> {
15//!     let info = open("video.mp4")?;
16//!
17//!     println!("Format: {}", info.format());
18//!     println!("Duration: {:?}", info.duration());
19//!
20//!     // Access video stream information
21//!     if let Some(video) = info.primary_video() {
22//!         println!("Video: {} {}x{} @ {:.2} fps",
23//!             video.codec_name(),
24//!             video.width(),
25//!             video.height(),
26//!             video.fps()
27//!         );
28//!     }
29//!
30//!     Ok(())
31//! }
32//! ```
33//!
34//! ## Checking for Video vs Audio-Only Files
35//!
36//! ```no_run
37//! use ff_probe::open;
38//!
39//! fn main() -> Result<(), Box<dyn std::error::Error>> {
40//!     let info = open("media_file.mp4")?;
41//!
42//!     if info.has_video() {
43//!         println!("This is a video file");
44//!     } else if info.has_audio() {
45//!         println!("This is an audio-only file");
46//!     }
47//!
48//!     Ok(())
49//! }
50//! ```
51
52// This module requires unsafe code for FFmpeg FFI interactions
53#![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
69/// `AV_TIME_BASE` constant from `FFmpeg` (microseconds per second).
70const AV_TIME_BASE: i64 = 1_000_000;
71
72/// Opens a media file and extracts its metadata.
73///
74/// This function opens the file at the given path using `FFmpeg`, reads the container
75/// format information, and returns a [`MediaInfo`] struct containing all extracted
76/// metadata.
77///
78/// # Arguments
79///
80/// * `path` - Path to the media file to probe. Accepts anything that can be converted
81///   to a [`Path`], including `&str`, `String`, `PathBuf`, etc.
82///
83/// # Returns
84///
85/// Returns `Ok(MediaInfo)` on success, or a [`ProbeError`] on failure.
86///
87/// # Errors
88///
89/// - [`ProbeError::FileNotFound`] if the file does not exist
90/// - [`ProbeError::CannotOpen`] if `FFmpeg` cannot open the file
91/// - [`ProbeError::InvalidMedia`] if stream information cannot be read
92/// - [`ProbeError::Io`] if there's an I/O error accessing the file
93///
94/// # Examples
95///
96/// ## Opening a Video File
97///
98/// ```no_run
99/// use ff_probe::open;
100/// use std::path::Path;
101///
102/// fn main() -> Result<(), Box<dyn std::error::Error>> {
103///     // Open by string path
104///     let info = open("video.mp4")?;
105///
106///     // Or by Path
107///     let path = Path::new("/path/to/video.mkv");
108///     let info = open(path)?;
109///
110///     if let Some(video) = info.primary_video() {
111///         println!("Resolution: {}x{}", video.width(), video.height());
112///     }
113///
114///     Ok(())
115/// }
116/// ```
117///
118/// ## Handling Errors
119///
120/// ```
121/// use ff_probe::{open, ProbeError};
122///
123/// // Non-existent file returns FileNotFound
124/// let result = open("/this/file/does/not/exist.mp4");
125/// assert!(matches!(result, Err(ProbeError::FileNotFound { .. })));
126/// ```
127pub fn open(path: impl AsRef<Path>) -> Result<MediaInfo, ProbeError> {
128    let path = path.as_ref();
129
130    // Check if file exists
131    if !path.exists() {
132        return Err(ProbeError::FileNotFound {
133            path: path.to_path_buf(),
134        });
135    }
136
137    // Get file size - propagate error since file may exist but be inaccessible (permission denied, etc.)
138    let file_size = std::fs::metadata(path).map(|m| m.len())?;
139
140    // Open file with FFmpeg
141    // SAFETY: We verified the file exists, and we properly close the context on all paths
142    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    // Find stream info - this populates codec information
150    // SAFETY: ctx is valid from open_input
151    if let Err(err_code) = unsafe { ff_sys::avformat::find_stream_info(ctx) } {
152        // SAFETY: ctx is valid
153        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    // Extract basic information from AVFormatContext
164    // SAFETY: ctx is valid and find_stream_info succeeded
165    let (format, format_long_name, duration) = unsafe { extract_format_info(ctx) };
166
167    // Calculate container bitrate
168    // SAFETY: ctx is valid and find_stream_info succeeded
169    let bitrate = unsafe { calculate_container_bitrate(ctx, file_size, duration) };
170
171    // Extract container metadata
172    // SAFETY: ctx is valid and find_stream_info succeeded
173    let metadata = unsafe { extract_metadata(ctx) };
174
175    // Extract video streams
176    // SAFETY: ctx is valid and find_stream_info succeeded
177    let video_streams = unsafe { extract_video_streams(ctx) };
178
179    // Extract audio streams
180    // SAFETY: ctx is valid and find_stream_info succeeded
181    let audio_streams = unsafe { extract_audio_streams(ctx) };
182
183    // Extract chapter info
184    // SAFETY: ctx is valid and find_stream_info succeeded
185    let chapters = unsafe { extract_chapters(ctx) };
186
187    // Close the format context
188    // SAFETY: ctx is valid
189    unsafe {
190        let mut ctx_ptr = ctx;
191        ff_sys::avformat::close_input(&raw mut ctx_ptr);
192    }
193
194    // Build MediaInfo
195    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
216/// Extracts format information from an `AVFormatContext`.
217///
218/// # Safety
219///
220/// The `ctx` pointer must be valid and properly initialized by `avformat_open_input`.
221unsafe fn extract_format_info(
222    ctx: *mut ff_sys::AVFormatContext,
223) -> (String, Option<String>, Duration) {
224    // SAFETY: Caller guarantees ctx is valid
225    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
234/// Extracts the format name from an `AVFormatContext`.
235///
236/// # Safety
237///
238/// The `ctx` pointer must be valid.
239unsafe fn extract_format_name(ctx: *mut ff_sys::AVFormatContext) -> String {
240    // SAFETY: Caller guarantees ctx is valid
241    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
256/// Extracts the long format name from an `AVFormatContext`.
257///
258/// # Safety
259///
260/// The `ctx` pointer must be valid.
261unsafe fn extract_format_long_name(ctx: *mut ff_sys::AVFormatContext) -> Option<String> {
262    // SAFETY: Caller guarantees ctx is valid
263    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
278/// Extracts the duration from an `AVFormatContext`.
279///
280/// The duration is stored in `AV_TIME_BASE` units (microseconds).
281/// If the duration is not available or is invalid, returns `Duration::ZERO`.
282///
283/// # Safety
284///
285/// The `ctx` pointer must be valid.
286unsafe fn extract_duration(ctx: *mut ff_sys::AVFormatContext) -> Duration {
287    // SAFETY: Caller guarantees ctx is valid
288    let duration_us = unsafe { (*ctx).duration };
289
290    // duration_us == 0: Container does not provide duration info (e.g., live streams)
291    // duration_us < 0: AV_NOPTS_VALUE (typically i64::MIN), indicating unknown duration
292    if duration_us <= 0 {
293        return Duration::ZERO;
294    }
295
296    // Convert from microseconds to Duration
297    // duration is in AV_TIME_BASE units (1/1000000 seconds)
298    // Safe cast: we verified duration_us > 0 above
299    #[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
307/// Calculates the overall bitrate for a media file.
308///
309/// This function first tries to get the bitrate directly from the `AVFormatContext`.
310/// If the bitrate is not available (i.e., 0 or negative), it falls back to calculating
311/// the bitrate from the file size and duration: `bitrate = file_size * 8 / duration`.
312///
313/// # Arguments
314///
315/// * `ctx` - The `AVFormatContext` to extract bitrate from
316/// * `file_size` - The file size in bytes
317/// * `duration` - The duration of the media
318///
319/// # Returns
320///
321/// Returns `Some(bitrate)` in bits per second, or `None` if neither method can determine
322/// the bitrate (e.g., if duration is zero).
323///
324/// # Safety
325///
326/// The `ctx` pointer must be valid.
327unsafe fn calculate_container_bitrate(
328    ctx: *mut ff_sys::AVFormatContext,
329    file_size: u64,
330    duration: Duration,
331) -> Option<u64> {
332    // SAFETY: Caller guarantees ctx is valid
333    let bitrate = unsafe { (*ctx).bit_rate };
334
335    // If bitrate is available from FFmpeg, use it directly
336    if bitrate > 0 {
337        #[expect(clippy::cast_sign_loss, reason = "verified bitrate > 0")]
338        return Some(bitrate as u64);
339    }
340
341    // Fallback: calculate from file size and duration
342    // bitrate (bps) = file_size (bytes) * 8 (bits/byte) / duration (seconds)
343    let duration_secs = duration.as_secs_f64();
344    if duration_secs > 0.0 && file_size > 0 {
345        // Note: Precision loss from u64->f64 is acceptable here because:
346        // 1. For files up to 9 PB, f64 provides sufficient precision
347        // 2. The result is used for display/metadata purposes, not exact calculations
348        #[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
369// ============================================================================
370// Container Metadata Extraction
371// ============================================================================
372
373/// Extracts container-level metadata from an `AVFormatContext`.
374///
375/// This function reads all metadata entries from the container's `AVDictionary`,
376/// including standard keys (title, artist, album, date, etc.) and custom metadata.
377///
378/// # Safety
379///
380/// The `ctx` pointer must be valid.
381///
382/// # Returns
383///
384/// Returns a `HashMap` containing all metadata key-value pairs.
385/// If no metadata is present, returns an empty `HashMap`.
386unsafe fn extract_metadata(ctx: *mut ff_sys::AVFormatContext) -> HashMap<String, String> {
387    let mut metadata = HashMap::new();
388
389    // SAFETY: Caller guarantees ctx is valid
390    unsafe {
391        let dict = (*ctx).metadata;
392        if dict.is_null() {
393            return metadata;
394        }
395
396        // Iterate through all dictionary entries using av_dict_get with AV_DICT_IGNORE_SUFFIX
397        // This iterates all entries when starting with an empty key and passing the previous entry
398        let mut entry: *const ff_sys::AVDictionaryEntry = std::ptr::null();
399
400        // AV_DICT_IGNORE_SUFFIX is a small constant (2) that safely fits in i32
401        let flags = ff_sys::AV_DICT_IGNORE_SUFFIX.cast_signed();
402
403        loop {
404            // Get the next entry by passing the previous one
405            // Using empty string as key and AV_DICT_IGNORE_SUFFIX to iterate all entries
406            entry = ff_sys::av_dict_get(dict, c"".as_ptr(), entry, flags);
407
408            if entry.is_null() {
409                break;
410            }
411
412            // Extract key and value from the entry
413            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            // Convert C strings to Rust strings
421            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
431// ============================================================================
432// Video Stream Extraction
433// ============================================================================
434
435/// Extracts all video streams from an `AVFormatContext`.
436///
437/// This function iterates through all streams in the container and extracts
438/// detailed information for each video stream.
439///
440/// # Safety
441///
442/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
443unsafe fn extract_video_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<VideoStreamInfo> {
444    // SAFETY: Caller guarantees ctx is valid
445    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            // SAFETY: i < nb_streams, so this is within bounds
457            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            // Check if this is a video stream
468            if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_VIDEO {
469                continue;
470            }
471
472            // Extract video stream info
473            let stream_info = extract_single_video_stream(stream, codecpar, i);
474            video_streams.push(stream_info);
475        }
476
477        video_streams
478    }
479}
480
481/// Extracts information from a single video stream.
482///
483/// # Safety
484///
485/// Both `stream` and `codecpar` pointers must be valid.
486unsafe fn extract_single_video_stream(
487    stream: *mut ff_sys::AVStream,
488    codecpar: *mut ff_sys::AVCodecParameters,
489    index: u32,
490) -> VideoStreamInfo {
491    // SAFETY: Caller guarantees pointers are valid
492    unsafe {
493        // Extract codec info
494        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        // Extract dimensions
499        #[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        // Extract pixel format
505        let pixel_format = map_pixel_format((*codecpar).format);
506
507        // Extract frame rate
508        let frame_rate = extract_frame_rate(stream);
509
510        // Extract bitrate
511        let bitrate = extract_stream_bitrate(codecpar);
512
513        // Extract color information
514        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        // Extract duration if available
519        let duration = extract_stream_duration(stream);
520
521        // Extract frame count if available
522        let frame_count = extract_frame_count(stream);
523
524        // Build the VideoStreamInfo
525        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
553/// Extracts the codec name from an `AVCodecID`.
554///
555/// # Safety
556///
557/// This function calls `FFmpeg`'s `avcodec_get_name` which is safe for any codec ID.
558unsafe fn extract_codec_name(codec_id: ff_sys::AVCodecID) -> String {
559    // SAFETY: avcodec_get_name is safe for any codec ID value
560    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    // SAFETY: avcodec_get_name returns a valid C string
567    unsafe { CStr::from_ptr(name_ptr).to_string_lossy().into_owned() }
568}
569
570/// Extracts the frame rate from an `AVStream`.
571///
572/// Tries to get the real frame rate (`r_frame_rate`), falling back to average
573/// frame rate (`avg_frame_rate`), and finally to a default of 30/1.
574///
575/// # Safety
576///
577/// The `stream` pointer must be valid.
578unsafe fn extract_frame_rate(stream: *mut ff_sys::AVStream) -> Rational {
579    // SAFETY: Caller guarantees stream is valid
580    unsafe {
581        // Try r_frame_rate first (real frame rate, most accurate for video)
582        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        // Fall back to avg_frame_rate
588        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        // Default to 30 fps
594        {
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
608/// Extracts the bitrate from an `AVCodecParameters`.
609///
610/// Returns `None` if the bitrate is not available or is zero.
611///
612/// # Safety
613///
614/// The `codecpar` pointer must be valid.
615unsafe fn extract_stream_bitrate(codecpar: *mut ff_sys::AVCodecParameters) -> Option<u64> {
616    // SAFETY: Caller guarantees codecpar is valid
617    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
627/// Extracts the duration from an `AVStream`.
628///
629/// Returns `None` if the duration is not available.
630///
631/// # Safety
632///
633/// The `stream` pointer must be valid.
634unsafe fn extract_stream_duration(stream: *mut ff_sys::AVStream) -> Option<Duration> {
635    // SAFETY: Caller guarantees stream is valid
636    unsafe {
637        let duration_pts = (*stream).duration;
638
639        // AV_NOPTS_VALUE indicates unknown duration
640        if duration_pts <= 0 {
641            return None;
642        }
643
644        // Get stream time base
645        let time_base = (*stream).time_base;
646        if time_base.den == 0 {
647            return None;
648        }
649
650        // Convert to seconds: pts * num / den
651        // Note: i64 to f64 cast may lose precision for very large values,
652        // but this is acceptable for media timestamps which are bounded
653        #[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
664/// Extracts the frame count from an `AVStream`.
665///
666/// Returns `None` if the frame count is not available.
667///
668/// # Safety
669///
670/// The `stream` pointer must be valid.
671unsafe fn extract_frame_count(stream: *mut ff_sys::AVStream) -> Option<u64> {
672    // SAFETY: Caller guarantees stream is valid
673    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
683// ============================================================================
684// Type Mapping Functions
685// ============================================================================
686
687/// Maps an `FFmpeg` `AVCodecID` to our [`VideoCodec`] enum.
688fn 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
709/// Maps an `FFmpeg` `AVPixelFormat` to our [`PixelFormat`] enum.
710fn 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
737/// Maps an `FFmpeg` `AVColorSpace` to our [`ColorSpace`] enum.
738fn 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
758/// Maps an `FFmpeg` `AVColorRange` to our [`ColorRange`] enum.
759fn 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
773/// Maps an `FFmpeg` `AVColorPrimaries` to our [`ColorPrimaries`] enum.
774fn 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
790// ============================================================================
791// Audio Stream Extraction
792// ============================================================================
793
794/// Extracts all audio streams from an `AVFormatContext`.
795///
796/// This function iterates through all streams in the container and extracts
797/// detailed information for each audio stream.
798///
799/// # Safety
800///
801/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
802unsafe fn extract_audio_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<AudioStreamInfo> {
803    // SAFETY: Caller guarantees ctx is valid and find_stream_info was called
804    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            // SAFETY: i < nb_streams, so this is within bounds
816            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            // Check if this is an audio stream
827            if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_AUDIO {
828                continue;
829            }
830
831            // Extract audio stream info
832            let stream_info = extract_single_audio_stream(stream, codecpar, i);
833            audio_streams.push(stream_info);
834        }
835
836        audio_streams
837    }
838}
839
840/// Extracts information from a single audio stream.
841///
842/// # Safety
843///
844/// Both `stream` and `codecpar` pointers must be valid.
845unsafe fn extract_single_audio_stream(
846    stream: *mut ff_sys::AVStream,
847    codecpar: *mut ff_sys::AVCodecParameters,
848    index: u32,
849) -> AudioStreamInfo {
850    // SAFETY: Caller guarantees pointers are valid
851    unsafe {
852        // Extract codec info
853        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        // Extract audio parameters
858        #[expect(clippy::cast_sign_loss, reason = "sample_rate is always positive")]
859        let sample_rate = (*codecpar).sample_rate as u32;
860
861        // FFmpeg 5.1+ uses ch_layout, older versions use channels
862        let channels = extract_channel_count(codecpar);
863
864        // Extract channel layout
865        let channel_layout = extract_channel_layout(codecpar, channels);
866
867        // Extract sample format
868        let sample_format = map_sample_format((*codecpar).format);
869
870        // Extract bitrate
871        let bitrate = extract_stream_bitrate(codecpar);
872
873        // Extract duration if available
874        let duration = extract_stream_duration(stream);
875
876        // Extract language from stream metadata
877        let language = extract_language(stream);
878
879        // Build the AudioStreamInfo
880        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
905/// Extracts the channel count from `AVCodecParameters`.
906///
907/// `FFmpeg` 5.1+ uses `ch_layout.nb_channels`, older versions used `channels` directly.
908///
909/// Returns the actual channel count from `FFmpeg`. If the channel count is 0 (which
910/// indicates uninitialized or unknown), returns 1 (mono) as a safe minimum.
911///
912/// # Safety
913///
914/// The `codecpar` pointer must be valid.
915unsafe fn extract_channel_count(codecpar: *mut ff_sys::AVCodecParameters) -> u32 {
916    // SAFETY: Caller guarantees codecpar is valid
917    // FFmpeg 5.1+ uses ch_layout structure
918    #[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 channel count is 0 (uninitialized/unknown), use 1 (mono) as safe minimum
922    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
933/// Extracts the channel layout from `AVCodecParameters`.
934///
935/// # Safety
936///
937/// The `codecpar` pointer must be valid.
938unsafe fn extract_channel_layout(
939    codecpar: *mut ff_sys::AVCodecParameters,
940    channels: u32,
941) -> ChannelLayout {
942    // SAFETY: Caller guarantees codecpar is valid
943    // FFmpeg 5.1+ uses ch_layout structure with channel masks
944    let ch_layout = unsafe { &(*codecpar).ch_layout };
945
946    // Check if we have a specific channel layout mask
947    // AV_CHANNEL_ORDER_NATIVE means we have a valid channel mask
948    if ch_layout.order == ff_sys::AVChannelOrder_AV_CHANNEL_ORDER_NATIVE {
949        // Map common FFmpeg channel masks to our ChannelLayout
950        // These are AVChannelLayout masks for standard configurations
951        // SAFETY: When order is AV_CHANNEL_ORDER_NATIVE, the mask field is valid
952        let mask = unsafe { ch_layout.u.mask };
953        match mask {
954            // AV_CH_LAYOUT_MONO = 0x4 (front center)
955            0x4 => ChannelLayout::Mono,
956            // AV_CH_LAYOUT_STEREO = 0x3 (front left + front right)
957            0x3 => ChannelLayout::Stereo,
958            // AV_CH_LAYOUT_2_1 = 0x103 (stereo + LFE)
959            0x103 => ChannelLayout::Stereo2_1,
960            // AV_CH_LAYOUT_SURROUND = 0x7 (FL + FR + FC)
961            0x7 => ChannelLayout::Surround3_0,
962            // AV_CH_LAYOUT_QUAD = 0x33 (FL + FR + BL + BR)
963            0x33 => ChannelLayout::Quad,
964            // AV_CH_LAYOUT_5POINT0 = 0x37 (FL + FR + FC + BL + BR)
965            0x37 => ChannelLayout::Surround5_0,
966            // AV_CH_LAYOUT_5POINT1 = 0x3F (FL + FR + FC + LFE + BL + BR)
967            0x3F => ChannelLayout::Surround5_1,
968            // AV_CH_LAYOUT_6POINT1 = 0x13F (FL + FR + FC + LFE + BC + SL + SR)
969            0x13F => ChannelLayout::Surround6_1,
970            // AV_CH_LAYOUT_7POINT1 = 0x63F (FL + FR + FC + LFE + BL + BR + SL + SR)
971            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
990/// Extracts the language tag from stream metadata.
991///
992/// # Safety
993///
994/// The `stream` pointer must be valid.
995unsafe fn extract_language(stream: *mut ff_sys::AVStream) -> Option<String> {
996    // SAFETY: Caller guarantees stream is valid
997    unsafe {
998        let metadata = (*stream).metadata;
999        if metadata.is_null() {
1000            return None;
1001        }
1002
1003        // Look for "language" tag in the stream metadata
1004        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
1020// ============================================================================
1021// Audio Type Mapping Functions
1022// ============================================================================
1023
1024/// Maps an `FFmpeg` `AVCodecID` to our [`AudioCodec`] enum.
1025fn 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        // PCM variants
1037        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
1058/// Maps an `FFmpeg` `AVSampleFormat` to our [`SampleFormat`] enum.
1059fn 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        // Packed (interleaved) formats
1065        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        // Planar formats
1071        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        // Unknown format
1077        _ => {
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
1087// ============================================================================
1088// Chapter Extraction
1089// ============================================================================
1090
1091/// Extracts all chapters from an `AVFormatContext`.
1092///
1093/// # Safety
1094///
1095/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
1096unsafe fn extract_chapters(ctx: *mut ff_sys::AVFormatContext) -> Vec<ChapterInfo> {
1097    // SAFETY: Caller guarantees ctx is valid
1098    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            // SAFETY: i < nb_chapters, so this is within bounds
1110            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
1122/// Extracts information from a single `AVChapter`.
1123///
1124/// # Safety
1125///
1126/// The `chapter` pointer must be valid.
1127unsafe fn extract_single_chapter(chapter: *mut ff_sys::AVChapter) -> ChapterInfo {
1128    // SAFETY: Caller guarantees chapter is valid
1129    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
1172/// Converts a PTS value to a [`Duration`] using the given time base.
1173///
1174/// Returns [`Duration::ZERO`] for non-positive PTS values.
1175fn pts_to_duration(pts: i64, time_base: Rational) -> std::time::Duration {
1176    if pts <= 0 {
1177        return std::time::Duration::ZERO;
1178    }
1179    // secs = pts * num / den
1180    // Note: precision loss from i64/i32 to f64 is acceptable for media timestamps
1181    #[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
1190/// Extracts the "title" metadata tag from a chapter's `AVDictionary`.
1191///
1192/// Returns `None` if the dict is null or the tag is absent.
1193///
1194/// # Safety
1195///
1196/// `dict` may be null (returns `None`) or a valid `AVDictionary` pointer.
1197unsafe fn extract_chapter_title(dict: *mut ff_sys::AVDictionary) -> Option<String> {
1198    // SAFETY: av_dict_get handles null dict by returning null
1199    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
1215/// Extracts all metadata tags except "title" from a chapter's `AVDictionary`.
1216///
1217/// Returns `None` if the dict is null or all tags are filtered out.
1218///
1219/// # Safety
1220///
1221/// `dict` may be null (returns `None`) or a valid `AVDictionary` pointer.
1222unsafe fn extract_chapter_metadata(
1223    dict: *mut ff_sys::AVDictionary,
1224) -> Option<HashMap<String, String>> {
1225    // SAFETY: av_dict_get handles null dict by returning null
1226    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        // Create a temporary file with invalid content
1279        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        // Clean up
1286        std::fs::remove_file(&temp_file).ok();
1287
1288        // FFmpeg should fail to open this as a valid media file
1289        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        // Verify our constant matches the expected value
1299        assert_eq!(AV_TIME_BASE, 1_000_000);
1300    }
1301
1302    // ========================================================================
1303    // pts_to_duration Tests
1304    // ========================================================================
1305
1306    #[test]
1307    fn pts_to_duration_should_convert_millisecond_timebase_correctly() {
1308        // 1/1000 timebase: 5000 pts = 5 seconds
1309        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        // 1/90000 timebase: 90000 pts = 1 second
1317        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        // Test duration calculation logic
1337        let duration_us: i64 = 5_500_000; // 5.5 seconds
1338        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    // ========================================================================
1347    // Video Codec Mapping Tests
1348    // ========================================================================
1349
1350    #[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        // Use a codec ID that's not explicitly mapped
1377        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_THEORA);
1378        assert_eq!(codec, VideoCodec::Unknown);
1379    }
1380
1381    // ========================================================================
1382    // Pixel Format Mapping Tests
1383    // ========================================================================
1384
1385    #[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        // Use a pixel format that's not explicitly mapped
1412        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_PAL8 as i32);
1413        assert!(matches!(format, PixelFormat::Other(_)));
1414    }
1415
1416    // ========================================================================
1417    // Color Space Mapping Tests
1418    // ========================================================================
1419
1420    #[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    // ========================================================================
1457    // Color Range Mapping Tests
1458    // ========================================================================
1459
1460    #[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    // ========================================================================
1479    // Color Primaries Mapping Tests
1480    // ========================================================================
1481
1482    #[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    // ========================================================================
1510    // Audio Codec Mapping Tests
1511    // ========================================================================
1512
1513    #[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        // Test various PCM formats
1570        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        // Use a codec ID that's not explicitly mapped
1583        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_WMAV2);
1584        assert_eq!(codec, AudioCodec::Unknown);
1585    }
1586
1587    // ========================================================================
1588    // Sample Format Mapping Tests
1589    // ========================================================================
1590
1591    #[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        // Use a format value that's not explicitly mapped
1642        let format = map_sample_format(999);
1643        assert!(matches!(format, SampleFormat::Other(_)));
1644    }
1645
1646    // ========================================================================
1647    // Bitrate Calculation Tests
1648    // ========================================================================
1649
1650    #[test]
1651    fn test_bitrate_fallback_calculation() {
1652        // Test the fallback bitrate calculation logic:
1653        // bitrate = file_size (bytes) * 8 (bits/byte) / duration (seconds)
1654        //
1655        // Example: 10 MB file, 10 second duration
1656        // Expected: 10_000_000 bytes * 8 / 10 seconds = 8_000_000 bps
1657        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        // Test with sub-second duration
1668        // 1 MB file, 0.5 second duration
1669        // Expected: 1_000_000 * 8 / 0.5 = 16_000_000 bps
1670        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        // When duration is zero, we cannot calculate bitrate
1681        let duration = Duration::ZERO;
1682        let duration_secs = duration.as_secs_f64();
1683
1684        // Should not divide when duration is zero
1685        assert!(duration_secs == 0.0);
1686    }
1687
1688    #[test]
1689    fn test_bitrate_zero_file_size() {
1690        // When file size is zero, bitrate should also be zero
1691        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            // file_size is 0, so we should not have calculated a bitrate
1700            assert_eq!(file_size, 0);
1701        }
1702    }
1703
1704    #[test]
1705    fn test_bitrate_typical_video_file() {
1706        // Test with typical video file parameters:
1707        // 100 MB file, 5 minute duration
1708        // Expected: 100_000_000 * 8 / 300 = 2_666_666 bps (~2.67 Mbps)
1709        let file_size: u64 = 100_000_000;
1710        let duration = Duration::from_secs(300); // 5 minutes
1711        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        // Test with high-quality video parameters:
1720        // 5 GB file, 2 hour duration
1721        // Expected: 5_000_000_000 * 8 / 7200 = 5_555_555 bps (~5.6 Mbps)
1722        let file_size: u64 = 5_000_000_000;
1723        let duration = Duration::from_secs(7200); // 2 hours
1724        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}