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