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