Skip to main content

oximedia_cli/
progress.rs

1//! Progress reporting for media processing operations.
2//!
3//! Provides progress bars with ETA, FPS counters, bitrate display,
4//! and frame counting for transcoding operations.
5
6use colored::Colorize;
7use indicatif::{ProgressBar, ProgressStyle};
8use std::time::{Duration, Instant};
9
10/// Progress tracker for transcoding operations.
11///
12/// Displays a progress bar with:
13/// - Current frame / total frames
14/// - Processing speed (FPS)
15/// - Estimated time remaining (ETA)
16/// - Current bitrate
17/// - File size
18pub struct TranscodeProgress {
19    bar: ProgressBar,
20    start_time: Instant,
21    frames_total: u64,
22    frames_done: u64,
23    bytes_written: u64,
24    last_update: Instant,
25    update_interval: Duration,
26}
27
28impl TranscodeProgress {
29    /// Create a new transcode progress tracker.
30    ///
31    /// # Arguments
32    ///
33    /// * `total_frames` - Total number of frames to process
34    pub fn new(total_frames: u64) -> Self {
35        let bar = ProgressBar::new(total_frames);
36
37        let style = ProgressStyle::default_bar()
38            .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} frames ({percent}%) {msg}")
39            .expect("Failed to create progress style")
40            .progress_chars("=>-");
41
42        bar.set_style(style);
43
44        Self {
45            bar,
46            start_time: Instant::now(),
47            frames_total: total_frames,
48            frames_done: 0,
49            bytes_written: 0,
50            last_update: Instant::now(),
51            update_interval: Duration::from_millis(100),
52        }
53    }
54
55    /// Create a progress tracker with unknown total.
56    ///
57    /// Useful when the total frame count is not known in advance.
58    pub fn new_spinner() -> Self {
59        let bar = ProgressBar::new_spinner();
60
61        let style = ProgressStyle::default_spinner()
62            .template("{spinner:.green} [{elapsed_precise}] {pos} frames {msg}")
63            .expect("Failed to create spinner style");
64
65        bar.set_style(style);
66
67        Self {
68            bar,
69            start_time: Instant::now(),
70            frames_total: 0,
71            frames_done: 0,
72            bytes_written: 0,
73            last_update: Instant::now(),
74            update_interval: Duration::from_millis(100),
75        }
76    }
77
78    /// Update progress with the number of frames processed.
79    ///
80    /// # Arguments
81    ///
82    /// * `frames` - Number of frames completed so far
83    pub fn update(&mut self, frames: u64) {
84        self.frames_done = frames;
85
86        // Throttle updates to avoid excessive CPU usage
87        let now = Instant::now();
88        if now.duration_since(self.last_update) < self.update_interval {
89            return;
90        }
91        self.last_update = now;
92
93        self.bar.set_position(frames);
94
95        // Calculate and display stats
96        let fps = self.fps();
97        let eta = self.eta();
98        let bitrate = self.bitrate();
99
100        let msg = format!(
101            "{:.1} fps | {} | {}",
102            fps,
103            format_eta(eta),
104            format_bitrate(bitrate)
105        );
106
107        self.bar.set_message(msg);
108    }
109
110    /// Update the number of bytes written to the output file.
111    ///
112    /// # Arguments
113    ///
114    /// * `bytes` - Total bytes written so far
115    pub fn set_bytes_written(&mut self, bytes: u64) {
116        self.bytes_written = bytes;
117    }
118
119    /// Set a status message on the progress bar.
120    ///
121    /// # Arguments
122    ///
123    /// * `status` - Status message to display
124    #[allow(dead_code)]
125    pub fn set_status(&self, status: &str) {
126        self.bar.set_message(status.to_string());
127    }
128
129    /// Mark the progress as complete and show final statistics.
130    pub fn finish(&self) {
131        let elapsed = self.start_time.elapsed();
132        let avg_fps = if elapsed.as_secs_f64() > 0.0 {
133            self.frames_done as f64 / elapsed.as_secs_f64()
134        } else {
135            0.0
136        };
137
138        let final_msg = format!(
139            "{} | Avg {:.1} fps | {}",
140            "Complete".green().bold(),
141            avg_fps,
142            format_size(self.bytes_written)
143        );
144
145        self.bar.finish_with_message(final_msg);
146    }
147
148    /// Mark the progress as failed with an error message.
149    ///
150    /// # Arguments
151    ///
152    /// * `error` - Error message to display
153    #[allow(dead_code)]
154    pub fn finish_with_error(&self, error: &str) {
155        let msg = format!("{} {}", "Failed:".red().bold(), error);
156        self.bar.finish_with_message(msg);
157    }
158
159    /// Calculate current processing speed in frames per second.
160    pub fn fps(&self) -> f64 {
161        let elapsed = self.start_time.elapsed();
162        if elapsed.as_secs_f64() > 0.0 {
163            self.frames_done as f64 / elapsed.as_secs_f64()
164        } else {
165            0.0
166        }
167    }
168
169    /// Calculate estimated time remaining.
170    pub fn eta(&self) -> Duration {
171        if self.frames_total == 0 || self.frames_done == 0 {
172            return Duration::from_secs(0);
173        }
174
175        let elapsed = self.start_time.elapsed();
176        let frames_remaining = self.frames_total.saturating_sub(self.frames_done);
177
178        if self.frames_done > 0 {
179            let time_per_frame = elapsed.as_secs_f64() / self.frames_done as f64;
180            let eta_secs = time_per_frame * frames_remaining as f64;
181            Duration::from_secs_f64(eta_secs)
182        } else {
183            Duration::from_secs(0)
184        }
185    }
186
187    /// Calculate current bitrate in bits per second.
188    pub fn bitrate(&self) -> f64 {
189        let elapsed = self.start_time.elapsed();
190        if elapsed.as_secs_f64() > 0.0 {
191            (self.bytes_written as f64 * 8.0) / elapsed.as_secs_f64()
192        } else {
193            0.0
194        }
195    }
196
197    /// Get the total number of frames.
198    #[allow(dead_code)]
199    pub fn total_frames(&self) -> u64 {
200        self.frames_total
201    }
202
203    /// Get the number of frames completed.
204    #[allow(dead_code)]
205    pub fn frames_completed(&self) -> u64 {
206        self.frames_done
207    }
208
209    /// Get the total elapsed time.
210    #[allow(dead_code)]
211    pub fn elapsed(&self) -> Duration {
212        self.start_time.elapsed()
213    }
214}
215
216/// Simple progress tracker for batch operations.
217pub struct BatchProgress {
218    bar: ProgressBar,
219    start_time: Instant,
220    #[allow(dead_code)]
221    total_files: usize,
222    completed: usize,
223    failed: usize,
224}
225
226impl BatchProgress {
227    /// Create a new batch progress tracker.
228    ///
229    /// # Arguments
230    ///
231    /// * `total_files` - Total number of files to process
232    pub fn new(total_files: usize) -> Self {
233        let bar = ProgressBar::new(total_files as u64);
234
235        let style = ProgressStyle::default_bar()
236            .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} files ({percent}%) {msg}")
237            .expect("Failed to create progress style")
238            .progress_chars("=>-");
239
240        bar.set_style(style);
241
242        Self {
243            bar,
244            start_time: Instant::now(),
245            total_files,
246            completed: 0,
247            failed: 0,
248        }
249    }
250
251    /// Mark a file as successfully completed.
252    pub fn inc_success(&mut self) {
253        self.completed += 1;
254        self.bar.inc(1);
255        self.update_message();
256    }
257
258    /// Mark a file as failed.
259    pub fn inc_failed(&mut self) {
260        self.failed += 1;
261        self.bar.inc(1);
262        self.update_message();
263    }
264
265    /// Update the status message.
266    fn update_message(&self) {
267        let msg = if self.failed > 0 {
268            format!(
269                "{} succeeded, {} failed",
270                self.completed.to_string().green(),
271                self.failed.to_string().red()
272            )
273        } else {
274            format!("{} succeeded", self.completed.to_string().green())
275        };
276
277        self.bar.set_message(msg);
278    }
279
280    /// Finish the progress display.
281    pub fn finish(&self) {
282        let elapsed = self.start_time.elapsed();
283        let msg = format!(
284            "{} | {} succeeded, {} failed | Took {}",
285            "Complete".green().bold(),
286            self.completed,
287            self.failed,
288            format_duration(elapsed)
289        );
290
291        self.bar.finish_with_message(msg);
292    }
293}
294
295/// Format a duration as a human-readable string (e.g., "1h 23m 45s").
296fn format_duration(duration: Duration) -> String {
297    let total_secs = duration.as_secs();
298    let hours = total_secs / 3600;
299    let minutes = (total_secs % 3600) / 60;
300    let seconds = total_secs % 60;
301
302    if hours > 0 {
303        format!("{}h {}m {}s", hours, minutes, seconds)
304    } else if minutes > 0 {
305        format!("{}m {}s", minutes, seconds)
306    } else {
307        format!("{}s", seconds)
308    }
309}
310
311/// Format ETA with appropriate color coding.
312fn format_eta(eta: Duration) -> String {
313    let eta_str = format!("ETA {}", format_duration(eta));
314
315    if eta.as_secs() > 3600 {
316        eta_str.red().to_string()
317    } else if eta.as_secs() > 600 {
318        eta_str.yellow().to_string()
319    } else {
320        eta_str.green().to_string()
321    }
322}
323
324/// Format bitrate in human-readable format (e.g., "2.5 Mbps").
325fn format_bitrate(bitrate: f64) -> String {
326    if bitrate >= 1_000_000.0 {
327        format!("{:.2} Mbps", bitrate / 1_000_000.0)
328    } else if bitrate >= 1_000.0 {
329        format!("{:.1} kbps", bitrate / 1_000.0)
330    } else {
331        format!("{:.0} bps", bitrate)
332    }
333}
334
335/// Format file size in human-readable format (e.g., "1.5 GB").
336fn format_size(bytes: u64) -> String {
337    const KB: u64 = 1024;
338    const MB: u64 = KB * 1024;
339    const GB: u64 = MB * 1024;
340
341    if bytes >= GB {
342        format!("{:.2} GB", bytes as f64 / GB as f64)
343    } else if bytes >= MB {
344        format!("{:.2} MB", bytes as f64 / MB as f64)
345    } else if bytes >= KB {
346        format!("{:.2} KB", bytes as f64 / KB as f64)
347    } else {
348        format!("{} B", bytes)
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_format_duration() {
358        assert_eq!(format_duration(Duration::from_secs(30)), "30s");
359        assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
360        assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m 1s");
361    }
362
363    #[test]
364    fn test_format_bitrate() {
365        assert_eq!(format_bitrate(500.0), "500 bps");
366        assert_eq!(format_bitrate(1500.0), "1.5 kbps");
367        assert_eq!(format_bitrate(2_500_000.0), "2.50 Mbps");
368    }
369
370    #[test]
371    fn test_format_size() {
372        assert_eq!(format_size(500), "500 B");
373        assert_eq!(format_size(1536), "1.50 KB");
374        assert_eq!(format_size(2_097_152), "2.00 MB");
375        assert_eq!(format_size(1_610_612_736), "1.50 GB");
376    }
377
378    #[test]
379    fn test_progress_fps() {
380        let mut progress = TranscodeProgress::new(100);
381        std::thread::sleep(Duration::from_millis(100));
382        progress.update(10);
383
384        let fps = progress.fps();
385        assert!(fps > 0.0);
386    }
387
388    #[test]
389    fn test_progress_eta() {
390        let mut progress = TranscodeProgress::new(100);
391        std::thread::sleep(Duration::from_millis(100));
392        progress.update(10);
393
394        let eta = progress.eta();
395        let _ = eta.as_secs(); // ETA is a Duration (always non-negative)
396    }
397}