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}