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}