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 path;
10mod probe;
11#[cfg(feature = "revelo")]
12mod probe_revelo;
13#[cfg(feature = "revelo")]
14pub use probe_revelo::probe as probe_revelo;
15
16pub use cache::*;
17pub use encode::*;
18pub use path::*;
19pub use probe::*;
20
21use serde::{Deserialize, Serialize};
22use std::fmt;
23
24/// Supported video codec.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum Codec {
27    /// H.264/AVC via `libx264`.
28    #[serde(rename = "libx264")]
29    X264,
30    /// H.265/HEVC via `libx265`.
31    #[serde(rename = "libx265")]
32    X265,
33    /// AV1 via `libsvtav1` (SVT-AV1).
34    #[serde(rename = "libsvtav1")]
35    SvtAv1,
36}
37
38impl Codec {
39    /// FFmpeg encoder name for this codec (e.g. `"libx264"`).
40    pub fn as_str(&self) -> &'static str {
41        match self {
42            Codec::X264 => "libx264",
43            Codec::X265 => "libx265",
44            Codec::SvtAv1 => "libsvtav1",
45        }
46    }
47}
48
49impl fmt::Display for Codec {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(self.as_str())
52    }
53}
54
55impl std::str::FromStr for Codec {
56    type Err = anyhow::Error;
57
58    fn from_str(s: &str) -> Result<Self, Self::Err> {
59        match s {
60            "libx264" | "x264" | "h264" => Ok(Codec::X264),
61            "libx265" | "x265" | "h265" | "hevc" => Ok(Codec::X265),
62            "libsvtav1" | "svtav1" | "av1" => Ok(Codec::SvtAv1),
63            _ => Err(anyhow::anyhow!("unknown codec: {s}")),
64        }
65    }
66}
67
68/// Video resolution.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub struct Resolution {
71    /// Pixel width.
72    pub width: i32,
73    /// Pixel height.
74    pub height: i32,
75}
76
77impl Resolution {
78    /// Creates a resolution from width and height in pixels.
79    pub const fn new(width: i32, height: i32) -> Self {
80        Self { width, height }
81    }
82
83    /// Human-friendly label like "1080p", "720p", etc.
84    pub fn label(&self) -> String {
85        match self.height {
86            h if h >= 2160 => "2160p".into(),
87            h if h >= 1440 => "1440p".into(),
88            h if h >= 1080 => "1080p".into(),
89            h if h >= 720 => "720p".into(),
90            h if h >= 480 => "480p".into(),
91            h if h >= 360 => "360p".into(),
92            h if h >= 240 => "240p".into(),
93            h => format!("{h}p"),
94        }
95    }
96}
97
98impl fmt::Display for Resolution {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "{}x{}", self.width, self.height)
101    }
102}
103
104impl std::str::FromStr for Resolution {
105    type Err = anyhow::Error;
106
107    fn from_str(s: &str) -> Result<Self, Self::Err> {
108        match s.to_lowercase().as_str() {
109            "2160p" | "4k" => Ok(RES_2160P),
110            "1440p" => Ok(RES_1440P),
111            "1080p" => Ok(RES_1080P),
112            "720p" => Ok(RES_720P),
113            "480p" => Ok(RES_480P),
114            "360p" => Ok(RES_360P),
115            "240p" => Ok(RES_240P),
116            other => {
117                if let Some((w, h)) = other.split_once('x') {
118                    Ok(Resolution::new(w.parse()?, h.parse()?))
119                } else {
120                    Err(anyhow::anyhow!("invalid resolution: {other}"))
121                }
122            }
123        }
124    }
125}
126
127/// 3840x2160 (4K UHD), 16:9.
128pub const RES_2160P: Resolution = Resolution::new(3840, 2160);
129/// 2560x1440 (QHD), 16:9.
130pub const RES_1440P: Resolution = Resolution::new(2560, 1440);
131/// 1920x1080 (Full HD), 16:9.
132pub const RES_1080P: Resolution = Resolution::new(1920, 1080);
133/// 1280x720 (HD), 16:9.
134pub const RES_720P: Resolution = Resolution::new(1280, 720);
135/// 854x480 (SD), 16:9.
136pub const RES_480P: Resolution = Resolution::new(854, 480);
137/// 640x360, 16:9.
138pub const RES_360P: Resolution = Resolution::new(640, 360);
139/// 426x240, 16:9.
140pub const RES_240P: Resolution = Resolution::new(426, 240);
141
142/// Rate control mode for encoding.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
144#[serde(rename_all = "lowercase")]
145pub enum RateControlMode {
146    /// Constant rate factor (default).
147    #[default]
148    Crf,
149    /// CRF with VBV/decoder-model bitrate cap.
150    CappedCrf,
151    /// Fixed quantizer (Netflix-style, no R-D optimization).
152    Qp,
153    /// 2-pass variable bitrate (for final delivery encodes).
154    Vbr,
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_codec_as_str() {
163        assert_eq!(Codec::X264.as_str(), "libx264");
164        assert_eq!(Codec::X265.as_str(), "libx265");
165        assert_eq!(Codec::SvtAv1.as_str(), "libsvtav1");
166    }
167
168    #[test]
169    fn test_codec_display() {
170        assert_eq!(format!("{}", Codec::X264), "libx264");
171        assert_eq!(format!("{}", Codec::X265), "libx265");
172        assert_eq!(format!("{}", Codec::SvtAv1), "libsvtav1");
173    }
174
175    #[test]
176    fn test_codec_from_str() {
177        assert_eq!("libx264".parse::<Codec>().unwrap(), Codec::X264);
178        assert_eq!("x264".parse::<Codec>().unwrap(), Codec::X264);
179        assert_eq!("h264".parse::<Codec>().unwrap(), Codec::X264);
180        assert_eq!("libx265".parse::<Codec>().unwrap(), Codec::X265);
181        assert_eq!("x265".parse::<Codec>().unwrap(), Codec::X265);
182        assert_eq!("h265".parse::<Codec>().unwrap(), Codec::X265);
183        assert_eq!("hevc".parse::<Codec>().unwrap(), Codec::X265);
184        assert_eq!("libsvtav1".parse::<Codec>().unwrap(), Codec::SvtAv1);
185        assert_eq!("svtav1".parse::<Codec>().unwrap(), Codec::SvtAv1);
186        assert_eq!("av1".parse::<Codec>().unwrap(), Codec::SvtAv1);
187        assert!("unknown".parse::<Codec>().is_err());
188    }
189
190    #[test]
191    fn test_codec_serde_roundtrip() {
192        for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1] {
193            let json = serde_json::to_string(codec).unwrap();
194            let back: Codec = serde_json::from_str(&json).unwrap();
195            assert_eq!(*codec, back);
196        }
197    }
198
199    #[test]
200    fn test_codec_eq() {
201        assert_eq!(Codec::X264, Codec::X264);
202        assert_ne!(Codec::X264, Codec::X265);
203    }
204
205    #[test]
206    fn test_codec_hash() {
207        use std::collections::HashSet;
208        let mut set = HashSet::new();
209        set.insert(Codec::X264);
210        set.insert(Codec::X264);
211        assert_eq!(set.len(), 1);
212    }
213
214    #[test]
215    fn test_resolution_new() {
216        let r = Resolution::new(1920, 1080);
217        assert_eq!(r.width, 1920);
218        assert_eq!(r.height, 1080);
219    }
220
221    #[test]
222    fn test_resolution_label() {
223        assert_eq!(Resolution::new(3840, 2160).label(), "2160p");
224        assert_eq!(Resolution::new(2560, 1440).label(), "1440p");
225        assert_eq!(Resolution::new(1920, 1080).label(), "1080p");
226        assert_eq!(Resolution::new(1280, 720).label(), "720p");
227        assert_eq!(Resolution::new(854, 480).label(), "480p");
228        assert_eq!(Resolution::new(640, 360).label(), "360p");
229        assert_eq!(Resolution::new(426, 240).label(), "240p");
230        assert_eq!(Resolution::new(320, 200).label(), "200p");
231    }
232
233    #[test]
234    fn test_resolution_display() {
235        assert_eq!(format!("{}", Resolution::new(1920, 1080)), "1920x1080");
236        assert_eq!(format!("{}", Resolution::new(640, 360)), "640x360");
237    }
238
239    #[test]
240    fn test_resolution_from_str() {
241        assert_eq!("1080p".parse::<Resolution>().unwrap(), RES_1080P);
242        assert_eq!("720p".parse::<Resolution>().unwrap(), RES_720P);
243        assert_eq!("480p".parse::<Resolution>().unwrap(), RES_480P);
244        assert_eq!("360p".parse::<Resolution>().unwrap(), RES_360P);
245        assert_eq!("240p".parse::<Resolution>().unwrap(), RES_240P);
246        assert_eq!("1440p".parse::<Resolution>().unwrap(), RES_1440P);
247        assert_eq!("2160p".parse::<Resolution>().unwrap(), RES_2160P);
248        assert_eq!("4k".parse::<Resolution>().unwrap(), RES_2160P);
249        assert_eq!("1920x1080".parse::<Resolution>().unwrap(), RES_1080P);
250        assert_eq!("640x360".parse::<Resolution>().unwrap(), RES_360P);
251        assert!("invalid".parse::<Resolution>().is_err());
252    }
253
254    #[test]
255    fn test_resolution_serde_roundtrip() {
256        let r = RES_1080P;
257        let json = serde_json::to_string(&r).unwrap();
258        let back: Resolution = serde_json::from_str(&json).unwrap();
259        assert_eq!(r, back);
260    }
261
262    #[test]
263    fn test_resolution_const_equality() {
264        assert_eq!(RES_2160P, Resolution::new(3840, 2160));
265        assert_eq!(RES_1440P, Resolution::new(2560, 1440));
266        assert_eq!(RES_1080P, Resolution::new(1920, 1080));
267        assert_eq!(RES_720P, Resolution::new(1280, 720));
268        assert_eq!(RES_480P, Resolution::new(854, 480));
269        assert_eq!(RES_360P, Resolution::new(640, 360));
270        assert_eq!(RES_240P, Resolution::new(426, 240));
271    }
272
273    #[test]
274    fn test_rate_control_mode_default() {
275        assert_eq!(RateControlMode::default(), RateControlMode::Crf);
276    }
277
278    #[test]
279    fn test_rate_control_mode_serde() {
280        let json = serde_json::to_string(&RateControlMode::Crf).unwrap();
281        assert_eq!(json, "\"crf\"");
282        let back: RateControlMode = serde_json::from_str("\"vbr\"").unwrap();
283        assert_eq!(back, RateControlMode::Vbr);
284    }
285
286    #[test]
287    fn test_ffmpeg_path_default() {
288        let path = ffmpeg_path();
289        assert!(!path.is_empty());
290    }
291
292    #[test]
293    fn test_ffprobe_path_default() {
294        let path = ffprobe_path();
295        assert!(!path.is_empty());
296    }
297
298    #[test]
299    fn test_ffmpeg_path_respects_env() {
300        // SAFETY: test-only env var manipulation, single-threaded test
301        let old = std::env::var("VISER_FFMPEG").ok();
302        unsafe {
303            std::env::set_var("VISER_FFMPEG", "/custom/ffmpeg");
304        }
305        assert_eq!(ffmpeg_path(), "/custom/ffmpeg");
306        unsafe {
307            match old {
308                Some(v) => std::env::set_var("VISER_FFMPEG", v),
309                None => std::env::remove_var("VISER_FFMPEG"),
310            }
311        }
312    }
313
314    #[test]
315    fn test_ffprobe_path_respects_env() {
316        // SAFETY: test-only env var manipulation, single-threaded test
317        let old = std::env::var("VISER_FFPROBE").ok();
318        unsafe {
319            std::env::set_var("VISER_FFPROBE", "/custom/ffprobe");
320        }
321        assert_eq!(ffprobe_path(), "/custom/ffprobe");
322        unsafe {
323            match old {
324                Some(v) => std::env::set_var("VISER_FFPROBE", v),
325                None => std::env::remove_var("VISER_FFPROBE"),
326            }
327        }
328    }
329}