Skip to main content

ff_decode/video/builder/
mod.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, VideoStreamInfo};
24
25use crate::HardwareAccel;
26use crate::error::DecodeError;
27use crate::video::decoder_inner::VideoDecoderInner;
28use ff_common::FramePool;
29
30mod decode;
31mod format;
32mod hw;
33mod network;
34mod scale;
35
36/// Requested output scale for decoded frames.
37///
38/// Controls how `libswscale` resizes the frame in the same pass as pixel-format
39/// conversion. The last setter wins — calling `output_width()` after
40/// `output_size()` replaces the earlier setting.
41///
42/// Both width and height are rounded up to the nearest even number if needed,
43/// because most pixel formats (e.g. `yuv420p`) require even dimensions.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub(crate) enum OutputScale {
46    /// Scale to an exact width × height.
47    Exact {
48        /// Target width in pixels.
49        width: u32,
50        /// Target height in pixels.
51        height: u32,
52    },
53    /// Scale to the given width; compute height to preserve aspect ratio.
54    FitWidth(u32),
55    /// Scale to the given height; compute width to preserve aspect ratio.
56    FitHeight(u32),
57}
58
59/// Builder for configuring and constructing a [`VideoDecoder`].
60///
61/// This struct provides a fluent interface for setting up decoder options
62/// before opening a video file. It is created by calling [`VideoDecoder::open()`].
63///
64/// # Examples
65///
66/// ## Basic Usage
67///
68/// ```ignore
69/// use ff_decode::VideoDecoder;
70///
71/// let decoder = VideoDecoder::open("video.mp4")?
72///     .build()?;
73/// ```
74///
75/// ## With Custom Format
76///
77/// ```ignore
78/// use ff_decode::VideoDecoder;
79/// use ff_format::PixelFormat;
80///
81/// let decoder = VideoDecoder::open("video.mp4")?
82///     .output_format(PixelFormat::Rgba)
83///     .build()?;
84/// ```
85///
86/// ## With Hardware Acceleration
87///
88/// ```ignore
89/// use ff_decode::{VideoDecoder, HardwareAccel};
90///
91/// let decoder = VideoDecoder::open("video.mp4")?
92///     .hardware_accel(HardwareAccel::Nvdec)
93///     .build()?;
94/// ```
95///
96/// ## With Frame Pool
97///
98/// ```ignore
99/// use ff_decode::{VideoDecoder, FramePool};
100/// use std::sync::Arc;
101///
102/// let pool: Arc<dyn FramePool> = create_frame_pool();
103/// let decoder = VideoDecoder::open("video.mp4")?
104///     .frame_pool(pool)
105///     .build()?;
106/// ```
107#[derive(Debug)]
108pub struct VideoDecoderBuilder {
109    /// Path to the media file
110    path: PathBuf,
111    /// Output pixel format (None = use source format)
112    output_format: Option<PixelFormat>,
113    /// Output scale (None = use source dimensions)
114    output_scale: Option<OutputScale>,
115    /// Hardware acceleration setting
116    hardware_accel: HardwareAccel,
117    /// Number of decoding threads (0 = auto)
118    thread_count: usize,
119    /// Optional frame pool for memory reuse
120    frame_pool: Option<Arc<dyn FramePool>>,
121    /// Frame rate override for image sequences (default 25 fps when path contains `%`).
122    frame_rate: Option<u32>,
123    /// Network options for URL-based sources (RTMP, RTSP, HTTP, etc.).
124    network_opts: Option<NetworkOptions>,
125    /// A caller-supplied byte source to demux from instead of `path`.
126    ///
127    /// Set by [`VideoDecoder::from_reader`]; when present it wins over `path`,
128    /// which is then only a label for diagnostics.
129    source: Option<SourceHandle>,
130}
131
132/// A boxed byte source that can sit in a `#[derive(Debug)]` struct.
133///
134/// A caller's reader carries no `Debug` bound -- requiring one would rule out
135/// most of what this feature exists to accept -- so the handle prints a label
136/// instead of its contents.
137struct SourceHandle(Box<dyn ff_sys::IoSource>);
138
139impl std::fmt::Debug for SourceHandle {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.write_str("<reader>")
142    }
143}
144
145impl VideoDecoderBuilder {
146    /// Creates a new builder for the specified file path.
147    ///
148    /// This is an internal constructor; use [`VideoDecoder::open()`] instead.
149    pub(crate) fn new(path: PathBuf) -> Self {
150        Self {
151            path,
152            output_format: None,
153            output_scale: None,
154            hardware_accel: HardwareAccel::Auto,
155            thread_count: 0,
156            frame_pool: None,
157            frame_rate: None,
158            network_opts: None,
159            source: None,
160        }
161    }
162
163    /// Creates a builder that demuxes `source` instead of a file.
164    ///
165    /// This is an internal constructor; use [`VideoDecoder::from_reader()`].
166    pub(crate) fn from_source(source: Box<dyn ff_sys::IoSource>) -> Self {
167        let mut builder = Self::new(PathBuf::from("<reader>"));
168        builder.source = Some(SourceHandle(source));
169        builder
170    }
171
172    /// Returns the configured file path.
173    #[must_use]
174    pub fn path(&self) -> &Path {
175        &self.path
176    }
177
178    /// Returns the configured output format, if any.
179    #[must_use]
180    pub fn get_output_format(&self) -> Option<PixelFormat> {
181        self.output_format
182    }
183
184    /// Returns the configured hardware acceleration mode.
185    #[must_use]
186    pub fn get_hardware_accel(&self) -> HardwareAccel {
187        self.hardware_accel
188    }
189
190    /// Returns the configured thread count.
191    #[must_use]
192    pub fn get_thread_count(&self) -> usize {
193        self.thread_count
194    }
195
196    /// Builds the decoder with the configured options.
197    ///
198    /// This method opens the media file, initializes the decoder context,
199    /// and prepares for frame decoding.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if:
204    /// - The file cannot be found ([`DecodeError::FileNotFound`])
205    /// - The file contains no video stream ([`DecodeError::NoVideoStream`])
206    /// - The codec is not supported ([`DecodeError::UnsupportedCodec`])
207    /// - Hardware acceleration is unavailable ([`DecodeError::HwAccelUnavailable`])
208    /// - Other `FFmpeg` errors occur ([`DecodeError::Ffmpeg`])
209    ///
210    /// # Examples
211    ///
212    /// ```ignore
213    /// use ff_decode::VideoDecoder;
214    ///
215    /// let decoder = VideoDecoder::open("video.mp4")?
216    ///     .build()?;
217    ///
218    /// // Start decoding
219    /// for result in &mut decoder {
220    ///     let frame = result?;
221    ///     // Process frame...
222    /// }
223    /// ```
224    pub fn build(self) -> Result<VideoDecoder, DecodeError> {
225        // Validate output scale dimensions before opening the file.
226        // FitWidth / FitHeight aspect-ratio dimensions are resolved at decode time
227        // from the actual source dimensions, so we only reject an explicit zero here.
228        if let Some(scale) = self.output_scale {
229            let (w, h) = match scale {
230                OutputScale::Exact { width, height } => (width, height),
231                OutputScale::FitWidth(w) => (w, 1), // height will be derived
232                OutputScale::FitHeight(h) => (1, h), // width will be derived
233            };
234            if w == 0 || h == 0 {
235                return Err(DecodeError::InvalidOutputDimensions {
236                    width: w,
237                    height: h,
238                });
239            }
240        }
241
242        // Image-sequence patterns contain '%' — the literal path does not exist.
243        // Network URLs must also skip the file-existence check, and so must a
244        // caller-supplied source, whose `path` is a label rather than a location.
245        let path_str = self.path.to_str().unwrap_or("");
246        let is_image_sequence = path_str.contains('%');
247        let is_network_url = crate::network::is_url(path_str);
248        if self.source.is_none() && !is_image_sequence && !is_network_url && !self.path.exists() {
249            return Err(DecodeError::FileNotFound {
250                path: self.path.clone(),
251            });
252        }
253
254        // Create the decoder inner
255        let (inner, stream_info, container_info) = VideoDecoderInner::new(
256            &self.path,
257            self.output_format,
258            self.output_scale,
259            self.hardware_accel,
260            self.thread_count,
261            self.frame_rate,
262            self.frame_pool.clone(),
263            self.network_opts,
264            self.source.map(|s| s.0),
265        )?;
266
267        Ok(VideoDecoder {
268            path: self.path,
269            frame_pool: self.frame_pool,
270            inner,
271            stream_info,
272            container_info,
273            fused: false,
274        })
275    }
276}
277
278/// A video decoder for extracting frames from media files.
279///
280/// The decoder provides frame-by-frame access to video content with support
281/// for seeking, hardware acceleration, and format conversion.
282///
283/// # Construction
284///
285/// Use [`VideoDecoder::open()`] to create a builder, then call [`VideoDecoderBuilder::build()`]:
286///
287/// ```ignore
288/// use ff_decode::VideoDecoder;
289/// use ff_format::PixelFormat;
290///
291/// let decoder = VideoDecoder::open("video.mp4")?
292///     .output_format(PixelFormat::Rgba)
293///     .build()?;
294/// ```
295///
296/// # Frame Decoding
297///
298/// Frames can be decoded one at a time or using the built-in iterator:
299///
300/// ```ignore
301/// // Decode one frame
302/// if let Some(frame) = decoder.decode_one()? {
303///     println!("Frame at {:?}", frame.timestamp().as_duration());
304/// }
305///
306/// // Iterator form — VideoDecoder implements Iterator directly
307/// for result in &mut decoder {
308///     let frame = result?;
309///     // Process frame...
310/// }
311/// ```
312///
313/// # Seeking
314///
315/// The decoder supports efficient seeking:
316///
317/// ```ignore
318/// use ff_decode::SeekMode;
319/// use std::time::Duration;
320///
321/// // Seek to 30 seconds (keyframe)
322/// decoder.seek(Duration::from_secs(30), SeekMode::Keyframe)?;
323///
324/// // Seek to exact frame
325/// decoder.seek(Duration::from_secs(30), SeekMode::Exact)?;
326/// ```
327pub struct VideoDecoder {
328    /// Path to the media file
329    path: PathBuf,
330    /// Optional frame pool for memory reuse
331    frame_pool: Option<Arc<dyn FramePool>>,
332    /// Internal decoder state
333    inner: VideoDecoderInner,
334    /// Video stream information
335    stream_info: VideoStreamInfo,
336    /// Container-level metadata
337    container_info: ContainerInfo,
338    /// Set to `true` after a decoding error; causes [`Iterator::next`] to return `None`.
339    fused: bool,
340}
341
342impl VideoDecoder {
343    /// Opens a media file and returns a builder for configuring the decoder.
344    ///
345    /// This is the entry point for creating a decoder. The returned builder
346    /// allows setting options before the decoder is fully initialized.
347    ///
348    /// # Arguments
349    ///
350    /// * `path` - Path to the media file to decode.
351    ///
352    /// # Examples
353    ///
354    /// ```ignore
355    /// use ff_decode::VideoDecoder;
356    ///
357    /// // Simple usage
358    /// let decoder = VideoDecoder::open("video.mp4")?
359    ///     .build()?;
360    ///
361    /// // With options
362    /// let decoder = VideoDecoder::open("video.mp4")?
363    ///     .output_format(PixelFormat::Rgba)
364    ///     .hardware_accel(HardwareAccel::Auto)
365    ///     .build()?;
366    /// ```
367    ///
368    /// # Note
369    ///
370    /// This method does not validate that the file exists or is a valid
371    /// media file. Validation occurs when [`VideoDecoderBuilder::build()`] is called.
372    pub fn open(path: impl AsRef<Path>) -> VideoDecoderBuilder {
373        VideoDecoderBuilder::new(path.as_ref().to_path_buf())
374    }
375
376    /// Creates a builder that decodes from `source` instead of a file.
377    ///
378    /// `source` is anything that reads and seeks and can move to the decoder's
379    /// thread -- an in-memory `Cursor<Vec<u8>>`, a `File`, a custom byte store.
380    /// The container format is probed from the bytes, so nothing has to be named.
381    ///
382    /// `Seek` is required: `FFmpeg` can demux without it, but the containers that
383    /// survive a non-seekable input are a narrower set with different failure
384    /// modes, and supporting it is separate work.
385    ///
386    /// # Examples
387    ///
388    /// ```ignore
389    /// use std::io::Cursor;
390    /// use ff_decode::VideoDecoder;
391    ///
392    /// let bytes: Vec<u8> = std::fs::read("input.mp4")?;
393    /// let mut decoder = VideoDecoder::from_reader(Cursor::new(bytes)).build()?;
394    /// let frame = decoder.decode_one()?;
395    /// # Ok::<(), ff_decode::DecodeError>(())
396    /// ```
397    ///
398    /// # Note
399    ///
400    /// As with [`open`](Self::open), nothing is read until
401    /// [`VideoDecoderBuilder::build()`] is called.
402    pub fn from_reader(source: impl ff_sys::IoSource + 'static) -> VideoDecoderBuilder {
403        VideoDecoderBuilder::from_source(Box::new(source))
404    }
405
406    // =========================================================================
407    // Information Methods
408    // =========================================================================
409
410    /// Returns the video stream information.
411    ///
412    /// This contains metadata about the video stream including resolution,
413    /// frame rate, codec, and color characteristics.
414    #[must_use]
415    pub fn stream_info(&self) -> &VideoStreamInfo {
416        &self.stream_info
417    }
418
419    /// Returns the video width in pixels.
420    #[must_use]
421    pub fn width(&self) -> u32 {
422        self.stream_info.width()
423    }
424
425    /// Returns the video height in pixels.
426    #[must_use]
427    pub fn height(&self) -> u32 {
428        self.stream_info.height()
429    }
430
431    /// Returns the frame rate in frames per second.
432    #[must_use]
433    pub fn frame_rate(&self) -> f64 {
434        self.stream_info.fps()
435    }
436
437    /// Returns the total duration of the video.
438    ///
439    /// Returns [`Duration::ZERO`] if duration is unknown.
440    #[must_use]
441    pub fn duration(&self) -> Duration {
442        self.stream_info.duration().unwrap_or(Duration::ZERO)
443    }
444
445    /// Returns the total duration of the video, or `None` for live streams
446    /// or formats that do not carry duration information.
447    #[must_use]
448    pub fn duration_opt(&self) -> Option<Duration> {
449        self.stream_info.duration()
450    }
451
452    /// Returns container-level metadata (format name, bitrate, stream count).
453    #[must_use]
454    pub fn container_info(&self) -> &ContainerInfo {
455        &self.container_info
456    }
457
458    /// Returns the current playback position.
459    #[must_use]
460    pub fn position(&self) -> Duration {
461        self.inner.position()
462    }
463
464    /// Returns `true` once the decoder has been fully drained.
465    ///
466    /// This becomes `true` only when no further frame will be returned. Reaching
467    /// the end of the file is not enough on its own: a decoder still holds
468    /// buffered frames at that point, and those are returned first.
469    #[must_use]
470    pub fn is_eof(&self) -> bool {
471        self.inner.is_eof()
472    }
473
474    /// Returns the file path being decoded.
475    #[must_use]
476    pub fn path(&self) -> &Path {
477        &self.path
478    }
479
480    /// Returns a reference to the frame pool, if configured.
481    #[must_use]
482    pub fn frame_pool(&self) -> Option<&Arc<dyn FramePool>> {
483        self.frame_pool.as_ref()
484    }
485
486    /// Returns the currently active hardware acceleration mode.
487    ///
488    /// This method returns the actual hardware acceleration being used,
489    /// which may differ from what was requested:
490    ///
491    /// - If [`HardwareAccel::Auto`] was requested, this returns the specific
492    ///   accelerator that was successfully initialized (e.g., [`HardwareAccel::Nvdec`]),
493    ///   or [`HardwareAccel::None`] if no hardware acceleration is available.
494    /// - If a specific accelerator was requested and initialization failed,
495    ///   the decoder creation would have returned an error.
496    /// - If [`HardwareAccel::None`] was requested, this always returns [`HardwareAccel::None`].
497    ///
498    /// # Examples
499    ///
500    /// ```ignore
501    /// use ff_decode::{VideoDecoder, HardwareAccel};
502    ///
503    /// // Request automatic hardware acceleration
504    /// let decoder = VideoDecoder::open("video.mp4")?
505    ///     .hardware_accel(HardwareAccel::Auto)
506    ///     .build()?;
507    ///
508    /// // Check which accelerator was selected
509    /// match decoder.hardware_accel() {
510    ///     HardwareAccel::None => println!("Using software decoding"),
511    ///     HardwareAccel::Nvdec => println!("Using NVIDIA NVDEC"),
512    ///     HardwareAccel::Qsv => println!("Using Intel Quick Sync"),
513    ///     HardwareAccel::VideoToolbox => println!("Using Apple VideoToolbox"),
514    ///     HardwareAccel::Vaapi => println!("Using VA-API"),
515    ///     HardwareAccel::Amf => println!("Using AMD AMF"),
516    ///     _ => unreachable!(),
517    /// }
518    /// ```
519    #[must_use]
520    pub fn hardware_accel(&self) -> HardwareAccel {
521        self.inner.hardware_accel()
522    }
523}
524
525#[cfg(test)]
526#[allow(clippy::panic, clippy::expect_used)]
527mod tests {
528    use super::*;
529    use std::path::PathBuf;
530
531    #[test]
532    fn builder_default_values_should_have_auto_hw_and_zero_threads() {
533        let builder = VideoDecoderBuilder::new(PathBuf::from("test.mp4"));
534
535        assert_eq!(builder.path(), Path::new("test.mp4"));
536        assert!(builder.get_output_format().is_none());
537        assert_eq!(builder.get_hardware_accel(), HardwareAccel::Auto);
538        assert_eq!(builder.get_thread_count(), 0);
539    }
540
541    #[test]
542    fn builder_chaining_should_set_all_fields() {
543        let builder = VideoDecoderBuilder::new(PathBuf::from("test.mp4"))
544            .output_format(PixelFormat::Bgra)
545            .hardware_accel(HardwareAccel::Qsv)
546            .thread_count(4);
547
548        assert_eq!(builder.get_output_format(), Some(PixelFormat::Bgra));
549        assert_eq!(builder.get_hardware_accel(), HardwareAccel::Qsv);
550        assert_eq!(builder.get_thread_count(), 4);
551    }
552
553    #[test]
554    fn decoder_open_should_return_builder_with_path() {
555        let builder = VideoDecoder::open("video.mp4");
556        assert_eq!(builder.path(), Path::new("video.mp4"));
557    }
558
559    #[test]
560    fn decoder_open_pathbuf_should_preserve_path() {
561        let path = PathBuf::from("/path/to/video.mp4");
562        let builder = VideoDecoder::open(&path);
563        assert_eq!(builder.path(), path.as_path());
564    }
565
566    #[test]
567    fn build_nonexistent_file_should_return_file_not_found() {
568        let result = VideoDecoder::open("nonexistent_file_12345.mp4").build();
569
570        assert!(result.is_err());
571        match result {
572            Err(DecodeError::FileNotFound { path }) => {
573                assert!(
574                    path.to_string_lossy()
575                        .contains("nonexistent_file_12345.mp4")
576                );
577            }
578            Err(e) => panic!("Expected FileNotFound error, got: {e:?}"),
579            Ok(_) => panic!("Expected error, got Ok"),
580        }
581    }
582
583    #[test]
584    fn build_invalid_video_file_should_fail() {
585        // Create a temporary test file (not a valid video)
586        let temp_dir = std::env::temp_dir();
587        let test_file = temp_dir.join("ff_decode_test_file.txt");
588        std::fs::write(&test_file, "test").expect("Failed to create test file");
589
590        let result = VideoDecoder::open(&test_file).build();
591
592        // Clean up
593        let _ = std::fs::remove_file(&test_file);
594
595        // The build should fail (not a valid video file)
596        assert!(result.is_err());
597        if let Err(e) = result {
598            // Should get either NoVideoStream or Ffmpeg error
599            assert!(
600                matches!(e, DecodeError::NoVideoStream { .. })
601                    || matches!(e, DecodeError::Ffmpeg { .. })
602            );
603        }
604    }
605}