ffmpeg_common/
utils.rs

1use regex::Regex;
2use std::collections::HashMap;
3use std::path::Path;
4use once_cell::sync::Lazy;
5
6use crate::error::{Error, Result};
7
8/// Regular expressions for parsing
9static TIME_REGEX: Lazy<Regex> = Lazy::new(|| {
10    Regex::new(r"^(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$").unwrap()
11});
12
13static BITRATE_REGEX: Lazy<Regex> = Lazy::new(|| {
14    Regex::new(r"^(\d+(?:\.\d+)?)\s*([kmgKMG])?(?:bit|bps|b)?(?:/s)?$").unwrap()
15});
16
17static RESOLUTION_REGEX: Lazy<Regex> = Lazy::new(|| {
18    Regex::new(r"^(\d+)[xX](\d+)$").unwrap()
19});
20
21/// Parse a bitrate string (e.g., "128k", "5M", "1000")
22/// Parse a bitrate string (e.g., "128k", "5M", "1000")
23pub fn parse_bitrate(s: &str) -> Result<u64> {
24    let s = s.trim();
25
26    if let Some(captures) = BITRATE_REGEX.captures(s) {
27        let number: f64 = captures[1].parse()
28            .map_err(|_| Error::ParseError(format!("Invalid bitrate number: {}", &captures[1])))?;
29
30        // Store the lowercase string in a variable to extend its lifetime.
31        let suffix = captures.get(2).map(|m| m.as_str().to_lowercase());
32
33        // Match on a slice (`&str`) of the `suffix` string.
34        let multiplier = match suffix.as_deref() {
35            Some("k") => 1_000.0,
36            Some("m") => 1_000_000.0,
37            Some("g") => 1_000_000_000.0,
38            None => 1.0,
39            _ => return Err(Error::ParseError(format!("Invalid bitrate suffix in: {}", s))),
40        };
41
42        Ok((number * multiplier) as u64)
43    } else {
44        // Try parsing as plain number
45        s.parse::<u64>()
46            .map_err(|_| Error::ParseError(format!("Invalid bitrate: {}", s)))
47    }
48}
49
50/// Parse a resolution string (e.g., "1920x1080")
51pub fn parse_resolution(s: &str) -> Result<(u32, u32)> {
52    if let Some(captures) = RESOLUTION_REGEX.captures(s.trim()) {
53        let width: u32 = captures[1].parse()
54            .map_err(|_| Error::ParseError(format!("Invalid width: {}", &captures[1])))?;
55        let height: u32 = captures[2].parse()
56            .map_err(|_| Error::ParseError(format!("Invalid height: {}", &captures[2])))?;
57        Ok((width, height))
58    } else {
59        Err(Error::ParseError(format!("Invalid resolution format: {}", s)))
60    }
61}
62
63/// Parse key=value pairs from FFmpeg output
64pub fn parse_key_value_pairs(text: &str) -> HashMap<String, String> {
65    let mut map = HashMap::new();
66
67    for line in text.lines() {
68        let line = line.trim();
69        if line.is_empty() || line.starts_with('#') {
70            continue;
71        }
72
73        if let Some((key, value)) = line.split_once('=') {
74            map.insert(key.trim().to_string(), value.trim().to_string());
75        }
76    }
77
78    map
79}
80
81/// Escape a string for use in filter graphs
82pub fn escape_filter_string(s: &str) -> String {
83    s.chars()
84        .flat_map(|c| match c {
85            '\\' => vec!['\\', '\\'],
86            ':' => vec!['\\', ':'],
87            '\'' => vec!['\\', '\''],
88            '[' => vec!['\\', '['],
89            ']' => vec!['\\', ']'],
90            ',' => vec!['\\', ','],
91            ';' => vec!['\\', ';'],
92            '=' => vec!['\\', '='],
93            c => vec![c],
94        })
95        .collect()
96}
97
98/// Quote a path for command line if needed
99pub fn quote_path(path: &Path) -> String {
100    let s = path.to_string_lossy();
101
102    // Check if quoting is needed
103    if s.contains(' ') || s.contains('\'') || s.contains('"') || s.contains('\\') {
104        // Use single quotes and escape any single quotes
105        format!("'{}'", s.replace('\'', "'\\''"))
106    } else {
107        s.into_owned()
108    }
109}
110
111/// Format a duration for display (human-readable)
112pub fn format_duration_human(duration: &std::time::Duration) -> String {
113    let total_secs = duration.as_secs();
114    let hours = total_secs / 3600;
115    let minutes = (total_secs % 3600) / 60;
116    let seconds = total_secs % 60;
117    let millis = duration.subsec_millis();
118
119    if hours > 0 {
120        format!("{}h {}m {}s", hours, minutes, seconds)
121    } else if minutes > 0 {
122        format!("{}m {}.{:03}s", minutes, seconds, millis)
123    } else {
124        format!("{}.{:03}s", seconds, millis)
125    }
126}
127
128/// Parse a frame rate string (e.g., "25", "29.97", "30000/1001")
129pub fn parse_framerate(s: &str) -> Result<f64> {
130    let s = s.trim();
131
132    // Handle fraction format (e.g., "30000/1001")
133    if let Some((num, den)) = s.split_once('/') {
134        let numerator: f64 = num.parse()
135            .map_err(|_| Error::ParseError(format!("Invalid framerate numerator: {}", num)))?;
136        let denominator: f64 = den.parse()
137            .map_err(|_| Error::ParseError(format!("Invalid framerate denominator: {}", den)))?;
138
139        if denominator == 0.0 {
140            return Err(Error::ParseError("Framerate denominator cannot be zero".to_string()));
141        }
142
143        Ok(numerator / denominator)
144    } else {
145        // Handle decimal format
146        s.parse::<f64>()
147            .map_err(|_| Error::ParseError(format!("Invalid framerate: {}", s)))
148    }
149}
150
151/// Get file extension from a path
152pub fn get_extension(path: &Path) -> Option<String> {
153    path.extension()
154        .and_then(|ext| ext.to_str())
155        .map(|s| s.to_lowercase())
156}
157
158/// Guess format from file extension
159pub fn guess_format_from_extension(path: &Path) -> Option<&'static str> {
160    match get_extension(path)?.as_str() {
161        // Video formats
162        "mp4" => Some("mp4"),
163        "m4v" => Some("mp4"),
164        "mkv" => Some("matroska"),
165        "webm" => Some("webm"),
166        "avi" => Some("avi"),
167        "mov" => Some("mov"),
168        "qt" => Some("mov"),
169        "flv" => Some("flv"),
170        "wmv" => Some("asf"),
171        "mpg" | "mpeg" => Some("mpeg"),
172        "ts" | "m2ts" => Some("mpegts"),
173        "vob" => Some("mpeg"),
174        "3gp" => Some("3gp"),
175        "ogv" => Some("ogg"),
176
177        // Audio formats
178        "mp3" => Some("mp3"),
179        "m4a" => Some("mp4"),
180        "aac" => Some("aac"),
181        "ogg" | "oga" => Some("ogg"),
182        "flac" => Some("flac"),
183        "wav" => Some("wav"),
184        "opus" => Some("opus"),
185        "wma" => Some("asf"),
186        "ac3" => Some("ac3"),
187        "dts" => Some("dts"),
188
189        // Image formats
190        "jpg" | "jpeg" => Some("image2"),
191        "png" => Some("image2"),
192        "bmp" => Some("image2"),
193        "gif" => Some("gif"),
194        "webp" => Some("webp"),
195
196        // Subtitle formats
197        "srt" => Some("srt"),
198        "ass" | "ssa" => Some("ass"),
199        "vtt" => Some("webvtt"),
200        "sub" => Some("subviewer"),
201
202        _ => None,
203    }
204}
205
206/// Sanitize a filename for safe file system usage
207pub fn sanitize_filename(name: &str) -> String {
208    name.chars()
209        .map(|c| match c {
210            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
211            c if c.is_control() => '_',
212            c => c,
213        })
214        .collect()
215}
216
217/// Check if a string looks like a URL
218pub fn is_url(s: &str) -> bool {
219    s.starts_with("http://") ||
220        s.starts_with("https://") ||
221        s.starts_with("rtmp://") ||
222        s.starts_with("rtmps://") ||
223        s.starts_with("rtsp://") ||
224        s.starts_with("rtsps://") ||
225        s.starts_with("file://") ||
226        s.starts_with("udp://") ||
227        s.starts_with("tcp://") ||
228        s.starts_with("pipe:") ||
229        s.contains("://")
230}
231
232/// Merge two sets of arguments, with later args overriding earlier ones
233pub fn merge_args(base: Vec<String>, overrides: Vec<String>) -> Vec<String> {
234    let mut result = base;
235    let mut seen_flags = std::collections::HashSet::new();
236
237    // Track which flags take values
238    let value_flags: std::collections::HashSet<&str> = [
239        "-i", "-f", "-c", "-codec", "-vf", "-af", "-s", "-r", "-b", "-aspect",
240        "-t", "-ss", "-to", "-fs", "-preset", "-crf", "-qp", "-profile", "-level",
241        "-pix_fmt", "-ar", "-ac", "-ab", "-map", "-metadata", "-filter_complex",
242    ].iter().cloned().collect();
243
244    // Process overrides
245    let mut i = 0;
246    while i < overrides.len() {
247        let flag = &overrides[i];
248
249        if value_flags.contains(flag.as_str()) && i + 1 < overrides.len() {
250            // Flag with value
251            if !seen_flags.contains(flag) {
252                result.push(flag.clone());
253                result.push(overrides[i + 1].clone());
254                seen_flags.insert(flag.clone());
255            }
256            i += 2;
257        } else {
258            // Standalone flag
259            if !seen_flags.contains(flag) {
260                result.push(flag.clone());
261                seen_flags.insert(flag.clone());
262            }
263            i += 1;
264        }
265    }
266
267    result
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_parse_bitrate() {
276        assert_eq!(parse_bitrate("128k").unwrap(), 128_000);
277        assert_eq!(parse_bitrate("5M").unwrap(), 5_000_000);
278        assert_eq!(parse_bitrate("1.5m").unwrap(), 1_500_000);
279        assert_eq!(parse_bitrate("1000").unwrap(), 1000);
280        assert_eq!(parse_bitrate("2.5G").unwrap(), 2_500_000_000);
281    }
282
283    #[test]
284    fn test_parse_resolution() {
285        assert_eq!(parse_resolution("1920x1080").unwrap(), (1920, 1080));
286        assert_eq!(parse_resolution("1280X720").unwrap(), (1280, 720));
287        assert_eq!(parse_resolution(" 640x480 ").unwrap(), (640, 480));
288    }
289
290    #[test]
291    fn test_parse_framerate() {
292        assert_eq!(parse_framerate("25").unwrap(), 25.0);
293        assert_eq!(parse_framerate("29.97").unwrap(), 29.97);
294        assert_eq!(parse_framerate("30000/1001").unwrap(), 29.97002997002997);
295        assert_eq!(parse_framerate("24").unwrap(), 24.0);
296    }
297
298    #[test]
299    fn test_escape_filter_string() {
300        assert_eq!(escape_filter_string("text"), "text");
301        assert_eq!(escape_filter_string("text:with:colons"), "text\\:with\\:colons");
302        assert_eq!(escape_filter_string("text[with]brackets"), "text\\[with\\]brackets");
303        assert_eq!(escape_filter_string("text='value'"), "text\\=\\'value\\'");
304    }
305
306    #[test]
307    fn test_sanitize_filename() {
308        assert_eq!(sanitize_filename("normal_file.mp4"), "normal_file.mp4");
309        assert_eq!(sanitize_filename("file:with*invalid?chars.mp4"), "file_with_invalid_chars.mp4");
310        assert_eq!(sanitize_filename("path/to/file.mp4"), "path_to_file.mp4");
311    }
312
313    #[test]
314    fn test_is_url() {
315        assert!(is_url("https://example.com/video.mp4"));
316        assert!(is_url("rtmp://server/live/stream"));
317        assert!(is_url("file:///path/to/file.mp4"));
318        assert!(!is_url("/path/to/file.mp4"));
319        assert!(!is_url("C:\\path\\to\\file.mp4"));
320    }
321
322    #[test]
323    fn test_guess_format() {
324        assert_eq!(guess_format_from_extension(Path::new("video.mp4")), Some("mp4"));
325        assert_eq!(guess_format_from_extension(Path::new("audio.mp3")), Some("mp3"));
326        assert_eq!(guess_format_from_extension(Path::new("video.mkv")), Some("matroska"));
327        assert_eq!(guess_format_from_extension(Path::new("image.jpg")), Some("image2"));
328    }
329}