Skip to main content

ff_decode/video/builder/
decode.rs

1//! Decode, seek, frame-extraction, and thumbnail methods for [`VideoDecoder`].
2
3use std::time::Duration;
4
5use ff_format::VideoFrame;
6
7use crate::error::DecodeError;
8
9use super::VideoDecoder;
10
11impl VideoDecoder {
12    // =========================================================================
13    // Decoding Methods
14    // =========================================================================
15
16    /// Decodes the next video frame.
17    ///
18    /// This method reads and decodes a single frame from the video stream.
19    /// Frames are returned in presentation order.
20    ///
21    /// # Returns
22    ///
23    /// - `Ok(Some(frame))` - A frame was successfully decoded
24    /// - `Ok(None)` - End of stream reached, no more frames
25    /// - `Err(_)` - An error occurred during decoding
26    ///
27    /// # Errors
28    ///
29    /// Returns [`DecodeError`] if:
30    /// - Reading from the file fails
31    /// - Decoding the frame fails
32    /// - Pixel format conversion fails
33    ///
34    /// # Examples
35    ///
36    /// ```ignore
37    /// use ff_decode::VideoDecoder;
38    ///
39    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
40    ///
41    /// while let Some(frame) = decoder.decode_one()? {
42    ///     println!("Frame at {:?}", frame.timestamp().as_duration());
43    ///     // Process frame...
44    /// }
45    /// ```
46    pub fn decode_one(&mut self) -> Result<Option<VideoFrame>, DecodeError> {
47        self.inner.decode_one()
48    }
49
50    /// Decodes all frames within a specified time range.
51    ///
52    /// This method seeks to the start position and decodes all frames until
53    /// the end position is reached. Frames outside the range are skipped.
54    ///
55    /// # Arguments
56    ///
57    /// * `start` - Start of the time range (inclusive).
58    /// * `end` - End of the time range (exclusive).
59    ///
60    /// # Returns
61    ///
62    /// A vector of frames with timestamps in the range `[start, end)`.
63    /// Frames are returned in presentation order.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`DecodeError`] if:
68    /// - Seeking to the start position fails
69    /// - Decoding frames fails
70    /// - The time range is invalid (start >= end)
71    pub fn decode_range(
72        &mut self,
73        start: Duration,
74        end: Duration,
75    ) -> Result<Vec<VideoFrame>, DecodeError> {
76        // Validate range
77        if start >= end {
78            return Err(DecodeError::DecodingFailed {
79                timestamp: Some(start),
80                reason: format!(
81                    "Invalid time range: start ({start:?}) must be before end ({end:?})"
82                ),
83            });
84        }
85
86        // Seek to start position (keyframe mode for efficiency)
87        self.seek(start, crate::SeekMode::Keyframe)?;
88
89        // Collect frames in the range
90        let mut frames = Vec::new();
91
92        while let Some(frame) = self.decode_one()? {
93            let frame_time = frame.timestamp().as_duration();
94
95            // Stop if we've passed the end of the range
96            if frame_time >= end {
97                break;
98            }
99
100            // Only collect frames within the range
101            if frame_time >= start {
102                frames.push(frame);
103            }
104            // Frames before start are automatically discarded
105        }
106
107        Ok(frames)
108    }
109
110    // =========================================================================
111    // Seeking Methods
112    // =========================================================================
113
114    /// Seeks to a specified position in the video stream.
115    ///
116    /// This method performs efficient seeking without reopening the file,
117    /// providing significantly better performance than file-reopen-based seeking
118    /// (5-10ms vs 50-100ms).
119    ///
120    /// # Arguments
121    ///
122    /// * `position` - Target position to seek to.
123    /// * `mode` - Seek mode determining accuracy and performance.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`DecodeError::SeekFailed`] if:
128    /// - The target position is beyond the video duration
129    /// - The file format doesn't support seeking
130    /// - The seek operation fails internally
131    ///
132    /// # Examples
133    ///
134    /// ```ignore
135    /// use ff_decode::{VideoDecoder, SeekMode};
136    /// use std::time::Duration;
137    ///
138    /// let mut decoder = VideoDecoder::open("video.mp4")?.build()?;
139    ///
140    /// // Fast seek to 30 seconds (keyframe)
141    /// decoder.seek(Duration::from_secs(30), SeekMode::Keyframe)?;
142    ///
143    /// // Exact seek to 1 minute
144    /// decoder.seek(Duration::from_secs(60), SeekMode::Exact)?;
145    /// ```
146    pub fn seek(&mut self, position: Duration, mode: crate::SeekMode) -> Result<(), DecodeError> {
147        if self.inner.is_live() {
148            return Err(DecodeError::SeekNotSupported);
149        }
150        self.inner.seek(position, mode)
151    }
152
153    /// Returns `true` if the source is a live or streaming input.
154    ///
155    /// Live sources (HLS live playlists, RTMP, RTSP, MPEG-TS) have the
156    /// `AVFMT_TS_DISCONT` flag set on their `AVInputFormat`. Seeking is not
157    /// supported on live sources — [`VideoDecoder::seek`] will return
158    /// [`DecodeError::SeekNotSupported`].
159    #[must_use]
160    pub fn is_live(&self) -> bool {
161        self.inner.is_live()
162    }
163
164    /// Flushes the decoder's internal buffers.
165    ///
166    /// This method clears any cached frames and resets the decoder state.
167    /// The decoder is ready to receive new packets after flushing.
168    ///
169    /// # Note
170    ///
171    /// Calling [`seek()`](Self::seek) automatically flushes the decoder,
172    /// so you don't need to call this method explicitly after seeking.
173    pub fn flush(&mut self) {
174        self.inner.flush();
175    }
176
177    // =========================================================================
178    // Frame Extraction Methods
179    // =========================================================================
180
181    /// Returns the video frame whose presentation timestamp is closest to and
182    /// at or after `timestamp`.
183    ///
184    /// Seeks to the keyframe immediately before `timestamp` (up to 10 seconds
185    /// back to guarantee keyframe coverage), then decodes forward until a frame
186    /// with PTS ≥ `timestamp` is found.  If the stream ends before that point
187    /// (e.g. `timestamp` is beyond the video's duration), returns
188    /// [`DecodeError::NoFrameAtTimestamp`].
189    ///
190    /// # Errors
191    ///
192    /// - [`DecodeError::NoFrameAtTimestamp`] — `timestamp` is at or beyond
193    ///   the end of the stream, or no decodable frame exists there.
194    /// - Any [`DecodeError`] propagated from seeking or decoding.
195    ///
196    /// # Examples
197    ///
198    /// ```ignore
199    /// use ff_decode::VideoDecoder;
200    /// use std::time::Duration;
201    ///
202    /// let mut decoder = VideoDecoder::open("video.mp4").build()?;
203    /// let frame = decoder.extract_frame(Duration::from_secs(5))?;
204    /// println!("Got frame at {:?}", frame.timestamp().as_duration());
205    /// ```
206    pub fn extract_frame(&mut self, timestamp: Duration) -> Result<VideoFrame, DecodeError> {
207        // Seek to the keyframe just before the target; ignore seek errors
208        // (e.g. the target is near the start) and decode from the beginning.
209        let seek_to = timestamp.saturating_sub(Duration::from_secs(10));
210        let _ = self.seek(seek_to, crate::SeekMode::Keyframe);
211
212        loop {
213            match self.decode_one()? {
214                None => {
215                    return Err(DecodeError::NoFrameAtTimestamp { timestamp });
216                }
217                Some(frame) => {
218                    let pts = frame.timestamp().as_duration();
219                    if pts >= timestamp {
220                        log::debug!("frame extracted timestamp={timestamp:?} pts={pts:?}");
221                        return Ok(frame);
222                    }
223                    // PTS is before the target; discard and keep decoding.
224                }
225            }
226        }
227    }
228
229    // =========================================================================
230    // Thumbnail Generation Methods
231    // =========================================================================
232
233    /// Generates a thumbnail at a specific timestamp.
234    ///
235    /// This method seeks to the specified position, decodes a frame, and scales
236    /// it to the target dimensions. It's optimized for thumbnail generation by
237    /// using keyframe seeking for speed.
238    ///
239    /// # Arguments
240    ///
241    /// * `position` - Timestamp to extract the thumbnail from.
242    /// * `width` - Target thumbnail width in pixels.
243    /// * `height` - Target thumbnail height in pixels.
244    ///
245    /// # Returns
246    ///
247    /// A scaled `VideoFrame` representing the thumbnail.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`DecodeError`] if:
252    /// - Seeking to the position fails
253    /// - No frame can be decoded at that position (returns `Ok(None)`)
254    /// - Scaling fails
255    pub fn thumbnail_at(
256        &mut self,
257        position: Duration,
258        width: u32,
259        height: u32,
260    ) -> Result<Option<VideoFrame>, DecodeError> {
261        // 1. Seek to the specified position (keyframe mode for speed)
262        self.seek(position, crate::SeekMode::Keyframe)?;
263
264        // 2. Decode one frame — Ok(None) means no frame at this position
265        match self.decode_one()? {
266            Some(frame) => self.inner.scale_frame(&frame, width, height).map(Some),
267            None => Ok(None),
268        }
269    }
270
271    /// Generates multiple thumbnails evenly distributed across the video.
272    ///
273    /// This method creates a series of thumbnails by dividing the video duration
274    /// into equal intervals and extracting a frame at each position.
275    ///
276    /// # Arguments
277    ///
278    /// * `count` - Number of thumbnails to generate.
279    /// * `width` - Target thumbnail width in pixels.
280    /// * `height` - Target thumbnail height in pixels.
281    ///
282    /// # Returns
283    ///
284    /// A vector of `VideoFrame` thumbnails in temporal order.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`DecodeError`] if:
289    /// - Any individual thumbnail generation fails (see [`thumbnail_at()`](Self::thumbnail_at))
290    /// - The video duration is unknown ([`Duration::ZERO`])
291    /// - Count is zero
292    pub fn thumbnails(
293        &mut self,
294        count: usize,
295        width: u32,
296        height: u32,
297    ) -> Result<Vec<VideoFrame>, DecodeError> {
298        // Validate count
299        if count == 0 {
300            return Err(DecodeError::DecodingFailed {
301                timestamp: None,
302                reason: "Thumbnail count must be greater than zero".to_string(),
303            });
304        }
305
306        let duration = self.duration();
307
308        // Check if duration is valid
309        if duration.is_zero() {
310            return Err(DecodeError::DecodingFailed {
311                timestamp: None,
312                reason: "Cannot generate thumbnails: video duration is unknown".to_string(),
313            });
314        }
315
316        // Calculate interval between thumbnails
317        let interval_nanos = duration.as_nanos() / count as u128;
318
319        // Generate thumbnails
320        let mut thumbnails = Vec::with_capacity(count);
321
322        for i in 0..count {
323            // Use saturating_mul to prevent u128 overflow
324            let position_nanos = interval_nanos.saturating_mul(i as u128);
325            // Clamp to u64::MAX to prevent overflow when converting to Duration
326            #[allow(clippy::cast_possible_truncation)]
327            let position_nanos_u64 = position_nanos.min(u128::from(u64::MAX)) as u64;
328            let position = Duration::from_nanos(position_nanos_u64);
329
330            if let Some(thumbnail) = self.thumbnail_at(position, width, height)? {
331                thumbnails.push(thumbnail);
332            }
333        }
334
335        Ok(thumbnails)
336    }
337}
338
339impl Iterator for VideoDecoder {
340    type Item = Result<VideoFrame, DecodeError>;
341
342    fn next(&mut self) -> Option<Self::Item> {
343        if self.fused {
344            return None;
345        }
346        match self.decode_one() {
347            Ok(Some(frame)) => Some(Ok(frame)),
348            Ok(None) => None,
349            Err(e) => {
350                self.fused = true;
351                Some(Err(e))
352            }
353        }
354    }
355}
356
357impl std::iter::FusedIterator for VideoDecoder {}
358
359#[cfg(test)]
360#[allow(clippy::panic, clippy::expect_used, clippy::float_cmp)]
361mod tests {
362    use super::*;
363    use crate::video::builder::VideoDecoder;
364
365    #[test]
366    fn seek_mode_variants_should_be_comparable() {
367        use crate::SeekMode;
368
369        let keyframe = SeekMode::Keyframe;
370        let exact = SeekMode::Exact;
371        let backward = SeekMode::Backward;
372
373        assert_eq!(keyframe, SeekMode::Keyframe);
374        assert_eq!(exact, SeekMode::Exact);
375        assert_eq!(backward, SeekMode::Backward);
376        assert_ne!(keyframe, exact);
377        assert_ne!(exact, backward);
378    }
379
380    #[test]
381    fn seek_mode_default_should_be_keyframe() {
382        use crate::SeekMode;
383
384        let default_mode = SeekMode::default();
385        assert_eq!(default_mode, SeekMode::Keyframe);
386    }
387
388    #[test]
389    fn decode_range_invalid_range_should_return_error() {
390        let temp_dir = std::env::temp_dir();
391        let test_file = temp_dir.join("ff_decode_range_test.txt");
392        std::fs::write(&test_file, "test").expect("Failed to create test file");
393
394        let result = VideoDecoder::open(&test_file).build();
395        let _ = std::fs::remove_file(&test_file);
396
397        if let Ok(mut decoder) = result {
398            let start = Duration::from_secs(10);
399            let end = Duration::from_secs(5); // end < start
400
401            let range_result = decoder.decode_range(start, end);
402            assert!(range_result.is_err());
403
404            if let Err(DecodeError::DecodingFailed { reason, .. }) = range_result {
405                assert!(reason.contains("Invalid time range"));
406            }
407        }
408    }
409
410    #[test]
411    fn decode_range_equal_start_end_should_return_error() {
412        let temp_dir = std::env::temp_dir();
413        let test_file = temp_dir.join("ff_decode_range_equal_test.txt");
414        std::fs::write(&test_file, "test").expect("Failed to create test file");
415
416        let result = VideoDecoder::open(&test_file).build();
417        let _ = std::fs::remove_file(&test_file);
418
419        if let Ok(mut decoder) = result {
420            let time = Duration::from_secs(5);
421            let range_result = decoder.decode_range(time, time);
422            assert!(range_result.is_err());
423
424            if let Err(DecodeError::DecodingFailed { reason, .. }) = range_result {
425                assert!(reason.contains("Invalid time range"));
426            }
427        }
428    }
429
430    #[test]
431    fn thumbnails_zero_count_should_return_error() {
432        let temp_dir = std::env::temp_dir();
433        let test_file = temp_dir.join("ff_decode_thumbnails_zero_test.txt");
434        std::fs::write(&test_file, "test").expect("Failed to create test file");
435
436        let result = VideoDecoder::open(&test_file).build();
437        let _ = std::fs::remove_file(&test_file);
438
439        if let Ok(mut decoder) = result {
440            let thumbnails_result = decoder.thumbnails(0, 160, 90);
441            assert!(thumbnails_result.is_err());
442
443            if let Err(DecodeError::DecodingFailed { reason, .. }) = thumbnails_result {
444                assert!(reason.contains("Thumbnail count must be greater than zero"));
445            }
446        }
447    }
448
449    #[test]
450    fn extract_frame_beyond_duration_should_err() {
451        let e = DecodeError::NoFrameAtTimestamp {
452            timestamp: Duration::from_secs(9999),
453        };
454        let msg = e.to_string();
455        assert!(
456            msg.contains("9999"),
457            "expected timestamp in error message, got: {msg}"
458        );
459    }
460
461    #[test]
462    fn thumbnail_dimensions_aspect_ratio_same_should_fit_exactly() {
463        // Source: 1920x1080 (16:9), Target: 320x180 (16:9) → exact fit
464        let src_width = 1920.0_f64;
465        let src_height = 1080.0_f64;
466        let target_width = 320.0_f64;
467        let target_height = 180.0_f64;
468
469        let src_aspect = src_width / src_height;
470        let target_aspect = target_width / target_height;
471
472        let (scaled_width, scaled_height) = if src_aspect > target_aspect {
473            let height = (target_width / src_aspect).round();
474            (target_width, height)
475        } else {
476            let width = (target_height * src_aspect).round();
477            (width, target_height)
478        };
479
480        assert_eq!(scaled_width, 320.0);
481        assert_eq!(scaled_height, 180.0);
482    }
483
484    #[test]
485    fn thumbnail_dimensions_wide_source_should_constrain_height() {
486        // Source: 1920x1080 (16:9), Target: 180x180 (1:1) → fits width, height adjusted
487        let src_width = 1920.0_f64;
488        let src_height = 1080.0_f64;
489        let target_width = 180.0_f64;
490        let target_height = 180.0_f64;
491
492        let src_aspect = src_width / src_height;
493        let target_aspect = target_width / target_height;
494
495        let (scaled_width, scaled_height) = if src_aspect > target_aspect {
496            let height = (target_width / src_aspect).round();
497            (target_width, height)
498        } else {
499            let width = (target_height * src_aspect).round();
500            (width, target_height)
501        };
502
503        assert_eq!(scaled_width, 180.0);
504        // 180 / (16/9) = 101.25 → 101
505        assert!((scaled_height - 101.0).abs() < 1.0);
506    }
507
508    #[test]
509    fn thumbnail_dimensions_tall_source_should_constrain_width() {
510        // Source: 1080x1920 (9:16 - portrait), Target: 180x180 (1:1) → fits height, width adjusted
511        let src_width = 1080.0_f64;
512        let src_height = 1920.0_f64;
513        let target_width = 180.0_f64;
514        let target_height = 180.0_f64;
515
516        let src_aspect = src_width / src_height;
517        let target_aspect = target_width / target_height;
518
519        let (scaled_width, scaled_height) = if src_aspect > target_aspect {
520            let height = (target_width / src_aspect).round();
521            (target_width, height)
522        } else {
523            let width = (target_height * src_aspect).round();
524            (width, target_height)
525        };
526
527        // 180 * (9/16) = 101.25 → 101
528        assert!((scaled_width - 101.0).abs() < 1.0);
529        assert_eq!(scaled_height, 180.0);
530    }
531}