Skip to main content

ff_decode/video/
builder.rs

1//! Video decoder builder for constructing video decoders with custom configuration.
2//!
3//! This module provides the [`VideoDecoderBuilder`] type which enables fluent
4//! configuration of video decoders. Use [`VideoDecoder::open()`] to start building.
5//!
6//! # Examples
7//!
8//! ```ignore
9//! use ff_decode::{VideoDecoder, HardwareAccel};
10//! use ff_format::PixelFormat;
11//!
12//! let decoder = VideoDecoder::open("video.mp4")?
13//!     .output_format(PixelFormat::Rgba)
14//!     .hardware_accel(HardwareAccel::Auto)
15//!     .thread_count(4)
16//!     .build()?;
17//! ```
18
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::Duration;
22
23use ff_format::{ContainerInfo, NetworkOptions, PixelFormat, VideoFrame, VideoStreamInfo};
24
25use crate::HardwareAccel;
26use crate::error::DecodeError;
27use crate::video::decoder_inner::VideoDecoderInner;
28use ff_common::FramePool;
29
30/// Requested output scale for decoded frames.
31///
32/// Controls how `libswscale` resizes the frame in the same pass as pixel-format
33/// conversion. The last setter wins — calling `output_width()` after
34/// `output_size()` replaces the earlier setting.
35///
36/// Both width and height are rounded up to the nearest even number if needed,
37/// because most pixel formats (e.g. `yuv420p`) require even dimensions.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub(crate) enum OutputScale {
40    /// Scale to an exact width × height.
41    Exact {
42        /// Target width in pixels.
43        width: u32,
44        /// Target height in pixels.
45        height: u32,
46    },
47    /// Scale to the given width; compute height to preserve aspect ratio.
48    FitWidth(u32),
49    /// Scale to the given height; compute width to preserve aspect ratio.
50    FitHeight(u32),
51}
52
53/// Internal configuration for the decoder.
54///
55/// NOTE: Fields are currently unused but will be used when `FFmpeg` integration
56/// is implemented in a future issue.
57#[derive(Debug, Clone)]
58#[allow(dead_code)]
59pub(crate) struct VideoDecoderConfig {
60    /// Output pixel format (None = use source format)
61    pub output_format: Option<PixelFormat>,
62    /// Output scale (None = use source dimensions)
63    pub output_scale: Option<OutputScale>,
64    /// Hardware acceleration setting
65    pub hardware_accel: HardwareAccel,
66    /// Number of decoding threads (0 = auto)
67    pub thread_count: usize,
68}
69
70impl Default for VideoDecoderConfig {
71    fn default() -> Self {
72        Self {
73            output_format: None,
74            output_scale: None,
75            hardware_accel: HardwareAccel::Auto,
76            thread_count: 0, // Auto-detect
77        }
78    }
79}
80
81/// Builder for configuring and constructing a [`VideoDecoder`].
82///
83/// This struct provides a fluent interface for setting up decoder options
84/// before opening a video file. It is created by calling [`VideoDecoder::open()`].
85///
86/// # Examples
87///
88/// ## Basic Usage
89///
90/// ```ignore
91/// use ff_decode::VideoDecoder;
92///
93/// let decoder = VideoDecoder::open("video.mp4")?
94///     .build()?;
95/// ```
96///
97/// ## With Custom Format
98///
99/// ```ignore
100/// use ff_decode::VideoDecoder;
101/// use ff_format::PixelFormat;
102///
103/// let decoder = VideoDecoder::open("video.mp4")?
104///     .output_format(PixelFormat::Rgba)
105///     .build()?;
106/// ```
107///
108/// ## With Hardware Acceleration
109///
110/// ```ignore
111/// use ff_decode::{VideoDecoder, HardwareAccel};
112///
113/// let decoder = VideoDecoder::open("video.mp4")?
114///     .hardware_accel(HardwareAccel::Nvdec)
115///     .build()?;
116/// ```
117///
118/// ## With Frame Pool
119///
120/// ```ignore
121/// use ff_decode::{VideoDecoder, FramePool};
122/// use std::sync::Arc;
123///
124/// let pool: Arc<dyn FramePool> = create_frame_pool();
125/// let decoder = VideoDecoder::open("video.mp4")?
126///     .frame_pool(pool)
127///     .build()?;
128/// ```
129#[derive(Debug)]
130pub struct VideoDecoderBuilder {
131    /// Path to the media file
132    path: PathBuf,
133    /// Output pixel format (None = use source format)
134    output_format: Option<PixelFormat>,
135    /// Output scale (None = use source dimensions)
136    output_scale: Option<OutputScale>,
137    /// Hardware acceleration setting
138    hardware_accel: HardwareAccel,
139    /// Number of decoding threads (0 = auto)
140    thread_count: usize,
141    /// Optional frame pool for memory reuse
142    frame_pool: Option<Arc<dyn FramePool>>,
143    /// Frame rate override for image sequences (default 25 fps when path contains `%`).
144    frame_rate: Option<u32>,
145    /// Network options for URL-based sources (RTMP, RTSP, HTTP, etc.).
146    network_opts: Option<NetworkOptions>,
147}
148
149impl VideoDecoderBuilder {
150    /// Creates a new builder for the specified file path.
151    ///
152    /// This is an internal constructor; use [`VideoDecoder::open()`] instead.
153    pub(crate) fn new(path: PathBuf) -> Self {
154        Self {
155            path,
156            output_format: None,
157            output_scale: None,
158            hardware_accel: HardwareAccel::Auto,
159            thread_count: 0,
160            frame_pool: None,
161            frame_rate: None,
162            network_opts: None,
163        }
164    }
165
166    /// Sets the output pixel format for decoded frames.
167    ///
168    /// If not set, frames are returned in the source format. Setting an
169    /// output format enables automatic conversion during decoding.
170    ///
171    /// # Common Formats
172    ///
173    /// - [`PixelFormat::Rgba`] - Best for UI rendering, includes alpha
174    /// - [`PixelFormat::Rgb24`] - RGB without alpha, smaller memory footprint
175    /// - [`PixelFormat::Yuv420p`] - Source format for most H.264/H.265 videos
176    ///
177    /// # Examples
178    ///
179    /// ```ignore
180    /// use ff_decode::VideoDecoder;
181    /// use ff_format::PixelFormat;
182    ///
183    /// let decoder = VideoDecoder::open("video.mp4")?
184    ///     .output_format(PixelFormat::Rgba)
185    ///     .build()?;
186    /// ```
187    #[must_use]
188    pub fn output_format(mut self, format: PixelFormat) -> Self {
189        self.output_format = Some(format);
190        self
191    }
192
193    /// Scales decoded frames to the given exact dimensions.
194    ///
195    /// The frame is scaled in the same `libswscale` pass as pixel-format
196    /// conversion, so there is no extra copy. If `output_format` is not set,
197    /// the source pixel format is preserved while scaling.
198    ///
199    /// Width and height must be greater than zero. They are rounded up to the
200    /// nearest even number if necessary (required by most pixel formats).
201    ///
202    /// Calling this method overwrites any previous `output_width` or
203    /// `output_height` call. The last setter wins.
204    ///
205    /// # Errors
206    ///
207    /// [`build()`](Self::build) returns [`DecodeError::InvalidOutputDimensions`]
208    /// if either dimension is zero after rounding.
209    ///
210    /// # Examples
211    ///
212    /// ```ignore
213    /// use ff_decode::VideoDecoder;
214    ///
215    /// // Decode every frame at 320×240
216    /// let decoder = VideoDecoder::open("video.mp4")?
217    ///     .output_size(320, 240)
218    ///     .build()?;
219    /// ```
220    #[must_use]
221    pub fn output_size(mut self, width: u32, height: u32) -> Self {
222        self.output_scale = Some(OutputScale::Exact { width, height });
223        self
224    }
225
226    /// Scales decoded frames to the given width, preserving the aspect ratio.
227    ///
228    /// The height is computed from the source aspect ratio and rounded to the
229    /// nearest even number. Calling this method overwrites any previous
230    /// `output_size` or `output_height` call. The last setter wins.
231    ///
232    /// # Errors
233    ///
234    /// [`build()`](Self::build) returns [`DecodeError::InvalidOutputDimensions`]
235    /// if `width` is zero.
236    ///
237    /// # Examples
238    ///
239    /// ```ignore
240    /// use ff_decode::VideoDecoder;
241    ///
242    /// // Decode at 1280 px wide, preserving aspect ratio
243    /// let decoder = VideoDecoder::open("video.mp4")?
244    ///     .output_width(1280)
245    ///     .build()?;
246    /// ```
247    #[must_use]
248    pub fn output_width(mut self, width: u32) -> Self {
249        self.output_scale = Some(OutputScale::FitWidth(width));
250        self
251    }
252
253    /// Scales decoded frames to the given height, preserving the aspect ratio.
254    ///
255    /// The width is computed from the source aspect ratio and rounded to the
256    /// nearest even number. Calling this method overwrites any previous
257    /// `output_size` or `output_width` call. The last setter wins.
258    ///
259    /// # Errors
260    ///
261    /// [`build()`](Self::build) returns [`DecodeError::InvalidOutputDimensions`]
262    /// if `height` is zero.
263    ///
264    /// # Examples
265    ///
266    /// ```ignore
267    /// use ff_decode::VideoDecoder;
268    ///
269    /// // Decode at 720 px tall, preserving aspect ratio
270    /// let decoder = VideoDecoder::open("video.mp4")?
271    ///     .output_height(720)
272    ///     .build()?;
273    /// ```
274    #[must_use]
275    pub fn output_height(mut self, height: u32) -> Self {
276        self.output_scale = Some(OutputScale::FitHeight(height));
277        self
278    }
279
280    /// Sets the hardware acceleration mode.
281    ///
282    /// Hardware acceleration can significantly improve decoding performance,
283    /// especially for high-resolution video (4K and above).
284    ///
285    /// # Available Modes
286    ///
287    /// - [`HardwareAccel::Auto`] - Automatically detect and use available hardware (default)
288    /// - [`HardwareAccel::None`] - Disable hardware acceleration (CPU only)
289    /// - [`HardwareAccel::Nvdec`] - NVIDIA NVDEC (requires NVIDIA GPU)
290    /// - [`HardwareAccel::Qsv`] - Intel Quick Sync Video
291    /// - [`HardwareAccel::Amf`] - AMD Advanced Media Framework
292    /// - [`HardwareAccel::VideoToolbox`] - Apple `VideoToolbox` (macOS/iOS)
293    /// - [`HardwareAccel::Vaapi`] - VA-API (Linux)
294    ///
295    /// # Fallback Behavior
296    ///
297    /// If the requested hardware accelerator is unavailable, the decoder
298    /// will fall back to software decoding unless
299    /// [`DecodeError::HwAccelUnavailable`] is explicitly requested.
300    ///
301    /// # Examples
302    ///
303    /// ```ignore
304    /// use ff_decode::{VideoDecoder, HardwareAccel};
305    ///
306    /// // Use NVIDIA NVDEC if available
307    /// let decoder = VideoDecoder::open("video.mp4")?
308    ///     .hardware_accel(HardwareAccel::Nvdec)
309    ///     .build()?;
310    ///
311    /// // Force CPU decoding
312    /// let cpu_decoder = Decoder::open("video.mp4")?
313    ///     .hardware_accel(HardwareAccel::None)
314    ///     .build()?;
315    /// ```
316    #[must_use]
317    pub fn hardware_accel(mut self, accel: HardwareAccel) -> Self {
318        self.hardware_accel = accel;
319        self
320    }
321
322    /// Sets the number of decoding threads.
323    ///
324    /// More threads can improve decoding throughput, especially for
325    /// high-resolution videos or codecs that support parallel decoding.
326    ///
327    /// # Thread Count Values
328    ///
329    /// - `0` - Auto-detect based on CPU cores (default)
330    /// - `1` - Single-threaded decoding
331    /// - `N` - Use N threads for decoding
332    ///
333    /// # Performance Notes
334    ///
335    /// - H.264/H.265: Benefit significantly from multi-threading
336    /// - VP9: Good parallel decoding support
337    /// - `ProRes`: Limited threading benefit
338    ///
339    /// Setting too many threads may increase memory usage without
340    /// proportional performance gains.
341    ///
342    /// # Examples
343    ///
344    /// ```ignore
345    /// use ff_decode::VideoDecoder;
346    ///
347    /// // Use 4 threads for decoding
348    /// let decoder = VideoDecoder::open("video.mp4")?
349    ///     .thread_count(4)
350    ///     .build()?;
351    ///
352    /// // Single-threaded for minimal memory
353    /// let decoder = VideoDecoder::open("video.mp4")?
354    ///     .thread_count(1)
355    ///     .build()?;
356    /// ```
357    #[must_use]
358    pub fn thread_count(mut self, count: usize) -> Self {
359        self.thread_count = count;
360        self
361    }
362
363    /// Sets the frame rate for image sequence decoding.
364    ///
365    /// Only used when the path contains `%` (e.g. `"frames/frame%04d.png"`).
366    /// Defaults to 25 fps when not set.
367    ///
368    /// # Examples
369    ///
370    /// ```ignore
371    /// use ff_decode::VideoDecoder;
372    ///
373    /// let decoder = VideoDecoder::open("frames/frame%04d.png")?
374    ///     .frame_rate(30)
375    ///     .build()?;
376    /// ```
377    #[must_use]
378    pub fn frame_rate(mut self, fps: u32) -> Self {
379        self.frame_rate = Some(fps);
380        self
381    }
382
383    /// Sets network options for URL-based sources.
384    ///
385    /// When set, the builder skips the file-existence check and passes connect
386    /// and read timeouts to `avformat_open_input` via an `AVDictionary`.
387    /// Call this before `.build()` when opening `rtmp://`, `rtsp://`, `http://`,
388    /// `https://`, `udp://`, `srt://`, or `rtp://` URLs.
389    ///
390    /// # HLS / M3U8 Playlists
391    ///
392    /// HLS playlists (`.m3u8`) are detected automatically by `FFmpeg` — no extra
393    /// configuration is required beyond calling `.network()`. Pass the full
394    /// HTTP(S) URL of the master or media playlist:
395    ///
396    /// ```ignore
397    /// use ff_decode::VideoDecoder;
398    /// use ff_format::NetworkOptions;
399    ///
400    /// let decoder = VideoDecoder::open("https://example.com/live/index.m3u8")
401    ///     .network(NetworkOptions::default())
402    ///     .build()?;
403    /// ```
404    ///
405    /// # DASH / MPD Streams
406    ///
407    /// MPEG-DASH manifests (`.mpd`) are detected automatically by `FFmpeg`'s
408    /// built-in `dash` demuxer. The demuxer downloads the manifest, selects the
409    /// highest-quality representation, and fetches segments automatically:
410    ///
411    /// ```ignore
412    /// use ff_decode::VideoDecoder;
413    /// use ff_format::NetworkOptions;
414    ///
415    /// let decoder = VideoDecoder::open("https://example.com/dash/manifest.mpd")
416    ///     .network(NetworkOptions::default())
417    ///     .build()?;
418    /// ```
419    ///
420    /// **Multi-period caveat**: multi-period DASH streams are supported by
421    /// `FFmpeg` but period boundaries may trigger an internal decoder reset,
422    /// which can cause a brief gap in decoded frames.
423    ///
424    /// **Adaptive bitrate**: representation selection (ABR switching) is handled
425    /// internally by `FFmpeg` and is not exposed through this API.
426    ///
427    /// # UDP / MPEG-TS
428    ///
429    /// `udp://` URLs are always live — `is_live()` returns `true` and seeking
430    /// is not supported. Two extra `AVDictionary` options are set automatically
431    /// to reduce packet loss on high-bitrate streams:
432    ///
433    /// | Option | Value | Reason |
434    /// |---|---|---|
435    /// | `buffer_size` | `65536` | Enlarges the UDP receive buffer |
436    /// | `overrun_nonfatal` | `1` | Discards excess data instead of erroring |
437    ///
438    /// ```ignore
439    /// use ff_decode::VideoDecoder;
440    /// use ff_format::NetworkOptions;
441    ///
442    /// let decoder = VideoDecoder::open("udp://224.0.0.1:1234")
443    ///     .network(NetworkOptions::default())
444    ///     .build()?;
445    /// ```
446    ///
447    /// # SRT (Secure Reliable Transport)
448    ///
449    /// SRT URLs (`srt://host:port`) require the `srt` feature flag **and** a
450    /// libsrt-enabled `FFmpeg` build.  Enable the feature in `Cargo.toml`:
451    ///
452    /// ```toml
453    /// [dependencies]
454    /// ff-decode = { version = "*", features = ["srt"] }
455    /// ```
456    ///
457    /// Without the `srt` feature, opening an `srt://` URL returns
458    /// [`DecodeError::ConnectionFailed`]. If the feature is enabled but the
459    /// linked `FFmpeg` was not built with `--enable-libsrt`, the same error is
460    /// returned with a message directing you to rebuild `FFmpeg`.
461    ///
462    /// ```ignore
463    /// use ff_decode::VideoDecoder;
464    /// use ff_format::NetworkOptions;
465    ///
466    /// let decoder = VideoDecoder::open("srt://ingest.example.com:4200")
467    ///     .network(NetworkOptions::default())
468    ///     .build()?;
469    /// ```
470    ///
471    /// # Credentials
472    ///
473    /// HTTP basic-auth credentials must be embedded directly in the URL:
474    /// `https://user:password@cdn.example.com/live/index.m3u8`.
475    /// The password is redacted in log output.
476    ///
477    /// # DRM Limitation
478    ///
479    /// DRM-protected streams are **not** supported:
480    /// - HLS: `FairPlay`, Widevine, AES-128 with external key servers
481    /// - DASH: CENC, `PlayReady`, Widevine
482    ///
483    /// `FFmpeg` can parse the manifest and fetch segments, but key delivery
484    /// to a DRM license server is outside the scope of this API.
485    ///
486    /// # Examples
487    ///
488    /// ```ignore
489    /// use ff_decode::VideoDecoder;
490    /// use ff_format::NetworkOptions;
491    ///
492    /// let decoder = VideoDecoder::open("rtmp://live.example.com/app/stream_key")
493    ///     .network(NetworkOptions::default())
494    ///     .build()?;
495    /// ```
496    #[must_use]
497    pub fn network(mut self, opts: NetworkOptions) -> Self {
498        self.network_opts = Some(opts);
499        self
500    }
501
502    /// Sets a frame pool for memory reuse.
503    ///
504    /// Using a frame pool can significantly reduce allocation overhead
505    /// during continuous video playback by reusing frame buffers.
506    ///
507    /// # Memory Management
508    ///
509    /// When a frame pool is set:
510    /// - Decoded frames attempt to acquire buffers from the pool
511    /// - When frames are dropped, their buffers are returned to the pool
512    /// - If the pool is exhausted, new buffers are allocated normally
513    ///
514    /// # Examples
515    ///
516    /// ```ignore
517    /// use ff_decode::{VideoDecoder, FramePool, PooledBuffer};
518    /// use std::sync::{Arc, Mutex};
519    ///
520    /// // Create a simple frame pool
521    /// struct SimplePool {
522    ///     buffers: Mutex<Vec<Vec<u8>>>,
523    /// }
524    ///
525    /// impl FramePool for SimplePool {
526    ///     fn acquire(&self, size: usize) -> Option<PooledBuffer> {
527    ///         // Implementation...
528    ///         None
529    ///     }
530    /// }
531    ///
532    /// let pool = Arc::new(SimplePool {
533    ///     buffers: Mutex::new(vec![]),
534    /// });
535    ///
536    /// let decoder = VideoDecoder::open("video.mp4")?
537    ///     .frame_pool(pool)
538    ///     .build()?;
539    /// ```
540    #[must_use]
541    pub fn frame_pool(mut self, pool: Arc<dyn FramePool>) -> Self {
542        self.frame_pool = Some(pool);
543        self
544    }
545
546    /// Returns the configured file path.
547    #[must_use]
548    pub fn path(&self) -> &Path {
549        &self.path
550    }
551
552    /// Returns the configured output format, if any.
553    #[must_use]
554    pub fn get_output_format(&self) -> Option<PixelFormat> {
555        self.output_format
556    }
557
558    /// Returns the configured hardware acceleration mode.
559    #[must_use]
560    pub fn get_hardware_accel(&self) -> HardwareAccel {
561        self.hardware_accel
562    }
563
564    /// Returns the configured thread count.
565    #[must_use]
566    pub fn get_thread_count(&self) -> usize {
567        self.thread_count
568    }
569
570    /// Builds the decoder with the configured options.
571    ///
572    /// This method opens the media file, initializes the decoder context,
573    /// and prepares for frame decoding.
574    ///
575    /// # Errors
576    ///
577    /// Returns an error if:
578    /// - The file cannot be found ([`DecodeError::FileNotFound`])
579    /// - The file contains no video stream ([`DecodeError::NoVideoStream`])
580    /// - The codec is not supported ([`DecodeError::UnsupportedCodec`])
581    /// - Hardware acceleration is unavailable ([`DecodeError::HwAccelUnavailable`])
582    /// - Other `FFmpeg` errors occur ([`DecodeError::Ffmpeg`])
583    ///
584    /// # Examples
585    ///
586    /// ```ignore
587    /// use ff_decode::VideoDecoder;
588    ///
589    /// let decoder = VideoDecoder::open("video.mp4")?
590    ///     .build()?;
591    ///
592    /// // Start decoding
593    /// for result in &mut decoder {
594    ///     let frame = result?;
595    ///     // Process frame...
596    /// }
597    /// ```
598    pub fn build(self) -> Result<VideoDecoder, DecodeError> {
599        // Validate output scale dimensions before opening the file.
600        // FitWidth / FitHeight aspect-ratio dimensions are resolved at decode time
601        // from the actual source dimensions, so we only reject an explicit zero here.
602        if let Some(scale) = self.output_scale {
603            let (w, h) = match scale {
604                OutputScale::Exact { width, height } => (width, height),
605                OutputScale::FitWidth(w) => (w, 1), // height will be derived
606                OutputScale::FitHeight(h) => (1, h), // width will be derived
607            };
608            if w == 0 || h == 0 {
609                return Err(DecodeError::InvalidOutputDimensions {
610                    width: w,
611                    height: h,
612                });
613            }
614        }
615
616        // Image-sequence patterns contain '%' — the literal path does not exist.
617        // Network URLs must also skip the file-existence check.
618        let path_str = self.path.to_str().unwrap_or("");
619        let is_image_sequence = path_str.contains('%');
620        let is_network_url = crate::network::is_url(path_str);
621        if !is_image_sequence && !is_network_url && !self.path.exists() {
622            return Err(DecodeError::FileNotFound {
623                path: self.path.clone(),
624            });
625        }
626
627        // Build the internal configuration
628        let config = VideoDecoderConfig {
629            output_format: self.output_format,
630            output_scale: self.output_scale,
631            hardware_accel: self.hardware_accel,
632            thread_count: self.thread_count,
633        };
634
635        // Create the decoder inner
636        let (inner, stream_info, container_info) = VideoDecoderInner::new(
637            &self.path,
638            self.output_format,
639            self.output_scale,
640            self.hardware_accel,
641            self.thread_count,
642            self.frame_rate,
643            self.frame_pool.clone(),
644            self.network_opts,
645        )?;
646
647        Ok(VideoDecoder {
648            path: self.path,
649            config,
650            frame_pool: self.frame_pool,
651            inner,
652            stream_info,
653            container_info,
654            fused: false,
655        })
656    }
657}
658
659/// A video decoder for extracting frames from media files.
660///
661/// The decoder provides frame-by-frame access to video content with support
662/// for seeking, hardware acceleration, and format conversion.
663///
664/// # Construction
665///
666/// Use [`VideoDecoder::open()`] to create a builder, then call [`VideoDecoderBuilder::build()`]:
667///
668/// ```ignore
669/// use ff_decode::VideoDecoder;
670/// use ff_format::PixelFormat;
671///
672/// let decoder = VideoDecoder::open("video.mp4")?
673///     .output_format(PixelFormat::Rgba)
674///     .build()?;
675/// ```
676///
677/// # Frame Decoding
678///
679/// Frames can be decoded one at a time or using the built-in iterator:
680///
681/// ```ignore
682/// // Decode one frame
683/// if let Some(frame) = decoder.decode_one()? {
684///     println!("Frame at {:?}", frame.timestamp().as_duration());
685/// }
686///
687/// // Iterator form — VideoDecoder implements Iterator directly
688/// for result in &mut decoder {
689///     let frame = result?;
690///     // Process frame...
691/// }
692/// ```
693///
694/// # Seeking
695///
696/// The decoder supports efficient seeking:
697///
698/// ```ignore
699/// use ff_decode::SeekMode;
700/// use std::time::Duration;
701///
702/// // Seek to 30 seconds (keyframe)
703/// decoder.seek(Duration::from_secs(30), SeekMode::Keyframe)?;
704///
705/// // Seek to exact frame
706/// decoder.seek(Duration::from_secs(30), SeekMode::Exact)?;
707/// ```
708pub struct VideoDecoder {
709    /// Path to the media file
710    path: PathBuf,
711    /// Decoder configuration
712    ///
713    /// NOTE: Currently unused but will be used when `FFmpeg` integration
714    /// is implemented in a future issue.
715    #[allow(dead_code)]
716    config: VideoDecoderConfig,
717    /// Optional frame pool for memory reuse
718    frame_pool: Option<Arc<dyn FramePool>>,
719    /// Internal decoder state
720    inner: VideoDecoderInner,
721    /// Video stream information
722    stream_info: VideoStreamInfo,
723    /// Container-level metadata
724    container_info: ContainerInfo,
725    /// Set to `true` after a decoding error; causes [`Iterator::next`] to return `None`.
726    fused: bool,
727}
728
729impl VideoDecoder {
730    /// Opens a media file and returns a builder for configuring the decoder.
731    ///
732    /// This is the entry point for creating a decoder. The returned builder
733    /// allows setting options before the decoder is fully initialized.
734    ///
735    /// # Arguments
736    ///
737    /// * `path` - Path to the media file to decode.
738    ///
739    /// # Examples
740    ///
741    /// ```ignore
742    /// use ff_decode::VideoDecoder;
743    ///
744    /// // Simple usage
745    /// let decoder = VideoDecoder::open("video.mp4")?
746    ///     .build()?;
747    ///
748    /// // With options
749    /// let decoder = VideoDecoder::open("video.mp4")?
750    ///     .output_format(PixelFormat::Rgba)
751    ///     .hardware_accel(HardwareAccel::Auto)
752    ///     .build()?;
753    /// ```
754    ///
755    /// # Note
756    ///
757    /// This method does not validate that the file exists or is a valid
758    /// media file. Validation occurs when [`VideoDecoderBuilder::build()`] is called.
759    pub fn open(path: impl AsRef<Path>) -> VideoDecoderBuilder {
760        VideoDecoderBuilder::new(path.as_ref().to_path_buf())
761    }
762
763    // =========================================================================
764    // Information Methods
765    // =========================================================================
766
767    /// Returns the video stream information.
768    ///
769    /// This contains metadata about the video stream including resolution,
770    /// frame rate, codec, and color characteristics.
771    #[must_use]
772    pub fn stream_info(&self) -> &VideoStreamInfo {
773        &self.stream_info
774    }
775
776    /// Returns the video width in pixels.
777    #[must_use]
778    pub fn width(&self) -> u32 {
779        self.stream_info.width()
780    }
781
782    /// Returns the video height in pixels.
783    #[must_use]
784    pub fn height(&self) -> u32 {
785        self.stream_info.height()
786    }
787
788    /// Returns the frame rate in frames per second.
789    #[must_use]
790    pub fn frame_rate(&self) -> f64 {
791        self.stream_info.fps()
792    }
793
794    /// Returns the total duration of the video.
795    ///
796    /// Returns [`Duration::ZERO`] if duration is unknown.
797    #[must_use]
798    pub fn duration(&self) -> Duration {
799        self.stream_info.duration().unwrap_or(Duration::ZERO)
800    }
801
802    /// Returns the total duration of the video, or `None` for live streams
803    /// or formats that do not carry duration information.
804    #[must_use]
805    pub fn duration_opt(&self) -> Option<Duration> {
806        self.stream_info.duration()
807    }
808
809    /// Returns container-level metadata (format name, bitrate, stream count).
810    #[must_use]
811    pub fn container_info(&self) -> &ContainerInfo {
812        &self.container_info
813    }
814
815    /// Returns the current playback position.
816    #[must_use]
817    pub fn position(&self) -> Duration {
818        self.inner.position()
819    }
820
821    /// Returns `true` if the end of stream has been reached.
822    #[must_use]
823    pub fn is_eof(&self) -> bool {
824        self.inner.is_eof()
825    }
826
827    /// Returns the file path being decoded.
828    #[must_use]
829    pub fn path(&self) -> &Path {
830        &self.path
831    }
832
833    /// Returns a reference to the frame pool, if configured.
834    #[must_use]
835    pub fn frame_pool(&self) -> Option<&Arc<dyn FramePool>> {
836        self.frame_pool.as_ref()
837    }
838
839    /// Returns the currently active hardware acceleration mode.
840    ///
841    /// This method returns the actual hardware acceleration being used,
842    /// which may differ from what was requested:
843    ///
844    /// - If [`HardwareAccel::Auto`] was requested, this returns the specific
845    ///   accelerator that was successfully initialized (e.g., [`HardwareAccel::Nvdec`]),
846    ///   or [`HardwareAccel::None`] if no hardware acceleration is available.
847    /// - If a specific accelerator was requested and initialization failed,
848    ///   the decoder creation would have returned an error.
849    /// - If [`HardwareAccel::None`] was requested, this always returns [`HardwareAccel::None`].
850    ///
851    /// # Examples
852    ///
853    /// ```ignore
854    /// use ff_decode::{VideoDecoder, HardwareAccel};
855    ///
856    /// // Request automatic hardware acceleration
857    /// let decoder = VideoDecoder::open("video.mp4")?
858    ///     .hardware_accel(HardwareAccel::Auto)
859    ///     .build()?;
860    ///
861    /// // Check which accelerator was selected
862    /// match decoder.hardware_accel() {
863    ///     HardwareAccel::None => println!("Using software decoding"),
864    ///     HardwareAccel::Nvdec => println!("Using NVIDIA NVDEC"),
865    ///     HardwareAccel::Qsv => println!("Using Intel Quick Sync"),
866    ///     HardwareAccel::VideoToolbox => println!("Using Apple VideoToolbox"),
867    ///     HardwareAccel::Vaapi => println!("Using VA-API"),
868    ///     HardwareAccel::Amf => println!("Using AMD AMF"),
869    ///     _ => unreachable!(),
870    /// }
871    /// ```
872    #[must_use]
873    pub fn hardware_accel(&self) -> HardwareAccel {
874        self.inner.hardware_accel()
875    }
876
877    // =========================================================================
878    // Decoding Methods
879    // =========================================================================
880
881    /// Decodes the next video frame.
882    ///
883    /// This method reads and decodes a single frame from the video stream.
884    /// Frames are returned in presentation order.
885    ///
886    /// # Returns
887    ///
888    /// - `Ok(Some(frame))` - A frame was successfully decoded
889    /// - `Ok(None)` - End of stream reached, no more frames
890    /// - `Err(_)` - An error occurred during decoding
891    ///
892    /// # Errors
893    ///
894    /// Returns [`DecodeError`] if:
895    /// - Reading from the file fails
896    /// - Decoding the frame fails
897    /// - Pixel format conversion fails
898    ///
899    /// # Examples
900    ///
901    /// ```ignore
902    /// use ff_decode::VideoDecoder;
903    ///
904    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
905    ///
906    /// while let Some(frame) = decoder.decode_one()? {
907    ///     println!("Frame at {:?}", frame.timestamp().as_duration());
908    ///     // Process frame...
909    /// }
910    /// ```
911    pub fn decode_one(&mut self) -> Result<Option<VideoFrame>, DecodeError> {
912        self.inner.decode_one()
913    }
914
915    /// Decodes all frames within a specified time range.
916    ///
917    /// This method seeks to the start position and decodes all frames until
918    /// the end position is reached. Frames outside the range are skipped.
919    ///
920    /// # Performance
921    ///
922    /// - The method performs a keyframe seek to the start position
923    /// - Frames before `start` (from nearest keyframe) are decoded but discarded
924    /// - All frames within `[start, end)` are collected and returned
925    /// - The decoder position after this call will be at or past `end`
926    ///
927    /// For large time ranges or high frame rates, this may allocate significant
928    /// memory. Consider iterating manually with [`decode_one()`](Self::decode_one)
929    /// for very large ranges.
930    ///
931    /// # Arguments
932    ///
933    /// * `start` - Start of the time range (inclusive).
934    /// * `end` - End of the time range (exclusive).
935    ///
936    /// # Returns
937    ///
938    /// A vector of frames with timestamps in the range `[start, end)`.
939    /// Frames are returned in presentation order.
940    ///
941    /// # Errors
942    ///
943    /// Returns [`DecodeError`] if:
944    /// - Seeking to the start position fails
945    /// - Decoding frames fails
946    /// - The time range is invalid (start >= end)
947    ///
948    /// # Examples
949    ///
950    /// ```ignore
951    /// use ff_decode::VideoDecoder;
952    /// use std::time::Duration;
953    ///
954    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
955    ///
956    /// // Decode frames from 5s to 10s
957    /// let frames = decoder.decode_range(
958    ///     Duration::from_secs(5),
959    ///     Duration::from_secs(10),
960    /// )?;
961    ///
962    /// println!("Decoded {} frames", frames.len());
963    /// for frame in frames {
964    ///     println!("Frame at {:?}", frame.timestamp().as_duration());
965    /// }
966    /// ```
967    ///
968    /// # Memory Usage
969    ///
970    /// At 30fps, a 5-second range will allocate ~150 frames. For 1080p RGBA:
971    /// - Each frame: ~8.3 MB (1920 × 1080 × 4 bytes)
972    /// - 150 frames: ~1.25 GB
973    ///
974    /// Consider using a frame pool to reduce allocation overhead.
975    pub fn decode_range(
976        &mut self,
977        start: Duration,
978        end: Duration,
979    ) -> Result<Vec<VideoFrame>, DecodeError> {
980        // Validate range
981        if start >= end {
982            return Err(DecodeError::DecodingFailed {
983                timestamp: Some(start),
984                reason: format!(
985                    "Invalid time range: start ({start:?}) must be before end ({end:?})"
986                ),
987            });
988        }
989
990        // Seek to start position (keyframe mode for efficiency)
991        self.seek(start, crate::SeekMode::Keyframe)?;
992
993        // Collect frames in the range
994        let mut frames = Vec::new();
995
996        while let Some(frame) = self.decode_one()? {
997            let frame_time = frame.timestamp().as_duration();
998
999            // Stop if we've passed the end of the range
1000            if frame_time >= end {
1001                break;
1002            }
1003
1004            // Only collect frames within the range
1005            if frame_time >= start {
1006                frames.push(frame);
1007            }
1008            // Frames before start are automatically discarded
1009        }
1010
1011        Ok(frames)
1012    }
1013
1014    // =========================================================================
1015    // Seeking Methods
1016    // =========================================================================
1017
1018    /// Seeks to a specified position in the video stream.
1019    ///
1020    /// This method performs efficient seeking without reopening the file,
1021    /// providing significantly better performance than file-reopen-based seeking
1022    /// (5-10ms vs 50-100ms).
1023    ///
1024    /// # Performance
1025    ///
1026    /// - **Keyframe seeking**: 5-10ms (typical GOP 1-2s)
1027    /// - **Exact seeking**: 10-50ms depending on GOP size
1028    /// - **Backward seeking**: Similar to keyframe seeking
1029    ///
1030    /// For videos with large GOP sizes (>5 seconds), exact seeking may take longer
1031    /// as it requires decoding all frames from the nearest keyframe to the target.
1032    ///
1033    /// # Choosing a Seek Mode
1034    ///
1035    /// - **Use [`crate::SeekMode::Keyframe`]** for:
1036    ///   - Video player scrubbing (approximate positioning)
1037    ///   - Thumbnail generation
1038    ///   - Quick preview navigation
1039    ///
1040    /// - **Use [`crate::SeekMode::Exact`]** for:
1041    ///   - Frame-accurate editing
1042    ///   - Precise timestamp extraction
1043    ///   - Quality-critical operations
1044    ///
1045    /// - **Use [`crate::SeekMode::Backward`]** for:
1046    ///   - Guaranteed keyframe positioning
1047    ///   - Preparing for forward playback
1048    ///
1049    /// # Arguments
1050    ///
1051    /// * `position` - Target position to seek to.
1052    /// * `mode` - Seek mode determining accuracy and performance.
1053    ///
1054    /// # Errors
1055    ///
1056    /// Returns [`DecodeError::SeekFailed`] if:
1057    /// - The target position is beyond the video duration
1058    /// - The file format doesn't support seeking
1059    /// - The seek operation fails internally
1060    ///
1061    /// # Examples
1062    ///
1063    /// ```ignore
1064    /// use ff_decode::{VideoDecoder, SeekMode};
1065    /// use std::time::Duration;
1066    ///
1067    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
1068    ///
1069    /// // Fast seek to 30 seconds (keyframe)
1070    /// decoder.seek(Duration::from_secs(30), SeekMode::Keyframe)?;
1071    ///
1072    /// // Exact seek to 1 minute
1073    /// decoder.seek(Duration::from_secs(60), SeekMode::Exact)?;
1074    ///
1075    /// // Seek and decode next frame
1076    /// decoder.seek(Duration::from_secs(10), SeekMode::Keyframe)?;
1077    /// if let Some(frame) = decoder.decode_one()? {
1078    ///     println!("Frame at {:?}", frame.timestamp().as_duration());
1079    /// }
1080    /// ```
1081    pub fn seek(&mut self, position: Duration, mode: crate::SeekMode) -> Result<(), DecodeError> {
1082        if self.inner.is_live() {
1083            return Err(DecodeError::SeekNotSupported);
1084        }
1085        self.inner.seek(position, mode)
1086    }
1087
1088    /// Returns `true` if the source is a live or streaming input.
1089    ///
1090    /// Live sources (HLS live playlists, RTMP, RTSP, MPEG-TS) have the
1091    /// `AVFMT_TS_DISCONT` flag set on their `AVInputFormat`. Seeking is not
1092    /// supported on live sources — [`VideoDecoder::seek`] will return
1093    /// [`DecodeError::SeekNotSupported`].
1094    #[must_use]
1095    pub fn is_live(&self) -> bool {
1096        self.inner.is_live()
1097    }
1098
1099    /// Flushes the decoder's internal buffers.
1100    ///
1101    /// This method clears any cached frames and resets the decoder state.
1102    /// The decoder is ready to receive new packets after flushing.
1103    ///
1104    /// # When to Use
1105    ///
1106    /// - After seeking to ensure clean state
1107    /// - Before switching between different parts of the video
1108    /// - To clear buffered frames after errors
1109    ///
1110    /// # Examples
1111    ///
1112    /// ```ignore
1113    /// use ff_decode::VideoDecoder;
1114    ///
1115    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
1116    ///
1117    /// // Decode some frames...
1118    /// for _ in 0..10 {
1119    ///     decoder.decode_one()?;
1120    /// }
1121    ///
1122    /// // Flush and start fresh
1123    /// decoder.flush();
1124    /// ```
1125    ///
1126    /// # Note
1127    ///
1128    /// Calling [`seek()`](Self::seek) automatically flushes the decoder,
1129    /// so you don't need to call this method explicitly after seeking.
1130    pub fn flush(&mut self) {
1131        self.inner.flush();
1132    }
1133
1134    // =========================================================================
1135    // Thumbnail Generation Methods
1136    // =========================================================================
1137
1138    /// Generates a thumbnail at a specific timestamp.
1139    ///
1140    /// This method seeks to the specified position, decodes a frame, and scales
1141    /// it to the target dimensions. It's optimized for thumbnail generation by
1142    /// using keyframe seeking for speed.
1143    ///
1144    /// # Performance
1145    ///
1146    /// - Seeking: 5-10ms (keyframe mode)
1147    /// - Decoding: 5-10ms for 1080p H.264
1148    /// - Scaling: 1-3ms for 1080p → 320x180
1149    /// - **Total: ~10-25ms per thumbnail**
1150    ///
1151    /// # Aspect Ratio
1152    ///
1153    /// The thumbnail preserves the video's aspect ratio using a "fit-within"
1154    /// strategy. The output dimensions will be at most the target size, with
1155    /// at least one dimension matching the target. No letterboxing is applied.
1156    ///
1157    /// # Arguments
1158    ///
1159    /// * `position` - Timestamp to extract the thumbnail from.
1160    /// * `width` - Target thumbnail width in pixels.
1161    /// * `height` - Target thumbnail height in pixels.
1162    ///
1163    /// # Returns
1164    ///
1165    /// A scaled `VideoFrame` representing the thumbnail.
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns [`DecodeError`] if:
1170    /// - Seeking to the position fails
1171    /// - No frame can be decoded at that position (returns `Ok(None)`)
1172    /// - Scaling fails
1173    ///
1174    /// # Examples
1175    ///
1176    /// ```ignore
1177    /// use ff_decode::VideoDecoder;
1178    /// use std::time::Duration;
1179    ///
1180    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
1181    ///
1182    /// // Generate a 320x180 thumbnail at 5 seconds
1183    /// let thumbnail = decoder.thumbnail_at(
1184    ///     Duration::from_secs(5),
1185    ///     320,
1186    ///     180,
1187    /// )?;
1188    ///
1189    /// assert_eq!(thumbnail.width(), 320);
1190    /// assert_eq!(thumbnail.height(), 180);
1191    /// ```
1192    ///
1193    /// # Use Cases
1194    ///
1195    /// - Video player scrubbing preview
1196    /// - Timeline thumbnail strips
1197    /// - Gallery view thumbnails
1198    /// - Social media preview images
1199    pub fn thumbnail_at(
1200        &mut self,
1201        position: Duration,
1202        width: u32,
1203        height: u32,
1204    ) -> Result<Option<VideoFrame>, DecodeError> {
1205        // 1. Seek to the specified position (keyframe mode for speed)
1206        self.seek(position, crate::SeekMode::Keyframe)?;
1207
1208        // 2. Decode one frame — Ok(None) means no frame at this position
1209        match self.decode_one()? {
1210            Some(frame) => self.inner.scale_frame(&frame, width, height).map(Some),
1211            None => Ok(None),
1212        }
1213    }
1214
1215    /// Generates multiple thumbnails evenly distributed across the video.
1216    ///
1217    /// This method creates a series of thumbnails by dividing the video duration
1218    /// into equal intervals and extracting a frame at each position. This is
1219    /// commonly used for timeline preview strips or video galleries.
1220    ///
1221    /// # Performance
1222    ///
1223    /// For a 2-minute video generating 10 thumbnails:
1224    /// - Per thumbnail: ~10-25ms (see [`thumbnail_at()`](Self::thumbnail_at))
1225    /// - **Total: ~100-250ms**
1226    ///
1227    /// Performance scales linearly with the number of thumbnails.
1228    ///
1229    /// # Thumbnail Positions
1230    ///
1231    /// Thumbnails are extracted at evenly spaced intervals:
1232    /// - Position 0: `0s`
1233    /// - Position 1: `duration / count`
1234    /// - Position 2: `2 * (duration / count)`
1235    /// - ...
1236    /// - Position N-1: `(N-1) * (duration / count)`
1237    ///
1238    /// # Arguments
1239    ///
1240    /// * `count` - Number of thumbnails to generate.
1241    /// * `width` - Target thumbnail width in pixels.
1242    /// * `height` - Target thumbnail height in pixels.
1243    ///
1244    /// # Returns
1245    ///
1246    /// A vector of `VideoFrame` thumbnails in temporal order.
1247    ///
1248    /// # Errors
1249    ///
1250    /// Returns [`DecodeError`] if:
1251    /// - Any individual thumbnail generation fails (see [`thumbnail_at()`](Self::thumbnail_at))
1252    /// - The video duration is unknown ([`Duration::ZERO`])
1253    /// - Count is zero
1254    ///
1255    /// # Examples
1256    ///
1257    /// ```ignore
1258    /// use ff_decode::VideoDecoder;
1259    ///
1260    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
1261    ///
1262    /// // Generate 10 thumbnails at 160x90 resolution
1263    /// let thumbnails = decoder.thumbnails(10, 160, 90)?;
1264    ///
1265    /// assert_eq!(thumbnails.len(), 10);
1266    /// for thumb in thumbnails {
1267    ///     assert_eq!(thumb.width(), 160);
1268    ///     assert_eq!(thumb.height(), 90);
1269    /// }
1270    /// ```
1271    ///
1272    /// # Use Cases
1273    ///
1274    /// - Timeline preview strips (like `YouTube`'s timeline hover)
1275    /// - Video gallery grid views
1276    /// - Storyboard generation for editing
1277    /// - Video summary/preview pages
1278    ///
1279    /// # Memory Usage
1280    ///
1281    /// For 10 thumbnails at 160x90 RGBA:
1282    /// - Per thumbnail: ~56 KB (160 × 90 × 4 bytes)
1283    /// - Total: ~560 KB
1284    ///
1285    /// This is typically acceptable, but consider using a smaller resolution
1286    /// or generating thumbnails on-demand for very large thumbnail counts.
1287    pub fn thumbnails(
1288        &mut self,
1289        count: usize,
1290        width: u32,
1291        height: u32,
1292    ) -> Result<Vec<VideoFrame>, DecodeError> {
1293        // Validate count
1294        if count == 0 {
1295            return Err(DecodeError::DecodingFailed {
1296                timestamp: None,
1297                reason: "Thumbnail count must be greater than zero".to_string(),
1298            });
1299        }
1300
1301        let duration = self.duration();
1302
1303        // Check if duration is valid
1304        if duration.is_zero() {
1305            return Err(DecodeError::DecodingFailed {
1306                timestamp: None,
1307                reason: "Cannot generate thumbnails: video duration is unknown".to_string(),
1308            });
1309        }
1310
1311        // Calculate interval between thumbnails
1312        let interval_nanos = duration.as_nanos() / count as u128;
1313
1314        // Generate thumbnails
1315        let mut thumbnails = Vec::with_capacity(count);
1316
1317        for i in 0..count {
1318            // Use saturating_mul to prevent u128 overflow
1319            let position_nanos = interval_nanos.saturating_mul(i as u128);
1320            // Clamp to u64::MAX to prevent overflow when converting to Duration
1321            #[allow(clippy::cast_possible_truncation)]
1322            let position_nanos_u64 = position_nanos.min(u128::from(u64::MAX)) as u64;
1323            let position = Duration::from_nanos(position_nanos_u64);
1324
1325            if let Some(thumbnail) = self.thumbnail_at(position, width, height)? {
1326                thumbnails.push(thumbnail);
1327            }
1328        }
1329
1330        Ok(thumbnails)
1331    }
1332}
1333
1334impl Iterator for VideoDecoder {
1335    type Item = Result<VideoFrame, DecodeError>;
1336
1337    fn next(&mut self) -> Option<Self::Item> {
1338        if self.fused {
1339            return None;
1340        }
1341        match self.decode_one() {
1342            Ok(Some(frame)) => Some(Ok(frame)),
1343            Ok(None) => None,
1344            Err(e) => {
1345                self.fused = true;
1346                Some(Err(e))
1347            }
1348        }
1349    }
1350}
1351
1352impl std::iter::FusedIterator for VideoDecoder {}
1353
1354#[cfg(test)]
1355#[allow(clippy::panic, clippy::expect_used, clippy::float_cmp)]
1356mod tests {
1357    use super::*;
1358    use std::path::PathBuf;
1359
1360    #[test]
1361    fn test_builder_default_values() {
1362        let builder = VideoDecoderBuilder::new(PathBuf::from("test.mp4"));
1363
1364        assert_eq!(builder.path(), Path::new("test.mp4"));
1365        assert!(builder.get_output_format().is_none());
1366        assert_eq!(builder.get_hardware_accel(), HardwareAccel::Auto);
1367        assert_eq!(builder.get_thread_count(), 0);
1368    }
1369
1370    #[test]
1371    fn test_builder_output_format() {
1372        let builder =
1373            VideoDecoderBuilder::new(PathBuf::from("test.mp4")).output_format(PixelFormat::Rgba);
1374
1375        assert_eq!(builder.get_output_format(), Some(PixelFormat::Rgba));
1376    }
1377
1378    #[test]
1379    fn test_builder_hardware_accel() {
1380        let builder = VideoDecoderBuilder::new(PathBuf::from("test.mp4"))
1381            .hardware_accel(HardwareAccel::Nvdec);
1382
1383        assert_eq!(builder.get_hardware_accel(), HardwareAccel::Nvdec);
1384    }
1385
1386    #[test]
1387    fn test_builder_thread_count() {
1388        let builder = VideoDecoderBuilder::new(PathBuf::from("test.mp4")).thread_count(8);
1389
1390        assert_eq!(builder.get_thread_count(), 8);
1391    }
1392
1393    #[test]
1394    fn test_builder_chaining() {
1395        let builder = VideoDecoderBuilder::new(PathBuf::from("test.mp4"))
1396            .output_format(PixelFormat::Bgra)
1397            .hardware_accel(HardwareAccel::Qsv)
1398            .thread_count(4);
1399
1400        assert_eq!(builder.get_output_format(), Some(PixelFormat::Bgra));
1401        assert_eq!(builder.get_hardware_accel(), HardwareAccel::Qsv);
1402        assert_eq!(builder.get_thread_count(), 4);
1403    }
1404
1405    #[test]
1406    fn test_decoder_open() {
1407        let builder = VideoDecoder::open("video.mp4");
1408        assert_eq!(builder.path(), Path::new("video.mp4"));
1409    }
1410
1411    #[test]
1412    fn test_decoder_open_pathbuf() {
1413        let path = PathBuf::from("/path/to/video.mp4");
1414        let builder = VideoDecoder::open(&path);
1415        assert_eq!(builder.path(), path.as_path());
1416    }
1417
1418    #[test]
1419    fn test_build_file_not_found() {
1420        let result = VideoDecoder::open("nonexistent_file_12345.mp4").build();
1421
1422        assert!(result.is_err());
1423        match result {
1424            Err(DecodeError::FileNotFound { path }) => {
1425                assert!(
1426                    path.to_string_lossy()
1427                        .contains("nonexistent_file_12345.mp4")
1428                );
1429            }
1430            Err(e) => panic!("Expected FileNotFound error, got: {e:?}"),
1431            Ok(_) => panic!("Expected error, got Ok"),
1432        }
1433    }
1434
1435    #[test]
1436    fn test_decoder_initial_state_with_invalid_file() {
1437        // Create a temporary test file (not a valid video)
1438        let temp_dir = std::env::temp_dir();
1439        let test_file = temp_dir.join("ff_decode_test_file.txt");
1440        std::fs::write(&test_file, "test").expect("Failed to create test file");
1441
1442        let result = VideoDecoder::open(&test_file).build();
1443
1444        // Clean up
1445        let _ = std::fs::remove_file(&test_file);
1446
1447        // The build should fail (not a valid video file)
1448        assert!(result.is_err());
1449        if let Err(e) = result {
1450            // Should get either NoVideoStream or Ffmpeg error
1451            assert!(
1452                matches!(e, DecodeError::NoVideoStream { .. })
1453                    || matches!(e, DecodeError::Ffmpeg { .. })
1454            );
1455        }
1456    }
1457
1458    #[test]
1459    fn test_decoder_config_default() {
1460        let config = VideoDecoderConfig::default();
1461
1462        assert!(config.output_format.is_none());
1463        assert_eq!(config.hardware_accel, HardwareAccel::Auto);
1464        assert_eq!(config.thread_count, 0);
1465    }
1466
1467    #[test]
1468    fn test_seek_mode_variants() {
1469        // Test that all SeekMode variants exist and are accessible
1470        use crate::SeekMode;
1471
1472        let keyframe = SeekMode::Keyframe;
1473        let exact = SeekMode::Exact;
1474        let backward = SeekMode::Backward;
1475
1476        // Verify they can be compared
1477        assert_eq!(keyframe, SeekMode::Keyframe);
1478        assert_eq!(exact, SeekMode::Exact);
1479        assert_eq!(backward, SeekMode::Backward);
1480        assert_ne!(keyframe, exact);
1481        assert_ne!(exact, backward);
1482    }
1483
1484    #[test]
1485    fn test_seek_mode_default() {
1486        use crate::SeekMode;
1487
1488        let default_mode = SeekMode::default();
1489        assert_eq!(default_mode, SeekMode::Keyframe);
1490    }
1491
1492    #[test]
1493    fn test_decode_range_invalid_range() {
1494        use std::time::Duration;
1495
1496        // Create a temporary test file
1497        let temp_dir = std::env::temp_dir();
1498        let test_file = temp_dir.join("ff_decode_range_test.txt");
1499        std::fs::write(&test_file, "test").expect("Failed to create test file");
1500
1501        // Try to build decoder (will fail, but that's ok for this test)
1502        let result = VideoDecoder::open(&test_file).build();
1503
1504        // Clean up
1505        let _ = std::fs::remove_file(&test_file);
1506
1507        // If we somehow got a decoder (shouldn't happen with text file),
1508        // test that invalid range returns error
1509        if let Ok(mut decoder) = result {
1510            let start = Duration::from_secs(10);
1511            let end = Duration::from_secs(5); // end < start
1512
1513            let range_result = decoder.decode_range(start, end);
1514            assert!(range_result.is_err());
1515
1516            if let Err(DecodeError::DecodingFailed { reason, .. }) = range_result {
1517                assert!(reason.contains("Invalid time range"));
1518            }
1519        }
1520    }
1521
1522    #[test]
1523    fn test_decode_range_equal_start_end() {
1524        use std::time::Duration;
1525
1526        // Test that start == end is treated as invalid range
1527        let temp_dir = std::env::temp_dir();
1528        let test_file = temp_dir.join("ff_decode_range_equal_test.txt");
1529        std::fs::write(&test_file, "test").expect("Failed to create test file");
1530
1531        let result = VideoDecoder::open(&test_file).build();
1532
1533        // Clean up
1534        let _ = std::fs::remove_file(&test_file);
1535
1536        if let Ok(mut decoder) = result {
1537            let time = Duration::from_secs(5);
1538            let range_result = decoder.decode_range(time, time);
1539            assert!(range_result.is_err());
1540
1541            if let Err(DecodeError::DecodingFailed { reason, .. }) = range_result {
1542                assert!(reason.contains("Invalid time range"));
1543            }
1544        }
1545    }
1546
1547    #[test]
1548    fn test_thumbnails_zero_count() {
1549        // Create a temporary test file
1550        let temp_dir = std::env::temp_dir();
1551        let test_file = temp_dir.join("ff_decode_thumbnails_zero_test.txt");
1552        std::fs::write(&test_file, "test").expect("Failed to create test file");
1553
1554        let result = VideoDecoder::open(&test_file).build();
1555
1556        // Clean up
1557        let _ = std::fs::remove_file(&test_file);
1558
1559        // If we somehow got a decoder (shouldn't happen with text file),
1560        // test that zero count returns error
1561        if let Ok(mut decoder) = result {
1562            let thumbnails_result = decoder.thumbnails(0, 160, 90);
1563            assert!(thumbnails_result.is_err());
1564
1565            if let Err(DecodeError::DecodingFailed { reason, .. }) = thumbnails_result {
1566                assert!(reason.contains("Thumbnail count must be greater than zero"));
1567            }
1568        }
1569    }
1570
1571    #[test]
1572    fn test_thumbnail_api_exists() {
1573        // Compile-time test to verify thumbnail methods exist on Decoder
1574        // This ensures the API surface is correct even without real video files
1575
1576        // Create a builder (won't actually build successfully with a nonexistent file)
1577        let builder = VideoDecoder::open("nonexistent.mp4");
1578
1579        // Verify the builder exists
1580        let _ = builder;
1581
1582        // The actual thumbnail generation tests require real video files
1583        // and should be in integration tests. This test just verifies
1584        // that the methods are accessible at compile time.
1585    }
1586
1587    #[test]
1588    fn test_thumbnail_dimensions_calculation() {
1589        // Test aspect ratio preservation logic (indirectly through DecoderInner)
1590        // This is a compile-time test to ensure the code structure is correct
1591
1592        // Source: 1920x1080 (16:9)
1593        // Target: 320x180 (16:9)
1594        // Expected: 320x180 (exact fit)
1595
1596        let src_width = 1920.0_f64;
1597        let src_height = 1080.0_f64;
1598        let target_width = 320.0_f64;
1599        let target_height = 180.0_f64;
1600
1601        let src_aspect = src_width / src_height;
1602        let target_aspect = target_width / target_height;
1603
1604        let (scaled_width, scaled_height) = if src_aspect > target_aspect {
1605            let height = (target_width / src_aspect).round();
1606            (target_width, height)
1607        } else {
1608            let width = (target_height * src_aspect).round();
1609            (width, target_height)
1610        };
1611
1612        assert_eq!(scaled_width, 320.0);
1613        assert_eq!(scaled_height, 180.0);
1614    }
1615
1616    #[test]
1617    fn test_thumbnail_aspect_ratio_wide_source() {
1618        // Test aspect ratio preservation for wide source
1619        // Source: 1920x1080 (16:9)
1620        // Target: 180x180 (1:1)
1621        // Expected: 180x101 (fits width, height adjusted)
1622
1623        let src_width = 1920.0_f64;
1624        let src_height = 1080.0_f64;
1625        let target_width = 180.0_f64;
1626        let target_height = 180.0_f64;
1627
1628        let src_aspect = src_width / src_height;
1629        let target_aspect = target_width / target_height;
1630
1631        let (scaled_width, scaled_height) = if src_aspect > target_aspect {
1632            let height = (target_width / src_aspect).round();
1633            (target_width, height)
1634        } else {
1635            let width = (target_height * src_aspect).round();
1636            (width, target_height)
1637        };
1638
1639        assert_eq!(scaled_width, 180.0);
1640        // 180 / (16/9) = 101.25 → 101
1641        assert!((scaled_height - 101.0).abs() < 1.0);
1642    }
1643
1644    #[test]
1645    fn test_thumbnail_aspect_ratio_tall_source() {
1646        // Test aspect ratio preservation for tall source
1647        // Source: 1080x1920 (9:16 - portrait)
1648        // Target: 180x180 (1:1)
1649        // Expected: 101x180 (fits height, width adjusted)
1650
1651        let src_width = 1080.0_f64;
1652        let src_height = 1920.0_f64;
1653        let target_width = 180.0_f64;
1654        let target_height = 180.0_f64;
1655
1656        let src_aspect = src_width / src_height;
1657        let target_aspect = target_width / target_height;
1658
1659        let (scaled_width, scaled_height) = if src_aspect > target_aspect {
1660            let height = (target_width / src_aspect).round();
1661            (target_width, height)
1662        } else {
1663            let width = (target_height * src_aspect).round();
1664            (width, target_height)
1665        };
1666
1667        // 180 * (9/16) = 101.25 → 101
1668        assert!((scaled_width - 101.0).abs() < 1.0);
1669        assert_eq!(scaled_height, 180.0);
1670    }
1671}