Skip to main content

wallr_core/video/
decoder.rs

1use crate::video::error::{VideoError, VideoResult};
2use crate::video::scheduler::ScheduledFrame;
3use crossbeam_channel::{Receiver, SendTimeoutError, Sender};
4use ffmpeg_next as ffmpeg;
5use std::ffi::{CString, c_char};
6use std::path::Path;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
9use std::thread;
10use std::time::Duration;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum HwAccel {
14    /// Try all hardware backends in priority order, then software
15    Auto,
16    Vaapi,
17    Nvdec,
18    VideoToolbox,
19    Software,
20}
21
22impl HwAccel {
23    pub fn name(&self) -> &'static str {
24        match self {
25            HwAccel::Auto => "Auto",
26            HwAccel::Vaapi => "VAAPI",
27            HwAccel::Nvdec => "NVDEC",
28            HwAccel::VideoToolbox => "VideoToolbox",
29            HwAccel::Software => "Software",
30        }
31    }
32
33    const fn code(self) -> u8 {
34        match self {
35            HwAccel::Auto => 0,
36            HwAccel::Software => 1,
37            HwAccel::Vaapi => 2,
38            HwAccel::Nvdec => 3,
39            HwAccel::VideoToolbox => 4,
40        }
41    }
42
43    const fn from_code(code: u8) -> HwAccel {
44        match code {
45            2 => HwAccel::Vaapi,
46            3 => HwAccel::Nvdec,
47            4 => HwAccel::VideoToolbox,
48            1 => HwAccel::Software,
49            _ => HwAccel::Auto,
50        }
51    }
52
53    pub fn from_config(value: &str) -> HwAccel {
54        match value.trim().to_ascii_lowercase().as_str() {
55            "vaapi" => HwAccel::Vaapi,
56            "nvdec" | "nvidia" | "cuda" => HwAccel::Nvdec,
57            "software" | "none" | "off" => HwAccel::Software,
58            _ => HwAccel::Auto,
59        }
60    }
61
62    /// All hardware backends in priority order for auto-detection fallback.
63    /// NVDEC preferred on Linux (common primary GPU on hybrid systems),
64    /// then VAAPI, then VideoToolbox on macOS.
65    fn all_hardware() -> &'static [HwAccel] {
66        &[HwAccel::Nvdec, HwAccel::Vaapi, HwAccel::VideoToolbox]
67    }
68}
69
70#[derive(Debug, Clone)]
71pub struct VideoMetadata {
72    pub width: u32,
73    pub height: u32,
74    pub duration: Duration,
75    pub fps: f64,
76    pub codec: String,
77    pub format: String,
78    pub total_frames: u64,
79}
80
81#[derive(Debug, Clone)]
82pub struct VideoFrame {
83    pub data: Vec<u8>,
84    pub width: u32,
85    pub height: u32,
86    pub pts: Duration,
87    pub index: u64,
88}
89
90impl VideoFrame {
91    pub fn into_scheduled(self) -> ScheduledFrame {
92        ScheduledFrame::new(self.data, self.width, self.height, self.pts, self.index)
93    }
94}
95
96#[derive(Debug, Clone)]
97pub struct DecoderInfo {
98    pub codec_name: String,
99    pub hardware_accel: Option<String>,
100    pub pixel_format: String,
101}
102
103#[derive(Debug, Clone, Copy)]
104enum DecoderControl {
105    Pause,
106    Resume,
107    Seek(Duration),
108}
109
110pub struct VideoDecoder {
111    metadata: VideoMetadata,
112    frame_rx: Receiver<VideoFrame>,
113    control_tx: Sender<DecoderControl>,
114    stop_flag: Arc<AtomicBool>,
115    hw_in_use: Arc<AtomicU8>,
116    decode_thread: Option<thread::JoinHandle<()>>,
117}
118
119impl VideoDecoder {
120    pub fn new<P: AsRef<Path>>(path: P, hw_accel: HwAccel) -> VideoResult<Self> {
121        let path = path.as_ref().to_path_buf();
122
123        ffmpeg::init()
124            .map_err(|e| VideoError::SoftwareDecoderInit(anyhow::anyhow!("FFmpeg init: {}", e)))?;
125
126        let metadata = Self::extract_metadata(&path)?;
127
128        tracing::info!(
129            "Opened video: {}x{} @ {:.2} fps, duration: {:?}, codec: {}",
130            metadata.width,
131            metadata.height,
132            metadata.fps,
133            metadata.duration,
134            metadata.codec
135        );
136
137        let (frame_tx, frame_rx) = crossbeam_channel::bounded(3);
138        let (control_tx, control_rx) = crossbeam_channel::unbounded();
139
140        let stop_flag = Arc::new(AtomicBool::new(false));
141        let stop_flag_clone = stop_flag.clone();
142        let hw_in_use = Arc::new(AtomicU8::new(0));
143        let hw_in_use_clone = hw_in_use.clone();
144
145        let decode_thread = thread::Builder::new()
146            .name("wallr-video-decoder".to_string())
147            .spawn(move || {
148                let used = Self::decode_loop(
149                    path,
150                    hw_accel,
151                    frame_tx,
152                    control_rx,
153                    stop_flag_clone,
154                    hw_in_use_clone.clone(),
155                );
156                let used = match used {
157                    Ok(used) => used,
158                    Err(e) => {
159                        tracing::error!("Video decode loop: {}", e);
160                        HwAccel::Software
161                    }
162                };
163                tracing::info!("Decode thread exited (backend: {})", used.name());
164                // Update again on exit in case it wasn't set during init
165                hw_in_use_clone.store(used.code(), Ordering::Relaxed);
166            })
167            .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
168
169        Ok(Self {
170            metadata,
171            frame_rx,
172            control_tx,
173            stop_flag,
174            hw_in_use,
175            decode_thread: Some(decode_thread),
176        })
177    }
178
179    fn extract_metadata(path: &Path) -> VideoResult<VideoMetadata> {
180        let ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
181            path: path.to_path_buf(),
182            source: std::io::Error::other(e.to_string()),
183        })?;
184
185        let stream = ictx
186            .streams()
187            .best(ffmpeg::media::Type::Video)
188            .ok_or_else(|| VideoError::NoVideoStream(path.to_path_buf()))?;
189
190        let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
191            .and_then(|ctx| ctx.decoder().video())
192            .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
193
194        let width = decoder.width();
195        let height = decoder.height();
196        let codec = decoder
197            .codec()
198            .map(|c| c.name().to_string())
199            .unwrap_or_else(|| "unknown".to_string());
200
201        let frame_rate = stream.avg_frame_rate();
202        let fps = if frame_rate.numerator() > 0 {
203            frame_rate.numerator() as f64 / frame_rate.denominator() as f64
204        } else {
205            30.0
206        };
207
208        let duration = {
209            let duration_ts = stream.duration();
210            let time_base = stream.time_base();
211            if duration_ts > 0 {
212                Duration::from_secs_f64(
213                    duration_ts as f64 * time_base.numerator() as f64
214                        / time_base.denominator() as f64,
215                )
216            } else {
217                let container_duration = ictx.duration() as f64 / ffmpeg::ffi::AV_TIME_BASE as f64;
218                Duration::from_secs_f64(container_duration)
219            }
220        };
221
222        let total_frames = if fps > 0.0 {
223            (duration.as_secs_f64() * fps) as u64
224        } else {
225            0
226        };
227
228        Ok(VideoMetadata {
229            width,
230            height,
231            duration,
232            fps,
233            codec,
234            format: ictx.format().name().to_string(),
235            total_frames,
236        })
237    }
238
239    fn init_hw_device(hw_accel: HwAccel) -> Option<(*mut ffmpeg::ffi::AVBufferRef, &'static str)> {
240        let (type_name, device) = match hw_accel {
241            HwAccel::Vaapi => ("vaapi", Some(c"/dev/dri/renderD128")),
242            HwAccel::Nvdec => ("cuda", Some(c"0")),
243            HwAccel::VideoToolbox => ("videotoolbox", None),
244            HwAccel::Software | HwAccel::Auto => return None,
245        };
246
247        let type_name_c = CString::new(type_name).ok()?;
248
249        // SAFETY: C strings passed to FFmpeg; returned device ctx owned by codec context.
250        unsafe {
251            let hw_type = ffmpeg::ffi::av_hwdevice_find_type_by_name(type_name_c.as_ptr());
252            if hw_type == ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_NONE {
253                tracing::warn!("{} hardware type unavailable", hw_accel.name());
254                return None;
255            }
256
257            let mut device_ctx: *mut ffmpeg::ffi::AVBufferRef = std::ptr::null_mut();
258            let device_ptr: *const c_char = device.map(|d| d.as_ptr()).unwrap_or(std::ptr::null());
259            let ret = ffmpeg::ffi::av_hwdevice_ctx_create(
260                &mut device_ctx,
261                hw_type,
262                device_ptr,
263                std::ptr::null_mut(),
264                0,
265            );
266            if ret < 0 || device_ctx.is_null() {
267                tracing::warn!("{} device init failed ({})", hw_accel.name(), ret);
268                return None;
269            }
270            Some((device_ctx, type_name))
271        }
272    }
273
274    /// Try to build a decoder with a hardware device context attached.
275    fn try_hw_decoder(
276        stream: &ffmpeg::format::stream::Stream,
277        hw_accel: HwAccel,
278    ) -> Option<(ffmpeg::codec::decoder::Video, HwAccel)> {
279        let (device_ctx, _) = Self::init_hw_device(hw_accel)?;
280
281        let mut context =
282            ffmpeg::codec::context::Context::from_parameters(stream.parameters()).ok()?;
283
284        // SAFETY: device_ctx is a valid FFmpeg allocation; the codec context
285        // unrefs it when dropped.
286        unsafe {
287            (*context.as_mut_ptr()).hw_device_ctx = device_ctx;
288        }
289
290        match context.decoder().video() {
291            Ok(decoder) => {
292                tracing::info!("Hardware decode active: {}", hw_accel.name());
293                Some((decoder, hw_accel))
294            }
295            Err(e) => {
296                tracing::warn!("{} decode init failed: {}", hw_accel.name(), e);
297                None
298            }
299        }
300    }
301
302    /// Build a decoder with appropriate hardware acceleration fallback.
303    ///
304    /// - Auto: Try all hardware backends in priority order, then software
305    /// - Explicit backend (Vaapi, Nvdec, VideoToolbox): Try that backend, then software
306    /// - Software: Use software decoder only (no hardware attempts)
307    fn build_decoder(
308        stream: &ffmpeg::format::stream::Stream,
309        hw_accel: HwAccel,
310    ) -> (ffmpeg::codec::decoder::Video, HwAccel) {
311        match hw_accel {
312            HwAccel::Auto => {
313                // Try all hardware backends in priority order
314                for &backend in HwAccel::all_hardware() {
315                    if let Some(result) = Self::try_hw_decoder(stream, backend) {
316                        return result;
317                    }
318                }
319                // Fall back to software
320                tracing::info!("All hardware backends failed, using software decoder");
321            }
322            HwAccel::Software => {
323                // Explicit software request: skip hardware entirely
324                tracing::info!("Software decoder explicitly requested");
325            }
326            specific => {
327                // Try the requested hardware backend first
328                if let Some(result) = Self::try_hw_decoder(stream, specific) {
329                    return result;
330                }
331                // Fall back to software
332                tracing::info!(
333                    "{} hardware decoder failed, falling back to software",
334                    specific.name()
335                );
336            }
337        }
338
339        // Software fallback.
340        let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
341            .and_then(|ctx| ctx.decoder().video())
342            .expect("software decoder must be available");
343        (decoder, HwAccel::Software)
344    }
345
346    #[allow(clippy::too_many_arguments)]
347    fn decode_loop(
348        path: std::path::PathBuf,
349        hw_accel: HwAccel,
350        frame_tx: Sender<VideoFrame>,
351        control_rx: Receiver<DecoderControl>,
352        stop_flag: Arc<AtomicBool>,
353        hw_in_use: Arc<AtomicU8>,
354    ) -> VideoResult<HwAccel> {
355        let mut ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
356            path: path.clone(),
357            source: std::io::Error::other(e.to_string()),
358        })?;
359
360        let stream = ictx
361            .streams()
362            .best(ffmpeg::media::Type::Video)
363            .ok_or_else(|| VideoError::NoVideoStream(path.clone()))?;
364
365        let video_stream_index = stream.index();
366        let time_base = stream.time_base();
367
368        let (mut decoder, used_hw) = Self::build_decoder(&stream, hw_accel);
369        tracing::info!("Decoder in use: {}", used_hw.name());
370
371        // Report the active backend immediately after successful initialization
372        hw_in_use.store(used_hw.code(), Ordering::Relaxed);
373
374        let mut scaler: Option<ffmpeg::software::scaling::Context> = None;
375        let mut scaler_src: Option<ffmpeg::format::Pixel> = None;
376
377        let mut paused = false;
378        let mut pending_seek: Option<Duration> = None;
379        let mut frame_index = 0u64;
380        let mut decoded_frame = ffmpeg::frame::Video::empty();
381        let mut sw_frame = ffmpeg::frame::Video::empty();
382        let mut rgb_frame = ffmpeg::frame::Video::empty();
383
384        'outer: loop {
385            if stop_flag.load(Ordering::Relaxed) {
386                break;
387            }
388
389            while let Ok(control) = control_rx.try_recv() {
390                match control {
391                    DecoderControl::Pause => paused = true,
392                    DecoderControl::Resume => paused = false,
393                    DecoderControl::Seek(ts) => pending_seek = Some(ts),
394                }
395            }
396
397            if let Some(ts) = pending_seek.take() {
398                Self::apply_seek(&mut ictx, &mut decoder, time_base, ts);
399            }
400
401            if paused {
402                thread::sleep(Duration::from_millis(10));
403                continue;
404            }
405
406            for (stream, packet) in ictx.packets() {
407                if stop_flag.load(Ordering::Relaxed) {
408                    break 'outer;
409                }
410
411                while let Ok(control) = control_rx.try_recv() {
412                    match control {
413                        DecoderControl::Pause => paused = true,
414                        DecoderControl::Resume => paused = false,
415                        DecoderControl::Seek(ts) => pending_seek = Some(ts),
416                    }
417                }
418                if paused || pending_seek.is_some() {
419                    break;
420                }
421
422                if stream.index() != video_stream_index {
423                    continue;
424                }
425
426                decoder
427                    .send_packet(&packet)
428                    .map_err(|e| VideoError::DecodeFailed(anyhow::anyhow!("send_packet: {}", e)))?;
429
430                while decoder.receive_frame(&mut decoded_frame).is_ok() {
431                    if stop_flag.load(Ordering::Relaxed) {
432                        break 'outer;
433                    }
434
435                    let is_hw_frame = unsafe { !(*decoded_frame.as_ptr()).hw_frames_ctx.is_null() };
436
437                    let src_frame = if is_hw_frame {
438                        let ret = unsafe {
439                            ffmpeg::ffi::av_frame_unref(sw_frame.as_mut_ptr());
440                            ffmpeg::ffi::av_hwframe_transfer_data(
441                                sw_frame.as_mut_ptr(),
442                                decoded_frame.as_ptr(),
443                                0,
444                            )
445                        };
446                        if ret < 0 {
447                            tracing::warn!("hwframe transfer failed: {ret}");
448                            continue;
449                        }
450                        &sw_frame
451                    } else {
452                        &decoded_frame
453                    };
454
455                    let src_format = src_frame.format();
456                    if scaler_src != Some(src_format) {
457                        scaler = Some(
458                            ffmpeg::software::scaling::context::Context::get(
459                                src_format,
460                                src_frame.width(),
461                                src_frame.height(),
462                                ffmpeg::format::Pixel::RGBA,
463                                src_frame.width(),
464                                src_frame.height(),
465                                ffmpeg::software::scaling::Flags::BILINEAR,
466                            )
467                            .map_err(|e| VideoError::FormatConversionFailed(e.into()))?,
468                        );
469                        scaler_src = Some(src_format);
470                    }
471
472                    scaler
473                        .as_mut()
474                        .expect("scaler initialized above")
475                        .run(src_frame, &mut rgb_frame)
476                        .map_err(|e| VideoError::FormatConversionFailed(e.into()))?;
477
478                    let pts_duration = if let Some(pts) = decoded_frame.timestamp() {
479                        Duration::from_secs_f64(
480                            pts as f64 * time_base.numerator() as f64
481                                / time_base.denominator() as f64,
482                        )
483                    } else {
484                        Duration::from_secs_f64(frame_index as f64 / 30.0)
485                    };
486
487                    let video_frame = VideoFrame {
488                        data: rgb_frame.data(0).to_vec(),
489                        width: rgb_frame.width(),
490                        height: rgb_frame.height(),
491                        pts: pts_duration,
492                        index: frame_index,
493                    };
494                    frame_index = frame_index.wrapping_add(1);
495
496                    match frame_tx.send_timeout(video_frame, Duration::from_millis(20)) {
497                        Ok(()) => {}
498                        Err(SendTimeoutError::Timeout(_)) => break,
499                        Err(SendTimeoutError::Disconnected(_)) => {
500                            tracing::warn!("Frame queue disconnected, ending decode loop");
501                            return Ok(used_hw);
502                        }
503                    }
504                }
505            }
506
507            tracing::debug!("End of stream after {} frames, looping", frame_index);
508            while decoder.receive_frame(&mut decoded_frame).is_ok() {
509                if stop_flag.load(Ordering::Relaxed) {
510                    break 'outer;
511                }
512            }
513            if stop_flag.load(Ordering::Relaxed) {
514                break;
515            }
516            if !paused {
517                ictx.seek(0, ..)
518                    .map_err(|e| VideoError::SeekFailed(Duration::ZERO, e.into()))?;
519                decoder.flush();
520            }
521        }
522
523        Ok(used_hw)
524    }
525
526    fn apply_seek(
527        ictx: &mut ffmpeg::format::context::Input,
528        decoder: &mut ffmpeg::codec::decoder::Video,
529        time_base: ffmpeg::Rational,
530        ts: Duration,
531    ) {
532        let tb_sec = time_base.numerator() as f64 / time_base.denominator() as f64;
533        let ts_tb = if tb_sec > 0.0 {
534            (ts.as_secs_f64() / tb_sec) as i64
535        } else {
536            0
537        };
538        match ictx.seek(ts_tb, ..) {
539            Ok(()) => {
540                decoder.flush();
541                let mut drain = ffmpeg::frame::Video::empty();
542                while decoder.receive_frame(&mut drain).is_ok() {}
543                tracing::info!("Seeked to {:?}", ts);
544            }
545            Err(e) => tracing::warn!("Seek to {:?} failed: {}", ts, e),
546        }
547    }
548
549    pub fn next_frame(&self) -> Option<VideoFrame> {
550        self.frame_rx.try_recv().ok()
551    }
552
553    pub fn metadata(&self) -> &VideoMetadata {
554        &self.metadata
555    }
556
557    pub fn decoder_info(&self) -> DecoderInfo {
558        DecoderInfo {
559            codec_name: self.metadata.codec.clone(),
560            hardware_accel: if self.hw_accel_in_use() != HwAccel::Software {
561                Some(self.hw_accel_in_use().name().to_string())
562            } else {
563                None
564            },
565            pixel_format: "RGBA".to_string(),
566        }
567    }
568
569    pub fn hw_accel_in_use(&self) -> HwAccel {
570        for _ in 0..50 {
571            let code = self.hw_in_use.load(Ordering::Relaxed);
572            if code != 0 {
573                return HwAccel::from_code(code);
574            }
575            std::thread::sleep(Duration::from_millis(5));
576        }
577        HwAccel::Software
578    }
579
580    pub fn pause(&self) {
581        let _ = self.control_tx.send(DecoderControl::Pause);
582    }
583
584    pub fn resume(&self) {
585        let _ = self.control_tx.send(DecoderControl::Resume);
586    }
587
588    pub fn seek(&self, timestamp: Duration) {
589        let _ = self.control_tx.send(DecoderControl::Seek(timestamp));
590    }
591
592    pub fn drain(&mut self) {
593        while self.frame_rx.try_recv().is_ok() {}
594    }
595
596    pub fn is_video_file<P: AsRef<Path>>(path: P) -> bool {
597        path.as_ref()
598            .extension()
599            .and_then(|e| e.to_str())
600            .is_some_and(|ext| {
601                matches!(
602                    ext.to_lowercase().as_str(),
603                    "mp4" | "webm" | "mkv" | "mov" | "avi" | "m4v"
604                )
605            })
606    }
607}
608
609impl Drop for VideoDecoder {
610    fn drop(&mut self) {
611        self.stop_flag.store(true, Ordering::Relaxed);
612        if let Some(thread) = self.decode_thread.take() {
613            let _ = thread.join();
614        }
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_is_video_file() {
624        assert!(VideoDecoder::is_video_file("test.mp4"));
625        assert!(VideoDecoder::is_video_file("test.MP4"));
626        assert!(VideoDecoder::is_video_file("test.webm"));
627        assert!(VideoDecoder::is_video_file("test.mkv"));
628        assert!(VideoDecoder::is_video_file("test.mov"));
629        assert!(!VideoDecoder::is_video_file("test.jpg"));
630        assert!(!VideoDecoder::is_video_file("test.gif"));
631        assert!(!VideoDecoder::is_video_file("test.png"));
632    }
633
634    #[test]
635    fn test_hwaccel_names() {
636        assert_eq!(HwAccel::Vaapi.name(), "VAAPI");
637        assert_eq!(HwAccel::Nvdec.name(), "NVDEC");
638        assert_eq!(HwAccel::Software.name(), "Software");
639    }
640
641    #[test]
642    fn test_hwaccel_codes_roundtrip() {
643        for accel in [
644            HwAccel::Software,
645            HwAccel::Vaapi,
646            HwAccel::Nvdec,
647            HwAccel::VideoToolbox,
648        ] {
649            assert_eq!(HwAccel::from_code(accel.code()), accel);
650        }
651    }
652
653    #[test]
654    fn test_hwaccel_from_config() {
655        assert_eq!(HwAccel::from_config("vaapi"), HwAccel::Vaapi);
656        assert_eq!(HwAccel::from_config("nvdec"), HwAccel::Nvdec);
657        assert_eq!(HwAccel::from_config("software"), HwAccel::Software);
658        assert_eq!(HwAccel::from_config("auto"), HwAccel::Auto);
659        assert_eq!(HwAccel::from_config("unknown"), HwAccel::Auto);
660    }
661}