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