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, AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::thread;
11use std::time::Duration;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum HwAccel {
15 Auto,
17 Vaapi,
18 Nvdec,
19 VideoToolbox,
20 Software,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum DecoderState {
25 Initializing,
26 HardwareNegotiating,
27 HardwareActive,
28 SoftwareActive,
29 SoftwareFallback,
30 Failed,
31}
32
33impl DecoderState {
34 const fn code(self) -> u8 {
35 match self {
36 Self::Initializing => 0,
37 Self::HardwareNegotiating => 1,
38 Self::HardwareActive => 2,
39 Self::SoftwareActive => 3,
40 Self::SoftwareFallback => 4,
41 Self::Failed => 5,
42 }
43 }
44
45 const fn from_code(code: u8) -> Self {
46 match code {
47 1 => Self::HardwareNegotiating,
48 2 => Self::HardwareActive,
49 3 => Self::SoftwareActive,
50 4 => Self::SoftwareFallback,
51 5 => Self::Failed,
52 _ => Self::Initializing,
53 }
54 }
55
56 pub const fn name(self) -> &'static str {
57 match self {
58 Self::Initializing => "initializing",
59 Self::HardwareNegotiating => "hardware negotiation",
60 Self::HardwareActive => "hardware active",
61 Self::SoftwareActive => "software active",
62 Self::SoftwareFallback => "software fallback",
63 Self::Failed => "failed",
64 }
65 }
66}
67
68impl HwAccel {
69 pub fn name(&self) -> &'static str {
70 match self {
71 HwAccel::Auto => "Auto",
72 HwAccel::Vaapi => "VAAPI",
73 HwAccel::Nvdec => "NVDEC",
74 HwAccel::VideoToolbox => "VideoToolbox",
75 HwAccel::Software => "Software",
76 }
77 }
78
79 const fn code(self) -> u8 {
80 match self {
81 HwAccel::Auto => 0,
82 HwAccel::Software => 1,
83 HwAccel::Vaapi => 2,
84 HwAccel::Nvdec => 3,
85 HwAccel::VideoToolbox => 4,
86 }
87 }
88
89 pub fn from_config(value: &str) -> HwAccel {
90 match value.trim().to_ascii_lowercase().as_str() {
91 "vaapi" => HwAccel::Vaapi,
92 "nvdec" | "nvidia" | "cuda" => HwAccel::Nvdec,
93 "software" | "none" | "off" => HwAccel::Software,
94 _ => HwAccel::Auto,
95 }
96 }
97
98 #[cfg(target_os = "linux")]
104 fn auto_hardware_order() -> Vec<HwAccel> {
105 let mut order = Vec::new();
106 if nvidia_gpu_present() {
107 order.push(HwAccel::Nvdec);
108 }
109 if vaapi_available() {
110 order.push(HwAccel::Vaapi);
111 }
112 order
113 }
114}
115
116#[derive(Debug, Clone)]
117pub struct VideoMetadata {
118 pub width: u32,
119 pub height: u32,
120 pub duration: Duration,
121 pub fps: f64,
122 pub codec: String,
123 pub format: String,
124 pub total_frames: u64,
125}
126
127#[derive(Debug)]
128pub enum VideoFrameData {
129 Rgba(Vec<u8>),
130 Nv12 {
131 y_plane: Vec<u8>,
132 uv_plane: Vec<u8>,
133 color: YuvColorInfo,
134 },
135}
136
137impl VideoFrameData {
138 pub fn len(&self) -> usize {
139 match self {
140 Self::Rgba(data) => data.len(),
141 Self::Nv12 {
142 y_plane, uv_plane, ..
143 } => y_plane.len() + uv_plane.len(),
144 }
145 }
146
147 pub fn is_empty(&self) -> bool {
148 self.len() == 0
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct YuvColorInfo {
154 pub matrix: YuvMatrix,
155 pub range: YuvRange,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum YuvMatrix {
160 Bt601,
161 Bt709,
162 Bt2020,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum YuvRange {
167 Limited,
168 Full,
169}
170
171#[derive(Debug)]
172pub struct VideoFrame {
173 pub data: VideoFrameData,
174 pub width: u32,
175 pub height: u32,
176 pub pts: Duration,
177 pub index: u64,
178}
179
180impl VideoFrame {
181 pub fn into_scheduled(self) -> ScheduledFrame {
184 ScheduledFrame::new(self.data, self.width, self.height, self.pts, self.index)
185 }
186}
187
188#[derive(Debug, Clone)]
189pub struct DecoderInfo {
190 pub codec_name: String,
191 pub hardware_accel: Option<String>,
192 pub pixel_format: String,
193 pub dropped_frames: u64,
194 pub state: DecoderState,
195}
196
197#[derive(Debug, Clone, Copy)]
198enum DecoderControl {
199 Pause,
200 Resume,
201 Seek(Duration, u64),
202}
203
204pub struct VideoDecoder {
205 metadata: VideoMetadata,
206 frame_queue: Arc<Mutex<VecDeque<VideoFrame>>>,
207 dropped_frames: Arc<AtomicU64>,
208 control_tx: Sender<DecoderControl>,
209 stop_flag: Arc<AtomicBool>,
210 seek_epoch: Arc<AtomicU64>,
211 hw_in_use: Arc<AtomicU8>,
212 decoder_state: Arc<AtomicU8>,
213 decode_thread: Option<thread::JoinHandle<()>>,
214}
215
216impl VideoDecoder {
217 pub fn new<P: AsRef<Path>>(path: P, hw_accel: HwAccel) -> VideoResult<Self> {
218 Self::with_preload(path, hw_accel, 2, true)
219 }
220
221 pub fn with_preload<P: AsRef<Path>>(
222 path: P,
223 hw_accel: HwAccel,
224 preload_frames: usize,
225 loop_video: bool,
226 ) -> VideoResult<Self> {
227 let path = path.as_ref().to_path_buf();
228
229 ensure_ffmpeg_init()?;
230
231 let metadata = Self::extract_metadata(&path)?;
232
233 tracing::info!(
234 "Opened video: {}x{} @ {:.2} fps, duration: {:?}, codec: {}",
235 metadata.width,
236 metadata.height,
237 metadata.fps,
238 metadata.duration,
239 metadata.codec
240 );
241
242 let preload = preload_frames.clamp(1, 3);
247 let frame_queue = Arc::new(Mutex::new(VecDeque::with_capacity(preload)));
248 let dropped_frames = Arc::new(AtomicU64::new(0));
249 let (control_tx, control_rx) = crossbeam_channel::unbounded();
250
251 let stop_flag = Arc::new(AtomicBool::new(false));
252 let stop_flag_clone = stop_flag.clone();
253 let seek_epoch = Arc::new(AtomicU64::new(0));
254 let seek_epoch_clone = seek_epoch.clone();
255 let hw_in_use = Arc::new(AtomicU8::new(0));
256 let hw_in_use_clone = hw_in_use.clone();
257 let decoder_state = Arc::new(AtomicU8::new(DecoderState::Initializing.code()));
258 let decoder_state_clone = decoder_state.clone();
259 let frame_queue_clone = frame_queue.clone();
260 let dropped_frames_clone = dropped_frames.clone();
261
262 let decode_thread = thread::Builder::new()
263 .name("wallr-video-decoder".to_string())
264 .spawn(move || {
265 let used = Self::decode_loop(
266 path,
267 hw_accel,
268 frame_queue_clone,
269 preload,
270 dropped_frames_clone,
271 control_rx,
272 stop_flag_clone,
273 seek_epoch_clone,
274 hw_in_use_clone.clone(),
275 decoder_state_clone.clone(),
276 loop_video,
277 );
278 let used = match used {
279 Ok(used) => used,
280 Err(e) => {
281 tracing::error!("Video decode loop: {}", e);
282 decoder_state_clone.store(DecoderState::Failed.code(), Ordering::Release);
283 HwAccel::Software
284 }
285 };
286 tracing::info!("Decode thread exited (backend: {})", used.name());
287 hw_in_use_clone.store(used.code(), Ordering::Relaxed);
289 })
290 .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
291
292 Ok(Self {
293 metadata,
294 frame_queue,
295 dropped_frames,
296 control_tx,
297 stop_flag,
298 seek_epoch,
299 hw_in_use,
300 decoder_state,
301 decode_thread: Some(decode_thread),
302 })
303 }
304
305 fn extract_metadata(path: &Path) -> VideoResult<VideoMetadata> {
306 let ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
307 path: path.to_path_buf(),
308 source: std::io::Error::other(e.to_string()),
309 })?;
310
311 let stream = ictx
312 .streams()
313 .best(ffmpeg::media::Type::Video)
314 .ok_or_else(|| VideoError::NoVideoStream(path.to_path_buf()))?;
315
316 let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
317 .and_then(|ctx| ctx.decoder().video())
318 .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
319
320 let width = decoder.width();
321 let height = decoder.height();
322 let codec = decoder
323 .codec()
324 .map(|c| c.name().to_string())
325 .unwrap_or_else(|| "unknown".to_string());
326
327 let frame_rate = stream.avg_frame_rate();
328 let fps = if frame_rate.numerator() > 0 {
329 frame_rate.numerator() as f64 / frame_rate.denominator() as f64
330 } else {
331 30.0
332 };
333
334 let duration = {
335 let duration_ts = stream.duration();
336 let time_base = stream.time_base();
337 if duration_ts > 0 {
338 Duration::from_secs_f64(
339 duration_ts as f64 * time_base.numerator() as f64
340 / time_base.denominator() as f64,
341 )
342 } else {
343 let container_duration = ictx.duration() as f64 / ffmpeg::ffi::AV_TIME_BASE as f64;
344 Duration::from_secs_f64(container_duration)
345 }
346 };
347
348 let total_frames = if fps > 0.0 {
349 (duration.as_secs_f64() * fps) as u64
350 } else {
351 0
352 };
353
354 Ok(VideoMetadata {
355 width,
356 height,
357 duration,
358 fps,
359 codec,
360 format: ictx.format().name().to_string(),
361 total_frames,
362 })
363 }
364
365 fn init_hw_device(hw_accel: HwAccel) -> Option<(*mut ffmpeg::ffi::AVBufferRef, &'static str)> {
366 if hw_accel == HwAccel::Vaapi {
371 return Self::init_vaapi_device();
372 }
373 let (type_name, device) = match hw_accel {
374 HwAccel::Nvdec => ("cuda", Some(c"0")),
375 HwAccel::VideoToolbox => ("videotoolbox", None),
376 HwAccel::Software | HwAccel::Auto | HwAccel::Vaapi => return None,
377 };
378
379 let type_name_c = CString::new(type_name).ok()?;
380
381 unsafe {
383 let hw_type = ffmpeg::ffi::av_hwdevice_find_type_by_name(type_name_c.as_ptr());
384 if hw_type == ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_NONE {
385 tracing::warn!("{} hardware type unavailable", hw_accel.name());
386 return None;
387 }
388
389 let mut device_ctx: *mut ffmpeg::ffi::AVBufferRef = std::ptr::null_mut();
390 let device_ptr: *const c_char = device.map(|d| d.as_ptr()).unwrap_or(std::ptr::null());
391 let ret = ffmpeg::ffi::av_hwdevice_ctx_create(
392 &mut device_ctx,
393 hw_type,
394 device_ptr,
395 std::ptr::null_mut(),
396 0,
397 );
398 if ret < 0 || device_ctx.is_null() {
399 tracing::warn!("{} device init failed ({})", hw_accel.name(), ret);
400 return None;
401 }
402 Some((device_ctx, type_name))
403 }
404 }
405
406 fn init_vaapi_device() -> Option<(*mut ffmpeg::ffi::AVBufferRef, &'static str)> {
407 let type_name_c = CString::new("vaapi").ok()?;
408 let hw_type = unsafe { ffmpeg::ffi::av_hwdevice_find_type_by_name(type_name_c.as_ptr()) };
410 if hw_type == ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_NONE {
411 tracing::warn!("VAAPI hardware type unavailable");
412 return None;
413 }
414 for candidate in vaapi_device_candidates() {
415 let result = unsafe {
418 let mut device_ctx: *mut ffmpeg::ffi::AVBufferRef = std::ptr::null_mut();
419 let ret = ffmpeg::ffi::av_hwdevice_ctx_create(
420 &mut device_ctx,
421 hw_type,
422 candidate.as_ptr(),
423 std::ptr::null_mut(),
424 0,
425 );
426 if ret < 0 || device_ctx.is_null() {
427 None
428 } else {
429 Some((device_ctx, "vaapi"))
430 }
431 };
432 if result.is_some() {
433 return result;
434 }
435 }
436 tracing::warn!("VAAPI device init failed on all render nodes");
437 None
438 }
439
440 fn try_hw_decoder(
442 stream: &ffmpeg::format::stream::Stream,
443 hw_accel: HwAccel,
444 ) -> Option<(ffmpeg::codec::decoder::Video, HwAccel)> {
445 let (device_ctx, _) = Self::init_hw_device(hw_accel)?;
446
447 let mut context =
448 ffmpeg::codec::context::Context::from_parameters(stream.parameters()).ok()?;
449
450 unsafe {
453 (*context.as_mut_ptr()).hw_device_ctx = device_ctx;
454 (*context.as_mut_ptr()).get_format = Some(select_hw_pixel_format);
455 }
456
457 match context.decoder().video() {
458 Ok(decoder) => {
459 tracing::info!(
460 "Hardware decoder initialized: {}; awaiting negotiated hardware frame",
461 hw_accel.name()
462 );
463 Some((decoder, hw_accel))
464 }
465 Err(e) => {
466 tracing::warn!("{} decode init failed: {}", hw_accel.name(), e);
467 None
468 }
469 }
470 }
471
472 fn build_decoder(
478 stream: &ffmpeg::format::stream::Stream,
479 hw_accel: HwAccel,
480 ) -> (ffmpeg::codec::decoder::Video, HwAccel) {
481 match hw_accel {
482 HwAccel::Auto => {
483 let mut tried = 0;
489 for backend in HwAccel::auto_hardware_order() {
490 tried += 1;
491 if let Some(result) = Self::try_hw_decoder(stream, backend) {
492 return result;
493 }
494 }
495 if tried == 0 {
496 tracing::info!("No hardware decoder present, using software decoder");
497 } else {
498 tracing::info!("Hardware decoders failed, using software decoder");
499 }
500 }
501 HwAccel::Software => {
502 tracing::info!("Software decoder explicitly requested");
504 }
505 specific => {
506 if let Some(result) = Self::try_hw_decoder(stream, specific) {
508 return result;
509 }
510 tracing::info!(
512 "{} hardware decoder failed, falling back to software",
513 specific.name()
514 );
515 }
516 }
517
518 let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
520 .and_then(|ctx| ctx.decoder().video())
521 .expect("software decoder must be available");
522 (decoder, HwAccel::Software)
523 }
524
525 #[allow(clippy::too_many_arguments)]
526 fn decode_loop(
527 path: std::path::PathBuf,
528 hw_accel: HwAccel,
529 frame_queue: Arc<Mutex<VecDeque<VideoFrame>>>,
530 frame_capacity: usize,
531 dropped_frames: Arc<AtomicU64>,
532 control_rx: Receiver<DecoderControl>,
533 stop_flag: Arc<AtomicBool>,
534 seek_epoch: Arc<AtomicU64>,
535 hw_in_use: Arc<AtomicU8>,
536 decoder_state: Arc<AtomicU8>,
537 loop_video: bool,
538 ) -> VideoResult<HwAccel> {
539 let mut ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
540 path: path.clone(),
541 source: std::io::Error::other(e.to_string()),
542 })?;
543
544 let stream = ictx
545 .streams()
546 .best(ffmpeg::media::Type::Video)
547 .ok_or_else(|| VideoError::NoVideoStream(path.clone()))?;
548
549 let video_stream_index = stream.index();
550 let time_base = stream.time_base();
551
552 let (mut decoder, used_hw) = Self::build_decoder(&stream, hw_accel);
553 tracing::info!("Decoder in use: {}", used_hw.name());
554 decoder_state.store(
555 if used_hw == HwAccel::Software {
556 DecoderState::SoftwareActive.code()
557 } else {
558 DecoderState::HardwareNegotiating.code()
559 },
560 Ordering::Release,
561 );
562
563 let mut scaler: Option<ffmpeg::software::scaling::Context> = None;
564 let mut scaler_src: Option<ffmpeg::format::Pixel> = None;
565
566 let mut paused = false;
567 let mut pending_seek: Option<(Duration, u64)> = None;
568 let mut applied_seek_epoch = 0;
569 let mut frame_index = 0u64;
570 let mut hardware_frame_seen = false;
571 let mut decoded_frame = ffmpeg::frame::Video::empty();
572 let mut sw_frame = ffmpeg::frame::Video::empty();
573 let mut rgb_frame = ffmpeg::frame::Video::empty();
574
575 'outer: loop {
576 if stop_flag.load(Ordering::Relaxed) {
577 break;
578 }
579
580 while let Ok(control) = control_rx.try_recv() {
581 match control {
582 DecoderControl::Pause => paused = true,
583 DecoderControl::Resume => paused = false,
584 DecoderControl::Seek(ts, epoch) => pending_seek = Some((ts, epoch)),
585 }
586 }
587
588 if let Some((ts, epoch)) = pending_seek.take() {
589 Self::apply_seek(&mut ictx, &mut decoder, time_base, ts);
590 applied_seek_epoch = epoch;
591 }
592
593 if paused {
594 thread::sleep(Duration::from_millis(10));
595 continue;
596 }
597
598 for (stream, packet) in ictx.packets() {
599 if stop_flag.load(Ordering::Relaxed) {
600 break 'outer;
601 }
602
603 while let Ok(control) = control_rx.try_recv() {
604 match control {
605 DecoderControl::Pause => paused = true,
606 DecoderControl::Resume => paused = false,
607 DecoderControl::Seek(ts, epoch) => pending_seek = Some((ts, epoch)),
608 }
609 }
610 if paused || pending_seek.is_some() {
611 break;
612 }
613
614 if stream.index() != video_stream_index {
615 continue;
616 }
617
618 if let Err(e) = decoder.send_packet(&packet) {
623 tracing::warn!("video packet skipped: {e}");
624 continue;
625 }
626
627 while decoder.receive_frame(&mut decoded_frame).is_ok() {
628 if stop_flag.load(Ordering::Relaxed) {
629 break 'outer;
630 }
631
632 let mut interrupted = false;
638 loop {
639 if stop_flag.load(Ordering::Relaxed) {
640 break 'outer;
641 }
642 while let Ok(control) = control_rx.try_recv() {
643 match control {
644 DecoderControl::Pause => paused = true,
645 DecoderControl::Resume => paused = false,
646 DecoderControl::Seek(ts, epoch) => {
647 pending_seek = Some((ts, epoch));
648 }
649 }
650 }
651 if pending_seek.is_some()
652 || seek_epoch.load(Ordering::Acquire) != applied_seek_epoch
653 {
654 interrupted = true;
655 break;
656 }
657 let queue_full = frame_queue
658 .lock()
659 .map(|queue| queue.len() >= frame_capacity)
660 .unwrap_or(false);
661 if !paused && !queue_full {
662 break;
663 }
664 thread::sleep(Duration::from_millis(2));
669 }
670 if interrupted {
671 break;
672 }
673
674 let is_hw_frame = unsafe { !(*decoded_frame.as_ptr()).hw_frames_ctx.is_null() };
675
676 let src_frame = if is_hw_frame {
677 let ret = unsafe {
678 ffmpeg::ffi::av_frame_unref(sw_frame.as_mut_ptr());
679 ffmpeg::ffi::av_hwframe_transfer_data(
680 sw_frame.as_mut_ptr(),
681 decoded_frame.as_ptr(),
682 0,
683 )
684 };
685 if ret < 0 {
686 tracing::warn!("hwframe transfer failed: {ret}");
687 continue;
688 }
689 hardware_frame_seen = true;
690 hw_in_use.store(used_hw.code(), Ordering::Release);
691 decoder_state.store(DecoderState::HardwareActive.code(), Ordering::Release);
692 &sw_frame
693 } else {
694 &decoded_frame
695 };
696
697 let pts_duration = if let Some(pts) = decoded_frame.timestamp() {
698 Duration::from_secs_f64(
699 pts as f64 * time_base.numerator() as f64
700 / time_base.denominator() as f64,
701 )
702 } else {
703 Duration::from_secs_f64(frame_index as f64 / 30.0)
704 };
705
706 let width = src_frame.width();
707 let height = src_frame.height();
708 let data = match convert_frame(
713 src_frame,
714 &decoded_frame,
715 &mut scaler,
716 &mut scaler_src,
717 &mut rgb_frame,
718 width,
719 height,
720 &decoder,
721 ) {
722 Ok(data) => data,
723 Err(e) => {
724 tracing::warn!("video frame conversion skipped: {e}");
725 continue;
726 }
727 };
728
729 let video_frame = VideoFrame {
730 data,
731 width,
732 height,
733 pts: pts_duration,
734 index: frame_index,
735 };
736 frame_index = frame_index.wrapping_add(1);
737
738 if let Ok(mut queue) = frame_queue.lock() {
739 if queue.len() >= frame_capacity {
740 queue.pop_front();
741 dropped_frames.fetch_add(1, Ordering::Relaxed);
742 }
743 queue.push_back(video_frame);
744 }
745 }
746 }
747
748 tracing::debug!("End of stream after {} frames, looping", frame_index);
749 while decoder.receive_frame(&mut decoded_frame).is_ok() {
750 if stop_flag.load(Ordering::Relaxed) {
751 break 'outer;
752 }
753 }
754 if stop_flag.load(Ordering::Relaxed) {
755 break;
756 }
757 if loop_video && !paused {
758 ictx.seek(0, ..)
759 .map_err(|e| VideoError::SeekFailed(Duration::ZERO, e.into()))?;
760 decoder.flush();
761 } else if !paused {
762 thread::sleep(Duration::from_millis(50));
766 }
767 }
768
769 if hardware_frame_seen {
770 Ok(used_hw)
771 } else {
772 if used_hw != HwAccel::Software {
773 decoder_state.store(DecoderState::SoftwareFallback.code(), Ordering::Release);
774 }
775 Ok(HwAccel::Software)
776 }
777 }
778
779 fn apply_seek(
780 ictx: &mut ffmpeg::format::context::Input,
781 decoder: &mut ffmpeg::codec::decoder::Video,
782 time_base: ffmpeg::Rational,
783 ts: Duration,
784 ) {
785 let tb_sec = time_base.numerator() as f64 / time_base.denominator() as f64;
786 let ts_tb = if tb_sec > 0.0 {
787 (ts.as_secs_f64() / tb_sec) as i64
788 } else {
789 0
790 };
791 match ictx.seek(ts_tb, ..) {
792 Ok(()) => {
793 decoder.flush();
794 let mut drain = ffmpeg::frame::Video::empty();
795 while decoder.receive_frame(&mut drain).is_ok() {}
796 tracing::info!("Seeked to {:?}", ts);
797 }
798 Err(e) => tracing::warn!("Seek to {:?} failed: {}", ts, e),
799 }
800 }
801
802 pub fn next_frame(&self) -> Option<VideoFrame> {
803 self.frame_queue.lock().ok()?.pop_front()
804 }
805
806 pub fn metadata(&self) -> &VideoMetadata {
807 &self.metadata
808 }
809
810 pub fn decoder_info(&self) -> DecoderInfo {
811 let hw_accel = self.hw_accel_in_use();
812 DecoderInfo {
813 codec_name: self.metadata.codec.clone(),
814 hardware_accel: if hw_accel != HwAccel::Software {
815 Some(hw_accel.name().to_string())
816 } else {
817 None
818 },
819 pixel_format: "NV12/RGBA".to_string(),
820 dropped_frames: self.dropped_frames(),
821 state: self.decoder_state(),
822 }
823 }
824
825 pub fn hw_accel_in_use(&self) -> HwAccel {
826 match self.hw_in_use.load(Ordering::Acquire) {
827 2 => HwAccel::Vaapi,
828 3 => HwAccel::Nvdec,
829 4 => HwAccel::VideoToolbox,
830 _ => HwAccel::Software,
831 }
832 }
833
834 pub fn pause(&self) {
835 let _ = self.control_tx.send(DecoderControl::Pause);
836 }
837
838 pub fn resume(&self) {
839 let _ = self.control_tx.send(DecoderControl::Resume);
840 }
841
842 pub fn seek(&self, timestamp: Duration) {
843 let epoch = self.seek_epoch.fetch_add(1, Ordering::AcqRel) + 1;
844 let _ = self.control_tx.send(DecoderControl::Seek(timestamp, epoch));
845 }
846
847 pub fn drain(&mut self) {
848 if let Ok(mut queue) = self.frame_queue.lock() {
849 queue.clear();
850 }
851 }
852
853 pub fn dropped_frames(&self) -> u64 {
854 self.dropped_frames.load(Ordering::Relaxed)
855 }
856
857 pub fn decoder_state(&self) -> DecoderState {
858 DecoderState::from_code(self.decoder_state.load(Ordering::Acquire))
859 }
860
861 pub fn is_video_file<P: AsRef<Path>>(path: P) -> bool {
862 path.as_ref()
863 .extension()
864 .and_then(|e| e.to_str())
865 .is_some_and(|ext| {
866 matches!(
867 ext.to_lowercase().as_str(),
868 "mp4" | "webm" | "mkv" | "mov" | "avi" | "m4v"
869 )
870 })
871 }
872}
873
874unsafe extern "C" fn select_hw_pixel_format(
878 context: *mut ffmpeg::ffi::AVCodecContext,
879 formats: *const ffmpeg::ffi::AVPixelFormat,
880) -> ffmpeg::ffi::AVPixelFormat {
881 if context.is_null() || formats.is_null() {
882 return ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE;
883 }
884
885 let device_type = unsafe {
886 (*context).hw_device_ctx.as_ref().and_then(|device| {
887 (!device.data.is_null())
888 .then(|| (*(device.data as *const ffmpeg::ffi::AVHWDeviceContext)).type_)
889 })
890 };
891 choose_hw_pixel_format(device_type, formats)
892}
893
894fn choose_hw_pixel_format(
895 device_type: Option<ffmpeg::ffi::AVHWDeviceType>,
896 formats: *const ffmpeg::ffi::AVPixelFormat,
897) -> ffmpeg::ffi::AVPixelFormat {
898 let preferred = match device_type {
899 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA) => {
900 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_CUDA
901 }
902 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI) => {
903 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI
904 }
905 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX) => {
906 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX
907 }
908 _ => ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE,
909 };
910
911 if preferred != ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
912 let mut current = formats;
913 loop {
914 let format = unsafe { *current };
915 if format == ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
916 break;
917 }
918 if format == preferred {
919 return format;
920 }
921 current = unsafe { current.add(1) };
922 }
923 }
924
925 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
926}
927
928fn ensure_ffmpeg_init() -> VideoResult<()> {
929 use std::sync::OnceLock;
930 static INIT: OnceLock<Result<(), String>> = OnceLock::new();
931 let result = INIT.get_or_init(|| {
932 ffmpeg::init().map_err(|e| format!("FFmpeg init: {e}"))?;
933 Ok(())
938 });
939 result
940 .as_ref()
941 .map_err(|e| VideoError::SoftwareDecoderInit(anyhow::anyhow!(e.clone())))
942 .map(|_| ())
943}
944
945fn nvidia_gpu_present() -> bool {
947 any_nvidia_card(std::path::Path::new("/sys/class/drm"))
948}
949
950fn any_nvidia_card(root: &std::path::Path) -> bool {
951 let Ok(entries) = std::fs::read_dir(root) else {
952 return false;
953 };
954 entries.filter_map(|e| e.ok()).any(|entry| {
955 let name = entry.file_name().to_string_lossy().into_owned();
956 if !name.starts_with("card") {
957 return false;
958 }
959 std::fs::read_to_string(entry.path().join("device/vendor"))
960 .is_ok_and(|vendor| vendor.trim().eq_ignore_ascii_case("0x10de"))
961 })
962}
963
964fn vaapi_available() -> bool {
966 any_render_node(std::path::Path::new("/dev/dri"))
967}
968
969fn any_render_node(root: &std::path::Path) -> bool {
970 let Ok(entries) = std::fs::read_dir(root) else {
971 return false;
972 };
973 entries
974 .filter_map(|e| e.ok())
975 .any(|entry| entry.file_name().to_string_lossy().starts_with("renderD"))
976}
977
978fn vaapi_device_candidates() -> Vec<CString> {
982 let mut candidates = Vec::new();
983 if let Ok(entries) = std::fs::read_dir("/dev/dri") {
984 let mut nodes: Vec<String> = entries
985 .flatten()
986 .map(|e| e.file_name().to_string_lossy().into_owned())
987 .filter(|n| n.starts_with("renderD"))
988 .collect();
989 nodes.sort();
990 for node in nodes {
991 let path = format!("/dev/dri/{node}");
992 if let Ok(c) = CString::new(path) {
993 candidates.push(c);
994 }
995 }
996 }
997 if candidates.is_empty()
998 && let Ok(fallback) = CString::new("/dev/dri/renderD128")
999 {
1000 candidates.push(fallback);
1001 }
1002 candidates
1003}
1004
1005fn copy_packed_rows(source: &[u8], stride: usize, row_bytes: usize, height: usize) -> Vec<u8> {
1006 let mut packed = Vec::with_capacity(row_bytes * height);
1007 for row in source.chunks(stride).take(height) {
1008 packed.extend_from_slice(&row[..row_bytes]);
1009 }
1010 packed
1011}
1012
1013#[allow(clippy::too_many_arguments)]
1017fn convert_frame(
1018 src_frame: &ffmpeg::frame::Video,
1019 decoded_frame: &ffmpeg::frame::Video,
1020 scaler: &mut Option<ffmpeg::software::scaling::Context>,
1021 scaler_src: &mut Option<ffmpeg::format::Pixel>,
1022 rgb_frame: &mut ffmpeg::frame::Video,
1023 width: u32,
1024 height: u32,
1025 decoder: &ffmpeg::codec::decoder::Video,
1026) -> VideoResult<VideoFrameData> {
1027 if src_frame.format() == ffmpeg::format::Pixel::NV12 {
1028 let color_space = match decoded_frame.color_space() {
1029 ffmpeg::color::Space::Unspecified => decoder.color_space(),
1030 value => value,
1031 };
1032 let color_range = match decoded_frame.color_range() {
1033 ffmpeg::color::Range::Unspecified => decoder.color_range(),
1034 value => value,
1035 };
1036 let color = select_yuv_color(color_space, color_range, width, height);
1037 let (y_plane, uv_plane) = copy_nv12_planes(src_frame);
1038 Ok(VideoFrameData::Nv12 {
1039 y_plane,
1040 uv_plane,
1041 color,
1042 })
1043 } else {
1044 let src_format = src_frame.format();
1045 if *scaler_src != Some(src_format) {
1046 *scaler = Some(
1047 ffmpeg::software::scaling::context::Context::get(
1048 src_format,
1049 width,
1050 height,
1051 ffmpeg::format::Pixel::RGBA,
1052 width,
1053 height,
1054 ffmpeg::software::scaling::Flags::BILINEAR,
1055 )
1056 .map_err(|e| VideoError::FormatConversionFailed(e.into()))?,
1057 );
1058 *scaler_src = Some(src_format);
1059 }
1060
1061 scaler
1062 .as_mut()
1063 .expect("scaler initialized above")
1064 .run(src_frame, rgb_frame)
1065 .map_err(|e| VideoError::FormatConversionFailed(e.into()))?;
1066 Ok(VideoFrameData::Rgba(copy_packed_rows(
1067 rgb_frame.data(0),
1068 rgb_frame.stride(0),
1069 rgb_frame.width() as usize * 4,
1070 rgb_frame.height() as usize,
1071 )))
1072 }
1073}
1074
1075fn copy_nv12_planes(frame: &ffmpeg::frame::Video) -> (Vec<u8>, Vec<u8>) {
1076 copy_nv12_data(
1077 frame.data(0),
1078 frame.stride(0),
1079 frame.data(1),
1080 frame.stride(1),
1081 frame.width() as usize,
1082 frame.height() as usize,
1083 )
1084}
1085
1086fn copy_nv12_data(
1087 y_source: &[u8],
1088 y_stride: usize,
1089 uv_source: &[u8],
1090 uv_stride: usize,
1091 width: usize,
1092 height: usize,
1093) -> (Vec<u8>, Vec<u8>) {
1094 let chroma_width = width.div_ceil(2);
1095 let chroma_height = height.div_ceil(2);
1096 (
1097 copy_packed_rows(y_source, y_stride, width, height),
1098 copy_packed_rows(uv_source, uv_stride, chroma_width * 2, chroma_height),
1099 )
1100}
1101
1102fn select_yuv_color(
1103 space: ffmpeg::color::Space,
1104 range: ffmpeg::color::Range,
1105 width: u32,
1106 height: u32,
1107) -> YuvColorInfo {
1108 let matrix = match space {
1109 ffmpeg::color::Space::BT470BG | ffmpeg::color::Space::SMPTE170M => YuvMatrix::Bt601,
1110 ffmpeg::color::Space::BT709 => YuvMatrix::Bt709,
1111 ffmpeg::color::Space::BT2020NCL => YuvMatrix::Bt2020,
1112 _ if width >= 1280 || height > 576 => YuvMatrix::Bt709,
1113 _ => YuvMatrix::Bt601,
1114 };
1115 let range = match range {
1116 ffmpeg::color::Range::JPEG => YuvRange::Full,
1117 ffmpeg::color::Range::MPEG | ffmpeg::color::Range::Unspecified => YuvRange::Limited,
1118 };
1119 YuvColorInfo { matrix, range }
1120}
1121
1122impl Drop for VideoDecoder {
1123 fn drop(&mut self) {
1124 self.stop_flag.store(true, Ordering::Relaxed);
1125 if let Some(thread) = self.decode_thread.take() {
1126 let _ = thread.join();
1127 }
1128 }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134
1135 #[test]
1136 fn nvidia_probe_detects_vendor_via_sysfs_layout() {
1137 let temp = tempfile::tempdir().expect("tempdir");
1138 let card0 = temp.path().join("card0");
1139 let card1 = temp.path().join("card1");
1140
1141 std::fs::create_dir_all(card0.join("device")).expect("dirs");
1142 std::fs::create_dir_all(card1.join("device")).expect("dirs");
1143 assert!(!any_nvidia_card(temp.path()));
1144
1145 std::fs::write(card1.join("device/vendor"), "0x10de\n").expect("vendor");
1146 assert!(any_nvidia_card(temp.path()));
1147
1148 std::fs::write(card1.join("device/vendor"), "0x1002\n").expect("vendor");
1149 assert!(!any_nvidia_card(temp.path()));
1150 }
1151
1152 #[test]
1153 fn render_node_probe_detects_render_entries() {
1154 let temp = tempfile::tempdir().expect("tempdir");
1155 assert!(!any_render_node(temp.path()));
1156 std::fs::create_dir_all(temp.path().join("renderD128")).expect("create");
1157 assert!(any_render_node(temp.path()));
1158 }
1159
1160 #[test]
1161 fn test_is_video_file() {
1162 assert!(VideoDecoder::is_video_file("test.mp4"));
1163 assert!(VideoDecoder::is_video_file("test.MP4"));
1164 assert!(VideoDecoder::is_video_file("test.webm"));
1165 assert!(VideoDecoder::is_video_file("test.mkv"));
1166 assert!(VideoDecoder::is_video_file("test.mov"));
1167 assert!(!VideoDecoder::is_video_file("test.jpg"));
1168 assert!(!VideoDecoder::is_video_file("test.gif"));
1169 assert!(!VideoDecoder::is_video_file("test.png"));
1170 }
1171
1172 #[test]
1173 fn test_hwaccel_names() {
1174 assert_eq!(HwAccel::Vaapi.name(), "VAAPI");
1175 assert_eq!(HwAccel::Nvdec.name(), "NVDEC");
1176 assert_eq!(HwAccel::Software.name(), "Software");
1177 }
1178
1179 #[test]
1180 fn decoder_states_have_stable_diagnostic_names() {
1181 assert_eq!(
1182 DecoderState::HardwareNegotiating.name(),
1183 "hardware negotiation"
1184 );
1185 assert_eq!(DecoderState::HardwareActive.name(), "hardware active");
1186 assert_eq!(DecoderState::SoftwareFallback.name(), "software fallback");
1187 assert_eq!(
1188 DecoderState::from_code(DecoderState::Failed.code()),
1189 DecoderState::Failed
1190 );
1191 }
1192
1193 #[test]
1194 fn test_hwaccel_from_config() {
1195 assert_eq!(HwAccel::from_config("vaapi"), HwAccel::Vaapi);
1196 assert_eq!(HwAccel::from_config("nvdec"), HwAccel::Nvdec);
1197 assert_eq!(HwAccel::from_config("software"), HwAccel::Software);
1198 assert_eq!(HwAccel::from_config("auto"), HwAccel::Auto);
1199 assert_eq!(HwAccel::from_config("unknown"), HwAccel::Auto);
1200 }
1201
1202 #[test]
1203 fn packs_strided_rows() {
1204 let y = [1, 2, 3, 99, 99, 4, 5, 6, 99, 99, 7, 8, 9, 99, 99];
1205 let uv = [10, 11, 12, 13, 99, 99, 14, 15, 16, 17, 99, 99];
1206 let (packed_y, packed_uv) = copy_nv12_data(&y, 5, &uv, 6, 3, 3);
1207 assert_eq!(packed_y, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
1208 assert_eq!(packed_uv, [10, 11, 12, 13, 14, 15, 16, 17]);
1209 }
1210
1211 #[test]
1212 fn selects_yuv_matrix_and_range() {
1213 assert_eq!(
1214 select_yuv_color(
1215 ffmpeg::color::Space::BT2020NCL,
1216 ffmpeg::color::Range::JPEG,
1217 3840,
1218 2160,
1219 ),
1220 YuvColorInfo {
1221 matrix: YuvMatrix::Bt2020,
1222 range: YuvRange::Full,
1223 }
1224 );
1225 assert_eq!(
1226 select_yuv_color(
1227 ffmpeg::color::Space::Unspecified,
1228 ffmpeg::color::Range::Unspecified,
1229 1920,
1230 1080,
1231 ),
1232 YuvColorInfo {
1233 matrix: YuvMatrix::Bt709,
1234 range: YuvRange::Limited,
1235 }
1236 );
1237 assert_eq!(
1238 select_yuv_color(
1239 ffmpeg::color::Space::Unspecified,
1240 ffmpeg::color::Range::MPEG,
1241 720,
1242 576,
1243 ),
1244 YuvColorInfo {
1245 matrix: YuvMatrix::Bt601,
1246 range: YuvRange::Limited,
1247 }
1248 );
1249 }
1250
1251 #[test]
1252 fn hardware_format_negotiation_selects_only_the_matching_backend() {
1253 let formats = [
1254 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
1255 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_CUDA,
1256 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE,
1257 ];
1258 assert_eq!(
1259 choose_hw_pixel_format(
1260 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA),
1261 formats.as_ptr()
1262 ),
1263 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_CUDA
1264 );
1265
1266 let software_only = [
1267 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
1268 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE,
1269 ];
1270 assert_eq!(
1271 choose_hw_pixel_format(
1272 Some(ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA),
1273 software_only.as_ptr()
1274 ),
1275 ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
1276 );
1277 }
1278}