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    format!("/tmp/transcode_stats_{job_id}.log")
318}
319
320/// Cleans up temporary files.
321pub fn cleanup_temp_files(job_id: &str) -> Result<()> {
322    let stats_file = temp_stats_file(job_id);
323    if Path::new(&stats_file).exists() {
324        std::fs::remove_file(&stats_file)?;
325    }
326    Ok(())
327}
328
329/// Calculates compression ratio.
330#[must_use]
331pub fn calculate_compression_ratio(input_size: u64, output_size: u64) -> f64 {
332    if output_size == 0 {
333        return 0.0;
334    }
335    input_size as f64 / output_size as f64
336}
337
338/// Formats compression ratio as a percentage.
339#[must_use]
340pub fn format_compression_ratio(ratio: f64) -> String {
341    if ratio >= 1.0 {
342        format!("{ratio:.2}x smaller")
343    } else {
344        format!("{:.2}x larger", 1.0 / ratio)
345    }
346}
347
348/// Calculates space savings.
349#[must_use]
350pub fn calculate_space_savings(input_size: u64, output_size: u64) -> i64 {
351    input_size as i64 - output_size as i64
352}
353
354/// Formats space savings.
355#[must_use]
356pub fn format_space_savings(savings: i64) -> String {
357    if savings > 0 {
358        format!("{} saved", format_file_size(savings as u64))
359    } else {
360        format!("{} larger", format_file_size((-savings) as u64))
361    }
362}
363
364/// Parses a duration string (e.g., "01:23:45" or "83:45").
365pub fn parse_duration(duration_str: &str) -> Result<f64> {
366    let parts: Vec<&str> = duration_str.split(':').collect();
367
368    let seconds = match parts.len() {
369        1 => {
370            // Just seconds
371            parts[0].parse::<f64>().map_err(|_| {
372                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
373                    "Invalid duration format".to_string(),
374                ))
375            })?
376        }
377        2 => {
378            // MM:SS
379            let minutes = parts[0].parse::<f64>().map_err(|_| {
380                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
381                    "Invalid duration format".to_string(),
382                ))
383            })?;
384            let secs = parts[1].parse::<f64>().map_err(|_| {
385                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
386                    "Invalid duration format".to_string(),
387                ))
388            })?;
389            minutes * 60.0 + secs
390        }
391        3 => {
392            // HH:MM:SS
393            let hours = parts[0].parse::<f64>().map_err(|_| {
394                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
395                    "Invalid duration format".to_string(),
396                ))
397            })?;
398            let minutes = parts[1].parse::<f64>().map_err(|_| {
399                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
400                    "Invalid duration format".to_string(),
401                ))
402            })?;
403            let secs = parts[2].parse::<f64>().map_err(|_| {
404                TranscodeError::ValidationError(crate::ValidationError::InvalidInputFormat(
405                    "Invalid duration format".to_string(),
406                ))
407            })?;
408            hours * 3600.0 + minutes * 60.0 + secs
409        }
410        _ => {
411            return Err(TranscodeError::ValidationError(
412                crate::ValidationError::InvalidInputFormat("Invalid duration format".to_string()),
413            ))
414        }
415    };
416
417    Ok(seconds)
418}
419
420/// Formats framerate as a string.
421#[must_use]
422pub fn format_framerate(num: u32, den: u32) -> String {
423    let fps = f64::from(num) / f64::from(den);
424    if den == 1 {
425        format!("{num} fps")
426    } else {
427        format!("{fps:.2} fps")
428    }
429}
430
431/// Checks if a framerate is standard.
432#[must_use]
433pub fn is_standard_framerate(num: u32, den: u32) -> bool {
434    matches!(
435        (num, den),
436        (24 | 25 | 30 | 50 | 60, 1) | (24000 | 30000 | 60000, 1001)
437    )
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_estimate_encoding_time() {
446        let time = estimate_encoding_time(60.0, crate::QualityMode::Medium, (1920, 1080), false);
447        assert!(time > 0.0);
448    }
449
450    #[test]
451    fn test_estimate_file_size() {
452        let size = estimate_file_size(60.0, 5_000_000, 128_000);
453        assert_eq!(size, (60.0 * 5_128_000.0 / 8.0) as u64);
454    }
455
456    #[test]
457    fn test_format_duration() {
458        assert_eq!(format_duration(90.0), "01:30");
459        assert_eq!(format_duration(3665.0), "01:01:05");
460    }
461
462    #[test]
463    fn test_format_file_size() {
464        assert_eq!(format_file_size(1024), "1.00 KB");
465        assert_eq!(format_file_size(1024 * 1024), "1.00 MB");
466        assert_eq!(format_file_size(1024 * 1024 * 1024), "1.00 GB");
467    }
468
469    #[test]
470    fn test_format_bitrate() {
471        assert_eq!(format_bitrate(1_000_000), "1.00 Mbps");
472        assert_eq!(format_bitrate(128_000), "128 kbps");
473    }
474
475    #[test]
476    fn test_get_file_extension() {
477        assert_eq!(get_file_extension("video.mp4"), Some("mp4".to_string()));
478        assert_eq!(get_file_extension("VIDEO.MP4"), Some("mp4".to_string()));
479        assert_eq!(get_file_extension("video"), None);
480    }
481
482    #[test]
483    fn test_container_from_extension() {
484        assert_eq!(
485            container_from_extension("video.mp4"),
486            Some("mp4".to_string())
487        );
488        assert_eq!(
489            container_from_extension("video.mkv"),
490            Some("matroska".to_string())
491        );
492        assert_eq!(
493            container_from_extension("video.webm"),
494            Some("webm".to_string())
495        );
496    }
497
498    #[test]
499    fn test_suggest_codecs() {
500        assert_eq!(suggest_video_codec("mp4"), Some("h264".to_string()));
501        assert_eq!(suggest_video_codec("webm"), Some("vp9".to_string()));
502        assert_eq!(suggest_audio_codec("mp4"), Some("aac".to_string()));
503        assert_eq!(suggest_audio_codec("webm"), Some("opus".to_string()));
504    }
505
506    #[test]
507    fn test_calculate_aspect_ratio() {
508        assert_eq!(calculate_aspect_ratio(1920, 1080), (16, 9));
509        assert_eq!(calculate_aspect_ratio(1280, 720), (16, 9));
510        assert_eq!(calculate_aspect_ratio(1920, 800), (12, 5));
511    }
512
513    #[test]
514    fn test_format_aspect_ratio() {
515        assert_eq!(format_aspect_ratio(1920, 1080), "16:9");
516        assert_eq!(format_aspect_ratio(1280, 720), "16:9");
517    }
518
519    #[test]
520    fn test_is_standard_resolution() {
521        assert!(is_standard_resolution(1920, 1080));
522        assert!(is_standard_resolution(1280, 720));
523        assert!(!is_standard_resolution(1000, 1000));
524    }
525
526    #[test]
527    fn test_resolution_name() {
528        assert_eq!(resolution_name(1920, 1080), "Full HD (1080p)");
529        assert_eq!(resolution_name(3840, 2160), "4K (2160p)");
530        assert_eq!(resolution_name(1000, 1000), "1000x1000");
531    }
532
533    #[test]
534    fn test_calculate_optimal_tiles() {
535        let (cols, rows) = calculate_optimal_tiles(1920, 1080, 8);
536        assert!(cols > 0 && rows > 0);
537    }
538
539    #[test]
540    fn test_suggest_bitrate() {
541        let bitrate = suggest_bitrate(1920, 1080, 30.0, crate::QualityMode::Medium);
542        assert!(bitrate > 0);
543    }
544
545    #[test]
546    fn test_calculate_compression_ratio() {
547        assert_eq!(calculate_compression_ratio(1000, 500), 2.0);
548        assert_eq!(calculate_compression_ratio(500, 1000), 0.5);
549    }
550
551    #[test]
552    fn test_parse_duration() {
553        assert_eq!(parse_duration("60").expect("should succeed in test"), 60.0);
554        assert_eq!(
555            parse_duration("01:30").expect("should succeed in test"),
556            90.0
557        );
558        assert_eq!(
559            parse_duration("01:01:30").expect("should succeed in test"),
560            3690.0
561        );
562    }
563
564    #[test]
565    fn test_format_framerate() {
566        assert_eq!(format_framerate(30, 1), "30 fps");
567        assert_eq!(format_framerate(30000, 1001), "29.97 fps");
568    }
569
570    #[test]
571    fn test_is_standard_framerate() {
572        assert!(is_standard_framerate(30, 1));
573        assert!(is_standard_framerate(60, 1));
574        assert!(is_standard_framerate(30000, 1001));
575        assert!(!is_standard_framerate(45, 1));
576    }
577}