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