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/// Output format for progress reporting.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
12pub enum ProgressFormat {
13    /// Human-readable progress bar (default).
14    #[default]
15    Plain,
16    /// NDJSON records emitted to stderr (one per tick).
17    Json,
18}
19
20/// Progress tracker for transcoding operations.
21///
22/// Displays a progress bar with:
23/// - Current frame / total frames
24/// - Processing speed (FPS)
25/// - Estimated time remaining (ETA)
26/// - Current bitrate
27/// - File size
28pub struct TranscodeProgress {
29    bar: ProgressBar,
30    start_time: Instant,
31    frames_total: u64,
32    frames_done: u64,
33    bytes_written: u64,
34    last_update: Instant,
35    update_interval: Duration,
36    /// Output format for progress ticks.
37    pub format: ProgressFormat,
38}
39
40impl TranscodeProgress {
41    /// Create a new transcode progress tracker.
42    ///
43    /// # Arguments
44    ///
45    /// * `total_frames` - Total number of frames to process
46    pub fn new(total_frames: u64) -> Self {
47        let bar = ProgressBar::new(total_frames);
48
49        let style = ProgressStyle::default_bar()
50            .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} frames ({percent}%) {msg}")
51            .unwrap_or_else(|_| ProgressStyle::default_bar())
52            .progress_chars("=>-");
53
54        bar.set_style(style);
55
56        Self {
57            bar,
58            start_time: Instant::now(),
59            frames_total: total_frames,
60            frames_done: 0,
61            bytes_written: 0,
62            last_update: Instant::now(),
63            update_interval: Duration::from_millis(100),
64            format: ProgressFormat::Plain,
65        }
66    }
67
68    /// Create a progress tracker with unknown total.
69    ///
70    /// Useful when the total frame count is not known in advance.
71    pub fn new_spinner() -> Self {
72        let bar = ProgressBar::new_spinner();
73
74        let style = ProgressStyle::default_spinner()
75            .template("{spinner:.green} [{elapsed_precise}] {pos} frames {msg}")
76            .unwrap_or_else(|_| ProgressStyle::default_spinner());
77
78        bar.set_style(style);
79
80        Self {
81            bar,
82            start_time: Instant::now(),
83            frames_total: 0,
84            frames_done: 0,
85            bytes_written: 0,
86            last_update: Instant::now(),
87            update_interval: Duration::from_millis(100),
88            format: ProgressFormat::Plain,
89        }
90    }
91
92    /// Create a new transcode progress tracker with an explicit output format.
93    ///
94    /// When `format` is [`ProgressFormat::Json`], the indicatif bar is hidden
95    /// so that NDJSON records written to stderr are not interleaved with bar
96    /// control codes.
97    pub fn new_with_format(total_frames: u64, fmt: ProgressFormat) -> Self {
98        let mut this = Self::new(total_frames);
99        this.set_format(fmt);
100        this
101    }
102
103    /// Set the progress output format.
104    pub fn set_format(&mut self, fmt: ProgressFormat) {
105        self.format = fmt;
106        if fmt == ProgressFormat::Json {
107            // Hide the indicatif bar when using JSON output.
108            self.bar
109                .set_draw_target(indicatif::ProgressDrawTarget::hidden());
110        }
111    }
112
113    /// Update progress with the number of frames processed.
114    ///
115    /// # Arguments
116    ///
117    /// * `frames` - Number of frames completed so far
118    pub fn update(&mut self, frames: u64) {
119        self.frames_done = frames;
120
121        // Throttle updates to avoid excessive CPU usage
122        let now = Instant::now();
123        if now.duration_since(self.last_update) < self.update_interval {
124            return;
125        }
126        self.last_update = now;
127
128        let fps = self.fps();
129        let eta = self.eta();
130        let bitrate = self.bitrate();
131
132        match self.format {
133            ProgressFormat::Plain => {
134                self.bar.set_position(frames);
135                let msg = format!(
136                    "{:.1} fps | {} | {}",
137                    fps,
138                    format_eta(eta),
139                    format_bitrate(bitrate)
140                );
141                self.bar.set_message(msg);
142            }
143            ProgressFormat::Json => {
144                let elapsed = self.start_time.elapsed().as_secs_f64();
145                let eta_secs = eta.as_secs_f64();
146                let record = serde_json::json!({
147                    "kind": "progress",
148                    "frames_done": frames,
149                    "frames_total": self.frames_total,
150                    "fps": fps,
151                    "bitrate_bps": bitrate,
152                    "eta_seconds": eta_secs,
153                    "elapsed_seconds": elapsed
154                });
155                eprintln!("{record}");
156            }
157        }
158    }
159
160    /// Update the number of bytes written to the output file.
161    ///
162    /// # Arguments
163    ///
164    /// * `bytes` - Total bytes written so far
165    pub fn set_bytes_written(&mut self, bytes: u64) {
166        self.bytes_written = bytes;
167    }
168
169    /// Set a status message on the progress bar.
170    ///
171    /// # Arguments
172    ///
173    /// * `status` - Status message to display
174    #[allow(dead_code)]
175    pub fn set_status(&self, status: &str) {
176        self.bar.set_message(status.to_string());
177    }
178
179    /// Mark the progress as complete and show final statistics.
180    pub fn finish(&self) {
181        let elapsed = self.start_time.elapsed();
182        let avg_fps = if elapsed.as_secs_f64() > 0.0 {
183            self.frames_done as f64 / elapsed.as_secs_f64()
184        } else {
185            0.0
186        };
187
188        match self.format {
189            ProgressFormat::Plain => {
190                let final_msg = format!(
191                    "{} | Avg {:.1} fps | {}",
192                    "Complete".green().bold(),
193                    avg_fps,
194                    format_size(self.bytes_written)
195                );
196                self.bar.finish_with_message(final_msg);
197            }
198            ProgressFormat::Json => {
199                let record = serde_json::json!({
200                    "kind": "done",
201                    "frames_done": self.frames_done,
202                    "frames_total": self.frames_total,
203                    "avg_fps": avg_fps,
204                    "bytes_written": self.bytes_written,
205                    "elapsed_seconds": elapsed.as_secs_f64()
206                });
207                eprintln!("{record}");
208            }
209        }
210    }
211
212    /// Mark the progress as failed with an error message.
213    ///
214    /// # Arguments
215    ///
216    /// * `error` - Error message to display
217    #[allow(dead_code)]
218    pub fn finish_with_error(&self, error: &str) {
219        let msg = format!("{} {}", "Failed:".red().bold(), error);
220        self.bar.finish_with_message(msg);
221    }
222
223    /// Calculate current processing speed in frames per second.
224    pub fn fps(&self) -> f64 {
225        let elapsed = self.start_time.elapsed();
226        if elapsed.as_secs_f64() > 0.0 {
227            self.frames_done as f64 / elapsed.as_secs_f64()
228        } else {
229            0.0
230        }
231    }
232
233    /// Calculate estimated time remaining.
234    pub fn eta(&self) -> Duration {
235        if self.frames_total == 0 || self.frames_done == 0 {
236            return Duration::from_secs(0);
237        }
238
239        let elapsed = self.start_time.elapsed();
240        let frames_remaining = self.frames_total.saturating_sub(self.frames_done);
241
242        if self.frames_done > 0 {
243            let time_per_frame = elapsed.as_secs_f64() / self.frames_done as f64;
244            let eta_secs = time_per_frame * frames_remaining as f64;
245            Duration::from_secs_f64(eta_secs)
246        } else {
247            Duration::from_secs(0)
248        }
249    }
250
251    /// Calculate current bitrate in bits per second.
252    pub fn bitrate(&self) -> f64 {
253        let elapsed = self.start_time.elapsed();
254        if elapsed.as_secs_f64() > 0.0 {
255            (self.bytes_written as f64 * 8.0) / elapsed.as_secs_f64()
256        } else {
257            0.0
258        }
259    }
260
261    /// Get the total number of frames.
262    #[allow(dead_code)]
263    pub fn total_frames(&self) -> u64 {
264        self.frames_total
265    }
266
267    /// Get the number of frames completed.
268    #[allow(dead_code)]
269    pub fn frames_completed(&self) -> u64 {
270        self.frames_done
271    }
272
273    /// Get the total elapsed time.
274    #[allow(dead_code)]
275    pub fn elapsed(&self) -> Duration {
276        self.start_time.elapsed()
277    }
278}
279
280/// Simple progress tracker for batch operations.
281pub struct BatchProgress {
282    bar: ProgressBar,
283    start_time: Instant,
284    #[allow(dead_code)]
285    total_files: usize,
286    completed: usize,
287    failed: usize,
288    /// Output format for progress events.
289    pub format: ProgressFormat,
290}
291
292impl BatchProgress {
293    /// Create a new batch progress tracker.
294    ///
295    /// # Arguments
296    ///
297    /// * `total_files` - Total number of files to process
298    pub fn new(total_files: usize) -> Self {
299        let bar = ProgressBar::new(total_files as u64);
300
301        let style = ProgressStyle::default_bar()
302            .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} files ({percent}%) {msg}")
303            .unwrap_or_else(|_| ProgressStyle::default_bar())
304            .progress_chars("=>-");
305
306        bar.set_style(style);
307
308        Self {
309            bar,
310            start_time: Instant::now(),
311            total_files,
312            completed: 0,
313            failed: 0,
314            format: ProgressFormat::Plain,
315        }
316    }
317
318    /// Set the progress output format.
319    pub fn set_format(&mut self, fmt: ProgressFormat) {
320        self.format = fmt;
321        if fmt == ProgressFormat::Json {
322            self.bar
323                .set_draw_target(indicatif::ProgressDrawTarget::hidden());
324        }
325    }
326
327    /// Mark a file as successfully completed.
328    pub fn inc_success(&mut self) {
329        self.completed += 1;
330        self.bar.inc(1);
331        self.emit_tick();
332    }
333
334    /// Mark a file as failed.
335    pub fn inc_failed(&mut self) {
336        self.failed += 1;
337        self.bar.inc(1);
338        self.emit_tick();
339    }
340
341    /// Emit a progress tick (plain bar or JSON).
342    fn emit_tick(&self) {
343        match self.format {
344            ProgressFormat::Plain => {
345                let msg = if self.failed > 0 {
346                    format!(
347                        "{} succeeded, {} failed",
348                        self.completed.to_string().green(),
349                        self.failed.to_string().red()
350                    )
351                } else {
352                    format!("{} succeeded", self.completed.to_string().green())
353                };
354                self.bar.set_message(msg);
355            }
356            ProgressFormat::Json => {
357                let elapsed = self.start_time.elapsed().as_secs_f64();
358                let record = serde_json::json!({
359                    "kind": "batch_progress",
360                    "completed": self.completed,
361                    "failed": self.failed,
362                    "total": self.total_files,
363                    "elapsed_seconds": elapsed
364                });
365                eprintln!("{record}");
366            }
367        }
368    }
369
370    /// Finish the progress display.
371    pub fn finish(&self) {
372        let elapsed = self.start_time.elapsed();
373        match self.format {
374            ProgressFormat::Plain => {
375                let msg = format!(
376                    "{} | {} succeeded, {} failed | Took {}",
377                    "Complete".green().bold(),
378                    self.completed,
379                    self.failed,
380                    format_duration(elapsed)
381                );
382                self.bar.finish_with_message(msg);
383            }
384            ProgressFormat::Json => {
385                let record = serde_json::json!({
386                    "kind": "batch_done",
387                    "completed": self.completed,
388                    "failed": self.failed,
389                    "total": self.total_files,
390                    "elapsed_seconds": elapsed.as_secs_f64()
391                });
392                eprintln!("{record}");
393            }
394        }
395    }
396}
397
398/// Format a duration as a human-readable string (e.g., "1h 23m 45s").
399fn format_duration(duration: Duration) -> String {
400    let total_secs = duration.as_secs();
401    let hours = total_secs / 3600;
402    let minutes = (total_secs % 3600) / 60;
403    let seconds = total_secs % 60;
404
405    if hours > 0 {
406        format!("{}h {}m {}s", hours, minutes, seconds)
407    } else if minutes > 0 {
408        format!("{}m {}s", minutes, seconds)
409    } else {
410        format!("{}s", seconds)
411    }
412}
413
414/// Format ETA with appropriate color coding.
415fn format_eta(eta: Duration) -> String {
416    let eta_str = format!("ETA {}", format_duration(eta));
417
418    if eta.as_secs() > 3600 {
419        eta_str.red().to_string()
420    } else if eta.as_secs() > 600 {
421        eta_str.yellow().to_string()
422    } else {
423        eta_str.green().to_string()
424    }
425}
426
427/// Format bitrate in human-readable format (e.g., "2.5 Mbps").
428fn format_bitrate(bitrate: f64) -> String {
429    if bitrate >= 1_000_000.0 {
430        format!("{:.2} Mbps", bitrate / 1_000_000.0)
431    } else if bitrate >= 1_000.0 {
432        format!("{:.1} kbps", bitrate / 1_000.0)
433    } else {
434        format!("{:.0} bps", bitrate)
435    }
436}
437
438/// Format file size in human-readable format (e.g., "1.5 GB").
439fn format_size(bytes: u64) -> String {
440    const KB: u64 = 1024;
441    const MB: u64 = KB * 1024;
442    const GB: u64 = MB * 1024;
443
444    if bytes >= GB {
445        format!("{:.2} GB", bytes as f64 / GB as f64)
446    } else if bytes >= MB {
447        format!("{:.2} MB", bytes as f64 / MB as f64)
448    } else if bytes >= KB {
449        format!("{:.2} KB", bytes as f64 / KB as f64)
450    } else {
451        format!("{} B", bytes)
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_format_duration() {
461        assert_eq!(format_duration(Duration::from_secs(30)), "30s");
462        assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
463        assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m 1s");
464    }
465
466    #[test]
467    fn test_format_bitrate() {
468        assert_eq!(format_bitrate(500.0), "500 bps");
469        assert_eq!(format_bitrate(1500.0), "1.5 kbps");
470        assert_eq!(format_bitrate(2_500_000.0), "2.50 Mbps");
471    }
472
473    #[test]
474    fn test_format_size() {
475        assert_eq!(format_size(500), "500 B");
476        assert_eq!(format_size(1536), "1.50 KB");
477        assert_eq!(format_size(2_097_152), "2.00 MB");
478        assert_eq!(format_size(1_610_612_736), "1.50 GB");
479    }
480
481    #[test]
482    fn test_progress_fps() {
483        let mut progress = TranscodeProgress::new(100);
484        std::thread::sleep(Duration::from_millis(100));
485        progress.update(10);
486
487        let fps = progress.fps();
488        assert!(fps > 0.0);
489    }
490
491    #[test]
492    fn test_progress_eta() {
493        let mut progress = TranscodeProgress::new(100);
494        std::thread::sleep(Duration::from_millis(100));
495        progress.update(10);
496
497        let eta = progress.eta();
498        let _ = eta.as_secs(); // ETA is a Duration (always non-negative)
499    }
500
501    #[test]
502    fn test_set_format_json_does_not_panic() {
503        let mut progress = TranscodeProgress::new(100);
504        progress.set_format(ProgressFormat::Json);
505        assert_eq!(progress.format, ProgressFormat::Json);
506        // JSON update should write to stderr, not panic
507        progress.update(5);
508    }
509
510    #[test]
511    fn test_set_format_plain_roundtrip() {
512        let mut progress = TranscodeProgress::new_spinner();
513        progress.set_format(ProgressFormat::Plain);
514        assert_eq!(progress.format, ProgressFormat::Plain);
515    }
516
517    #[test]
518    fn test_batch_progress_json_emit() {
519        let mut bp = BatchProgress::new(3);
520        bp.set_format(ProgressFormat::Json);
521        bp.inc_success();
522        bp.inc_failed();
523        // finish should emit JSON to stderr without panicking
524        bp.finish();
525    }
526}