Skip to main content

ff_preview/playback/
decode_buffer.rs

1//! Background-threaded video frame buffer for ff-preview.
2//!
3//! [`DecodeBuffer`] decouples decoder latency from the presentation loop by
4//! running a [`VideoDecoder`] on a background thread and buffering decoded
5//! frames in a bounded ring channel.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel};
11use std::thread::{self, JoinHandle};
12use std::time::Duration;
13
14use ff_decode::{HardwareAccel, SeekMode, VideoDecoder};
15use ff_format::VideoFrame;
16
17use crate::error::PreviewError;
18
19// Constants
20
21/// Default ring buffer capacity for [`DecodeBuffer`] (frames).
22const DEFAULT_DECODE_BUFFER_CAPACITY: usize = 8;
23
24// FrameResult
25
26/// The result of a [`DecodeBuffer::pop_frame`] call.
27///
28/// Callers should match on all three variants; discarding `Seeking` is a
29/// common pattern for scrub-bar UIs that want to display the last good frame
30/// while a seek is in progress.
31#[derive(Debug, Clone)]
32pub enum FrameResult {
33    /// A decoded frame ready for presentation.
34    Frame(VideoFrame),
35    /// A seek is in progress; the wrapped value is the last successfully
36    /// decoded frame, or `None` if no frame has been decoded yet.
37    /// Call [`pop_frame`](DecodeBuffer::pop_frame) again after a short delay
38    /// to check whether seeking has completed.
39    Seeking(Option<VideoFrame>),
40    /// End of file — no more frames will be produced.
41    Eof,
42}
43
44// SeekEvent
45
46/// An event emitted by [`DecodeBuffer`] after a
47/// [`seek_async`](DecodeBuffer::seek_async) completes.
48///
49/// Obtain the receiver via [`DecodeBuffer::seek_events`] and poll it with
50/// `try_recv()` (non-blocking) or `recv()` (blocking).
51#[derive(Debug)]
52pub enum SeekEvent {
53    /// The seek initiated by `seek_async` has completed.
54    ///
55    /// `pts` is the presentation timestamp of the first frame available after
56    /// the seek. Events are typically delivered within ~200 ms for local files.
57    Completed { pts: Duration },
58}
59
60// DecodeBufferBuilder
61
62/// Builder for [`DecodeBuffer`].
63///
64/// Created via [`DecodeBuffer::open`]; call [`capacity`](Self::capacity) to
65/// override the default ring buffer size, then [`build`](Self::build) to start
66/// the background decode thread and obtain a [`DecodeBuffer`].
67pub struct DecodeBufferBuilder {
68    pub(super) path: PathBuf,
69    pub(super) capacity: usize,
70    pub(super) hw_accel: HardwareAccel,
71}
72
73impl DecodeBufferBuilder {
74    /// Set the ring buffer capacity in frames. Default: 8.
75    ///
76    /// The background thread blocks when the buffer is full and resumes as soon
77    /// as the consumer calls [`DecodeBuffer::pop_frame`].
78    #[must_use]
79    pub fn capacity(self, n: usize) -> Self {
80        Self {
81            capacity: n,
82            ..self
83        }
84    }
85
86    /// Set the hardware acceleration mode. Default: [`HardwareAccel::Auto`].
87    ///
88    /// [`HardwareAccel::Auto`] probes available backends in priority order
89    /// (NVDEC → QSV → `VideoToolbox` → VAAPI → AMF) and falls back to software
90    /// decoding without error if none are available.
91    ///
92    /// [`HardwareAccel::None`] forces CPU-only decoding.
93    #[must_use]
94    pub fn hardware_accel(self, accel: HardwareAccel) -> Self {
95        Self {
96            hw_accel: accel,
97            ..self
98        }
99    }
100
101    /// Build and start the background decode thread.
102    ///
103    /// The thread pre-fills the ring buffer; frames are delivered in
104    /// presentation order. The caller receives a [`DecodeBuffer`] immediately;
105    /// frames become available as the thread decodes them.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`PreviewError`] if the video file cannot be opened or contains
110    /// no decodable video stream.
111    pub fn build(self) -> Result<DecodeBuffer, PreviewError> {
112        // Open decoder on the calling thread for early validation.
113        // Propagates FileNotFound / NoVideoStream / Ffmpeg errors immediately.
114        let mut decoder = VideoDecoder::open(&self.path)
115            .hardware_accel(self.hw_accel)
116            .build()?;
117
118        let (tx, rx) = sync_channel(self.capacity);
119        let buffered = Arc::new(AtomicUsize::new(0));
120        let cancel = Arc::new(AtomicBool::new(false));
121
122        let buffered_thread = Arc::clone(&buffered);
123        let cancel_thread = Arc::clone(&cancel);
124
125        let (seek_tx, seek_rx) = channel::<SeekEvent>();
126        let (error_tx, error_rx) = channel::<String>();
127
128        let error_tx_thread = error_tx.clone();
129        let handle = thread::spawn(move || -> VideoDecoder {
130            decode_loop(
131                &mut decoder,
132                &tx,
133                &buffered_thread,
134                &cancel_thread,
135                &error_tx_thread,
136            );
137            decoder
138        });
139
140        Ok(DecodeBuffer {
141            rx: Some(rx),
142            buffered,
143            handle: Some(handle),
144            cancel,
145            capacity: self.capacity,
146            seeking: Arc::new(AtomicBool::new(false)),
147            last_good_frame: None,
148            seek_tx,
149            seek_rx,
150            error_tx,
151            error_rx,
152        })
153    }
154}
155
156// DecodeBuffer
157
158/// Pre-decodes frames from a video file into a ring buffer on a background thread.
159///
160/// `DecodeBuffer` decouples decoder latency from the presentation loop: the
161/// background thread keeps the buffer filled so [`pop_frame`](Self::pop_frame)
162/// can return the next frame without waiting for the decoder.
163///
164/// The default ring buffer capacity is 8 frames. Use
165/// [`open`](Self::open) → [`capacity`](DecodeBufferBuilder::capacity) →
166/// [`build`](DecodeBufferBuilder::build) to configure a different size.
167///
168/// # Usage
169///
170/// ```ignore
171/// let mut buf = DecodeBuffer::open(Path::new("clip.mp4"))
172///     .capacity(16)
173///     .build()?;
174///
175/// while let Some(frame) = buf.pop_frame() {
176///     // present frame…
177/// }
178/// ```
179///
180/// # Thread safety
181///
182/// `DecodeBuffer` is `Send` but **not** `Sync`; it must be owned by a single
183/// consumer. The internal [`std::sync::mpsc::Receiver`] enforces this.
184pub struct DecodeBuffer {
185    /// `Option` so `Drop` can take and drop the receiver before joining the thread.
186    rx: Option<Receiver<VideoFrame>>,
187    /// Approximate count of frames waiting in the ring buffer.
188    /// Incremented by the background thread on send; decremented by `pop_frame`.
189    buffered: Arc<AtomicUsize>,
190    /// Background decode thread handle. Returns the decoder on exit so `seek()`
191    /// can recover it without reopening the file.
192    handle: Option<JoinHandle<VideoDecoder>>,
193    /// Set to `true` to ask the background thread to exit its decode loop.
194    cancel: Arc<AtomicBool>,
195    /// Channel capacity; needed by `seek()` to create a replacement channel.
196    capacity: usize,
197    /// Set to `true` while an async seek is in progress.
198    seeking: Arc<AtomicBool>,
199    /// The last frame returned by `pop_frame`; replayed as a placeholder
200    /// while `seeking` is true.
201    last_good_frame: Option<VideoFrame>,
202    /// Sender side of the seek event channel; cloned into each seek worker.
203    seek_tx: Sender<SeekEvent>,
204    /// Receiver for seek completion events; exposed via `seek_events()`.
205    seek_rx: Receiver<SeekEvent>,
206    /// Sender side of the decode error channel; cloned into each decode thread.
207    error_tx: Sender<String>,
208    /// Receiver for non-fatal decode error messages; exposed via `error_events()`.
209    error_rx: Receiver<String>,
210}
211
212impl DecodeBuffer {
213    /// Open the video at `path` and return a builder for configuring the buffer.
214    ///
215    /// Chain with [`DecodeBufferBuilder::capacity`] and
216    /// [`DecodeBufferBuilder::build`] to start decoding.
217    #[must_use]
218    pub fn open(path: &Path) -> DecodeBufferBuilder {
219        DecodeBufferBuilder {
220            path: path.to_path_buf(),
221            capacity: DEFAULT_DECODE_BUFFER_CAPACITY,
222            hw_accel: HardwareAccel::Auto,
223        }
224    }
225
226    /// Pop the next decoded frame.
227    ///
228    /// - Returns [`FrameResult::Seeking`] immediately (non-blocking) while a
229    ///   [`seek_async`](Self::seek_async) is in progress.
230    /// - Returns [`FrameResult::Frame`] when a frame is available; blocks until
231    ///   the background thread produces one.
232    /// - Returns [`FrameResult::Eof`] when the background thread reaches end of
233    ///   file or the channel is disconnected.
234    #[must_use]
235    pub fn pop_frame(&mut self) -> FrameResult {
236        if self.seeking.load(Ordering::Acquire) {
237            return FrameResult::Seeking(self.last_good_frame.clone());
238        }
239        match self.rx.as_ref().and_then(|rx| rx.recv().ok()) {
240            Some(frame) => {
241                self.buffered.fetch_sub(1, Ordering::Relaxed);
242                self.last_good_frame = Some(frame.clone());
243                FrameResult::Frame(frame)
244            }
245            None => FrameResult::Eof,
246        }
247    }
248
249    /// Returns an approximation of the number of decoded frames currently
250    /// waiting in the buffer.
251    ///
252    /// This value is advisory only; it may lag the actual buffer state by one
253    /// scheduling quantum. Use it for diagnostics, not flow control.
254    #[must_use]
255    pub fn buffered_frames(&self) -> usize {
256        self.buffered.load(Ordering::Relaxed)
257    }
258
259    /// Returns a reference to the seek event receiver.
260    ///
261    /// After calling [`seek_async`](Self::seek_async), poll this receiver to
262    /// detect when the seek has completed:
263    /// - `try_recv()` — non-blocking; returns `Err(TryRecvError::Empty)` while
264    ///   the seek is still in progress.
265    /// - `recv()` — blocks until the seek finishes.
266    ///
267    /// Events are delivered within ~200 ms for local files.
268    /// Unconsumed events accumulate in the channel (one per completed seek).
269    #[must_use]
270    pub fn seek_events(&self) -> &Receiver<SeekEvent> {
271        &self.seek_rx
272    }
273
274    /// Returns the receiver for non-fatal decode error messages.
275    ///
276    /// Poll with `try_recv()` in the presentation loop. Each message
277    /// corresponds to one failed `decode_one()` call in the background thread.
278    /// The background thread exits after sending the error, so
279    /// [`pop_frame`](Self::pop_frame) will return `Eof` shortly after.
280    #[must_use]
281    pub fn error_events(&self) -> &Receiver<String> {
282        &self.error_rx
283    }
284
285    /// Frame-accurate seek to `target_pts`.
286    ///
287    /// Stops the background decode thread, seeks the underlying decoder to the
288    /// nearest preceding I-frame (`AVSEEK_FLAG_BACKWARD` + codec buffer flush),
289    /// then restarts the thread. The restarted thread discards frames until
290    /// `PTS ≥ target_pts` before making them available via [`pop_frame`](Self::pop_frame).
291    ///
292    /// Blocks until the thread has stopped and the seek has been accepted by
293    /// the decoder. Frames are filled asynchronously after the method returns.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`PreviewError::DecodeThreadPoisoned`] if the previous decode
298    /// thread panicked and cannot be recovered, or [`PreviewError::SeekFailed`]
299    /// if the underlying `FFmpeg` seek fails.
300    pub fn seek(&mut self, target_pts: Duration) -> Result<(), PreviewError> {
301        let (mut decoder, tx) = self.stop_and_seek(target_pts)?;
302        let buffered_thread = Arc::clone(&self.buffered);
303        let cancel_thread = Arc::clone(&self.cancel);
304        let error_tx_thread = self.error_tx.clone();
305
306        self.handle = Some(thread::spawn(move || -> VideoDecoder {
307            // Forward-decode discard: drop frames whose PTS is before target_pts.
308            loop {
309                if cancel_thread.load(Ordering::Acquire) {
310                    return decoder;
311                }
312                match decoder.decode_one() {
313                    Ok(Some(frame)) => {
314                        let pts = if frame.timestamp().is_valid() {
315                            frame.timestamp().as_duration()
316                        } else {
317                            Duration::ZERO
318                        };
319                        if pts >= target_pts {
320                            if tx.send(frame).is_ok() {
321                                buffered_thread.fetch_add(1, Ordering::Relaxed);
322                            } else {
323                                return decoder; // receiver dropped
324                            }
325                            break; // target frame sent; switch to normal loop
326                        }
327                        // Frame is before target — discard and continue.
328                    }
329                    Ok(None) => return decoder, // EOF before target
330                    Err(e) => {
331                        log::warn!("decode error during seek discard error={e}");
332                        let _ = error_tx_thread.send(e.to_string());
333                        return decoder;
334                    }
335                }
336            }
337
338            // Normal decode loop after the discard phase.
339            decode_loop(
340                &mut decoder,
341                &tx,
342                &buffered_thread,
343                &cancel_thread,
344                &error_tx_thread,
345            );
346            decoder
347        }));
348
349        Ok(())
350    }
351
352    /// Coarse seek to the nearest I-frame at or before `target_pts`.
353    ///
354    /// Faster than [`seek`](Self::seek) because it skips the forward-decode
355    /// discard step. The next [`pop_frame`](Self::pop_frame) returns the frame
356    /// at the I-frame position, which may be up to ±½ GOP before `target_pts`
357    /// (typically ±1–2 s for H.264 at default settings).
358    ///
359    /// **Typical use:** call repeatedly while a scrub-bar is being dragged;
360    /// call [`seek`](Self::seek) on mouse-up for frame accuracy.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`PreviewError::DecodeThreadPoisoned`] if the previous decode
365    /// thread panicked and cannot be recovered, or [`PreviewError::SeekFailed`]
366    /// if the underlying `FFmpeg` seek fails.
367    pub fn seek_coarse(&mut self, target_pts: Duration) -> Result<(), PreviewError> {
368        log::debug!("coarse seek target_pts={target_pts:?}");
369        let (mut decoder, tx) = self.stop_and_seek(target_pts)?;
370        let buffered_thread = Arc::clone(&self.buffered);
371        let cancel_thread = Arc::clone(&self.cancel);
372        let error_tx_thread = self.error_tx.clone();
373
374        // No discard loop — start the normal decode loop directly from the I-frame.
375        self.handle = Some(thread::spawn(move || -> VideoDecoder {
376            decode_loop(
377                &mut decoder,
378                &tx,
379                &buffered_thread,
380                &cancel_thread,
381                &error_tx_thread,
382            );
383            decoder
384        }));
385
386        Ok(())
387    }
388
389    /// Initiate a frame-accurate seek on a background thread and return immediately.
390    ///
391    /// While seeking is in progress, [`pop_frame`](Self::pop_frame) returns
392    /// [`FrameResult::Seeking`] with the last successfully decoded frame as a
393    /// placeholder. Normal [`FrameResult::Frame`] values resume once the seek
394    /// completes.
395    ///
396    /// The seek uses the same frame-accurate strategy as [`seek`](Self::seek):
397    /// `FFmpeg` jumps to the nearest preceding I-frame, then frames before
398    /// `target_pts` are discarded before the first frame is made available.
399    ///
400    /// If called again before the previous seek completes, the new seek
401    /// supersedes the old one; the old worker exits at the next cancel check.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`PreviewError::DecodeThreadPoisoned`] if the previous decode
406    /// thread panicked and its decoder cannot be recovered. The frame-accurate
407    /// seek runs on the background worker: a seek failure there is logged and
408    /// ends the seek, while discard-phase decode errors are reported via the
409    /// [`error_events`](Self::error_events) channel.
410    pub fn seek_async(&mut self, target_pts: Duration) -> Result<(), PreviewError> {
411        log::debug!("async seek started target_pts={target_pts:?}");
412
413        self.seeking.store(true, Ordering::Release);
414        self.cancel.store(true, Ordering::Release);
415
416        if let Some(rx) = &self.rx {
417            while rx.try_recv().is_ok() {
418                self.buffered.fetch_sub(1, Ordering::Relaxed);
419            }
420        }
421
422        // Recover the decoder from the old worker synchronously (bounded: the old
423        // worker returns promptly once `cancel` is set). A failed join means the
424        // decode thread panicked; report it as a typed, recoverable error rather
425        // than panicking. The frame-accurate seek then runs on the new worker, so
426        // `seek_async` stays non-blocking for the expensive FFmpeg work.
427        let Some(mut decoder) = self.handle.take().and_then(|h| h.join().ok()) else {
428            self.seeking.store(false, Ordering::Release);
429            return Err(PreviewError::DecodeThreadPoisoned);
430        };
431        drop(self.rx.take());
432
433        let (new_tx, new_rx) = sync_channel(self.capacity);
434        self.rx = Some(new_rx);
435
436        let buffered = Arc::clone(&self.buffered);
437        let cancel = Arc::clone(&self.cancel);
438        let seeking = Arc::clone(&self.seeking);
439        let seek_event_tx = self.seek_tx.clone();
440        let error_tx_async = self.error_tx.clone();
441
442        let worker = thread::spawn(move || -> VideoDecoder {
443            if let Err(e) = decoder.seek(target_pts, SeekMode::Backward) {
444                log::warn!("seek_async seek failed target_pts={target_pts:?} error={e}");
445                if !cancel.load(Ordering::Acquire) {
446                    seeking.store(false, Ordering::Release);
447                }
448                return decoder;
449            }
450
451            buffered.store(0, Ordering::Relaxed);
452            cancel.store(false, Ordering::Release);
453            // Mark seek as complete so pop_frame() transitions to blocking
454            // recv(). Only clear if no newer seek_async has superseded us.
455            if !cancel.load(Ordering::Acquire) {
456                seeking.store(false, Ordering::Release);
457            }
458
459            // Forward-decode discard: skip frames before target_pts.
460            loop {
461                if cancel.load(Ordering::Acquire) {
462                    return decoder;
463                }
464                match decoder.decode_one() {
465                    Ok(Some(frame)) => {
466                        let pts = if frame.timestamp().is_valid() {
467                            frame.timestamp().as_duration()
468                        } else {
469                            Duration::ZERO
470                        };
471                        if pts >= target_pts {
472                            let first_pts = pts;
473                            // Send the event BEFORE pushing the frame so that
474                            // when pop_frame() wakes up the event is already in
475                            // the seek_events channel (avoids a try_recv race).
476                            let _ = seek_event_tx.send(SeekEvent::Completed { pts: first_pts });
477                            if new_tx.send(frame).is_ok() {
478                                buffered.fetch_add(1, Ordering::Relaxed);
479                            } else {
480                                return decoder; // receiver dropped
481                            }
482                            break;
483                        }
484                        // Frame before target — discard.
485                    }
486                    Ok(None) => return decoder, // EOF before target
487                    Err(e) => {
488                        log::warn!("seek_async discard error error={e}");
489                        let _ = error_tx_async.send(e.to_string());
490                        return decoder;
491                    }
492                }
493            }
494
495            decode_loop(&mut decoder, &new_tx, &buffered, &cancel, &error_tx_async);
496            decoder
497        });
498
499        self.handle = Some(worker);
500        Ok(())
501    }
502
503    /// Shared helper for `seek` and `seek_coarse`.
504    ///
505    /// 1. Signals cancel, drains the channel, joins the thread to recover the decoder.
506    /// 2. Seeks the decoder to the nearest I-frame at or before `target_pts`.
507    /// 3. Resets the buffered counter, creates a fresh channel, clears the cancel flag.
508    ///
509    /// Returns `(decoder, SyncSender)` ready for the caller to spawn a new thread.
510    fn stop_and_seek(
511        &mut self,
512        target_pts: Duration,
513    ) -> Result<(VideoDecoder, SyncSender<VideoFrame>), PreviewError> {
514        // 1. Signal the background thread to exit its decode loop.
515        self.cancel.store(true, Ordering::Release);
516
517        // 2. Drain the channel so the background thread is not blocked on send().
518        if let Some(rx) = &self.rx {
519            while rx.try_recv().is_ok() {
520                self.buffered.fetch_sub(1, Ordering::Relaxed);
521            }
522        }
523
524        // 3. Join the thread to recover the decoder. A failed join means the
525        //    decode thread panicked and cannot be recovered.
526        let mut decoder = self
527            .handle
528            .take()
529            .and_then(|h| h.join().ok())
530            .ok_or(PreviewError::DecodeThreadPoisoned)?;
531
532        // 4. Seek to the nearest I-frame at or before target_pts.
533        //    avformat_seek_file with AVSEEK_FLAG_BACKWARD and avcodec_flush_buffers
534        //    are handled inside VideoDecoder::seek (ff-decode/video/decoder_inner/seeking.rs).
535        decoder
536            .seek(target_pts, SeekMode::Backward)
537            .map_err(|e| PreviewError::SeekFailed {
538                target: target_pts,
539                reason: e.to_string(),
540            })?;
541
542        // 5. Reset counter, create a fresh channel, clear the cancel flag.
543        self.buffered.store(0, Ordering::Relaxed);
544        let (tx, rx) = sync_channel(self.capacity);
545        self.rx = Some(rx);
546        self.cancel.store(false, Ordering::Release);
547
548        Ok((decoder, tx))
549    }
550}
551
552impl Drop for DecodeBuffer {
553    fn drop(&mut self) {
554        // Signal cancel so the thread exits the decode loop promptly.
555        self.cancel.store(true, Ordering::Release);
556        // Drop the receiver so SyncSender::send() returns Err, unblocking the
557        // thread if it is waiting for space in a full channel.
558        drop(self.rx.take());
559        // Join (ignoring the returned decoder).
560        if let Some(h) = self.handle.take() {
561            let _ = h.join();
562        }
563    }
564}
565
566// decode_loop
567
568/// Normal decode loop body shared between `build()` and the post-seek thread.
569///
570/// Exits when EOF is reached, a decode error occurs, or the `cancel` flag is set,
571/// or the receiver drops (i.e., `DecodeBuffer` was dropped).
572pub(super) fn decode_loop(
573    decoder: &mut VideoDecoder,
574    tx: &SyncSender<VideoFrame>,
575    buffered: &AtomicUsize,
576    cancel: &AtomicBool,
577    error_tx: &Sender<String>,
578) {
579    loop {
580        if cancel.load(Ordering::Acquire) {
581            break;
582        }
583        match decoder.decode_one() {
584            Ok(Some(frame)) => {
585                if tx.send(frame).is_ok() {
586                    buffered.fetch_add(1, Ordering::Relaxed);
587                } else {
588                    // Receiver was dropped — DecodeBuffer has been dropped.
589                    break;
590                }
591            }
592            Ok(None) => break, // EOF
593            Err(e) => {
594                log::warn!("decode error in background thread error={e}");
595                let _ = error_tx.send(e.to_string());
596                break;
597            }
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use std::path::Path;
606    use std::thread;
607
608    fn test_video_path() -> std::path::PathBuf {
609        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
610    }
611
612    #[test]
613    fn decode_buffer_build_should_fail_for_nonexistent_file() {
614        let result = DecodeBuffer::open(Path::new("nonexistent_placeholder.mp4")).build();
615        assert!(
616            result.is_err(),
617            "build() must return Err for a non-existent file"
618        );
619    }
620
621    #[test]
622    fn decode_buffer_open_should_use_default_capacity() {
623        let path = test_video_path();
624        let buf = match DecodeBuffer::open(&path).build() {
625            Ok(buf) => buf,
626            Err(e) => {
627                println!("skipping: video file not available: {e}");
628                return;
629            }
630        };
631        // Buffer starts empty; frames arrive asynchronously.
632        assert_eq!(
633            buf.buffered_frames(),
634            0,
635            "buffer must report 0 before any frames have been consumed"
636        );
637    }
638
639    #[test]
640    fn decode_buffer_pop_frame_should_return_some_then_none_at_eof() {
641        let path = test_video_path();
642        let mut buf = match DecodeBuffer::open(&path).capacity(4).build() {
643            Ok(buf) => buf,
644            Err(e) => {
645                println!("skipping: video file not available: {e}");
646                return;
647            }
648        };
649        // Pop at least one frame to confirm the decoder is running.
650        assert!(
651            matches!(buf.pop_frame(), FrameResult::Frame(_)),
652            "pop_frame() must return Frame for a valid video file"
653        );
654    }
655
656    #[test]
657    fn seek_should_reposition_to_target_pts() {
658        let path = test_video_path();
659        let mut buf = match DecodeBuffer::open(&path).capacity(4).build() {
660            Ok(buf) => buf,
661            Err(e) => {
662                println!("skipping: video file not available: {e}");
663                return;
664            }
665        };
666
667        // Consume a few frames to advance past the start.
668        for _ in 0..5 {
669            if matches!(buf.pop_frame(), FrameResult::Eof) {
670                println!("skipping: EOF before seek target");
671                return;
672            }
673        }
674
675        let seek_target = Duration::from_secs(1);
676        match buf.seek(seek_target) {
677            Ok(()) => {}
678            Err(e) => {
679                println!("skipping: seek not supported or failed: {e}");
680                return;
681            }
682        }
683
684        // After seek, the first frame's PTS must be at or near the target.
685        let frame = match buf.pop_frame() {
686            FrameResult::Frame(f) => f,
687            FrameResult::Eof | FrameResult::Seeking(_) => {
688                println!("skipping: no frame after seek");
689                return;
690            }
691        };
692
693        if frame.timestamp().is_valid() {
694            let pts = frame.timestamp().as_duration();
695            // Allow ±1 second of tolerance (one GOP) for I-frame alignment.
696            assert!(
697                pts >= seek_target.saturating_sub(Duration::from_secs(1)),
698                "post-seek frame PTS must be near target; target={seek_target:?} pts={pts:?}"
699            );
700        }
701    }
702
703    #[test]
704    fn seek_should_fail_for_stopped_buffer() {
705        // Build with non-existent file → build() fails.
706        // This confirms seek errors are propagated correctly.
707        let result = DecodeBuffer::open(Path::new("nonexistent.mp4")).build();
708        assert!(
709            result.is_err(),
710            "build() must fail for non-existent file (precondition for seek error path)"
711        );
712    }
713
714    #[test]
715    fn seek_async_should_send_completed_event_with_first_frame_pts() {
716        let path = test_video_path();
717        let mut buf = match DecodeBuffer::open(&path).capacity(4).build() {
718            Ok(buf) => buf,
719            Err(e) => {
720                println!("skipping: video file not available: {e}");
721                return;
722            }
723        };
724
725        // Pop one frame to establish last_good_frame.
726        match buf.pop_frame() {
727            FrameResult::Frame(_) => {}
728            _ => {
729                println!("skipping: no initial frame available");
730                return;
731            }
732        }
733
734        let seek_target = Duration::from_secs(1);
735        buf.seek_async(seek_target)
736            .expect("seek_async on a healthy buffer must return Ok");
737
738        // Drive the seek to completion by polling pop_frame.
739        let deadline = std::time::Instant::now() + Duration::from_secs(10);
740        loop {
741            assert!(
742                std::time::Instant::now() < deadline,
743                "timed out waiting for seek to complete"
744            );
745            match buf.pop_frame() {
746                FrameResult::Frame(_) => break, // seek done, first post-seek frame received
747                FrameResult::Seeking(_) => thread::sleep(Duration::from_millis(10)),
748                FrameResult::Eof => {
749                    println!("skipping: EOF reached during seek event test");
750                    return;
751                }
752            }
753        }
754
755        // After pop_frame returned Frame, SeekEvent::Completed must be in the channel.
756        let event = buf.seek_events().try_recv();
757        assert!(
758            event.is_ok(),
759            "expected SeekEvent::Completed after pop_frame returned Frame; got Err"
760        );
761        if let Ok(SeekEvent::Completed { pts }) = event {
762            assert!(
763                pts >= Duration::ZERO,
764                "seek event pts must be non-negative; got pts={pts:?}"
765            );
766        }
767    }
768
769    #[test]
770    fn seek_async_should_deliver_frames_after_completion() {
771        let path = test_video_path();
772        let mut buf = match DecodeBuffer::open(&path).capacity(4).build() {
773            Ok(buf) => buf,
774            Err(e) => {
775                println!("skipping: video file not available: {e}");
776                return;
777            }
778        };
779
780        // Pop one frame to establish last_good_frame.
781        match buf.pop_frame() {
782            FrameResult::Frame(_) => {}
783            _ => {
784                println!("skipping: no initial frame available");
785                return;
786            }
787        }
788
789        let seek_target = Duration::from_secs(1);
790        buf.seek_async(seek_target)
791            .expect("seek_async on a healthy buffer must return Ok");
792
793        // Poll until a Frame arrives (seek complete) or we time out.
794        let deadline = std::time::Instant::now() + Duration::from_secs(10);
795        loop {
796            match buf.pop_frame() {
797                FrameResult::Frame(_) => break, // seek completed successfully
798                FrameResult::Seeking(_) => {
799                    thread::sleep(Duration::from_millis(10));
800                }
801                FrameResult::Eof => {
802                    println!("skipping: EOF reached during seek_async test");
803                    return;
804                }
805            }
806            assert!(
807                std::time::Instant::now() < deadline,
808                "seek_async: timed out waiting for seek to complete"
809            );
810        }
811    }
812
813    /// Drives an async seek to completion by polling `pop_frame`, returning true
814    /// when a post-seek frame arrives and false on EOF (a legitimate skip).
815    fn drain_async_seek(buf: &mut DecodeBuffer) -> bool {
816        let deadline = std::time::Instant::now() + Duration::from_secs(10);
817        loop {
818            match buf.pop_frame() {
819                FrameResult::Frame(_) => return true,
820                FrameResult::Seeking(_) => thread::sleep(Duration::from_millis(10)),
821                FrameResult::Eof => return false,
822            }
823            if std::time::Instant::now() >= deadline {
824                return false;
825            }
826        }
827    }
828
829    // Exercises the decoder-recovery path across all three seek entry points in
830    // sequence: each recovers the decoder from the prior worker (poisoned would
831    // surface as DecodeThreadPoisoned) and resumes decoding. Probe-gated (RK-002):
832    // skips when the fixture video is unavailable or EOF is reached early.
833    #[test]
834    fn seek_recovery_should_survive_interleaved_seek_variants() {
835        let path = test_video_path();
836        let mut buf = match DecodeBuffer::open(&path).capacity(4).build() {
837            Ok(buf) => buf,
838            Err(e) => {
839                println!("skipping: video file not available: {e}");
840                return;
841            }
842        };
843
844        if !matches!(buf.pop_frame(), FrameResult::Frame(_)) {
845            println!("skipping: no initial frame available");
846            return;
847        }
848
849        // Frame-accurate seek → must recover and deliver a frame.
850        if buf.seek(Duration::from_millis(500)).is_err() {
851            println!("skipping: seek failed on fixture");
852            return;
853        }
854        if !matches!(buf.pop_frame(), FrameResult::Frame(_) | FrameResult::Eof) {
855            println!("skipping: no frame after seek");
856            return;
857        }
858
859        // Coarse seek → recovers the decoder from the previous seek's worker.
860        if buf.seek_coarse(Duration::from_secs(1)).is_err() {
861            println!("skipping: seek_coarse failed on fixture");
862            return;
863        }
864        if matches!(buf.pop_frame(), FrameResult::Seeking(_)) {
865            println!("skipping: unexpected seeking state after coarse seek");
866            return;
867        }
868
869        // Async seek → also recovers the decoder; must return Ok on a healthy
870        // buffer and eventually deliver a frame (or hit EOF).
871        buf.seek_async(Duration::from_millis(250))
872            .expect("seek_async on a healthy buffer must return Ok");
873        let _ = drain_async_seek(&mut buf);
874    }
875}