Skip to main content

viser_ffmpeg/
lib.rs

1//! FFmpeg/FFprobe wrapper for the `viser` video-encoding-optimizer workspace.
2//!
3//! Provides typed primitives (codecs, resolutions, rate-control modes) plus
4//! functions to encode (`encode`), probe media (`probe`), and resolve the
5//! `ffmpeg`/`ffprobe` binary paths. A `ProbeCache` deduplicates probe calls.
6
7mod cache;
8mod encode;
9mod hw_encode;
10mod path;
11mod probe;
12#[cfg(feature = "revelo")]
13mod probe_revelo;
14#[cfg(feature = "revelo")]
15pub use probe_revelo::probe as probe_revelo;
16
17pub use cache::*;
18pub use encode::*;
19pub use hw_encode::*;
20pub use path::*;
21pub use probe::*;
22
23use serde::{Deserialize, Serialize};
24use std::fmt;
25
26/// Supported video codec.
27///
28/// Software encoders (libx264, libx265, libsvtav1) are always available.
29/// Hardware encoder variants require FFmpeg built with the matching SDK
30/// and a GPU with the matching ASIC at runtime; availability is detected
31/// via `ffmpeg -encoders`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub enum Codec {
34    /// H.264/AVC via `libx264`.
35    #[serde(rename = "libx264")]
36    X264,
37    /// H.265/HEVC via `libx265`.
38    #[serde(rename = "libx265")]
39    X265,
40    /// AV1 via `libsvtav1` (SVT-AV1).
41    #[serde(rename = "libsvtav1")]
42    SvtAv1,
43
44    // ── Hardware encoders (H.264) ──
45    /// NVIDIA NVENC H.264 (`h264_nvenc`).
46    #[serde(rename = "h264_nvenc")]
47    NvencH264,
48    /// Intel QuickSync H.264 (`h264_qsv`).
49    #[serde(rename = "h264_qsv")]
50    QsvH264,
51    /// Apple VideoToolbox H.264 (`h264_videotoolbox`).
52    #[serde(rename = "h264_videotoolbox")]
53    VideoToolboxH264,
54    /// Linux VAAPI H.264 (`h264_vaapi`).
55    #[serde(rename = "h264_vaapi")]
56    VaapiH264,
57    /// AMD AMF H.264 (`h264_amf`).
58    #[serde(rename = "h264_amf")]
59    AmfH264,
60
61    // ── Hardware encoders (H.265/HEVC) ──
62    /// NVIDIA NVENC HEVC (`hevc_nvenc`).
63    #[serde(rename = "hevc_nvenc")]
64    NvencH265,
65    /// Intel QuickSync HEVC (`hevc_qsv`).
66    #[serde(rename = "hevc_qsv")]
67    QsvH265,
68    /// Apple VideoToolbox HEVC (`hevc_videotoolbox`).
69    #[serde(rename = "hevc_videotoolbox")]
70    VideoToolboxH265,
71    /// Linux VAAPI HEVC (`hevc_vaapi`).
72    #[serde(rename = "hevc_vaapi")]
73    VaapiH265,
74    /// AMD AMF HEVC (`hevc_amf`).
75    #[serde(rename = "hevc_amf")]
76    AmfH265,
77
78    // ── Hardware encoders (AV1) ──
79    // Apple VideoToolbox has no AV1 encoder, so there is no `av1_videotoolbox`.
80    /// NVIDIA NVENC AV1 (`av1_nvenc`) — Ada/Blackwell and newer.
81    #[serde(rename = "av1_nvenc")]
82    NvencAv1,
83    /// Intel QuickSync AV1 (`av1_qsv`) — Arc/Battlemage and newer.
84    #[serde(rename = "av1_qsv")]
85    QsvAv1,
86    /// Linux VAAPI AV1 (`av1_vaapi`) — Arc/Battlemage, RDNA3+ and newer.
87    #[serde(rename = "av1_vaapi")]
88    VaapiAv1,
89    /// AMD AMF AV1 (`av1_amf`) — RDNA3+ and newer.
90    #[serde(rename = "av1_amf")]
91    AmfAv1,
92}
93
94/// Hardware encoder backend (GPU vendor / API).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum EncoderBackend {
97    /// Software encoder (libx264, libx265, libsvtav1).
98    Software,
99    /// NVIDIA NVENC.
100    Nvenc,
101    /// Intel QuickSync.
102    Qsv,
103    /// Apple VideoToolbox.
104    VideoToolbox,
105    /// Linux VAAPI.
106    Vaapi,
107    /// AMD AMF.
108    Amf,
109}
110
111/// Codec family (compression standard).
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum CodecFamily {
114    /// H.264/AVC.
115    H264,
116    /// H.265/HEVC.
117    H265,
118    /// AV1.
119    Av1,
120}
121
122impl Codec {
123    /// FFmpeg encoder name for this codec (e.g. `"libx264"`).
124    pub fn as_str(&self) -> &'static str {
125        match self {
126            Codec::X264 => "libx264",
127            Codec::X265 => "libx265",
128            Codec::SvtAv1 => "libsvtav1",
129            Codec::NvencH264 => "h264_nvenc",
130            Codec::QsvH264 => "h264_qsv",
131            Codec::VideoToolboxH264 => "h264_videotoolbox",
132            Codec::VaapiH264 => "h264_vaapi",
133            Codec::AmfH264 => "h264_amf",
134            Codec::NvencH265 => "hevc_nvenc",
135            Codec::QsvH265 => "hevc_qsv",
136            Codec::VideoToolboxH265 => "hevc_videotoolbox",
137            Codec::VaapiH265 => "hevc_vaapi",
138            Codec::AmfH265 => "hevc_amf",
139            Codec::NvencAv1 => "av1_nvenc",
140            Codec::QsvAv1 => "av1_qsv",
141            Codec::VaapiAv1 => "av1_vaapi",
142            Codec::AmfAv1 => "av1_amf",
143        }
144    }
145
146    /// Hardware encoder backend for this codec.
147    pub fn backend(&self) -> EncoderBackend {
148        match self {
149            Codec::X264 | Codec::X265 | Codec::SvtAv1 => EncoderBackend::Software,
150            Codec::NvencH264 | Codec::NvencH265 | Codec::NvencAv1 => EncoderBackend::Nvenc,
151            Codec::QsvH264 | Codec::QsvH265 | Codec::QsvAv1 => EncoderBackend::Qsv,
152            Codec::VideoToolboxH264 | Codec::VideoToolboxH265 => EncoderBackend::VideoToolbox,
153            Codec::VaapiH264 | Codec::VaapiH265 | Codec::VaapiAv1 => EncoderBackend::Vaapi,
154            Codec::AmfH264 | Codec::AmfH265 | Codec::AmfAv1 => EncoderBackend::Amf,
155        }
156    }
157
158    /// Codec family (compression standard).
159    pub fn family(&self) -> CodecFamily {
160        match self {
161            Codec::X264
162            | Codec::NvencH264
163            | Codec::QsvH264
164            | Codec::VideoToolboxH264
165            | Codec::VaapiH264
166            | Codec::AmfH264 => CodecFamily::H264,
167            Codec::X265
168            | Codec::NvencH265
169            | Codec::QsvH265
170            | Codec::VideoToolboxH265
171            | Codec::VaapiH265
172            | Codec::AmfH265 => CodecFamily::H265,
173            Codec::SvtAv1 | Codec::NvencAv1 | Codec::QsvAv1 | Codec::VaapiAv1 | Codec::AmfAv1 => {
174                CodecFamily::Av1
175            }
176        }
177    }
178
179    /// Whether this codec uses a hardware encoder backend.
180    pub fn is_hardware(&self) -> bool {
181        !matches!(self.backend(), EncoderBackend::Software)
182    }
183
184    /// Whether this codec is a software encoder.
185    pub fn is_software(&self) -> bool {
186        matches!(self.backend(), EncoderBackend::Software)
187    }
188}
189
190impl fmt::Display for Codec {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        f.write_str(self.as_str())
193    }
194}
195
196impl std::str::FromStr for Codec {
197    type Err = anyhow::Error;
198
199    fn from_str(s: &str) -> Result<Self, Self::Err> {
200        match s {
201            "libx264" | "x264" | "h264" => Ok(Codec::X264),
202            "libx265" | "x265" | "h265" | "hevc" => Ok(Codec::X265),
203            "libsvtav1" | "svtav1" | "av1" => Ok(Codec::SvtAv1),
204            // NVENC
205            "h264_nvenc" | "nvenc" | "nvenc_h264" => Ok(Codec::NvencH264),
206            "hevc_nvenc" | "nvenc_h265" | "nvenc_hevc" => Ok(Codec::NvencH265),
207            // QuickSync
208            "h264_qsv" | "qsv" | "qsv_h264" => Ok(Codec::QsvH264),
209            "hevc_qsv" | "qsv_h265" | "qsv_hevc" => Ok(Codec::QsvH265),
210            // VideoToolbox
211            "h264_videotoolbox" | "vt" | "vt_h264" | "videotoolbox" => Ok(Codec::VideoToolboxH264),
212            "hevc_videotoolbox" | "vt_h265" | "vt_hevc" => Ok(Codec::VideoToolboxH265),
213            // VAAPI
214            "h264_vaapi" | "vaapi" | "vaapi_h264" => Ok(Codec::VaapiH264),
215            "hevc_vaapi" | "vaapi_h265" | "vaapi_hevc" => Ok(Codec::VaapiH265),
216            // AMF
217            "h264_amf" | "amf" | "amf_h264" => Ok(Codec::AmfH264),
218            "hevc_amf" | "amf_h265" | "amf_hevc" => Ok(Codec::AmfH265),
219            // AV1 hardware
220            "av1_nvenc" | "nvenc_av1" => Ok(Codec::NvencAv1),
221            "av1_qsv" | "qsv_av1" => Ok(Codec::QsvAv1),
222            "av1_vaapi" | "vaapi_av1" => Ok(Codec::VaapiAv1),
223            "av1_amf" | "amf_av1" => Ok(Codec::AmfAv1),
224            _ => Err(anyhow::anyhow!("unknown codec: {s}")),
225        }
226    }
227}
228
229/// Video resolution.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
231pub struct Resolution {
232    /// Pixel width.
233    pub width: i32,
234    /// Pixel height.
235    pub height: i32,
236}
237
238impl Resolution {
239    /// Creates a resolution from width and height in pixels.
240    pub const fn new(width: i32, height: i32) -> Self {
241        Self { width, height }
242    }
243
244    /// Human-friendly label like "1080p", "720p", etc.
245    pub fn label(&self) -> String {
246        match self.height {
247            h if h >= 2160 => "2160p".into(),
248            h if h >= 1440 => "1440p".into(),
249            h if h >= 1080 => "1080p".into(),
250            h if h >= 720 => "720p".into(),
251            h if h >= 480 => "480p".into(),
252            h if h >= 360 => "360p".into(),
253            h if h >= 240 => "240p".into(),
254            h => format!("{h}p"),
255        }
256    }
257}
258
259impl fmt::Display for Resolution {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        write!(f, "{}x{}", self.width, self.height)
262    }
263}
264
265impl std::str::FromStr for Resolution {
266    type Err = anyhow::Error;
267
268    fn from_str(s: &str) -> Result<Self, Self::Err> {
269        match s.to_lowercase().as_str() {
270            "2160p" | "4k" => Ok(RES_2160P),
271            "1440p" => Ok(RES_1440P),
272            "1080p" => Ok(RES_1080P),
273            "720p" => Ok(RES_720P),
274            "480p" => Ok(RES_480P),
275            "360p" => Ok(RES_360P),
276            "240p" => Ok(RES_240P),
277            other => {
278                if let Some((w, h)) = other.split_once('x') {
279                    Ok(Resolution::new(w.parse()?, h.parse()?))
280                } else {
281                    Err(anyhow::anyhow!("invalid resolution: {other}"))
282                }
283            }
284        }
285    }
286}
287
288/// 3840x2160 (4K UHD), 16:9.
289pub const RES_2160P: Resolution = Resolution::new(3840, 2160);
290/// 2560x1440 (QHD), 16:9.
291pub const RES_1440P: Resolution = Resolution::new(2560, 1440);
292/// 1920x1080 (Full HD), 16:9.
293pub const RES_1080P: Resolution = Resolution::new(1920, 1080);
294/// 1280x720 (HD), 16:9.
295pub const RES_720P: Resolution = Resolution::new(1280, 720);
296/// 854x480 (SD), 16:9.
297pub const RES_480P: Resolution = Resolution::new(854, 480);
298/// 640x360, 16:9.
299pub const RES_360P: Resolution = Resolution::new(640, 360);
300/// 426x240, 16:9.
301pub const RES_240P: Resolution = Resolution::new(426, 240);
302
303/// Rate control mode for encoding.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
305#[serde(rename_all = "lowercase")]
306pub enum RateControlMode {
307    /// Constant rate factor (default).
308    #[default]
309    Crf,
310    /// CRF with VBV/decoder-model bitrate cap.
311    CappedCrf,
312    /// Fixed quantizer (Netflix-style, no R-D optimization).
313    Qp,
314    /// 2-pass variable bitrate (for final delivery encodes).
315    Vbr,
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_codec_as_str() {
324        assert_eq!(Codec::X264.as_str(), "libx264");
325        assert_eq!(Codec::X265.as_str(), "libx265");
326        assert_eq!(Codec::SvtAv1.as_str(), "libsvtav1");
327        assert_eq!(Codec::NvencH264.as_str(), "h264_nvenc");
328        assert_eq!(Codec::QsvH264.as_str(), "h264_qsv");
329        assert_eq!(Codec::VideoToolboxH264.as_str(), "h264_videotoolbox");
330        assert_eq!(Codec::VaapiH264.as_str(), "h264_vaapi");
331        assert_eq!(Codec::AmfH264.as_str(), "h264_amf");
332        assert_eq!(Codec::NvencH265.as_str(), "hevc_nvenc");
333    }
334
335    #[test]
336    fn test_codec_display() {
337        assert_eq!(format!("{}", Codec::X264), "libx264");
338        assert_eq!(format!("{}", Codec::NvencH264), "h264_nvenc");
339    }
340
341    #[test]
342    fn test_codec_from_str() {
343        assert_eq!("libx264".parse::<Codec>().unwrap(), Codec::X264);
344        assert_eq!("x264".parse::<Codec>().unwrap(), Codec::X264);
345        assert_eq!("h264".parse::<Codec>().unwrap(), Codec::X264);
346        assert_eq!("libx265".parse::<Codec>().unwrap(), Codec::X265);
347        assert_eq!("x265".parse::<Codec>().unwrap(), Codec::X265);
348        assert_eq!("h265".parse::<Codec>().unwrap(), Codec::X265);
349        assert_eq!("hevc".parse::<Codec>().unwrap(), Codec::X265);
350        assert_eq!("libsvtav1".parse::<Codec>().unwrap(), Codec::SvtAv1);
351        assert_eq!("svtav1".parse::<Codec>().unwrap(), Codec::SvtAv1);
352        assert_eq!("av1".parse::<Codec>().unwrap(), Codec::SvtAv1);
353        assert_eq!("h264_nvenc".parse::<Codec>().unwrap(), Codec::NvencH264);
354        assert_eq!("nvenc".parse::<Codec>().unwrap(), Codec::NvencH264);
355        assert_eq!("hevc_nvenc".parse::<Codec>().unwrap(), Codec::NvencH265);
356        assert_eq!("h264_qsv".parse::<Codec>().unwrap(), Codec::QsvH264);
357        assert_eq!("qsv".parse::<Codec>().unwrap(), Codec::QsvH264);
358        assert_eq!("vt".parse::<Codec>().unwrap(), Codec::VideoToolboxH264);
359        assert_eq!("h264_vaapi".parse::<Codec>().unwrap(), Codec::VaapiH264);
360        assert_eq!("vaapi".parse::<Codec>().unwrap(), Codec::VaapiH264);
361        assert_eq!("h264_amf".parse::<Codec>().unwrap(), Codec::AmfH264);
362        assert_eq!("amf".parse::<Codec>().unwrap(), Codec::AmfH264);
363        assert!("unknown".parse::<Codec>().is_err());
364    }
365
366    #[test]
367    fn test_codec_backend() {
368        assert_eq!(Codec::X264.backend(), EncoderBackend::Software);
369        assert_eq!(Codec::NvencH264.backend(), EncoderBackend::Nvenc);
370        assert_eq!(Codec::QsvH264.backend(), EncoderBackend::Qsv);
371        assert_eq!(Codec::VideoToolboxH264.backend(), EncoderBackend::VideoToolbox);
372        assert_eq!(Codec::VaapiH264.backend(), EncoderBackend::Vaapi);
373        assert_eq!(Codec::AmfH264.backend(), EncoderBackend::Amf);
374    }
375
376    #[test]
377    fn test_codec_family() {
378        assert_eq!(Codec::X264.family(), CodecFamily::H264);
379        assert_eq!(Codec::NvencH264.family(), CodecFamily::H264);
380        assert_eq!(Codec::X265.family(), CodecFamily::H265);
381        assert_eq!(Codec::NvencH265.family(), CodecFamily::H265);
382        assert_eq!(Codec::SvtAv1.family(), CodecFamily::Av1);
383    }
384
385    #[test]
386    fn test_codec_is_hardware() {
387        assert!(!Codec::X264.is_hardware());
388        assert!(!Codec::X265.is_hardware());
389        assert!(!Codec::SvtAv1.is_hardware());
390        assert!(Codec::NvencH264.is_hardware());
391        assert!(Codec::QsvH265.is_hardware());
392        assert!(Codec::VideoToolboxH264.is_hardware());
393    }
394
395    #[test]
396    fn test_codec_is_software() {
397        assert!(Codec::X264.is_software());
398        assert!(!Codec::NvencH264.is_software());
399    }
400
401    #[test]
402    fn test_codec_serde_roundtrip() {
403        for codec in &[
404            Codec::X264,
405            Codec::X265,
406            Codec::SvtAv1,
407            Codec::NvencH264,
408            Codec::NvencH265,
409            Codec::QsvH264,
410        ] {
411            let json = serde_json::to_string(codec).unwrap();
412            let back: Codec = serde_json::from_str(&json).unwrap();
413            assert_eq!(*codec, back);
414        }
415    }
416
417    #[test]
418    fn test_av1_hw_codec_as_str() {
419        assert_eq!(Codec::NvencAv1.as_str(), "av1_nvenc");
420        assert_eq!(Codec::QsvAv1.as_str(), "av1_qsv");
421        assert_eq!(Codec::VaapiAv1.as_str(), "av1_vaapi");
422        assert_eq!(Codec::AmfAv1.as_str(), "av1_amf");
423    }
424
425    #[test]
426    fn test_av1_hw_codec_from_str() {
427        assert_eq!("av1_nvenc".parse::<Codec>().unwrap(), Codec::NvencAv1);
428        assert_eq!("nvenc_av1".parse::<Codec>().unwrap(), Codec::NvencAv1);
429        assert_eq!("av1_qsv".parse::<Codec>().unwrap(), Codec::QsvAv1);
430        assert_eq!("av1_vaapi".parse::<Codec>().unwrap(), Codec::VaapiAv1);
431        assert_eq!("av1_amf".parse::<Codec>().unwrap(), Codec::AmfAv1);
432    }
433
434    #[test]
435    fn test_av1_hw_codec_backend_and_family() {
436        for codec in &[Codec::NvencAv1, Codec::QsvAv1, Codec::VaapiAv1, Codec::AmfAv1] {
437            assert_eq!(codec.family(), CodecFamily::Av1);
438            assert!(codec.is_hardware());
439        }
440        assert_eq!(Codec::NvencAv1.backend(), EncoderBackend::Nvenc);
441        assert_eq!(Codec::QsvAv1.backend(), EncoderBackend::Qsv);
442        assert_eq!(Codec::VaapiAv1.backend(), EncoderBackend::Vaapi);
443        assert_eq!(Codec::AmfAv1.backend(), EncoderBackend::Amf);
444    }
445
446    #[test]
447    fn test_codec_eq() {
448        assert_eq!(Codec::X264, Codec::X264);
449        assert_ne!(Codec::X264, Codec::X265);
450        assert_ne!(Codec::X264, Codec::NvencH264);
451    }
452
453    #[test]
454    fn test_codec_hash() {
455        use std::collections::HashSet;
456        let mut set = HashSet::new();
457        set.insert(Codec::X264);
458        set.insert(Codec::X264);
459        assert_eq!(set.len(), 1);
460    }
461
462    #[test]
463    fn test_resolution_new() {
464        let r = Resolution::new(1920, 1080);
465        assert_eq!(r.width, 1920);
466        assert_eq!(r.height, 1080);
467    }
468
469    #[test]
470    fn test_resolution_label() {
471        assert_eq!(Resolution::new(3840, 2160).label(), "2160p");
472        assert_eq!(Resolution::new(2560, 1440).label(), "1440p");
473        assert_eq!(Resolution::new(1920, 1080).label(), "1080p");
474        assert_eq!(Resolution::new(1280, 720).label(), "720p");
475        assert_eq!(Resolution::new(854, 480).label(), "480p");
476        assert_eq!(Resolution::new(640, 360).label(), "360p");
477        assert_eq!(Resolution::new(426, 240).label(), "240p");
478        assert_eq!(Resolution::new(320, 200).label(), "200p");
479    }
480
481    #[test]
482    fn test_resolution_display() {
483        assert_eq!(format!("{}", Resolution::new(1920, 1080)), "1920x1080");
484        assert_eq!(format!("{}", Resolution::new(640, 360)), "640x360");
485    }
486
487    #[test]
488    fn test_resolution_from_str() {
489        assert_eq!("1080p".parse::<Resolution>().unwrap(), RES_1080P);
490        assert_eq!("720p".parse::<Resolution>().unwrap(), RES_720P);
491        assert_eq!("480p".parse::<Resolution>().unwrap(), RES_480P);
492        assert_eq!("360p".parse::<Resolution>().unwrap(), RES_360P);
493        assert_eq!("240p".parse::<Resolution>().unwrap(), RES_240P);
494        assert_eq!("1440p".parse::<Resolution>().unwrap(), RES_1440P);
495        assert_eq!("2160p".parse::<Resolution>().unwrap(), RES_2160P);
496        assert_eq!("4k".parse::<Resolution>().unwrap(), RES_2160P);
497        assert_eq!("1920x1080".parse::<Resolution>().unwrap(), RES_1080P);
498        assert_eq!("640x360".parse::<Resolution>().unwrap(), RES_360P);
499        assert!("invalid".parse::<Resolution>().is_err());
500    }
501
502    #[test]
503    fn test_resolution_serde_roundtrip() {
504        let r = RES_1080P;
505        let json = serde_json::to_string(&r).unwrap();
506        let back: Resolution = serde_json::from_str(&json).unwrap();
507        assert_eq!(r, back);
508    }
509
510    #[test]
511    fn test_resolution_const_equality() {
512        assert_eq!(RES_2160P, Resolution::new(3840, 2160));
513        assert_eq!(RES_1440P, Resolution::new(2560, 1440));
514        assert_eq!(RES_1080P, Resolution::new(1920, 1080));
515        assert_eq!(RES_720P, Resolution::new(1280, 720));
516        assert_eq!(RES_480P, Resolution::new(854, 480));
517        assert_eq!(RES_360P, Resolution::new(640, 360));
518        assert_eq!(RES_240P, Resolution::new(426, 240));
519    }
520
521    #[test]
522    fn test_rate_control_mode_default() {
523        assert_eq!(RateControlMode::default(), RateControlMode::Crf);
524    }
525
526    #[test]
527    fn test_rate_control_mode_serde() {
528        let json = serde_json::to_string(&RateControlMode::Crf).unwrap();
529        assert_eq!(json, "\"crf\"");
530        let back: RateControlMode = serde_json::from_str("\"vbr\"").unwrap();
531        assert_eq!(back, RateControlMode::Vbr);
532    }
533
534    #[test]
535    fn test_ffmpeg_path_default() {
536        let path = ffmpeg_path();
537        assert!(!path.is_empty());
538    }
539
540    #[test]
541    fn test_ffprobe_path_default() {
542        let path = ffprobe_path();
543        assert!(!path.is_empty());
544    }
545
546    #[test]
547    fn test_ffmpeg_path_respects_env() {
548        // SAFETY: test-only env var manipulation, single-threaded test
549        let old = std::env::var("VISER_FFMPEG").ok();
550        unsafe {
551            std::env::set_var("VISER_FFMPEG", "/custom/ffmpeg");
552        }
553        assert_eq!(ffmpeg_path(), "/custom/ffmpeg");
554        unsafe {
555            match old {
556                Some(v) => std::env::set_var("VISER_FFMPEG", v),
557                None => std::env::remove_var("VISER_FFMPEG"),
558            }
559        }
560    }
561
562    #[test]
563    fn test_ffprobe_path_respects_env() {
564        // SAFETY: test-only env var manipulation, single-threaded test
565        let old = std::env::var("VISER_FFPROBE").ok();
566        unsafe {
567            std::env::set_var("VISER_FFPROBE", "/custom/ffprobe");
568        }
569        assert_eq!(ffprobe_path(), "/custom/ffprobe");
570        unsafe {
571            match old {
572                Some(v) => std::env::set_var("VISER_FFPROBE", v),
573                None => std::env::remove_var("VISER_FFPROBE"),
574            }
575        }
576    }
577}