Skip to main content

oximedia_transcode/
utils.rs

1//! Utility functions and helpers for transcode operations.
2
3use crate::{Result, TranscodeError};
4use std::path::Path;
5
6/// Estimates encoding time based on video duration and quality settings.
7///
8/// # Arguments
9///
10/// * `duration` - Video duration in seconds
11/// * `quality` - Quality mode (affects encoding speed)
12/// * `resolution` - Resolution (width, height)
13/// * `hw_accel` - Whether hardware acceleration is enabled
14///
15/// # Returns
16///
17/// Estimated encoding time in seconds
18#[must_use]
19pub fn estimate_encoding_time(
20    duration: f64,
21    quality: crate::QualityMode,
22    resolution: (u32, u32),
23    hw_accel: bool,
24) -> f64 {
25    let base_speed_factor = quality.speed_factor();
26
27    // Adjust for resolution
28    let pixel_count = f64::from(resolution.0 * resolution.1);
29    let resolution_factor = pixel_count / (1920.0 * 1080.0);
30
31    // Adjust for hardware acceleration
32    let hw_factor = if hw_accel { 0.3 } else { 1.0 };
33
34    duration * base_speed_factor * resolution_factor * hw_factor
35}
36
37/// Calculates the file size estimate for a transcode.
38///
39/// # Arguments
40///
41/// * `duration` - Video duration in seconds
42/// * `video_bitrate` - Video bitrate in bits per second
43/// * `audio_bitrate` - Audio bitrate in bits per second
44///
45/// # Returns
46///
47/// Estimated file size in bytes
48#[must_use]
49pub fn estimate_file_size(duration: f64, video_bitrate: u64, audio_bitrate: u64) -> u64 {
50    let total_bitrate = video_bitrate + audio_bitrate;
51    let bits = (duration * total_bitrate as f64) as u64;
52    bits / 8 // Convert to bytes
53}
54
55/// Formats a duration in seconds to a human-readable string.
56#[must_use]
57pub fn format_duration(seconds: f64) -> String {
58    let hours = (seconds / 3600.0) as u64;
59    let minutes = ((seconds % 3600.0) / 60.0) as u64;
60    let secs = (seconds % 60.0) as u64;
61
62    if hours > 0 {
63        format!("{hours:02}:{minutes:02}:{secs:02}")
64    } else {
65        format!("{minutes:02}:{secs:02}")
66    }
67}
68
69/// Formats a file size in bytes to a human-readable string.
70#[must_use]
71pub fn format_file_size(bytes: u64) -> String {
72    const KB: u64 = 1024;
73    const MB: u64 = KB * 1024;
74    const GB: u64 = MB * 1024;
75    const TB: u64 = GB * 1024;
76
77    if bytes >= TB {
78        format!("{:.2} TB", bytes as f64 / TB as f64)
79    } else if bytes >= GB {
80        format!("{:.2} GB", bytes as f64 / GB as f64)
81    } else if bytes >= MB {
82        format!("{:.2} MB", bytes as f64 / MB as f64)
83    } else if bytes >= KB {
84        format!("{:.2} KB", bytes as f64 / KB as f64)
85    } else {
86        format!("{bytes} B")
87    }
88}
89
90/// Formats a bitrate in bits per second to a human-readable string.
91#[must_use]
92pub fn format_bitrate(bps: u64) -> String {
93    const KBPS: u64 = 1000;
94    const MBPS: u64 = KBPS * 1000;
95
96    if bps >= MBPS {
97        format!("{:.2} Mbps", bps as f64 / MBPS as f64)
98    } else if bps >= KBPS {
99        format!("{:.0} kbps", bps as f64 / KBPS as f64)
100    } else {
101        format!("{bps} bps")
102    }
103}
104
105/// Validates that a file exists and is readable.
106///
107/// # Errors
108///
109/// Returns an error if the file doesn't exist or isn't readable.
110pub fn validate_input_file(path: &str) -> Result<()> {
111    let path_obj = Path::new(path);
112
113    if !path_obj.exists() {
114        return Err(TranscodeError::InvalidInput(format!(
115            "File does not exist: {path}"
116        )));
117    }
118
119    if !path_obj.is_file() {
120        return Err(TranscodeError::InvalidInput(format!(
121            "Path is not a file: {path}"
122        )));
123    }
124
125    match std::fs::metadata(path_obj) {
126        Ok(metadata) => {
127            if metadata.len() == 0 {
128                return Err(TranscodeError::InvalidInput(format!(
129                    "File is empty: {path}"
130                )));
131            }
132        }
133        Err(e) => {
134            return Err(TranscodeError::InvalidInput(format!(
135                "Cannot read file {path}: {e}"
136            )));
137        }
138    }
139
140    Ok(())
141}
142
143/// Gets the file extension from a path.
144#[must_use]
145pub fn get_file_extension(path: &str) -> Option<String> {
146    Path::new(path)
147        .extension()
148        .and_then(|e| e.to_str())
149        .map(str::to_lowercase)
150}
151
152/// Determines the container format from a file extension.
153#[must_use]
154pub fn container_from_extension(path: &str) -> Option<String> {
155    let ext = get_file_extension(path)?;
156
157    match ext.as_str() {
158        "mp4" | "m4v" => Some("mp4".to_string()),
159        "mkv" => Some("matroska".to_string()),
160        "webm" => Some("webm".to_string()),
161        "avi" => Some("avi".to_string()),
162        "mov" => Some("mov".to_string()),
163        "flv" => Some("flv".to_string()),
164        "wmv" => Some("asf".to_string()),
165        "ogv" => Some("ogg".to_string()),
166        _ => None,
167    }
168}
169
170/// Suggests optimal codec based on container format.
171#[must_use]
172pub fn suggest_video_codec(container: &str) -> Option<String> {
173    match container.to_lowercase().as_str() {
174        "mp4" | "m4v" => Some("h264".to_string()),
175        "webm" => Some("vp9".to_string()),
176        "mkv" => Some("vp9".to_string()),
177        "ogv" => Some("theora".to_string()),
178        _ => None,
179    }
180}
181
182/// Suggests optimal audio codec based on container format.
183#[must_use]
184pub fn suggest_audio_codec(container: &str) -> Option<String> {
185    match container.to_lowercase().as_str() {
186        "mp4" | "m4v" => Some("aac".to_string()),
187        "webm" => Some("opus".to_string()),
188        "mkv" => Some("opus".to_string()),
189        "ogv" => Some("vorbis".to_string()),
190        _ => None,
191    }
192}
193
194/// Calculates the aspect ratio from width and height.
195#[must_use]
196pub fn calculate_aspect_ratio(width: u32, height: u32) -> (u32, u32) {
197    fn gcd(mut a: u32, mut b: u32) -> u32 {
198        while b != 0 {
199            let temp = b;
200            b = a % b;
201            a = temp;
202        }
203        a
204    }
205
206    let divisor = gcd(width, height);
207    (width / divisor, height / divisor)
208}
209
210/// Formats an aspect ratio as a string.
211#[must_use]
212pub fn format_aspect_ratio(width: u32, height: u32) -> String {
213    let (w, h) = calculate_aspect_ratio(width, height);
214    format!("{w}:{h}")
215}
216
217/// Checks if a resolution is standard (common resolution).
218#[must_use]
219pub fn is_standard_resolution(width: u32, height: u32) -> bool {
220    matches!(
221        (width, height),
222        (1920, 1080)
223            | (1280, 720)
224            | (3840, 2160)
225            | (2560, 1440)
226            | (854, 480)
227            | (640, 360)
228            | (426, 240)
229    )
230}
231
232/// Gets the name of a standard resolution.
233#[must_use]
234pub fn resolution_name(width: u32, height: u32) -> String {
235    match (width, height) {
236        (3840, 2160) => "4K (2160p)".to_string(),
237        (2560, 1440) => "2K (1440p)".to_string(),
238        (1920, 1080) => "Full HD (1080p)".to_string(),
239        (1280, 720) => "HD (720p)".to_string(),
240        (854, 480) => "SD (480p)".to_string(),
241        (640, 360) => "nHD (360p)".to_string(),
242        (426, 240) => "240p".to_string(),
243        _ => format!("{width}x{height}"),
244    }
245}
246
247/// Calculates the optimal tile configuration for parallel encoding.
248#[must_use]
249pub fn calculate_optimal_tiles(width: u32, height: u32, threads: u32) -> (u8, u8) {
250    let pixel_count = width * height;
251
252    // For smaller resolutions, use fewer tiles
253    if pixel_count < 1280 * 720 {
254        return (1, 1);
255    }
256
257    // Calculate based on thread count
258    let tiles = match threads {
259        1..=2 => 1,
260        3..=4 => 2,
261        5..=8 => 4,
262        9..=16 => 8,
263        _ => 16,
264    };
265
266    // Prefer column tiles for better parallelism
267    let cols = tiles.min(8);
268    let rows = (tiles / cols).min(8);
269
270    (cols as u8, rows as u8)
271}
272
273/// Suggests optimal bitrate for a given resolution and framerate.
274#[must_use]
275pub fn suggest_bitrate(width: u32, height: u32, fps: f64, quality: crate::QualityMode) -> u64 {
276    let pixel_count = u64::from(width * height);
277    let motion_factor = if fps > 30.0 { 1.5 } else { 1.0 };
278
279    let base_bitrate = match quality {
280        crate::QualityMode::Low => pixel_count / 1500,
281        crate::QualityMode::Medium => pixel_count / 1000,
282        crate::QualityMode::High => pixel_count / 750,
283        crate::QualityMode::VeryHigh => pixel_count / 500,
284        crate::QualityMode::Custom => pixel_count / 1000,
285    };
286
287    (base_bitrate as f64 * motion_factor) as u64
288}
289
290/// Validates resolution constraints.
291pub fn validate_resolution_constraints(
292    input_width: u32,
293    input_height: u32,
294    output_width: u32,
295    output_height: u32,
296) -> Result<()> {
297    // Check for upscaling
298    if output_width > input_width || output_height > input_height {
299        // Warn but allow
300    }
301
302    // Check aspect ratio change
303    let input_ratio = f64::from(input_width) / f64::from(input_height);
304    let output_ratio = f64::from(output_width) / f64::from(output_height);
305    let ratio_diff = (input_ratio - output_ratio).abs();
306
307    if ratio_diff > 0.01 {
308        // Aspect ratio changed significantly
309    }
310
311    Ok(())
312}
313
314/// Creates a temporary file path for statistics.
315#[must_use]
316pub fn temp_stats_file(job_id: &str) -> String {
317    std::env::temp_dir()
318        .join(format!("oximedia-transcode-stats-{job_id}.log"))
319        .to_string_lossy()
320        .into_owned()
321}
322
323/// Cleans up temporary files.
324pub fn cleanup_temp_files(job_id: &str) -> Result<()> {
325    let stats_file = temp_stats_file(job_id);
326    if Path::new(&stats_file).exists() {
327        std::fs::remove_file(&stats_file)?;
328    }
329    Ok(())
330}
331
332/// Calculates compression ratio.
333#[must_use]
334pub fn calculate_compression_ratio(input_size: u64, output_size: u64) -> f64 {
335    if output_size == 0 {
336        return 0.0;
337    }
338    input_size as f64 / output_size as f64
339}
340
341/// Formats compression ratio as a percentage.
342#[must_use]
343pub fn format_compression_ratio(ratio: f64) -> String {
344    if ratio >= 1.0 {
345        format!("{ratio:.2}x smaller")
346    } else {
347        format!("{:.2}x larger", 1.0 / ratio)
348    }
349}
350
351/// Calculates space savings.
352#[must_use]
353pub fn calculate_space_savings(input_size: u64, output_size: u64) -> i64 {
354    input_size as i64 - output_size as i64
355}
356
357/// Formats space savings.
358#[must_use]
359pub fn format_space_savings(savings: i64) -> String {
360    if savings > 0 {
361        format!("{} saved", format_file_size(savings as u64))
362    } else {
363        format!("{} larger", format_file_size((-savings) as u64))
364    }
365}
366
367/// Parses a duration string (e.g., "01:23:45" or "83:45").
368pub fn parse_duration(duration_str: &str) -> Result<f64> {
369    let parts: Vec<&str> = duration_str.split(':').collect();
370
371    let seconds = match parts.len() {
372        1 => {
373            // Just seconds
374            parts[0].parse::<f64>().map_err(|_| {
375                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
376                    "Invalid duration format".to_string(),
377                ))
378            })?
379        }
380        2 => {
381            // MM:SS
382            let minutes = parts[0].parse::<f64>().map_err(|_| {
383                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
384                    "Invalid duration format".to_string(),
385                ))
386            })?;
387            let secs = parts[1].parse::<f64>().map_err(|_| {
388                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
389                    "Invalid duration format".to_string(),
390                ))
391            })?;
392            minutes * 60.0 + secs
393        }
394        3 => {
395            // HH:MM:SS
396            let hours = parts[0].parse::<f64>().map_err(|_| {
397                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
398                    "Invalid duration format".to_string(),
399                ))
400            })?;
401            let minutes = parts[1].parse::<f64>().map_err(|_| {
402                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
403                    "Invalid duration format".to_string(),
404                ))
405            })?;
406            let secs = parts[2].parse::<f64>().map_err(|_| {
407                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
408                    "Invalid duration format".to_string(),
409                ))
410            })?;
411            hours * 3600.0 + minutes * 60.0 + secs
412        }
413        _ => {
414            return Err(TranscodeError::ValidationError(
415                crate::ValidationError::InvalidInputFormat("Invalid duration format".to_string()),
416            ))
417        }
418    };
419
420    Ok(seconds)
421}
422
423/// Formats framerate as a string.
424#[must_use]
425pub fn format_framerate(num: u32, den: u32) -> String {
426    let fps = f64::from(num) / f64::from(den);
427    if den == 1 {
428        format!("{num} fps")
429    } else {
430        format!("{fps:.2} fps")
431    }
432}
433
434/// Checks if a framerate is standard.
435#[must_use]
436pub fn is_standard_framerate(num: u32, den: u32) -> bool {
437    matches!(
438        (num, den),
439        (24 | 25 | 30 | 50 | 60, 1) | (24000 | 30000 | 60000, 1001)
440    )
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_estimate_encoding_time() {
449        let time = estimate_encoding_time(60.0, crate::QualityMode::Medium, (1920, 1080), false);
450        assert!(time > 0.0);
451    }
452
453    #[test]
454    fn test_estimate_file_size() {
455        let size = estimate_file_size(60.0, 5_000_000, 128_000);
456        assert_eq!(size, (60.0 * 5_128_000.0 / 8.0) as u64);
457    }
458
459    #[test]
460    fn test_format_duration() {
461        assert_eq!(format_duration(90.0), "01:30");
462        assert_eq!(format_duration(3665.0), "01:01:05");
463    }
464
465    #[test]
466    fn test_format_file_size() {
467        assert_eq!(format_file_size(1024), "1.00 KB");
468        assert_eq!(format_file_size(1024 * 1024), "1.00 MB");
469        assert_eq!(format_file_size(1024 * 1024 * 1024), "1.00 GB");
470    }
471
472    #[test]
473    fn test_format_bitrate() {
474        assert_eq!(format_bitrate(1_000_000), "1.00 Mbps");
475        assert_eq!(format_bitrate(128_000), "128 kbps");
476    }
477
478    #[test]
479    fn test_get_file_extension() {
480        assert_eq!(get_file_extension("video.mp4"), Some("mp4".to_string()));
481        assert_eq!(get_file_extension("VIDEO.MP4"), Some("mp4".to_string()));
482        assert_eq!(get_file_extension("video"), None);
483    }
484
485    #[test]
486    fn test_container_from_extension() {
487        assert_eq!(
488            container_from_extension("video.mp4"),
489            Some("mp4".to_string())
490        );
491        assert_eq!(
492            container_from_extension("video.mkv"),
493            Some("matroska".to_string())
494        );
495        assert_eq!(
496            container_from_extension("video.webm"),
497            Some("webm".to_string())
498        );
499    }
500
501    #[test]
502    fn test_suggest_codecs() {
503        assert_eq!(suggest_video_codec("mp4"), Some("h264".to_string()));
504        assert_eq!(suggest_video_codec("webm"), Some("vp9".to_string()));
505        assert_eq!(suggest_audio_codec("mp4"), Some("aac".to_string()));
506        assert_eq!(suggest_audio_codec("webm"), Some("opus".to_string()));
507    }
508
509    #[test]
510    fn test_calculate_aspect_ratio() {
511        assert_eq!(calculate_aspect_ratio(1920, 1080), (16, 9));
512        assert_eq!(calculate_aspect_ratio(1280, 720), (16, 9));
513        assert_eq!(calculate_aspect_ratio(1920, 800), (12, 5));
514    }
515
516    #[test]
517    fn test_format_aspect_ratio() {
518        assert_eq!(format_aspect_ratio(1920, 1080), "16:9");
519        assert_eq!(format_aspect_ratio(1280, 720), "16:9");
520    }
521
522    #[test]
523    fn test_is_standard_resolution() {
524        assert!(is_standard_resolution(1920, 1080));
525        assert!(is_standard_resolution(1280, 720));
526        assert!(!is_standard_resolution(1000, 1000));
527    }
528
529    #[test]
530    fn test_resolution_name() {
531        assert_eq!(resolution_name(1920, 1080), "Full HD (1080p)");
532        assert_eq!(resolution_name(3840, 2160), "4K (2160p)");
533        assert_eq!(resolution_name(1000, 1000), "1000x1000");
534    }
535
536    #[test]
537    fn test_calculate_optimal_tiles() {
538        let (cols, rows) = calculate_optimal_tiles(1920, 1080, 8);
539        assert!(cols > 0 && rows > 0);
540    }
541
542    #[test]
543    fn test_suggest_bitrate() {
544        let bitrate = suggest_bitrate(1920, 1080, 30.0, crate::QualityMode::Medium);
545        assert!(bitrate > 0);
546    }
547
548    #[test]
549    fn test_calculate_compression_ratio() {
550        assert_eq!(calculate_compression_ratio(1000, 500), 2.0);
551        assert_eq!(calculate_compression_ratio(500, 1000), 0.5);
552    }
553
554    #[test]
555    fn test_parse_duration() {
556        assert_eq!(parse_duration("60").expect("should succeed in test"), 60.0);
557        assert_eq!(
558            parse_duration("01:30").expect("should succeed in test"),
559            90.0
560        );
561        assert_eq!(
562            parse_duration("01:01:30").expect("should succeed in test"),
563            3690.0
564        );
565    }
566
567    #[test]
568    fn test_format_framerate() {
569        assert_eq!(format_framerate(30, 1), "30 fps");
570        assert_eq!(format_framerate(30000, 1001), "29.97 fps");
571    }
572
573    #[test]
574    fn test_is_standard_framerate() {
575        assert!(is_standard_framerate(30, 1));
576        assert!(is_standard_framerate(60, 1));
577        assert!(is_standard_framerate(30000, 1001));
578        assert!(!is_standard_framerate(45, 1));
579    }
580}