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, 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    /// Try all hardware backends in priority order, then software
19    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
71/// Minimum frames a decode probe must produce before it counts as usable.
72pub const PROBE_MIN_FRAMES: u64 = 30;
73
74/// Whether a decode probe run should be reported as successful.
75///
76/// A run passes only when it produced more than [`PROBE_MIN_FRAMES`] frames
77/// *and* the decoder never reported failure. Checking the frame count alone
78/// would let a decoder that failed after decoding part of the stream pass.
79pub 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    /// Hardware backends in priority order for auto-detection, matched to
114    /// the GPU actually present. Probing NVDEC on an AMD or iGPU machine
115    /// makes FFmpeg attempt to load `libcuda.so.1` first, fail, and print a
116    /// scary CUDA warning before ever reaching VAAPI; only NVIDIA hardware
117    /// should ever touch the NVDEC path.
118    #[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    /// Move-only conversion into the scheduler representation. No frame
197    /// bytes are cloned: ownership of the decoded planes transfers.
198    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        // Aggressively bounded decoded-frame queue: wallpaper playback never
259        // needs a backlog. At most `preload` frames are in flight, plus one
260        // pending frame in `VideoPlayback`. Stale frames are dropped by the
261        // scheduler rather than preserved. All transfers are moves.
262        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                // Update again on exit in case it wasn't set during init
313                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        // VA-API render nodes are enumerated instead of hardcoding
404        // renderD128 so multi-GPU systems use the first working node and
405        // stay on the same physical GPU as the compositor where possible.
406        // NVDEC keeps its device index; VideoToolbox needs no device string.
407        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        // SAFETY: C strings passed to FFmpeg; returned device ctx owned by codec context.
419        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        // SAFETY: read-only FFmpeg type lookup with a valid C string.
446        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            // SAFETY: candidate is a valid NUL-terminated path; the returned
453            // device ctx is owned by the codec context on success.
454            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    /// Try to build a decoder with a hardware device context attached.
478    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        // SAFETY: device_ctx is a valid FFmpeg allocation; the codec context
490        // unrefs it when dropped.
491        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    /// Build a decoder with appropriate hardware acceleration fallback.
517    ///
518    /// - Auto: Try all hardware backends in priority order, then software
519    /// - Explicit backend (Vaapi, Nvdec, VideoToolbox): Try that backend, then software
520    /// - Software: Use software decoder only (no hardware attempts)
521    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                // Probe only what this machine can actually use, in
529                // vendor-matched priority: NVDEC for NVIDIA hardware, VAAPI
530                // for everything else with a render node. Never probe NVDEC
531                // on AMD/iGPU machines (that makes FFmpeg try to load
532                // libcuda.so.1, print a CUDA error, then fall through).
533                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                // Explicit software request: skip hardware entirely
549                tracing::info!("Software decoder explicitly requested");
550            }
551            specific => {
552                // Try the requested hardware backend first
553                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                // Fall back to software
558                tracing::info!(
559                    "{} hardware decoder failed, falling back to software",
560                    specific.name()
561                );
562            }
563        }
564
565        // Software fallback. An unavailable software decoder (unsupported
566        // codec) is a normal error, not a reason to panic the decode thread.
567        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                // A corrupt or transiently unreadable packet must not end
669                // wallpaper playback: skip it and keep decoding. Previously a
670                // single `send_packet` error terminated the thread, silently
671                // freezing the video forever.
672                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                    // A full queue is backpressure, not a decoder failure. Do
683                    // not block forever and do not terminate the decoder. The
684                    // consumer owns presentation timing, so replace the
685                    // oldest queued frame when necessary and keep the newest
686                    // frame available.
687                    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                        // The queue is intentionally bounded. Avoid a 1 ms
714                        // busy-poll when the consumer/GPU is behind or when
715                        // playback is paused; 2 ms still keeps seek/pause
716                        // control responsive while cutting wakeups in half.
717                        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                        // Preserve initialization fallback and remember any software
768                        // frames even if hardware decoding resumes later.
769                        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                    // Conversion failures (unexpected pixel format, hardware
779                    // readback hiccup) skip the frame instead of killing the
780                    // decode thread. Previous behavior froze playback forever
781                    // on the first hiccup.
782                    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                // Reached the end without looping (single play-through).
835                // Stay parked so pause/seek/stop still work, and a later
836                // seek restarts the stream from the requested position.
837                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    /// Whether this decoder has ever fallen back, including before recovery.
934    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
951/// Find a decoder for this codec that advertises support for the requested
952/// hardware device context. FFmpeg's default decoder can be a software-only
953/// implementation (for example libdav1d), even when a hardware decoder for
954/// the same codec is available.
955fn 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    // SAFETY: FFmpeg owns the codec registry and returned codec/config pointers;
964    // they remain valid for the process lifetime. The iterator is local.
965    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
1017/// Select the hardware pixel format advertised by FFmpeg for the attached
1018/// device. Merely attaching `hw_device_ctx` does not make a decoder produce
1019/// hardware frames; FFmpeg calls this callback during codec negotiation.
1020unsafe 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        // Metadata extraction opens the input once on the calling thread so
1077        // a failed video never replaces active playback; the decode thread
1078        // then opens it a second time for the actual stream. The two opens
1079        // are intentional: the first validates cheaply before committing.
1080        Ok(())
1081    });
1082    result
1083        .as_ref()
1084        .map_err(|e| VideoError::SoftwareDecoderInit(anyhow::anyhow!(e.clone())))
1085        .map(|_| ())
1086}
1087
1088/// Converts untrusted container timing to a `Duration` without panicking on
1089/// NaN, negative, infinite, or beyond-`Duration::MAX` values.
1090fn 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
1100/// Converts an untrusted presentation timestamp to a `Duration`.
1101///
1102/// Container PTS values are arbitrary user-controlled data: streams with edit
1103/// lists start before zero, and `AV_NOPTS_VALUE` can surface as an extreme
1104/// negative value. `Duration::from_secs_f64` panics on negative, NaN, and
1105/// out-of-range input - and the release profile builds with
1106/// `panic = "abort"`, so a malformed PTS would abort the daemon. Returns
1107/// `None` for every timestamp that cannot be represented; the decode loop
1108/// drops the frame and keeps playing.
1109fn 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            // Missing PTS: fall back to the nominal position of this frame in
1119            // the stream. Bounds-checked against 30fps decode leftovers.
1120            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    // Some valid containers use an edit list or codec delay that makes the
1131    // first PTS negative. Normalize the stream against its first timestamp
1132    // instead of dropping those frames and appearing frozen at startup.
1133    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    // Frames that decode but cannot schedule anywhere (u64::MAX ms and
1139    // beyond) are dropped; every reasonable container publishes times far
1140    // below this.
1141    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
1180/// True when any `/sys/class/drm/card*` sibling reports NVIDIA vendor 0x10de.
1181fn 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
1199/// True when a usable DRM render node exists (`/dev/dri/renderD*`).
1200fn 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
1213/// VA-API render-node candidates in probe order. Enumerates
1214/// `/dev/dri/renderD*` so hybrid/multi-GPU systems try every node instead
1215/// of assuming renderD128; falls back to renderD128 when enumeration fails.
1216fn 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/// Converts decoded frames to NV12, avoiding a full-resolution CPU RGB
1275/// conversion and reducing queued/uploaded frame bytes versus RGBA.
1276#[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}