1use crate::video::error::{VideoError, VideoResult};
2use crate::video::scheduler::ScheduledFrame;
3use crossbeam_channel::{Receiver, Sender};
4use ffmpeg_next as ffmpeg;
5use std::collections::VecDeque;
6use std::ffi::{CString, c_char};
7use std::path::Path;
8use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::thread;
11use std::time::Duration;
12
13const MAX_VIDEO_DIMENSION: u32 = 16_384;
14const MAX_DECODED_FRAME_BYTES: u64 = 128 * 1024 * 1024;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum HwAccel {
18 Auto,
20 Vaapi,
21 Nvdec,
22 VideoToolbox,
23 Software,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DecoderState {
28 Initializing,
29 HardwareNegotiating,
30 HardwareActive,
31 SoftwareActive,
32 SoftwareFallback,
33 Failed,
34}
35
36impl DecoderState {
37 const fn code(self) -> u8 {
38 match self {
39 Self::Initializing => 0,
40 Self::HardwareNegotiating => 1,
41 Self::HardwareActive => 2,
42 Self::SoftwareActive => 3,
43 Self::SoftwareFallback => 4,
44 Self::Failed => 5,
45 }
46 }
47
48 const fn from_code(code: u8) -> Self {
49 match code {
50 1 => Self::HardwareNegotiating,
51 2 => Self::HardwareActive,
52 3 => Self::SoftwareActive,
53 4 => Self::SoftwareFallback,
54 5 => Self::Failed,
55 _ => Self::Initializing,
56 }
57 }
58
59 pub const fn name(self) -> &'static str {
60 match self {
61 Self::Initializing => "initializing",
62 Self::HardwareNegotiating => "hardware negotiation",
63 Self::HardwareActive => "hardware active",
64 Self::SoftwareActive => "software active",
65 Self::SoftwareFallback => "software fallback",
66 Self::Failed => "failed",
67 }
68 }
69}
70
71pub const PROBE_MIN_FRAMES: u64 = 30;
73
74pub const fn probe_succeeded(frames: u64, state: DecoderState) -> bool {
80 frames > PROBE_MIN_FRAMES && !matches!(state, DecoderState::Failed)
81}
82
83impl HwAccel {
84 pub fn name(&self) -> &'static str {
85 match self {
86 HwAccel::Auto => "Auto",
87 HwAccel::Vaapi => "VAAPI",
88 HwAccel::Nvdec => "NVDEC",
89 HwAccel::VideoToolbox => "VideoToolbox",
90 HwAccel::Software => "Software",
91 }
92 }
93
94 const fn code(self) -> u8 {
95 match self {
96 HwAccel::Auto => 0,
97 HwAccel::Software => 1,
98 HwAccel::Vaapi => 2,
99 HwAccel::Nvdec => 3,
100 HwAccel::VideoToolbox => 4,
101 }
102 }
103
104 pub fn from_config(value: &str) -> HwAccel {
105 match value.trim().to_ascii_lowercase().as_str() {
106 "vaapi" => HwAccel::Vaapi,
107 "nvdec" | "nvidia" | "cuda" => HwAccel::Nvdec,
108 "software" | "none" | "off" => HwAccel::Software,
109 _ => HwAccel::Auto,
110 }
111 }
112
113 #[cfg(target_os = "linux")]
119 fn auto_hardware_order() -> Vec<HwAccel> {
120 let mut order = Vec::new();
121 if nvidia_gpu_present() {
122 order.push(HwAccel::Nvdec);
123 }
124 if vaapi_available() {
125 order.push(HwAccel::Vaapi);
126 }
127 order
128 }
129}
130
131#[derive(Debug, Clone)]
132pub struct VideoMetadata {
133 pub width: u32,
134 pub height: u32,
135 pub duration: Duration,
136 pub fps: f64,
137 pub codec: String,
138 pub format: String,
139 pub total_frames: u64,
140}
141
142#[derive(Debug)]
143pub enum VideoFrameData {
144 Rgba(Vec<u8>),
145 Nv12 {
146 y_plane: Vec<u8>,
147 uv_plane: Vec<u8>,
148 color: YuvColorInfo,
149 },
150}
151
152impl VideoFrameData {
153 pub fn len(&self) -> usize {
154 match self {
155 Self::Rgba(data) => data.len(),
156 Self::Nv12 {
157 y_plane, uv_plane, ..
158 } => y_plane.len() + uv_plane.len(),
159 }
160 }
161
162 pub fn is_empty(&self) -> bool {
163 self.len() == 0
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub struct YuvColorInfo {
169 pub matrix: YuvMatrix,
170 pub range: YuvRange,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum YuvMatrix {
175 Bt601,
176 Bt709,
177 Bt2020,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum YuvRange {
182 Limited,
183 Full,
184}
185
186#[derive(Debug)]
187pub struct VideoFrame {
188 pub data: VideoFrameData,
189 pub width: u32,
190 pub height: u32,
191 pub pts: Duration,
192 pub index: u64,
193}
194
195impl VideoFrame {
196 pub fn into_scheduled(self) -> ScheduledFrame {
199 ScheduledFrame::new(self.data, self.width, self.height, self.pts, self.index)
200 }
201}
202
203#[derive(Debug, Clone)]
204pub struct DecoderInfo {
205 pub codec_name: String,
206 pub hardware_accel: Option<String>,
207 pub pixel_format: String,
208 pub dropped_frames: u64,
209 pub state: DecoderState,
210}
211
212#[derive(Debug, Clone, Copy)]
213enum DecoderControl {
214 Seek(Duration, u64),
215}
216
217pub struct VideoDecoder {
218 metadata: VideoMetadata,
219 frame_queue: Arc<Mutex<VecDeque<VideoFrame>>>,
220 dropped_frames: Arc<AtomicU64>,
221 control_tx: Sender<DecoderControl>,
222 stop_flag: Arc<AtomicBool>,
223 pause_requested: Arc<AtomicBool>,
224 seek_epoch: Arc<AtomicU64>,
225 hw_in_use: Arc<AtomicU8>,
226 decoder_state: Arc<AtomicU8>,
227 fallback_occurred: Arc<AtomicBool>,
228 max_output_fps: Arc<AtomicU32>,
229 decode_thread: Option<thread::JoinHandle<()>>,
230}
231
232impl VideoDecoder {
233 pub fn new<P: AsRef<Path>>(path: P, hw_accel: HwAccel) -> VideoResult<Self> {
234 Self::with_preload(path, hw_accel, 2, true)
235 }
236
237 pub fn with_preload<P: AsRef<Path>>(
238 path: P,
239 hw_accel: HwAccel,
240 preload_frames: usize,
241 loop_video: bool,
242 ) -> VideoResult<Self> {
243 let path = path.as_ref().to_path_buf();
244
245 ensure_ffmpeg_init()?;
246
247 let metadata = Self::extract_metadata(&path)?;
248
249 tracing::info!(
250 "Opened video: {}x{} @ {:.2} fps, duration: {:?}, codec: {}",
251 metadata.width,
252 metadata.height,
253 metadata.fps,
254 metadata.duration,
255 metadata.codec
256 );
257
258 let preload = preload_frames.clamp(1, 3);
263 let frame_queue = Arc::new(Mutex::new(VecDeque::with_capacity(preload)));
264 let dropped_frames = Arc::new(AtomicU64::new(0));
265 let (control_tx, control_rx) = crossbeam_channel::unbounded();
266
267 let stop_flag = Arc::new(AtomicBool::new(false));
268 let stop_flag_clone = stop_flag.clone();
269 let pause_requested = Arc::new(AtomicBool::new(false));
270 let pause_requested_clone = pause_requested.clone();
271 let seek_epoch = Arc::new(AtomicU64::new(0));
272 let seek_epoch_clone = seek_epoch.clone();
273 let hw_in_use = Arc::new(AtomicU8::new(0));
274 let hw_in_use_clone = hw_in_use.clone();
275 let decoder_state = Arc::new(AtomicU8::new(DecoderState::Initializing.code()));
276 let decoder_state_clone = decoder_state.clone();
277 let fallback_occurred = Arc::new(AtomicBool::new(false));
278 let fallback_clone = fallback_occurred.clone();
279 let max_output_fps = Arc::new(AtomicU32::new(0));
280 let max_output_fps_clone = max_output_fps.clone();
281 let frame_queue_clone = frame_queue.clone();
282 let dropped_frames_clone = dropped_frames.clone();
283
284 let decode_thread = thread::Builder::new()
285 .name("wallr-video-decoder".to_string())
286 .spawn(move || {
287 let used = Self::decode_loop(
288 path,
289 hw_accel,
290 frame_queue_clone,
291 preload,
292 dropped_frames_clone,
293 control_rx,
294 stop_flag_clone,
295 pause_requested_clone,
296 seek_epoch_clone,
297 hw_in_use_clone.clone(),
298 decoder_state_clone.clone(),
299 fallback_clone,
300 max_output_fps_clone,
301 loop_video,
302 );
303 let used = match used {
304 Ok(used) => used,
305 Err(e) => {
306 tracing::error!("Video decode loop: {}", e);
307 decoder_state_clone.store(DecoderState::Failed.code(), Ordering::Release);
308 HwAccel::Software
309 }
310 };
311 tracing::info!("Decode thread exited (backend: {})", used.name());
312 hw_in_use_clone.store(used.code(), Ordering::Relaxed);
314 })
315 .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
316
317 Ok(Self {
318 metadata,
319 frame_queue,
320 dropped_frames,
321 control_tx,
322 stop_flag,
323 pause_requested,
324 seek_epoch,
325 hw_in_use,
326 decoder_state,
327 fallback_occurred,
328 max_output_fps,
329 decode_thread: Some(decode_thread),
330 })
331 }
332
333 pub fn set_max_output_fps(&self, fps: Option<u32>) {
334 self.max_output_fps
335 .store(fps.filter(|fps| *fps > 0).unwrap_or(0), Ordering::Release);
336 }
337
338 fn extract_metadata(path: &Path) -> VideoResult<VideoMetadata> {
339 let ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
340 path: path.to_path_buf(),
341 source: std::io::Error::other(e.to_string()),
342 })?;
343
344 let stream = ictx
345 .streams()
346 .best(ffmpeg::media::Type::Video)
347 .ok_or_else(|| VideoError::NoVideoStream(path.to_path_buf()))?;
348
349 let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
350 .and_then(|ctx| ctx.decoder().video())
351 .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
352
353 let width = decoder.width();
354 let height = decoder.height();
355 let codec = decoder
356 .codec()
357 .map(|c| c.name().to_string())
358 .unwrap_or_else(|| "unknown".to_string());
359
360 let frame_rate = stream.avg_frame_rate();
361 let fps = if frame_rate.numerator() > 0 && frame_rate.denominator() > 0 {
362 let fps = frame_rate.numerator() as f64 / frame_rate.denominator() as f64;
363 if fps.is_finite() && fps > 0.0 {
364 fps
365 } else {
366 30.0
367 }
368 } else {
369 30.0
370 };
371
372 let duration = {
373 let duration_ts = stream.duration();
374 let time_base = stream.time_base();
375 let seconds = if duration_ts > 0 && time_base.denominator() > 0 {
376 duration_ts as f64 * time_base.numerator() as f64 / time_base.denominator() as f64
377 } else {
378 ictx.duration() as f64 / ffmpeg::ffi::AV_TIME_BASE as f64
379 };
380 duration_from_seconds(seconds)?
381 };
382
383 if width == 0 || height == 0 {
384 return Err(VideoError::UnsupportedFormat(
385 "video has empty dimensions".to_string(),
386 ));
387 }
388 validate_video_dimensions(width, height)?;
389 let total_frames = (duration.as_secs_f64() * fps).min(u64::MAX as f64) as u64;
390
391 Ok(VideoMetadata {
392 width,
393 height,
394 duration,
395 fps,
396 codec,
397 format: ictx.format().name().to_string(),
398 total_frames,
399 })
400 }
401
402 fn init_hw_device(hw_accel: HwAccel) -> Option<(*mut ffmpeg::ffi::AVBufferRef, &'static str)> {
403 if hw_accel == HwAccel::Vaapi {
408 return Self::init_vaapi_device();
409 }
410 let (type_name, device) = match hw_accel {
411 HwAccel::Nvdec => ("cuda", Some(c"0")),
412 HwAccel::VideoToolbox => ("videotoolbox", None),
413 HwAccel::Software | HwAccel::Auto | HwAccel::Vaapi => return None,
414 };
415
416 let type_name_c = CString::new(type_name).ok()?;
417
418 unsafe {
420 let hw_type = ffmpeg::ffi::av_hwdevice_find_type_by_name(type_name_c.as_ptr());
421 if hw_type == ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_NONE {
422 tracing::warn!("{} hardware type unavailable", hw_accel.name());
423 return None;
424 }
425
426 let mut device_ctx: *mut ffmpeg::ffi::AVBufferRef = std::ptr::null_mut();
427 let device_ptr: *const c_char = device.map(|d| d.as_ptr()).unwrap_or(std::ptr::null());
428 let ret = ffmpeg::ffi::av_hwdevice_ctx_create(
429 &mut device_ctx,
430 hw_type,
431 device_ptr,
432 std::ptr::null_mut(),
433 0,
434 );
435 if ret < 0 || device_ctx.is_null() {
436 tracing::warn!("{} device init failed ({})", hw_accel.name(), ret);
437 return None;
438 }
439 Some((device_ctx, type_name))
440 }
441 }
442
443 fn init_vaapi_device() -> Option<(*mut ffmpeg::ffi::AVBufferRef, &'static str)> {
444 let type_name_c = CString::new("vaapi").ok()?;
445 let hw_type = unsafe { ffmpeg::ffi::av_hwdevice_find_type_by_name(type_name_c.as_ptr()) };
447 if hw_type == ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_NONE {
448 tracing::warn!("VAAPI hardware type unavailable");
449 return None;
450 }
451 for candidate in vaapi_device_candidates() {
452 let result = unsafe {
455 let mut device_ctx: *mut ffmpeg::ffi::AVBufferRef = std::ptr::null_mut();
456 let ret = ffmpeg::ffi::av_hwdevice_ctx_create(
457 &mut device_ctx,
458 hw_type,
459 candidate.as_ptr(),
460 std::ptr::null_mut(),
461 0,
462 );
463 if ret < 0 || device_ctx.is_null() {
464 None
465 } else {
466 Some((device_ctx, "vaapi"))
467 }
468 };
469 if result.is_some() {
470 return result;
471 }
472 }
473 tracing::warn!("VAAPI device init failed on all render nodes");
474 None
475 }
476
477 fn try_hw_decoder(
479 stream: &ffmpeg::format::stream::Stream,
480 hw_accel: HwAccel,
481 ) -> Option<(ffmpeg::codec::decoder::Video, HwAccel)> {
482 let (device_ctx, _) = Self::init_hw_device(hw_accel)?;
483
484 let mut context =
485 ffmpeg::codec::context::Context::from_parameters(stream.parameters()).ok()?;
486
487 let codec = hardware_decoder(stream.parameters().id(), hw_accel)?;
488
489 unsafe {
492 (*context.as_mut_ptr()).hw_device_ctx = device_ctx;
493 (*context.as_mut_ptr()).get_format = Some(select_hw_pixel_format);
494 }
495
496 match context
497 .decoder()
498 .open_as(codec)
499 .and_then(|opened| opened.video())
500 {
501 Ok(decoder) => {
502 tracing::info!(
503 "Hardware decoder initialized: {} using {}; awaiting negotiated hardware frame",
504 hw_accel.name(),
505 codec.name()
506 );
507 Some((decoder, hw_accel))
508 }
509 Err(e) => {
510 tracing::warn!("{} decode init failed: {}", hw_accel.name(), e);
511 None
512 }
513 }
514 }
515
516 fn build_decoder(
522 stream: &ffmpeg::format::stream::Stream,
523 hw_accel: HwAccel,
524 ) -> VideoResult<(ffmpeg::codec::decoder::Video, HwAccel, DecoderState)> {
525 let mut hardware_attempted = false;
526 match hw_accel {
527 HwAccel::Auto => {
528 let mut tried = 0;
534 for backend in HwAccel::auto_hardware_order() {
535 tried += 1;
536 hardware_attempted = true;
537 if let Some(result) = Self::try_hw_decoder(stream, backend) {
538 return Ok((result.0, result.1, DecoderState::HardwareNegotiating));
539 }
540 }
541 if tried == 0 {
542 tracing::info!("No hardware decoder present, using software decoder");
543 } else {
544 tracing::info!("Hardware decoders failed, using software decoder");
545 }
546 }
547 HwAccel::Software => {
548 tracing::info!("Software decoder explicitly requested");
550 }
551 specific => {
552 hardware_attempted = true;
554 if let Some(result) = Self::try_hw_decoder(stream, specific) {
555 return Ok((result.0, result.1, DecoderState::HardwareNegotiating));
556 }
557 tracing::info!(
559 "{} hardware decoder failed, falling back to software",
560 specific.name()
561 );
562 }
563 }
564
565 let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
568 .and_then(|ctx| ctx.decoder().video())
569 .map_err(|e| VideoError::UnsupportedCodec(e.to_string()))?;
570 let state = software_decoder_state(hardware_attempted);
571 Ok((decoder, HwAccel::Software, state))
572 }
573
574 #[allow(clippy::too_many_arguments)]
575 fn decode_loop(
576 path: std::path::PathBuf,
577 hw_accel: HwAccel,
578 frame_queue: Arc<Mutex<VecDeque<VideoFrame>>>,
579 frame_capacity: usize,
580 dropped_frames: Arc<AtomicU64>,
581 control_rx: Receiver<DecoderControl>,
582 stop_flag: Arc<AtomicBool>,
583 pause_requested: Arc<AtomicBool>,
584 seek_epoch: Arc<AtomicU64>,
585 hw_in_use: Arc<AtomicU8>,
586 decoder_state: Arc<AtomicU8>,
587 fallback_occurred: Arc<AtomicBool>,
588 max_output_fps: Arc<AtomicU32>,
589 loop_video: bool,
590 ) -> VideoResult<HwAccel> {
591 let mut ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
592 path: path.clone(),
593 source: std::io::Error::other(e.to_string()),
594 })?;
595
596 let stream = ictx
597 .streams()
598 .best(ffmpeg::media::Type::Video)
599 .ok_or_else(|| VideoError::NoVideoStream(path.clone()))?;
600
601 let video_stream_index = stream.index();
602 let time_base = stream.time_base();
603
604 let (mut decoder, used_hw, initial_state) = Self::build_decoder(&stream, hw_accel)?;
605 tracing::info!("Decoder in use: {}", used_hw.name());
606 decoder_state.store(initial_state.code(), Ordering::Release);
607 fallback_occurred.store(
608 initial_state == DecoderState::SoftwareFallback,
609 Ordering::Release,
610 );
611
612 let mut scaler: Option<ffmpeg::software::scaling::Context> = None;
613 let mut scaler_src: Option<ffmpeg::format::Pixel> = None;
614
615 let mut paused: bool;
616 let mut pending_seek: Option<(Duration, u64)> = None;
617 let mut applied_seek_epoch = 0;
618 let mut frame_index = 0u64;
619 let mut pts_origin: Option<f64> = None;
620 let mut last_output_pts = None;
621 let mut hardware_frame_seen = false;
622 let mut decoded_frame = ffmpeg::frame::Video::empty();
623 let mut sw_frame = ffmpeg::frame::Video::empty();
624 let mut converted_frame = ffmpeg::frame::Video::empty();
625
626 'outer: loop {
627 if stop_flag.load(Ordering::Relaxed) {
628 break;
629 }
630
631 while let Ok(control) = control_rx.try_recv() {
632 match control {
633 DecoderControl::Seek(ts, epoch) => pending_seek = Some((ts, epoch)),
634 }
635 }
636 paused = pause_requested.load(Ordering::Acquire);
637
638 if let Some((ts, epoch)) = pending_seek.take() {
639 Self::apply_seek(&mut ictx, &mut decoder, time_base, ts);
640 applied_seek_epoch = epoch;
641 last_output_pts = None;
642 }
643
644 if paused {
645 thread::sleep(Duration::from_millis(10));
646 continue;
647 }
648
649 for (stream, packet) in ictx.packets() {
650 if stop_flag.load(Ordering::Relaxed) {
651 break 'outer;
652 }
653
654 while let Ok(control) = control_rx.try_recv() {
655 match control {
656 DecoderControl::Seek(ts, epoch) => pending_seek = Some((ts, epoch)),
657 }
658 }
659 paused = pause_requested.load(Ordering::Acquire);
660 if paused || pending_seek.is_some() {
661 break;
662 }
663
664 if stream.index() != video_stream_index {
665 continue;
666 }
667
668 if let Err(e) = decoder.send_packet(&packet) {
673 tracing::warn!("video packet skipped: {e}");
674 continue;
675 }
676
677 while decoder.receive_frame(&mut decoded_frame).is_ok() {
678 if stop_flag.load(Ordering::Relaxed) {
679 break 'outer;
680 }
681
682 let mut interrupted = false;
688 loop {
689 if stop_flag.load(Ordering::Relaxed) {
690 break 'outer;
691 }
692 while let Ok(control) = control_rx.try_recv() {
693 match control {
694 DecoderControl::Seek(ts, epoch) => {
695 pending_seek = Some((ts, epoch));
696 }
697 }
698 }
699 paused = pause_requested.load(Ordering::Acquire);
700 if pending_seek.is_some()
701 || seek_epoch.load(Ordering::Acquire) != applied_seek_epoch
702 {
703 interrupted = true;
704 break;
705 }
706 let queue_full = frame_queue
707 .lock()
708 .map(|queue| queue.len() >= frame_capacity)
709 .unwrap_or(false);
710 if !paused && !queue_full {
711 break;
712 }
713 thread::sleep(Duration::from_millis(2));
718 }
719 if interrupted {
720 break;
721 }
722
723 let pts_duration = match pts_to_duration(
724 decoded_frame.timestamp(),
725 time_base,
726 frame_index,
727 &mut pts_origin,
728 ) {
729 Some(duration) => duration,
730 None => {
731 tracing::warn!("video frame dropped: invalid presentation timestamp");
732 continue;
733 }
734 };
735 if !frame_due_for_upload(
736 pts_duration,
737 max_output_fps.load(Ordering::Acquire),
738 last_output_pts,
739 ) {
740 frame_index = frame_index.wrapping_add(1);
741 dropped_frames.fetch_add(1, Ordering::Relaxed);
742 continue;
743 }
744
745 let is_hw_frame = unsafe { !(*decoded_frame.as_ptr()).hw_frames_ctx.is_null() };
746
747 let src_frame = if is_hw_frame {
748 let ret = unsafe {
749 ffmpeg::ffi::av_frame_unref(sw_frame.as_mut_ptr());
750 ffmpeg::ffi::av_hwframe_transfer_data(
751 sw_frame.as_mut_ptr(),
752 decoded_frame.as_ptr(),
753 0,
754 )
755 };
756 if ret < 0 {
757 tracing::warn!("hwframe transfer failed: {ret}");
758 continue;
759 }
760 hardware_frame_seen = true;
761 hw_in_use.store(used_hw.code(), Ordering::Release);
762 decoder_state
763 .store(frame_decoder_state(used_hw, true).code(), Ordering::Release);
764 &sw_frame
765 } else {
766 hw_in_use.store(HwAccel::Software.code(), Ordering::Release);
767 decoder_state.store(
770 observe_software_frame(used_hw, &fallback_occurred).code(),
771 Ordering::Release,
772 );
773 &decoded_frame
774 };
775
776 let width = src_frame.width();
777 let height = src_frame.height();
778 let data = match convert_frame(
783 src_frame,
784 &decoded_frame,
785 &mut scaler,
786 &mut scaler_src,
787 &mut converted_frame,
788 width,
789 height,
790 &decoder,
791 ) {
792 Ok(data) => data,
793 Err(e) => {
794 tracing::warn!("video frame conversion skipped: {e}");
795 continue;
796 }
797 };
798
799 let video_frame = VideoFrame {
800 data,
801 width,
802 height,
803 pts: pts_duration,
804 index: frame_index,
805 };
806 last_output_pts = Some(pts_duration);
807 frame_index = frame_index.wrapping_add(1);
808
809 if let Ok(mut queue) = frame_queue.lock() {
810 if queue.len() >= frame_capacity {
811 queue.pop_front();
812 dropped_frames.fetch_add(1, Ordering::Relaxed);
813 }
814 queue.push_back(video_frame);
815 }
816 }
817 }
818
819 tracing::debug!("End of stream after {} frames, looping", frame_index);
820 while decoder.receive_frame(&mut decoded_frame).is_ok() {
821 if stop_flag.load(Ordering::Relaxed) {
822 break 'outer;
823 }
824 }
825 if stop_flag.load(Ordering::Relaxed) {
826 break;
827 }
828 if loop_video && !paused {
829 ictx.seek(0, ..)
830 .map_err(|e| VideoError::SeekFailed(Duration::ZERO, e.into()))?;
831 decoder.flush();
832 last_output_pts = None;
833 } else if !paused {
834 thread::sleep(Duration::from_millis(50));
838 }
839 }
840
841 if hardware_frame_seen {
842 Ok(used_hw)
843 } else {
844 if used_hw != HwAccel::Software {
845 decoder_state.store(DecoderState::SoftwareFallback.code(), Ordering::Release);
846 }
847 Ok(HwAccel::Software)
848 }
849 }
850
851 fn apply_seek(
852 ictx: &mut ffmpeg::format::context::Input,
853 decoder: &mut ffmpeg::codec::decoder::Video,
854 time_base: ffmpeg::Rational,
855 ts: Duration,
856 ) {
857 let tb_sec = time_base.numerator() as f64 / time_base.denominator() as f64;
858 let ts_tb = if tb_sec > 0.0 {
859 (ts.as_secs_f64() / tb_sec) as i64
860 } else {
861 0
862 };
863 match ictx.seek(ts_tb, ..) {
864 Ok(()) => {
865 decoder.flush();
866 let mut drain = ffmpeg::frame::Video::empty();
867 while decoder.receive_frame(&mut drain).is_ok() {}
868 tracing::info!("Seeked to {:?}", ts);
869 }
870 Err(e) => tracing::warn!("Seek to {:?} failed: {}", ts, e),
871 }
872 }
873
874 pub fn next_frame(&self) -> Option<VideoFrame> {
875 self.frame_queue.lock().ok()?.pop_front()
876 }
877
878 pub fn metadata(&self) -> &VideoMetadata {
879 &self.metadata
880 }
881
882 pub fn decoder_info(&self) -> DecoderInfo {
883 let hw_accel = self.hw_accel_in_use();
884 DecoderInfo {
885 codec_name: self.metadata.codec.clone(),
886 hardware_accel: if hw_accel != HwAccel::Software {
887 Some(hw_accel.name().to_string())
888 } else {
889 None
890 },
891 pixel_format: "NV12/RGBA".to_string(),
892 dropped_frames: self.dropped_frames(),
893 state: self.decoder_state(),
894 }
895 }
896
897 pub fn hw_accel_in_use(&self) -> HwAccel {
898 match self.hw_in_use.load(Ordering::Acquire) {
899 2 => HwAccel::Vaapi,
900 3 => HwAccel::Nvdec,
901 4 => HwAccel::VideoToolbox,
902 _ => HwAccel::Software,
903 }
904 }
905
906 pub fn pause(&self) {
907 self.pause_requested.store(true, Ordering::Release);
908 }
909
910 pub fn resume(&self) {
911 self.pause_requested.store(false, Ordering::Release);
912 }
913
914 pub fn seek(&self, timestamp: Duration) {
915 let epoch = self.seek_epoch.fetch_add(1, Ordering::AcqRel) + 1;
916 let _ = self.control_tx.send(DecoderControl::Seek(timestamp, epoch));
917 }
918
919 pub fn drain(&mut self) {
920 if let Ok(mut queue) = self.frame_queue.lock() {
921 queue.clear();
922 }
923 }
924
925 pub fn dropped_frames(&self) -> u64 {
926 self.dropped_frames.load(Ordering::Relaxed)
927 }
928
929 pub fn decoder_state(&self) -> DecoderState {
930 DecoderState::from_code(self.decoder_state.load(Ordering::Acquire))
931 }
932
933 pub fn fallback_occurred(&self) -> bool {
935 self.fallback_occurred.load(Ordering::Acquire)
936 }
937
938 pub fn is_video_file<P: AsRef<Path>>(path: P) -> bool {
939 path.as_ref()
940 .extension()
941 .and_then(|e| e.to_str())
942 .is_some_and(|ext| {
943 matches!(
944 ext.to_lowercase().as_str(),
945 "mp4" | "webm" | "mkv" | "mov" | "avi" | "m4v"
946 )
947 })
948 }
949}
950
951fn hardware_decoder(id: ffmpeg::codec::Id, hw_accel: HwAccel) -> Option<ffmpeg::Codec> {
956 let device_type = match hw_accel {
957 HwAccel::Nvdec => ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
958 HwAccel::Vaapi => ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
959 HwAccel::VideoToolbox => ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
960 HwAccel::Auto | HwAccel::Software => return None,
961 };
962
963 unsafe {
966 let mut iterator = std::ptr::null_mut();
967 loop {
968 let codec = ffmpeg::ffi::av_codec_iterate(&mut iterator);
969 if codec.is_null() {
970 return None;
971 }
972 if ffmpeg::ffi::av_codec_is_decoder(codec) == 0 || (*codec).id != id.into() {
973 continue;
974 }
975
976 let mut index = 0;
977 loop {
978 let config = ffmpeg::ffi::avcodec_get_hw_config(codec, index);
979 if config.is_null() {
980 break;
981 }
982 if (*config).device_type == device_type
983 && (*config).methods
984 & ffmpeg::ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX as i32
985 != 0
986 {
987 return Some(ffmpeg::Codec::wrap(codec));
988 }
989 index += 1;
990 }
991 }
992 }
993}
994
995fn validate_video_dimensions(width: u32, height: u32) -> VideoResult<()> {
996 if width > MAX_VIDEO_DIMENSION || height > MAX_VIDEO_DIMENSION {
997 return Err(VideoError::UnsupportedFormat(format!(
998 "video dimensions {width}x{height} exceed the {MAX_VIDEO_DIMENSION}x{MAX_VIDEO_DIMENSION} safety limit"
999 )));
1000 }
1001 let pixels = u64::from(width)
1002 .checked_mul(u64::from(height))
1003 .ok_or_else(|| VideoError::UnsupportedFormat("video dimensions overflow".to_string()))?;
1004 let estimated_bytes = pixels
1005 .checked_mul(4)
1006 .ok_or_else(|| VideoError::UnsupportedFormat("video frame size overflow".to_string()))?;
1007 if estimated_bytes > MAX_DECODED_FRAME_BYTES {
1008 return Err(VideoError::UnsupportedFormat(format!(
1009 "decoded video frame {width}x{height} requires approximately {:.1} MiB, exceeding the {:.0} MiB safety limit",
1010 estimated_bytes as f64 / (1024.0 * 1024.0),
1011 MAX_DECODED_FRAME_BYTES as f64 / (1024.0 * 1024.0)
1012 )));
1013 }
1014 Ok(())
1015}
1016
1017unsafe extern "C" fn select_hw_pixel_format(
1021 context: *mut ffmpeg::ffi::AVCodecContext,
1022 formats: *const ffmpeg::ffi::AVPixelFormat,
1023) -> ffmpeg::ffi::AVPixelFormat {
1024 if context.is_null() || formats.is_null() {
1025 return ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE;
1026 }
1027
1028 let device_type = unsafe {
1029 (*context).hw_device_ctx.as_ref().and_then(|device| {
1030 (!device.data.is_null())
1031 .then(|| (*(device.data as *const ffmpeg::ffi::AVHWDeviceContext)).type_)
1032 })
1033 };
1034 choose_hw_pixel_format(device_type, formats)
1035}
1036
1037fn choose_hw_pixel_format(
1038 device_type: Option<ffmpeg::ffi::AVHWDeviceType>,
1039 formats: *const ffmpeg::ffi::AVPixelFormat,
1040) -> ffmpeg::ffi::AVPixelFormat {
1041 let preferred = match device_type {
1042 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA) => {
1043 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_CUDA
1044 }
1045 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI) => {
1046 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI
1047 }
1048 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX) => {
1049 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX
1050 }
1051 _ => ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE,
1052 };
1053
1054 if preferred != ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
1055 let mut current = formats;
1056 loop {
1057 let format = unsafe { *current };
1058 if format == ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
1059 break;
1060 }
1061 if format == preferred {
1062 return format;
1063 }
1064 current = unsafe { current.add(1) };
1065 }
1066 }
1067
1068 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
1069}
1070
1071fn ensure_ffmpeg_init() -> VideoResult<()> {
1072 use std::sync::OnceLock;
1073 static INIT: OnceLock<Result<(), String>> = OnceLock::new();
1074 let result = INIT.get_or_init(|| {
1075 ffmpeg::init().map_err(|e| format!("FFmpeg init: {e}"))?;
1076 Ok(())
1081 });
1082 result
1083 .as_ref()
1084 .map_err(|e| VideoError::SoftwareDecoderInit(anyhow::anyhow!(e.clone())))
1085 .map(|_| ())
1086}
1087
1088fn duration_from_seconds(seconds: f64) -> VideoResult<Duration> {
1091 if !seconds.is_finite() || seconds < 0.0 {
1092 return Err(VideoError::InvalidDuration(format!(
1093 "container reported {seconds} seconds"
1094 )));
1095 }
1096 Duration::try_from_secs_f64(seconds)
1097 .map_err(|_| VideoError::InvalidDuration(format!("{seconds} seconds is out of range")))
1098}
1099
1100fn pts_to_duration(
1110 timestamp: Option<i64>,
1111 time_base: ffmpeg::Rational,
1112 frame_index: u64,
1113 pts_origin: &mut Option<f64>,
1114) -> Option<Duration> {
1115 let pts = match timestamp {
1116 Some(pts) => pts,
1117 None => {
1118 return Duration::try_from_secs_f64(frame_index as f64 / 30.0).ok();
1121 }
1122 };
1123 if time_base.numerator() <= 0 || time_base.denominator() <= 0 {
1124 return None;
1125 }
1126 let seconds = pts as f64 * time_base.numerator() as f64 / time_base.denominator() as f64;
1127 if !seconds.is_finite() {
1128 return None;
1129 }
1130 let origin = *pts_origin.get_or_insert(seconds);
1134 let normalized = seconds - origin;
1135 if !normalized.is_finite() || normalized < 0.0 {
1136 return None;
1137 }
1138 let deadline = Duration::from_secs(24 * 3600 * 3650);
1142 Duration::try_from_secs_f64(normalized)
1143 .ok()
1144 .filter(|duration| *duration <= deadline)
1145}
1146
1147fn frame_due_for_upload(pts: Duration, max_fps: u32, last_pts: Option<Duration>) -> bool {
1148 if max_fps == 0 {
1149 return true;
1150 }
1151 let interval = Duration::from_secs_f64(1.0 / f64::from(max_fps));
1152 last_pts.is_none_or(|last| pts.saturating_sub(last) >= interval)
1153}
1154
1155const fn software_decoder_state(hardware_attempted: bool) -> DecoderState {
1156 if hardware_attempted {
1157 DecoderState::SoftwareFallback
1158 } else {
1159 DecoderState::SoftwareActive
1160 }
1161}
1162
1163const fn frame_decoder_state(selected_backend: HwAccel, hardware_frame: bool) -> DecoderState {
1164 if hardware_frame {
1165 DecoderState::HardwareActive
1166 } else if matches!(selected_backend, HwAccel::Software) {
1167 DecoderState::SoftwareActive
1168 } else {
1169 DecoderState::SoftwareFallback
1170 }
1171}
1172
1173fn observe_software_frame(selected_backend: HwAccel, fallback: &AtomicBool) -> DecoderState {
1174 if selected_backend != HwAccel::Software {
1175 fallback.store(true, Ordering::Release);
1176 }
1177 software_decoder_state(fallback.load(Ordering::Acquire))
1178}
1179
1180fn nvidia_gpu_present() -> bool {
1182 any_nvidia_card(std::path::Path::new("/sys/class/drm"))
1183}
1184
1185fn any_nvidia_card(root: &std::path::Path) -> bool {
1186 let Ok(entries) = std::fs::read_dir(root) else {
1187 return false;
1188 };
1189 entries.filter_map(|e| e.ok()).any(|entry| {
1190 let name = entry.file_name().to_string_lossy().into_owned();
1191 if !name.starts_with("card") {
1192 return false;
1193 }
1194 std::fs::read_to_string(entry.path().join("device/vendor"))
1195 .is_ok_and(|vendor| vendor.trim().eq_ignore_ascii_case("0x10de"))
1196 })
1197}
1198
1199fn vaapi_available() -> bool {
1201 any_render_node(std::path::Path::new("/dev/dri"))
1202}
1203
1204fn any_render_node(root: &std::path::Path) -> bool {
1205 let Ok(entries) = std::fs::read_dir(root) else {
1206 return false;
1207 };
1208 entries
1209 .filter_map(|e| e.ok())
1210 .any(|entry| entry.file_name().to_string_lossy().starts_with("renderD"))
1211}
1212
1213fn vaapi_device_candidates() -> Vec<CString> {
1217 let mut candidates = Vec::new();
1218 if let Ok(entries) = std::fs::read_dir("/dev/dri") {
1219 let mut nodes: Vec<String> = entries
1220 .flatten()
1221 .map(|e| e.file_name().to_string_lossy().into_owned())
1222 .filter(|n| n.starts_with("renderD"))
1223 .collect();
1224 nodes.sort();
1225 for node in nodes {
1226 let path = format!("/dev/dri/{node}");
1227 if let Ok(c) = CString::new(path) {
1228 candidates.push(c);
1229 }
1230 }
1231 }
1232 if candidates.is_empty()
1233 && let Ok(fallback) = CString::new("/dev/dri/renderD128")
1234 {
1235 candidates.push(fallback);
1236 }
1237 candidates
1238}
1239
1240fn copy_packed_rows(
1241 source: &[u8],
1242 stride: usize,
1243 row_bytes: usize,
1244 height: usize,
1245) -> VideoResult<Vec<u8>> {
1246 let invalid = |message: String| VideoError::FormatConversionFailed(anyhow::anyhow!(message));
1247 if height == 0 {
1248 return Err(invalid("frame has zero height".to_string()));
1249 }
1250 if stride < row_bytes {
1251 return Err(invalid(format!(
1252 "frame stride {stride} is smaller than row width {row_bytes}"
1253 )));
1254 }
1255 let needed = stride
1256 .checked_mul(height)
1257 .ok_or_else(|| invalid("frame stride overflow".to_string()))?;
1258 if source.len() < needed {
1259 return Err(invalid(format!(
1260 "frame buffer {} is smaller than required {needed}",
1261 source.len()
1262 )));
1263 }
1264 let capacity = row_bytes
1265 .checked_mul(height)
1266 .ok_or_else(|| invalid("frame size overflow".to_string()))?;
1267 let mut packed = Vec::with_capacity(capacity);
1268 for row in source.chunks(stride).take(height) {
1269 packed.extend_from_slice(&row[..row_bytes]);
1270 }
1271 Ok(packed)
1272}
1273
1274#[allow(clippy::too_many_arguments)]
1277fn convert_frame(
1278 src_frame: &ffmpeg::frame::Video,
1279 decoded_frame: &ffmpeg::frame::Video,
1280 scaler: &mut Option<ffmpeg::software::scaling::Context>,
1281 scaler_src: &mut Option<ffmpeg::format::Pixel>,
1282 converted_frame: &mut ffmpeg::frame::Video,
1283 width: u32,
1284 height: u32,
1285 decoder: &ffmpeg::codec::decoder::Video,
1286) -> VideoResult<VideoFrameData> {
1287 if src_frame.format() == ffmpeg::format::Pixel::NV12 {
1288 let color_space = match decoded_frame.color_space() {
1289 ffmpeg::color::Space::Unspecified => decoder.color_space(),
1290 value => value,
1291 };
1292 let color_range = match decoded_frame.color_range() {
1293 ffmpeg::color::Range::Unspecified => decoder.color_range(),
1294 value => value,
1295 };
1296 let color = select_yuv_color(color_space, color_range, width, height);
1297 let (y_plane, uv_plane) = copy_nv12_planes(src_frame)?;
1298 Ok(VideoFrameData::Nv12 {
1299 y_plane,
1300 uv_plane,
1301 color,
1302 })
1303 } else {
1304 let src_format = src_frame.format();
1305 if *scaler_src != Some(src_format) {
1306 *scaler = Some(
1307 ffmpeg::software::scaling::context::Context::get(
1308 src_format,
1309 width,
1310 height,
1311 ffmpeg::format::Pixel::NV12,
1312 width,
1313 height,
1314 ffmpeg::software::scaling::Flags::FAST_BILINEAR,
1315 )
1316 .map_err(|e| VideoError::FormatConversionFailed(e.into()))?,
1317 );
1318 *scaler_src = Some(src_format);
1319 }
1320
1321 scaler
1322 .as_mut()
1323 .expect("scaler initialized above")
1324 .run(src_frame, converted_frame)
1325 .map_err(|e| VideoError::FormatConversionFailed(e.into()))?;
1326 let color_space = match decoded_frame.color_space() {
1327 ffmpeg::color::Space::Unspecified => decoder.color_space(),
1328 value => value,
1329 };
1330 let color_range = match decoded_frame.color_range() {
1331 ffmpeg::color::Range::Unspecified => decoder.color_range(),
1332 value => value,
1333 };
1334 let color = select_yuv_color(color_space, color_range, width, height);
1335 let (y_plane, uv_plane) = copy_nv12_planes(converted_frame)?;
1336 Ok(VideoFrameData::Nv12 {
1337 y_plane,
1338 uv_plane,
1339 color,
1340 })
1341 }
1342}
1343
1344fn copy_nv12_planes(frame: &ffmpeg::frame::Video) -> VideoResult<(Vec<u8>, Vec<u8>)> {
1345 copy_nv12_data(
1346 frame.data(0),
1347 frame.stride(0),
1348 frame.data(1),
1349 frame.stride(1),
1350 frame.width() as usize,
1351 frame.height() as usize,
1352 )
1353}
1354
1355fn copy_nv12_data(
1356 y_source: &[u8],
1357 y_stride: usize,
1358 uv_source: &[u8],
1359 uv_stride: usize,
1360 width: usize,
1361 height: usize,
1362) -> VideoResult<(Vec<u8>, Vec<u8>)> {
1363 if width == 0 || height == 0 {
1364 return Err(VideoError::FormatConversionFailed(anyhow::anyhow!(
1365 "NV12 frame is empty"
1366 )));
1367 }
1368 let chroma_width = width.div_ceil(2);
1369 let chroma_height = height.div_ceil(2);
1370 let uv_row = chroma_width.checked_mul(2).ok_or_else(|| {
1371 VideoError::FormatConversionFailed(anyhow::anyhow!("NV12 chroma row overflow"))
1372 })?;
1373 Ok((
1374 copy_packed_rows(y_source, y_stride, width, height)?,
1375 copy_packed_rows(uv_source, uv_stride, uv_row, chroma_height)?,
1376 ))
1377}
1378
1379fn select_yuv_color(
1380 space: ffmpeg::color::Space,
1381 range: ffmpeg::color::Range,
1382 width: u32,
1383 height: u32,
1384) -> YuvColorInfo {
1385 let matrix = match space {
1386 ffmpeg::color::Space::BT470BG | ffmpeg::color::Space::SMPTE170M => YuvMatrix::Bt601,
1387 ffmpeg::color::Space::BT709 => YuvMatrix::Bt709,
1388 ffmpeg::color::Space::BT2020NCL => YuvMatrix::Bt2020,
1389 _ if width >= 1280 || height > 576 => YuvMatrix::Bt709,
1390 _ => YuvMatrix::Bt601,
1391 };
1392 let range = match range {
1393 ffmpeg::color::Range::JPEG => YuvRange::Full,
1394 ffmpeg::color::Range::MPEG | ffmpeg::color::Range::Unspecified => YuvRange::Limited,
1395 };
1396 YuvColorInfo { matrix, range }
1397}
1398
1399impl Drop for VideoDecoder {
1400 fn drop(&mut self) {
1401 self.stop_flag.store(true, Ordering::Relaxed);
1402 if let Some(thread) = self.decode_thread.take() {
1403 let _ = thread.join();
1404 }
1405 }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 #[test]
1413 fn output_fps_cap_skips_expensive_frame_preparation() {
1414 let last = Duration::ZERO;
1415 assert!(!frame_due_for_upload(
1416 Duration::from_millis(16),
1417 30,
1418 Some(last)
1419 ));
1420 assert!(frame_due_for_upload(
1421 Duration::from_millis(34),
1422 30,
1423 Some(last)
1424 ));
1425 assert!(frame_due_for_upload(
1426 Duration::from_millis(16),
1427 0,
1428 Some(last)
1429 ));
1430 assert!(frame_due_for_upload(Duration::ZERO, 30, None));
1431 }
1432
1433 #[test]
1434 fn nvidia_probe_detects_vendor_via_sysfs_layout() {
1435 let temp = tempfile::tempdir().expect("tempdir");
1436 let card0 = temp.path().join("card0");
1437 let card1 = temp.path().join("card1");
1438
1439 std::fs::create_dir_all(card0.join("device")).expect("dirs");
1440 std::fs::create_dir_all(card1.join("device")).expect("dirs");
1441 assert!(!any_nvidia_card(temp.path()));
1442
1443 std::fs::write(card1.join("device/vendor"), "0x10de\n").expect("vendor");
1444 assert!(any_nvidia_card(temp.path()));
1445
1446 std::fs::write(card1.join("device/vendor"), "0x1002\n").expect("vendor");
1447 assert!(!any_nvidia_card(temp.path()));
1448 }
1449
1450 #[test]
1451 fn render_node_probe_detects_render_entries() {
1452 let temp = tempfile::tempdir().expect("tempdir");
1453 assert!(!any_render_node(temp.path()));
1454 std::fs::create_dir_all(temp.path().join("renderD128")).expect("create");
1455 assert!(any_render_node(temp.path()));
1456 }
1457
1458 #[test]
1459 fn test_is_video_file() {
1460 assert!(VideoDecoder::is_video_file("test.mp4"));
1461 assert!(VideoDecoder::is_video_file("test.MP4"));
1462 assert!(VideoDecoder::is_video_file("test.webm"));
1463 assert!(VideoDecoder::is_video_file("test.mkv"));
1464 assert!(VideoDecoder::is_video_file("test.mov"));
1465 assert!(!VideoDecoder::is_video_file("test.jpg"));
1466 assert!(!VideoDecoder::is_video_file("test.gif"));
1467 assert!(!VideoDecoder::is_video_file("test.png"));
1468 }
1469
1470 #[test]
1471 fn test_hwaccel_names() {
1472 assert_eq!(HwAccel::Vaapi.name(), "VAAPI");
1473 assert_eq!(HwAccel::Nvdec.name(), "NVDEC");
1474 assert_eq!(HwAccel::Software.name(), "Software");
1475 }
1476
1477 #[test]
1478 fn decoder_states_have_stable_diagnostic_names() {
1479 assert_eq!(
1480 DecoderState::HardwareNegotiating.name(),
1481 "hardware negotiation"
1482 );
1483 assert_eq!(DecoderState::HardwareActive.name(), "hardware active");
1484 assert_eq!(DecoderState::SoftwareFallback.name(), "software fallback");
1485 assert_eq!(
1486 DecoderState::from_code(DecoderState::Failed.code()),
1487 DecoderState::Failed
1488 );
1489 assert_eq!(software_decoder_state(true), DecoderState::SoftwareFallback);
1490 assert_eq!(software_decoder_state(false), DecoderState::SoftwareActive);
1491 }
1492
1493 #[test]
1494 fn software_frame_from_hardware_decoder_reports_fallback() {
1495 assert_eq!(
1496 frame_decoder_state(HwAccel::Nvdec, false),
1497 DecoderState::SoftwareFallback
1498 );
1499 assert_eq!(
1500 frame_decoder_state(HwAccel::Nvdec, true),
1501 DecoderState::HardwareActive
1502 );
1503 assert_eq!(
1504 frame_decoder_state(HwAccel::Software, false),
1505 DecoderState::SoftwareActive
1506 );
1507 }
1508
1509 #[test]
1510 fn fallback_history_survives_recovery_and_software_frames() {
1511 let fallback = AtomicBool::new(false);
1512 assert_eq!(
1513 observe_software_frame(HwAccel::Software, &fallback),
1514 DecoderState::SoftwareActive
1515 );
1516 assert!(!fallback.load(Ordering::Acquire));
1517 assert_eq!(
1518 observe_software_frame(HwAccel::Nvdec, &fallback),
1519 DecoderState::SoftwareFallback
1520 );
1521 assert_eq!(
1522 frame_decoder_state(HwAccel::Nvdec, true),
1523 DecoderState::HardwareActive
1524 );
1525 assert!(fallback.load(Ordering::Acquire));
1526 assert_eq!(
1527 observe_software_frame(HwAccel::Software, &fallback),
1528 DecoderState::SoftwareFallback
1529 );
1530 }
1531
1532 #[test]
1533 fn failed_decoder_cannot_pass_after_producing_frames() {
1534 assert!(!probe_succeeded(300, DecoderState::Failed));
1535 assert!(!probe_succeeded(30, DecoderState::SoftwareActive));
1536 assert!(probe_succeeded(31, DecoderState::SoftwareActive));
1537 assert!(probe_succeeded(300, DecoderState::HardwareActive));
1538 }
1539
1540 #[test]
1541 fn probe_fails_on_low_frames_even_when_hardware_active() {
1542 assert!(!probe_succeeded(0, DecoderState::HardwareActive));
1543 assert!(!probe_succeeded(
1544 PROBE_MIN_FRAMES,
1545 DecoderState::HardwareActive
1546 ));
1547 assert!(probe_succeeded(
1548 PROBE_MIN_FRAMES + 1,
1549 DecoderState::HardwareActive
1550 ));
1551 }
1552
1553 #[test]
1554 fn test_hwaccel_from_config() {
1555 assert_eq!(HwAccel::from_config("vaapi"), HwAccel::Vaapi);
1556 assert_eq!(HwAccel::from_config("nvdec"), HwAccel::Nvdec);
1557 assert_eq!(HwAccel::from_config("software"), HwAccel::Software);
1558 assert_eq!(HwAccel::from_config("auto"), HwAccel::Auto);
1559 assert_eq!(HwAccel::from_config("unknown"), HwAccel::Auto);
1560 }
1561
1562 #[test]
1563 fn packs_strided_rows() {
1564 let y = [1, 2, 3, 99, 99, 4, 5, 6, 99, 99, 7, 8, 9, 99, 99];
1565 let uv = [10, 11, 12, 13, 99, 99, 14, 15, 16, 17, 99, 99];
1566 let (packed_y, packed_uv) = copy_nv12_data(&y, 5, &uv, 6, 3, 3).unwrap();
1567 assert_eq!(packed_y, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
1568 assert_eq!(packed_uv, [10, 11, 12, 13, 14, 15, 16, 17]);
1569 }
1570
1571 #[test]
1572 fn selects_yuv_matrix_and_range() {
1573 assert_eq!(
1574 select_yuv_color(
1575 ffmpeg::color::Space::BT2020NCL,
1576 ffmpeg::color::Range::JPEG,
1577 3840,
1578 2160,
1579 ),
1580 YuvColorInfo {
1581 matrix: YuvMatrix::Bt2020,
1582 range: YuvRange::Full,
1583 }
1584 );
1585 assert_eq!(
1586 select_yuv_color(
1587 ffmpeg::color::Space::Unspecified,
1588 ffmpeg::color::Range::Unspecified,
1589 1920,
1590 1080,
1591 ),
1592 YuvColorInfo {
1593 matrix: YuvMatrix::Bt709,
1594 range: YuvRange::Limited,
1595 }
1596 );
1597 assert_eq!(
1598 select_yuv_color(
1599 ffmpeg::color::Space::Unspecified,
1600 ffmpeg::color::Range::MPEG,
1601 720,
1602 576,
1603 ),
1604 YuvColorInfo {
1605 matrix: YuvMatrix::Bt601,
1606 range: YuvRange::Limited,
1607 }
1608 );
1609 }
1610
1611 #[test]
1612 fn hardware_format_negotiation_selects_only_the_matching_backend() {
1613 let formats = [
1614 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
1615 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_CUDA,
1616 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE,
1617 ];
1618 assert_eq!(
1619 choose_hw_pixel_format(
1620 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA),
1621 formats.as_ptr()
1622 ),
1623 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_CUDA
1624 );
1625
1626 let software_only = [
1627 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
1628 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE,
1629 ];
1630 assert_eq!(
1631 choose_hw_pixel_format(
1632 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA),
1633 software_only.as_ptr()
1634 ),
1635 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
1636 );
1637 }
1638 #[test]
1639 fn rejects_truncated_or_inverted_frame_rows() {
1640 assert!(copy_packed_rows(&[0; 10], 5, 6, 2).is_err());
1641 assert!(copy_packed_rows(&[0; 10], 5, 5, 3).is_err());
1642 assert!(copy_packed_rows(&[0; 10], 4, 5, 2).is_err());
1643 assert!(copy_packed_rows(&[0; 15], 5, 3, 3).is_ok());
1644 }
1645 #[test]
1646 fn untrusted_duration_values_never_panic() {
1647 for value in [f64::NAN, f64::INFINITY, -1.0, f64::MAX] {
1648 assert!(duration_from_seconds(value).is_err(), "{value}");
1649 }
1650 assert_eq!(
1651 duration_from_seconds(1.5).unwrap(),
1652 Duration::from_millis(1500)
1653 );
1654 assert_eq!(duration_from_seconds(0.0).unwrap(), Duration::ZERO);
1655 }
1656}
1657#[test]
1658fn normalizes_negative_start_pts_without_dropping_frames() {
1659 let time_base = ffmpeg::Rational(1, 1000);
1660 let mut origin = None;
1661 assert_eq!(
1662 pts_to_duration(Some(-80), time_base, 0, &mut origin),
1663 Some(Duration::ZERO)
1664 );
1665 assert_eq!(
1666 pts_to_duration(Some(-64), time_base, 1, &mut origin),
1667 Some(Duration::from_millis(16))
1668 );
1669}
1670
1671#[test]
1672fn accepts_8k_frames_but_rejects_unbounded_allocations() {
1673 assert!(validate_video_dimensions(7680, 4320).is_ok());
1674 assert!(validate_video_dimensions(16_384, 16_384).is_err());
1675 assert!(validate_video_dimensions(20_000, 1).is_err());
1676}