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