Skip to main content

wallr_core/video/
decoder.rs

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    /// Try all hardware backends in priority order, then software
16    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    /// Hardware backends in priority order for auto-detection, matched to
99    /// the GPU actually present. Probing NVDEC on an AMD or iGPU machine
100    /// makes FFmpeg attempt to load `libcuda.so.1` first, fail, and print a
101    /// scary CUDA warning before ever reaching VAAPI; only NVIDIA hardware
102    /// should ever touch the NVDEC path.
103    #[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    /// Move-only conversion into the scheduler representation. No frame
182    /// bytes are cloned: ownership of the decoded planes transfers.
183    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        // Aggressively bounded decoded-frame queue: wallpaper playback never
243        // needs a backlog. At most `preload` frames are in flight, plus one
244        // pending frame in `VideoPlayback`. Stale frames are dropped by the
245        // scheduler rather than preserved. All transfers are moves.
246        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                // Update again on exit in case it wasn't set during init
288                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        // VA-API render nodes are enumerated instead of hardcoding
367        // renderD128 so multi-GPU systems use the first working node and
368        // stay on the same physical GPU as the compositor where possible.
369        // NVDEC keeps its device index; VideoToolbox needs no device string.
370        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        // SAFETY: C strings passed to FFmpeg; returned device ctx owned by codec context.
382        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        // SAFETY: read-only FFmpeg type lookup with a valid C string.
409        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            // SAFETY: candidate is a valid NUL-terminated path; the returned
416            // device ctx is owned by the codec context on success.
417            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    /// Try to build a decoder with a hardware device context attached.
441    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        // SAFETY: device_ctx is a valid FFmpeg allocation; the codec context
451        // unrefs it when dropped.
452        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    /// Build a decoder with appropriate hardware acceleration fallback.
473    ///
474    /// - Auto: Try all hardware backends in priority order, then software
475    /// - Explicit backend (Vaapi, Nvdec, VideoToolbox): Try that backend, then software
476    /// - Software: Use software decoder only (no hardware attempts)
477    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                // Probe only what this machine can actually use, in
484                // vendor-matched priority: NVDEC for NVIDIA hardware, VAAPI
485                // for everything else with a render node. Never probe NVDEC
486                // on AMD/iGPU machines (that makes FFmpeg try to load
487                // libcuda.so.1, print a CUDA error, then fall through).
488                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                // Explicit software request: skip hardware entirely
503                tracing::info!("Software decoder explicitly requested");
504            }
505            specific => {
506                // Try the requested hardware backend first
507                if let Some(result) = Self::try_hw_decoder(stream, specific) {
508                    return result;
509                }
510                // Fall back to software
511                tracing::info!(
512                    "{} hardware decoder failed, falling back to software",
513                    specific.name()
514                );
515            }
516        }
517
518        // Software fallback.
519        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                // A corrupt or transiently unreadable packet must not end
619                // wallpaper playback: skip it and keep decoding. Previously a
620                // single `send_packet` error terminated the thread, silently
621                // freezing the video forever.
622                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                    // A full queue is backpressure, not a decoder failure. Do
633                    // not block forever and do not terminate the decoder. The
634                    // consumer owns presentation timing, so replace the
635                    // oldest queued frame when necessary and keep the newest
636                    // frame available.
637                    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                        // The queue is intentionally bounded. Avoid a 1 ms
665                        // busy-poll when the consumer/GPU is behind or when
666                        // playback is paused; 2 ms still keeps seek/pause
667                        // control responsive while cutting wakeups in half.
668                        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                    // Conversion failures (unexpected pixel format, hardware
709                    // readback hiccup) skip the frame instead of killing the
710                    // decode thread. Previous behavior froze playback forever
711                    // on the first hiccup.
712                    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                // Reached the end without looping (single play-through).
763                // Stay parked so pause/seek/stop still work, and a later
764                // seek restarts the stream from the requested position.
765                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
874/// Select the hardware pixel format advertised by FFmpeg for the attached
875/// device. Merely attaching `hw_device_ctx` does not make a decoder produce
876/// hardware frames; FFmpeg calls this callback during codec negotiation.
877unsafe 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        // Metadata extraction opens the input once on the calling thread so
934        // a failed video never replaces active playback; the decode thread
935        // then opens it a second time for the actual stream. The two opens
936        // are intentional: the first validates cheaply before committing.
937        Ok(())
938    });
939    result
940        .as_ref()
941        .map_err(|e| VideoError::SoftwareDecoderInit(anyhow::anyhow!(e.clone())))
942        .map(|_| ())
943}
944
945/// True when any `/sys/class/drm/card*` sibling reports NVIDIA vendor 0x10de.
946fn 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
964/// True when a usable DRM render node exists (`/dev/dri/renderD*`).
965fn 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
978/// VA-API render-node candidates in probe order. Enumerates
979/// `/dev/dri/renderD*` so hybrid/multi-GPU systems try every node instead
980/// of assuming renderD128; falls back to renderD128 when enumeration fails.
981fn 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/// Converts a decoded frame into `VideoFrameData`, keeping NV12 on the GPU
1014/// path when the decoder produced it natively and falling back to a software
1015/// RGBA conversion for anything else.
1016#[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}