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, AtomicU64, 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 enum VideoFrameData {
83    Rgba(Vec<u8>),
84    Nv12 {
85        y_plane: Vec<u8>,
86        uv_plane: Vec<u8>,
87        color: YuvColorInfo,
88    },
89}
90
91impl VideoFrameData {
92    pub fn len(&self) -> usize {
93        match self {
94            Self::Rgba(data) => data.len(),
95            Self::Nv12 {
96                y_plane, uv_plane, ..
97            } => y_plane.len() + uv_plane.len(),
98        }
99    }
100
101    pub fn is_empty(&self) -> bool {
102        self.len() == 0
103    }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct YuvColorInfo {
108    pub matrix: YuvMatrix,
109    pub range: YuvRange,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum YuvMatrix {
114    Bt601,
115    Bt709,
116    Bt2020,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum YuvRange {
121    Limited,
122    Full,
123}
124
125#[derive(Debug, Clone)]
126pub struct VideoFrame {
127    pub data: VideoFrameData,
128    pub width: u32,
129    pub height: u32,
130    pub pts: Duration,
131    pub index: u64,
132}
133
134impl VideoFrame {
135    pub fn into_scheduled(self) -> ScheduledFrame {
136        ScheduledFrame::new(self.data, self.width, self.height, self.pts, self.index)
137    }
138}
139
140#[derive(Debug, Clone)]
141pub struct DecoderInfo {
142    pub codec_name: String,
143    pub hardware_accel: Option<String>,
144    pub pixel_format: String,
145}
146
147#[derive(Debug, Clone, Copy)]
148enum DecoderControl {
149    Pause,
150    Resume,
151    Seek(Duration, u64),
152}
153
154pub struct VideoDecoder {
155    metadata: VideoMetadata,
156    frame_rx: Receiver<VideoFrame>,
157    control_tx: Sender<DecoderControl>,
158    stop_flag: Arc<AtomicBool>,
159    seek_epoch: Arc<AtomicU64>,
160    hw_in_use: Arc<AtomicU8>,
161    decode_thread: Option<thread::JoinHandle<()>>,
162}
163
164impl VideoDecoder {
165    pub fn new<P: AsRef<Path>>(path: P, hw_accel: HwAccel) -> VideoResult<Self> {
166        Self::with_preload(path, hw_accel, 2)
167    }
168
169    pub fn with_preload<P: AsRef<Path>>(
170        path: P,
171        hw_accel: HwAccel,
172        preload_frames: usize,
173    ) -> VideoResult<Self> {
174        let path = path.as_ref().to_path_buf();
175
176        ffmpeg::init()
177            .map_err(|e| VideoError::SoftwareDecoderInit(anyhow::anyhow!("FFmpeg init: {}", e)))?;
178
179        let metadata = Self::extract_metadata(&path)?;
180
181        tracing::info!(
182            "Opened video: {}x{} @ {:.2} fps, duration: {:?}, codec: {}",
183            metadata.width,
184            metadata.height,
185            metadata.fps,
186            metadata.duration,
187            metadata.codec
188        );
189
190        let (frame_tx, frame_rx) = crossbeam_channel::bounded(preload_frames.max(1));
191        let (control_tx, control_rx) = crossbeam_channel::unbounded();
192
193        let stop_flag = Arc::new(AtomicBool::new(false));
194        let stop_flag_clone = stop_flag.clone();
195        let seek_epoch = Arc::new(AtomicU64::new(0));
196        let seek_epoch_clone = seek_epoch.clone();
197        let hw_in_use = Arc::new(AtomicU8::new(0));
198        let hw_in_use_clone = hw_in_use.clone();
199
200        let decode_thread = thread::Builder::new()
201            .name("wallr-video-decoder".to_string())
202            .spawn(move || {
203                let used = Self::decode_loop(
204                    path,
205                    hw_accel,
206                    frame_tx,
207                    control_rx,
208                    stop_flag_clone,
209                    seek_epoch_clone,
210                    hw_in_use_clone.clone(),
211                );
212                let used = match used {
213                    Ok(used) => used,
214                    Err(e) => {
215                        tracing::error!("Video decode loop: {}", e);
216                        HwAccel::Software
217                    }
218                };
219                tracing::info!("Decode thread exited (backend: {})", used.name());
220                // Update again on exit in case it wasn't set during init
221                hw_in_use_clone.store(used.code(), Ordering::Relaxed);
222            })
223            .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
224
225        Ok(Self {
226            metadata,
227            frame_rx,
228            control_tx,
229            stop_flag,
230            seek_epoch,
231            hw_in_use,
232            decode_thread: Some(decode_thread),
233        })
234    }
235
236    fn extract_metadata(path: &Path) -> VideoResult<VideoMetadata> {
237        let ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
238            path: path.to_path_buf(),
239            source: std::io::Error::other(e.to_string()),
240        })?;
241
242        let stream = ictx
243            .streams()
244            .best(ffmpeg::media::Type::Video)
245            .ok_or_else(|| VideoError::NoVideoStream(path.to_path_buf()))?;
246
247        let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
248            .and_then(|ctx| ctx.decoder().video())
249            .map_err(|e| VideoError::SoftwareDecoderInit(e.into()))?;
250
251        let width = decoder.width();
252        let height = decoder.height();
253        let codec = decoder
254            .codec()
255            .map(|c| c.name().to_string())
256            .unwrap_or_else(|| "unknown".to_string());
257
258        let frame_rate = stream.avg_frame_rate();
259        let fps = if frame_rate.numerator() > 0 {
260            frame_rate.numerator() as f64 / frame_rate.denominator() as f64
261        } else {
262            30.0
263        };
264
265        let duration = {
266            let duration_ts = stream.duration();
267            let time_base = stream.time_base();
268            if duration_ts > 0 {
269                Duration::from_secs_f64(
270                    duration_ts as f64 * time_base.numerator() as f64
271                        / time_base.denominator() as f64,
272                )
273            } else {
274                let container_duration = ictx.duration() as f64 / ffmpeg::ffi::AV_TIME_BASE as f64;
275                Duration::from_secs_f64(container_duration)
276            }
277        };
278
279        let total_frames = if fps > 0.0 {
280            (duration.as_secs_f64() * fps) as u64
281        } else {
282            0
283        };
284
285        Ok(VideoMetadata {
286            width,
287            height,
288            duration,
289            fps,
290            codec,
291            format: ictx.format().name().to_string(),
292            total_frames,
293        })
294    }
295
296    fn init_hw_device(hw_accel: HwAccel) -> Option<(*mut ffmpeg::ffi::AVBufferRef, &'static str)> {
297        let (type_name, device) = match hw_accel {
298            HwAccel::Vaapi => ("vaapi", Some(c"/dev/dri/renderD128")),
299            HwAccel::Nvdec => ("cuda", Some(c"0")),
300            HwAccel::VideoToolbox => ("videotoolbox", None),
301            HwAccel::Software | HwAccel::Auto => return None,
302        };
303
304        let type_name_c = CString::new(type_name).ok()?;
305
306        // SAFETY: C strings passed to FFmpeg; returned device ctx owned by codec context.
307        unsafe {
308            let hw_type = ffmpeg::ffi::av_hwdevice_find_type_by_name(type_name_c.as_ptr());
309            if hw_type == ffmpeg::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_NONE {
310                tracing::warn!("{} hardware type unavailable", hw_accel.name());
311                return None;
312            }
313
314            let mut device_ctx: *mut ffmpeg::ffi::AVBufferRef = std::ptr::null_mut();
315            let device_ptr: *const c_char = device.map(|d| d.as_ptr()).unwrap_or(std::ptr::null());
316            let ret = ffmpeg::ffi::av_hwdevice_ctx_create(
317                &mut device_ctx,
318                hw_type,
319                device_ptr,
320                std::ptr::null_mut(),
321                0,
322            );
323            if ret < 0 || device_ctx.is_null() {
324                tracing::warn!("{} device init failed ({})", hw_accel.name(), ret);
325                return None;
326            }
327            Some((device_ctx, type_name))
328        }
329    }
330
331    /// Try to build a decoder with a hardware device context attached.
332    fn try_hw_decoder(
333        stream: &ffmpeg::format::stream::Stream,
334        hw_accel: HwAccel,
335    ) -> Option<(ffmpeg::codec::decoder::Video, HwAccel)> {
336        let (device_ctx, _) = Self::init_hw_device(hw_accel)?;
337
338        let mut context =
339            ffmpeg::codec::context::Context::from_parameters(stream.parameters()).ok()?;
340
341        // SAFETY: device_ctx is a valid FFmpeg allocation; the codec context
342        // unrefs it when dropped.
343        unsafe {
344            (*context.as_mut_ptr()).hw_device_ctx = device_ctx;
345        }
346
347        match context.decoder().video() {
348            Ok(decoder) => {
349                tracing::info!("Hardware decode active: {}", hw_accel.name());
350                Some((decoder, hw_accel))
351            }
352            Err(e) => {
353                tracing::warn!("{} decode init failed: {}", hw_accel.name(), e);
354                None
355            }
356        }
357    }
358
359    /// Build a decoder with appropriate hardware acceleration fallback.
360    ///
361    /// - Auto: Try all hardware backends in priority order, then software
362    /// - Explicit backend (Vaapi, Nvdec, VideoToolbox): Try that backend, then software
363    /// - Software: Use software decoder only (no hardware attempts)
364    fn build_decoder(
365        stream: &ffmpeg::format::stream::Stream,
366        hw_accel: HwAccel,
367    ) -> (ffmpeg::codec::decoder::Video, HwAccel) {
368        match hw_accel {
369            HwAccel::Auto => {
370                // Try all hardware backends in priority order
371                for &backend in HwAccel::all_hardware() {
372                    if let Some(result) = Self::try_hw_decoder(stream, backend) {
373                        return result;
374                    }
375                }
376                // Fall back to software
377                tracing::info!("All hardware backends failed, using software decoder");
378            }
379            HwAccel::Software => {
380                // Explicit software request: skip hardware entirely
381                tracing::info!("Software decoder explicitly requested");
382            }
383            specific => {
384                // Try the requested hardware backend first
385                if let Some(result) = Self::try_hw_decoder(stream, specific) {
386                    return result;
387                }
388                // Fall back to software
389                tracing::info!(
390                    "{} hardware decoder failed, falling back to software",
391                    specific.name()
392                );
393            }
394        }
395
396        // Software fallback.
397        let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
398            .and_then(|ctx| ctx.decoder().video())
399            .expect("software decoder must be available");
400        (decoder, HwAccel::Software)
401    }
402
403    #[allow(clippy::too_many_arguments)]
404    fn decode_loop(
405        path: std::path::PathBuf,
406        hw_accel: HwAccel,
407        frame_tx: Sender<VideoFrame>,
408        control_rx: Receiver<DecoderControl>,
409        stop_flag: Arc<AtomicBool>,
410        seek_epoch: Arc<AtomicU64>,
411        hw_in_use: Arc<AtomicU8>,
412    ) -> VideoResult<HwAccel> {
413        let mut ictx = ffmpeg::format::input(&path).map_err(|e| VideoError::FileOpen {
414            path: path.clone(),
415            source: std::io::Error::other(e.to_string()),
416        })?;
417
418        let stream = ictx
419            .streams()
420            .best(ffmpeg::media::Type::Video)
421            .ok_or_else(|| VideoError::NoVideoStream(path.clone()))?;
422
423        let video_stream_index = stream.index();
424        let time_base = stream.time_base();
425
426        let (mut decoder, used_hw) = Self::build_decoder(&stream, hw_accel);
427        tracing::info!("Decoder in use: {}", used_hw.name());
428
429        // Report the active backend immediately after successful initialization
430        hw_in_use.store(used_hw.code(), Ordering::Relaxed);
431
432        let mut scaler: Option<ffmpeg::software::scaling::Context> = None;
433        let mut scaler_src: Option<ffmpeg::format::Pixel> = None;
434
435        let mut paused = false;
436        let mut pending_seek: Option<(Duration, u64)> = None;
437        let mut applied_seek_epoch = 0;
438        let mut frame_index = 0u64;
439        let mut decoded_frame = ffmpeg::frame::Video::empty();
440        let mut sw_frame = ffmpeg::frame::Video::empty();
441        let mut rgb_frame = ffmpeg::frame::Video::empty();
442
443        'outer: loop {
444            if stop_flag.load(Ordering::Relaxed) {
445                break;
446            }
447
448            while let Ok(control) = control_rx.try_recv() {
449                match control {
450                    DecoderControl::Pause => paused = true,
451                    DecoderControl::Resume => paused = false,
452                    DecoderControl::Seek(ts, epoch) => pending_seek = Some((ts, epoch)),
453                }
454            }
455
456            if let Some((ts, epoch)) = pending_seek.take() {
457                Self::apply_seek(&mut ictx, &mut decoder, time_base, ts);
458                applied_seek_epoch = epoch;
459            }
460
461            if paused {
462                thread::sleep(Duration::from_millis(10));
463                continue;
464            }
465
466            for (stream, packet) in ictx.packets() {
467                if stop_flag.load(Ordering::Relaxed) {
468                    break 'outer;
469                }
470
471                while let Ok(control) = control_rx.try_recv() {
472                    match control {
473                        DecoderControl::Pause => paused = true,
474                        DecoderControl::Resume => paused = false,
475                        DecoderControl::Seek(ts, epoch) => pending_seek = Some((ts, epoch)),
476                    }
477                }
478                if paused || pending_seek.is_some() {
479                    break;
480                }
481
482                if stream.index() != video_stream_index {
483                    continue;
484                }
485
486                decoder
487                    .send_packet(&packet)
488                    .map_err(|e| VideoError::DecodeFailed(anyhow::anyhow!("send_packet: {}", e)))?;
489
490                while decoder.receive_frame(&mut decoded_frame).is_ok() {
491                    if stop_flag.load(Ordering::Relaxed) {
492                        break 'outer;
493                    }
494
495                    // Apply backpressure before GPU readback and color
496                    // conversion, retaining this decoded frame across pause.
497                    let mut interrupted = false;
498                    loop {
499                        if stop_flag.load(Ordering::Relaxed) {
500                            break 'outer;
501                        }
502                        while let Ok(control) = control_rx.try_recv() {
503                            match control {
504                                DecoderControl::Pause => paused = true,
505                                DecoderControl::Resume => paused = false,
506                                DecoderControl::Seek(ts, epoch) => {
507                                    pending_seek = Some((ts, epoch));
508                                }
509                            }
510                        }
511                        if pending_seek.is_some()
512                            || seek_epoch.load(Ordering::Acquire) != applied_seek_epoch
513                        {
514                            interrupted = true;
515                            break;
516                        }
517                        if !paused && !frame_tx.is_full() {
518                            break;
519                        }
520                        thread::sleep(Duration::from_millis(1));
521                    }
522                    if interrupted {
523                        break;
524                    }
525
526                    let is_hw_frame = unsafe { !(*decoded_frame.as_ptr()).hw_frames_ctx.is_null() };
527
528                    let src_frame = if is_hw_frame {
529                        let ret = unsafe {
530                            ffmpeg::ffi::av_frame_unref(sw_frame.as_mut_ptr());
531                            ffmpeg::ffi::av_hwframe_transfer_data(
532                                sw_frame.as_mut_ptr(),
533                                decoded_frame.as_ptr(),
534                                0,
535                            )
536                        };
537                        if ret < 0 {
538                            tracing::warn!("hwframe transfer failed: {ret}");
539                            continue;
540                        }
541                        &sw_frame
542                    } else {
543                        &decoded_frame
544                    };
545
546                    let pts_duration = if let Some(pts) = decoded_frame.timestamp() {
547                        Duration::from_secs_f64(
548                            pts as f64 * time_base.numerator() as f64
549                                / time_base.denominator() as f64,
550                        )
551                    } else {
552                        Duration::from_secs_f64(frame_index as f64 / 30.0)
553                    };
554
555                    let width = src_frame.width();
556                    let height = src_frame.height();
557                    let data = if src_frame.format() == ffmpeg::format::Pixel::NV12 {
558                        let color_space = match decoded_frame.color_space() {
559                            ffmpeg::color::Space::Unspecified => decoder.color_space(),
560                            value => value,
561                        };
562                        let color_range = match decoded_frame.color_range() {
563                            ffmpeg::color::Range::Unspecified => decoder.color_range(),
564                            value => value,
565                        };
566                        let color = select_yuv_color(color_space, color_range, width, height);
567                        let (y_plane, uv_plane) = copy_nv12_planes(src_frame);
568                        VideoFrameData::Nv12 {
569                            y_plane,
570                            uv_plane,
571                            color,
572                        }
573                    } else {
574                        let src_format = src_frame.format();
575                        if scaler_src != Some(src_format) {
576                            scaler = Some(
577                                ffmpeg::software::scaling::context::Context::get(
578                                    src_format,
579                                    width,
580                                    height,
581                                    ffmpeg::format::Pixel::RGBA,
582                                    width,
583                                    height,
584                                    ffmpeg::software::scaling::Flags::BILINEAR,
585                                )
586                                .map_err(|e| VideoError::FormatConversionFailed(e.into()))?,
587                            );
588                            scaler_src = Some(src_format);
589                        }
590
591                        scaler
592                            .as_mut()
593                            .expect("scaler initialized above")
594                            .run(src_frame, &mut rgb_frame)
595                            .map_err(|e| VideoError::FormatConversionFailed(e.into()))?;
596                        VideoFrameData::Rgba(copy_packed_rows(
597                            rgb_frame.data(0),
598                            rgb_frame.stride(0),
599                            rgb_frame.width() as usize * 4,
600                            rgb_frame.height() as usize,
601                        ))
602                    };
603
604                    let video_frame = VideoFrame {
605                        data,
606                        width,
607                        height,
608                        pts: pts_duration,
609                        index: frame_index,
610                    };
611                    frame_index = frame_index.wrapping_add(1);
612
613                    match frame_tx.send_timeout(video_frame, Duration::from_millis(20)) {
614                        Ok(()) => {}
615                        Err(SendTimeoutError::Timeout(_)) => break,
616                        Err(SendTimeoutError::Disconnected(_)) => {
617                            tracing::warn!("Frame queue disconnected, ending decode loop");
618                            return Ok(used_hw);
619                        }
620                    }
621                }
622            }
623
624            tracing::debug!("End of stream after {} frames, looping", frame_index);
625            while decoder.receive_frame(&mut decoded_frame).is_ok() {
626                if stop_flag.load(Ordering::Relaxed) {
627                    break 'outer;
628                }
629            }
630            if stop_flag.load(Ordering::Relaxed) {
631                break;
632            }
633            if !paused {
634                ictx.seek(0, ..)
635                    .map_err(|e| VideoError::SeekFailed(Duration::ZERO, e.into()))?;
636                decoder.flush();
637            }
638        }
639
640        Ok(used_hw)
641    }
642
643    fn apply_seek(
644        ictx: &mut ffmpeg::format::context::Input,
645        decoder: &mut ffmpeg::codec::decoder::Video,
646        time_base: ffmpeg::Rational,
647        ts: Duration,
648    ) {
649        let tb_sec = time_base.numerator() as f64 / time_base.denominator() as f64;
650        let ts_tb = if tb_sec > 0.0 {
651            (ts.as_secs_f64() / tb_sec) as i64
652        } else {
653            0
654        };
655        match ictx.seek(ts_tb, ..) {
656            Ok(()) => {
657                decoder.flush();
658                let mut drain = ffmpeg::frame::Video::empty();
659                while decoder.receive_frame(&mut drain).is_ok() {}
660                tracing::info!("Seeked to {:?}", ts);
661            }
662            Err(e) => tracing::warn!("Seek to {:?} failed: {}", ts, e),
663        }
664    }
665
666    pub fn next_frame(&self) -> Option<VideoFrame> {
667        self.frame_rx.try_recv().ok()
668    }
669
670    pub fn metadata(&self) -> &VideoMetadata {
671        &self.metadata
672    }
673
674    pub fn decoder_info(&self) -> DecoderInfo {
675        DecoderInfo {
676            codec_name: self.metadata.codec.clone(),
677            hardware_accel: if self.hw_accel_in_use() != HwAccel::Software {
678                Some(self.hw_accel_in_use().name().to_string())
679            } else {
680                None
681            },
682            pixel_format: "NV12/RGBA".to_string(),
683        }
684    }
685
686    pub fn hw_accel_in_use(&self) -> HwAccel {
687        for _ in 0..50 {
688            let code = self.hw_in_use.load(Ordering::Relaxed);
689            if code != 0 {
690                return HwAccel::from_code(code);
691            }
692            std::thread::sleep(Duration::from_millis(5));
693        }
694        HwAccel::Software
695    }
696
697    pub fn pause(&self) {
698        let _ = self.control_tx.send(DecoderControl::Pause);
699    }
700
701    pub fn resume(&self) {
702        let _ = self.control_tx.send(DecoderControl::Resume);
703    }
704
705    pub fn seek(&self, timestamp: Duration) {
706        let epoch = self.seek_epoch.fetch_add(1, Ordering::AcqRel) + 1;
707        let _ = self.control_tx.send(DecoderControl::Seek(timestamp, epoch));
708    }
709
710    pub fn drain(&mut self) {
711        while self.frame_rx.try_recv().is_ok() {}
712    }
713
714    pub fn is_video_file<P: AsRef<Path>>(path: P) -> bool {
715        path.as_ref()
716            .extension()
717            .and_then(|e| e.to_str())
718            .is_some_and(|ext| {
719                matches!(
720                    ext.to_lowercase().as_str(),
721                    "mp4" | "webm" | "mkv" | "mov" | "avi" | "m4v"
722                )
723            })
724    }
725}
726
727fn copy_packed_rows(source: &[u8], stride: usize, row_bytes: usize, height: usize) -> Vec<u8> {
728    let mut packed = Vec::with_capacity(row_bytes * height);
729    for row in source.chunks(stride).take(height) {
730        packed.extend_from_slice(&row[..row_bytes]);
731    }
732    packed
733}
734
735fn copy_nv12_planes(frame: &ffmpeg::frame::Video) -> (Vec<u8>, Vec<u8>) {
736    copy_nv12_data(
737        frame.data(0),
738        frame.stride(0),
739        frame.data(1),
740        frame.stride(1),
741        frame.width() as usize,
742        frame.height() as usize,
743    )
744}
745
746fn copy_nv12_data(
747    y_source: &[u8],
748    y_stride: usize,
749    uv_source: &[u8],
750    uv_stride: usize,
751    width: usize,
752    height: usize,
753) -> (Vec<u8>, Vec<u8>) {
754    let chroma_width = width.div_ceil(2);
755    let chroma_height = height.div_ceil(2);
756    (
757        copy_packed_rows(y_source, y_stride, width, height),
758        copy_packed_rows(uv_source, uv_stride, chroma_width * 2, chroma_height),
759    )
760}
761
762fn select_yuv_color(
763    space: ffmpeg::color::Space,
764    range: ffmpeg::color::Range,
765    width: u32,
766    height: u32,
767) -> YuvColorInfo {
768    let matrix = match space {
769        ffmpeg::color::Space::BT470BG | ffmpeg::color::Space::SMPTE170M => YuvMatrix::Bt601,
770        ffmpeg::color::Space::BT709 => YuvMatrix::Bt709,
771        ffmpeg::color::Space::BT2020NCL => YuvMatrix::Bt2020,
772        _ if width >= 1280 || height > 576 => YuvMatrix::Bt709,
773        _ => YuvMatrix::Bt601,
774    };
775    let range = match range {
776        ffmpeg::color::Range::JPEG => YuvRange::Full,
777        ffmpeg::color::Range::MPEG | ffmpeg::color::Range::Unspecified => YuvRange::Limited,
778    };
779    YuvColorInfo { matrix, range }
780}
781
782impl Drop for VideoDecoder {
783    fn drop(&mut self) {
784        self.stop_flag.store(true, Ordering::Relaxed);
785        if let Some(thread) = self.decode_thread.take() {
786            let _ = thread.join();
787        }
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn test_is_video_file() {
797        assert!(VideoDecoder::is_video_file("test.mp4"));
798        assert!(VideoDecoder::is_video_file("test.MP4"));
799        assert!(VideoDecoder::is_video_file("test.webm"));
800        assert!(VideoDecoder::is_video_file("test.mkv"));
801        assert!(VideoDecoder::is_video_file("test.mov"));
802        assert!(!VideoDecoder::is_video_file("test.jpg"));
803        assert!(!VideoDecoder::is_video_file("test.gif"));
804        assert!(!VideoDecoder::is_video_file("test.png"));
805    }
806
807    #[test]
808    fn test_hwaccel_names() {
809        assert_eq!(HwAccel::Vaapi.name(), "VAAPI");
810        assert_eq!(HwAccel::Nvdec.name(), "NVDEC");
811        assert_eq!(HwAccel::Software.name(), "Software");
812    }
813
814    #[test]
815    fn test_hwaccel_codes_roundtrip() {
816        for accel in [
817            HwAccel::Software,
818            HwAccel::Vaapi,
819            HwAccel::Nvdec,
820            HwAccel::VideoToolbox,
821        ] {
822            assert_eq!(HwAccel::from_code(accel.code()), accel);
823        }
824    }
825
826    #[test]
827    fn test_hwaccel_from_config() {
828        assert_eq!(HwAccel::from_config("vaapi"), HwAccel::Vaapi);
829        assert_eq!(HwAccel::from_config("nvdec"), HwAccel::Nvdec);
830        assert_eq!(HwAccel::from_config("software"), HwAccel::Software);
831        assert_eq!(HwAccel::from_config("auto"), HwAccel::Auto);
832        assert_eq!(HwAccel::from_config("unknown"), HwAccel::Auto);
833    }
834
835    #[test]
836    fn packs_strided_rows() {
837        let y = [1, 2, 3, 99, 99, 4, 5, 6, 99, 99, 7, 8, 9, 99, 99];
838        let uv = [10, 11, 12, 13, 99, 99, 14, 15, 16, 17, 99, 99];
839        let (packed_y, packed_uv) = copy_nv12_data(&y, 5, &uv, 6, 3, 3);
840        assert_eq!(packed_y, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
841        assert_eq!(packed_uv, [10, 11, 12, 13, 14, 15, 16, 17]);
842    }
843
844    #[test]
845    fn selects_yuv_matrix_and_range() {
846        assert_eq!(
847            select_yuv_color(
848                ffmpeg::color::Space::BT2020NCL,
849                ffmpeg::color::Range::JPEG,
850                3840,
851                2160,
852            ),
853            YuvColorInfo {
854                matrix: YuvMatrix::Bt2020,
855                range: YuvRange::Full,
856            }
857        );
858        assert_eq!(
859            select_yuv_color(
860                ffmpeg::color::Space::Unspecified,
861                ffmpeg::color::Range::Unspecified,
862                1920,
863                1080,
864            ),
865            YuvColorInfo {
866                matrix: YuvMatrix::Bt709,
867                range: YuvRange::Limited,
868            }
869        );
870        assert_eq!(
871            select_yuv_color(
872                ffmpeg::color::Space::Unspecified,
873                ffmpeg::color::Range::MPEG,
874                720,
875                576,
876            ),
877            YuvColorInfo {
878                matrix: YuvMatrix::Bt601,
879                range: YuvRange::Limited,
880            }
881        );
882    }
883}