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}