Skip to main content

oximedia_transcode/
progress.rs

1//! Progress tracking and estimation for transcode operations.
2
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, Instant};
5
6/// Progress information for a transcode operation.
7#[derive(Debug, Clone)]
8pub struct ProgressInfo {
9    /// Current frame number being processed.
10    pub current_frame: u64,
11    /// Total number of frames to process.
12    pub total_frames: u64,
13    /// Percentage complete (0-100).
14    pub percent: f64,
15    /// Estimated time remaining.
16    pub eta: Option<Duration>,
17    /// Current encoding speed (frames per second).
18    pub fps: f64,
19    /// Current bitrate in bits per second.
20    pub bitrate: u64,
21    /// Elapsed time since start.
22    pub elapsed: Duration,
23    /// Current pass number (for multi-pass encoding).
24    pub pass: u32,
25    /// Total number of passes.
26    pub total_passes: u32,
27}
28
29/// Callback function for progress updates.
30pub type ProgressCallback = Arc<dyn Fn(&ProgressInfo) + Send + Sync>;
31
32/// Progress tracker for transcode operations.
33pub struct ProgressTracker {
34    start_time: Instant,
35    total_frames: u64,
36    current_frame: Arc<Mutex<u64>>,
37    total_passes: u32,
38    current_pass: Arc<Mutex<u32>>,
39    callback: Option<ProgressCallback>,
40    update_interval: Duration,
41    last_update: Arc<Mutex<Instant>>,
42    frame_times: Arc<Mutex<Vec<Instant>>>,
43}
44
45impl ProgressTracker {
46    /// Creates a new progress tracker.
47    ///
48    /// # Arguments
49    ///
50    /// * `total_frames` - Total number of frames to process
51    /// * `total_passes` - Total number of encoding passes
52    #[must_use]
53    pub fn new(total_frames: u64, total_passes: u32) -> Self {
54        Self {
55            start_time: Instant::now(),
56            total_frames,
57            current_frame: Arc::new(Mutex::new(0)),
58            total_passes,
59            current_pass: Arc::new(Mutex::new(1)),
60            callback: None,
61            update_interval: Duration::from_millis(500),
62            last_update: Arc::new(Mutex::new(Instant::now())),
63            frame_times: Arc::new(Mutex::new(Vec::new())),
64        }
65    }
66
67    /// Sets the progress callback function.
68    pub fn set_callback(&mut self, callback: ProgressCallback) {
69        self.callback = Some(callback);
70    }
71
72    /// Sets the update interval for callbacks.
73    pub fn set_update_interval(&mut self, interval: Duration) {
74        self.update_interval = interval;
75    }
76
77    /// Updates the current frame number.
78    pub fn update_frame(&self, frame: u64) {
79        if let Ok(mut current) = self.current_frame.lock() {
80            *current = frame;
81
82            // Record frame time for FPS calculation
83            if let Ok(mut times) = self.frame_times.lock() {
84                times.push(Instant::now());
85                // Keep only last 30 frames for moving average
86                if times.len() > 30 {
87                    times.remove(0);
88                }
89            }
90        }
91
92        self.maybe_trigger_callback();
93    }
94
95    /// Increments the current frame by one.
96    pub fn increment_frame(&self) {
97        if let Ok(mut current) = self.current_frame.lock() {
98            *current += 1;
99            let frame = *current;
100            drop(current);
101
102            // Record frame time
103            if let Ok(mut times) = self.frame_times.lock() {
104                times.push(Instant::now());
105                if times.len() > 30 {
106                    times.remove(0);
107                }
108            }
109
110            // Check if we should trigger callback
111            if frame % 10 == 0 {
112                self.maybe_trigger_callback();
113            }
114        }
115    }
116
117    /// Sets the current pass number.
118    pub fn set_pass(&self, pass: u32) {
119        if let Ok(mut current_pass) = self.current_pass.lock() {
120            *current_pass = pass;
121        }
122        // Reset frame counter when starting a new pass
123        if let Ok(mut current_frame) = self.current_frame.lock() {
124            *current_frame = 0;
125        }
126        self.maybe_trigger_callback();
127    }
128
129    /// Gets the current progress information.
130    #[must_use]
131    pub fn get_info(&self) -> ProgressInfo {
132        let current_frame = self.current_frame.lock().map_or(0, |f| *f);
133        let current_pass = self.current_pass.lock().map_or(1, |p| *p);
134        let elapsed = self.start_time.elapsed();
135
136        // Calculate percentage
137        let frames_per_pass = self.total_frames;
138        let total_work = frames_per_pass * u64::from(self.total_passes);
139        let completed_work = frames_per_pass * u64::from(current_pass - 1) + current_frame;
140        let percent = if total_work > 0 {
141            (completed_work as f64 / total_work as f64) * 100.0
142        } else {
143            0.0
144        };
145
146        // Calculate FPS
147        let fps = self.calculate_fps();
148
149        // Calculate ETA
150        let eta = if fps > 0.0 && total_work > completed_work {
151            let remaining_frames = total_work - completed_work;
152            let remaining_seconds = remaining_frames as f64 / fps;
153            Some(Duration::from_secs_f64(remaining_seconds))
154        } else {
155            None
156        };
157
158        ProgressInfo {
159            current_frame,
160            total_frames: self.total_frames,
161            percent,
162            eta,
163            fps,
164            bitrate: 0, // Will be updated by encoder
165            elapsed,
166            pass: current_pass,
167            total_passes: self.total_passes,
168        }
169    }
170
171    /// Resets the tracker for a new pass.
172    pub fn reset_for_pass(&self, pass: u32) {
173        if let Ok(mut current_frame) = self.current_frame.lock() {
174            *current_frame = 0;
175        }
176        if let Ok(mut current_pass) = self.current_pass.lock() {
177            *current_pass = pass;
178        }
179        if let Ok(mut times) = self.frame_times.lock() {
180            times.clear();
181        }
182    }
183
184    fn calculate_fps(&self) -> f64 {
185        if let Ok(times) = self.frame_times.lock() {
186            if times.len() < 2 {
187                return 0.0;
188            }
189
190            let first = times[0];
191            let last = *times.last().expect("invariant: len >= 2 checked above");
192            let duration = last.duration_since(first);
193
194            if duration.as_secs_f64() > 0.0 {
195                (times.len() - 1) as f64 / duration.as_secs_f64()
196            } else {
197                0.0
198            }
199        } else {
200            0.0
201        }
202    }
203
204    fn maybe_trigger_callback(&self) {
205        if let Some(callback) = &self.callback {
206            if let Ok(mut last_update) = self.last_update.lock() {
207                if last_update.elapsed() >= self.update_interval {
208                    *last_update = Instant::now();
209                    let info = self.get_info();
210                    callback(&info);
211                }
212            }
213        }
214    }
215}
216
217/// Builder for creating a progress tracker with custom configuration.
218pub struct ProgressTrackerBuilder {
219    #[allow(dead_code)]
220    total_frames: u64,
221    #[allow(dead_code)]
222    total_passes: u32,
223    #[allow(dead_code)]
224    callback: Option<ProgressCallback>,
225    #[allow(dead_code)]
226    update_interval: Duration,
227}
228impl ProgressTrackerBuilder {
229    #[allow(dead_code)]
230    /// Creates a new progress tracker builder.
231    #[must_use]
232    pub fn new(total_frames: u64) -> Self {
233        Self {
234            total_frames,
235            total_passes: 1,
236            callback: None,
237            update_interval: Duration::from_millis(500),
238        }
239    }
240
241    /// Sets the number of encoding passes.
242    #[must_use]
243    #[allow(dead_code)]
244    pub fn passes(mut self, passes: u32) -> Self {
245        self.total_passes = passes;
246        self
247    }
248
249    /// Sets the progress callback.
250    #[must_use]
251    #[allow(dead_code)]
252    pub fn callback(mut self, callback: ProgressCallback) -> Self {
253        self.callback = Some(callback);
254        self
255    }
256
257    /// Sets the update interval.
258    #[must_use]
259    #[allow(dead_code)]
260    pub fn update_interval(mut self, interval: Duration) -> Self {
261        self.update_interval = interval;
262        self
263    }
264
265    /// Builds the progress tracker.
266    #[must_use]
267    #[allow(dead_code)]
268    pub fn build(self) -> ProgressTracker {
269        let mut tracker = ProgressTracker::new(self.total_frames, self.total_passes);
270        if let Some(callback) = self.callback {
271            tracker.set_callback(callback);
272        }
273        tracker.set_update_interval(self.update_interval);
274        tracker
275    }
276}
277
278impl ProgressInfo {
279    /// Formats the ETA as a human-readable string.
280    #[must_use]
281    pub fn format_eta(&self) -> String {
282        if let Some(eta) = self.eta {
283            let total_secs = eta.as_secs();
284            let hours = total_secs / 3600;
285            let minutes = (total_secs % 3600) / 60;
286            let seconds = total_secs % 60;
287
288            if hours > 0 {
289                format!("{hours}h {minutes}m {seconds}s")
290            } else if minutes > 0 {
291                format!("{minutes}m {seconds}s")
292            } else {
293                format!("{seconds}s")
294            }
295        } else {
296            "Unknown".to_string()
297        }
298    }
299
300    /// Formats the elapsed time as a human-readable string.
301    #[must_use]
302    pub fn format_elapsed(&self) -> String {
303        let total_secs = self.elapsed.as_secs();
304        let hours = total_secs / 3600;
305        let minutes = (total_secs % 3600) / 60;
306        let seconds = total_secs % 60;
307
308        if hours > 0 {
309            format!("{hours}h {minutes}m {seconds}s")
310        } else if minutes > 0 {
311            format!("{minutes}m {seconds}s")
312        } else {
313            format!("{seconds}s")
314        }
315    }
316
317    /// Formats the bitrate as a human-readable string.
318    #[must_use]
319    pub fn format_bitrate(&self) -> String {
320        let kbps = self.bitrate / 1000;
321        if kbps > 1000 {
322            format!("{:.2} Mbps", kbps as f64 / 1000.0)
323        } else {
324            format!("{kbps} kbps")
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_progress_tracker_creation() {
335        let tracker = ProgressTracker::new(1000, 1);
336        let info = tracker.get_info();
337
338        assert_eq!(info.current_frame, 0);
339        assert_eq!(info.total_frames, 1000);
340        assert_eq!(info.percent, 0.0);
341        assert_eq!(info.pass, 1);
342        assert_eq!(info.total_passes, 1);
343    }
344
345    #[test]
346    fn test_progress_update() {
347        let tracker = ProgressTracker::new(1000, 1);
348        tracker.update_frame(500);
349
350        let info = tracker.get_info();
351        assert_eq!(info.current_frame, 500);
352        assert!((info.percent - 50.0).abs() < 0.1);
353    }
354
355    #[test]
356    fn test_progress_increment() {
357        let tracker = ProgressTracker::new(1000, 1);
358
359        for _ in 0..100 {
360            tracker.increment_frame();
361        }
362
363        let info = tracker.get_info();
364        assert_eq!(info.current_frame, 100);
365        assert!((info.percent - 10.0).abs() < 0.1);
366    }
367
368    #[test]
369    fn test_multipass_progress() {
370        let tracker = ProgressTracker::new(1000, 2);
371        tracker.update_frame(1000);
372        tracker.set_pass(2);
373
374        let info = tracker.get_info();
375        assert_eq!(info.pass, 2);
376        // After first pass complete, we're at 50%
377        assert!((info.percent - 50.0).abs() < 0.1);
378    }
379
380    #[test]
381    fn test_progress_reset() {
382        let tracker = ProgressTracker::new(1000, 2);
383        tracker.update_frame(500);
384        tracker.reset_for_pass(2);
385
386        let info = tracker.get_info();
387        assert_eq!(info.current_frame, 0);
388        assert_eq!(info.pass, 2);
389    }
390
391    #[test]
392    fn test_progress_builder() {
393        let tracker = ProgressTrackerBuilder::new(1000)
394            .passes(2)
395            .update_interval(Duration::from_secs(1))
396            .build();
397
398        let info = tracker.get_info();
399        assert_eq!(info.total_frames, 1000);
400        assert_eq!(info.total_passes, 2);
401    }
402
403    #[test]
404    fn test_format_eta() {
405        let info = ProgressInfo {
406            current_frame: 500,
407            total_frames: 1000,
408            percent: 50.0,
409            eta: Some(Duration::from_secs(3725)), // 1h 2m 5s
410            fps: 30.0,
411            bitrate: 5_000_000,
412            elapsed: Duration::from_secs(60),
413            pass: 1,
414            total_passes: 1,
415        };
416
417        assert_eq!(info.format_eta(), "1h 2m 5s");
418    }
419
420    #[test]
421    fn test_format_elapsed() {
422        let info = ProgressInfo {
423            current_frame: 500,
424            total_frames: 1000,
425            percent: 50.0,
426            eta: None,
427            fps: 30.0,
428            bitrate: 5_000_000,
429            elapsed: Duration::from_secs(125), // 2m 5s
430            pass: 1,
431            total_passes: 1,
432        };
433
434        assert_eq!(info.format_elapsed(), "2m 5s");
435    }
436
437    #[test]
438    fn test_format_bitrate() {
439        let info = ProgressInfo {
440            current_frame: 500,
441            total_frames: 1000,
442            percent: 50.0,
443            eta: None,
444            fps: 30.0,
445            bitrate: 5_500_000,
446            elapsed: Duration::from_secs(60),
447            pass: 1,
448            total_passes: 1,
449        };
450
451        assert_eq!(info.format_bitrate(), "5.50 Mbps");
452    }
453
454    #[test]
455    fn test_format_bitrate_kbps() {
456        let info = ProgressInfo {
457            current_frame: 500,
458            total_frames: 1000,
459            percent: 50.0,
460            eta: None,
461            fps: 30.0,
462            bitrate: 500_000,
463            elapsed: Duration::from_secs(60),
464            pass: 1,
465            total_passes: 1,
466        };
467
468        assert_eq!(info.format_bitrate(), "500 kbps");
469    }
470}