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