Skip to main content

oximedia_proxy/workflow/
planner.rs

1//! Workflow planning and estimation.
2
3use crate::{ProxyGenerationSettings, ProxyOptimizer, Result};
4use std::path::Path;
5
6/// Workflow planner for estimating time and resources.
7pub struct WorkflowPlanner {
8    optimizer: ProxyOptimizer,
9}
10
11impl WorkflowPlanner {
12    /// Create a new workflow planner.
13    #[must_use]
14    pub fn new() -> Self {
15        Self {
16            optimizer: ProxyOptimizer::new(),
17        }
18    }
19
20    /// Plan a proxy generation workflow.
21    pub fn plan_generation(
22        &self,
23        inputs: &[MediaInfo],
24        settings: ProxyGenerationSettings,
25    ) -> Result<WorkflowPlan> {
26        let total_duration: f64 = inputs.iter().map(|m| m.duration).sum();
27        let total_input_size: u64 = inputs.iter().map(|m| m.file_size).sum();
28
29        let mut estimated_output_size = 0u64;
30        let mut estimated_encoding_time = 0.0;
31
32        for media in inputs {
33            let output_size = self
34                .optimizer
35                .estimate_output_size(&settings, media.duration);
36            let encoding_time = self
37                .optimizer
38                .estimate_encoding_time(&settings, media.duration);
39
40            estimated_output_size += output_size;
41            estimated_encoding_time += encoding_time;
42        }
43
44        let space_savings = if total_input_size > estimated_output_size {
45            total_input_size - estimated_output_size
46        } else {
47            0
48        };
49
50        let compression_ratio = if total_input_size > 0 {
51            estimated_output_size as f64 / total_input_size as f64
52        } else {
53            0.0
54        };
55
56        Ok(WorkflowPlan {
57            total_files: inputs.len(),
58            total_duration,
59            total_input_size,
60            estimated_output_size,
61            estimated_encoding_time,
62            space_savings,
63            compression_ratio,
64            recommended_parallel_jobs: calculate_recommended_jobs(inputs.len()),
65        })
66    }
67
68    /// Plan a complete offline-to-online workflow.
69    pub fn plan_offline_workflow(
70        &self,
71        inputs: &[MediaInfo],
72        settings: ProxyGenerationSettings,
73        estimated_editing_time: f64,
74    ) -> Result<OfflineWorkflowPlan> {
75        let generation_plan = self.plan_generation(inputs, settings)?;
76        let encoding_time = generation_plan.estimated_encoding_time;
77
78        // Estimate conforming time (typically faster than generation)
79        let estimated_conform_time = encoding_time * 0.1;
80
81        // Total workflow time
82        let total_time = encoding_time + estimated_editing_time + estimated_conform_time;
83
84        Ok(OfflineWorkflowPlan {
85            generation_plan,
86            estimated_editing_time,
87            estimated_conform_time,
88            total_workflow_time: total_time,
89            phases: vec![
90                WorkflowPhase {
91                    name: "Proxy Generation".to_string(),
92                    estimated_time: encoding_time,
93                },
94                WorkflowPhase {
95                    name: "Offline Editing".to_string(),
96                    estimated_time: estimated_editing_time,
97                },
98                WorkflowPhase {
99                    name: "Conforming".to_string(),
100                    estimated_time: estimated_conform_time,
101                },
102            ],
103        })
104    }
105
106    /// Estimate storage requirements for a workflow.
107    #[must_use]
108    pub fn estimate_storage(
109        &self,
110        inputs: &[MediaInfo],
111        settings: &ProxyGenerationSettings,
112        keep_originals: bool,
113    ) -> StorageEstimate {
114        let total_original_size: u64 = inputs.iter().map(|m| m.file_size).sum();
115
116        let total_proxy_size: u64 = inputs
117            .iter()
118            .map(|m| self.optimizer.estimate_output_size(settings, m.duration))
119            .sum();
120
121        let working_storage = if keep_originals {
122            total_original_size + total_proxy_size
123        } else {
124            total_proxy_size
125        };
126
127        // Add 20% buffer for temporary files and renders
128        let recommended_storage = (working_storage as f64 * 1.2) as u64;
129
130        StorageEstimate {
131            original_size: total_original_size,
132            proxy_size: total_proxy_size,
133            working_storage,
134            recommended_storage,
135            space_saved: if total_original_size > total_proxy_size {
136                total_original_size - total_proxy_size
137            } else {
138                0
139            },
140        }
141    }
142}
143
144impl Default for WorkflowPlanner {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150/// Media file information.
151#[derive(Debug, Clone)]
152pub struct MediaInfo {
153    /// File path.
154    pub path: std::path::PathBuf,
155
156    /// File size in bytes.
157    pub file_size: u64,
158
159    /// Duration in seconds.
160    pub duration: f64,
161
162    /// Video resolution (width, height).
163    pub resolution: (u32, u32),
164
165    /// Frame rate.
166    pub frame_rate: f64,
167}
168
169impl MediaInfo {
170    /// Create media info from a file path.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the file cannot be read.
175    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
176        let path = path.as_ref();
177
178        let metadata = std::fs::metadata(path)?;
179        let file_size = metadata.len();
180
181        // Placeholder: would use oximedia-core to extract actual media info
182        Ok(Self {
183            path: path.to_path_buf(),
184            file_size,
185            duration: 0.0,
186            resolution: (0, 0),
187            frame_rate: 0.0,
188        })
189    }
190}
191
192/// Workflow plan.
193#[derive(Debug, Clone)]
194pub struct WorkflowPlan {
195    /// Total number of files.
196    pub total_files: usize,
197
198    /// Total duration in seconds.
199    pub total_duration: f64,
200
201    /// Total input size in bytes.
202    pub total_input_size: u64,
203
204    /// Estimated output size in bytes.
205    pub estimated_output_size: u64,
206
207    /// Estimated encoding time in seconds.
208    pub estimated_encoding_time: f64,
209
210    /// Space savings in bytes.
211    pub space_savings: u64,
212
213    /// Compression ratio.
214    pub compression_ratio: f64,
215
216    /// Recommended number of parallel jobs.
217    pub recommended_parallel_jobs: usize,
218}
219
220impl WorkflowPlan {
221    /// Get a human-readable summary.
222    #[must_use]
223    pub fn summary(&self) -> String {
224        format!(
225            "Workflow Plan:\n\
226             Files: {}\n\
227             Total Duration: {:.1} hours\n\
228             Input Size: {}\n\
229             Estimated Output: {}\n\
230             Space Savings: {} ({:.1}%)\n\
231             Estimated Time: {}\n\
232             Recommended Parallel Jobs: {}",
233            self.total_files,
234            self.total_duration / 3600.0,
235            format_bytes(self.total_input_size),
236            format_bytes(self.estimated_output_size),
237            format_bytes(self.space_savings),
238            (1.0 - self.compression_ratio) * 100.0,
239            format_duration(self.estimated_encoding_time),
240            self.recommended_parallel_jobs
241        )
242    }
243}
244
245/// Offline workflow plan.
246#[derive(Debug, Clone)]
247pub struct OfflineWorkflowPlan {
248    /// Generation plan.
249    pub generation_plan: WorkflowPlan,
250
251    /// Estimated editing time.
252    pub estimated_editing_time: f64,
253
254    /// Estimated conforming time.
255    pub estimated_conform_time: f64,
256
257    /// Total workflow time.
258    pub total_workflow_time: f64,
259
260    /// Workflow phases.
261    pub phases: Vec<WorkflowPhase>,
262}
263
264/// A phase in the workflow.
265#[derive(Debug, Clone)]
266pub struct WorkflowPhase {
267    /// Phase name.
268    pub name: String,
269
270    /// Estimated time in seconds.
271    pub estimated_time: f64,
272}
273
274/// Storage estimate.
275#[derive(Debug, Clone)]
276pub struct StorageEstimate {
277    /// Original files size.
278    pub original_size: u64,
279
280    /// Proxy files size.
281    pub proxy_size: u64,
282
283    /// Working storage needed.
284    pub working_storage: u64,
285
286    /// Recommended storage with buffer.
287    pub recommended_storage: u64,
288
289    /// Space saved compared to originals.
290    pub space_saved: u64,
291}
292
293fn calculate_recommended_jobs(total_files: usize) -> usize {
294    let cpu_count = std::thread::available_parallelism()
295        .map(|n| n.get())
296        .unwrap_or(1);
297
298    // Use half of CPUs for encoding, or number of files, whichever is smaller
299    (cpu_count / 2).max(1).min(total_files)
300}
301
302fn format_bytes(bytes: u64) -> String {
303    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
304    let mut size = bytes as f64;
305    let mut unit_index = 0;
306
307    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
308        size /= 1024.0;
309        unit_index += 1;
310    }
311
312    format!("{:.2} {}", size, UNITS[unit_index])
313}
314
315fn format_duration(seconds: f64) -> String {
316    let hours = (seconds / 3600.0).floor() as u64;
317    let minutes = ((seconds % 3600.0) / 60.0).floor() as u64;
318    let secs = (seconds % 60.0).floor() as u64;
319
320    if hours > 0 {
321        format!("{}h {}m {}s", hours, minutes, secs)
322    } else if minutes > 0 {
323        format!("{}m {}s", minutes, secs)
324    } else {
325        format!("{}s", secs)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_workflow_planner() {
335        let planner = WorkflowPlanner::new();
336
337        let inputs = vec![
338            MediaInfo {
339                path: "test1.mov".into(),
340                file_size: 1_000_000_000,
341                duration: 60.0,
342                resolution: (1920, 1080),
343                frame_rate: 25.0,
344            },
345            MediaInfo {
346                path: "test2.mov".into(),
347                file_size: 1_000_000_000,
348                duration: 60.0,
349                resolution: (1920, 1080),
350                frame_rate: 25.0,
351            },
352        ];
353
354        let settings = ProxyGenerationSettings::quarter_res_h264();
355        let plan = planner
356            .plan_generation(&inputs, settings)
357            .expect("should succeed in test");
358
359        assert_eq!(plan.total_files, 2);
360        assert_eq!(plan.total_duration, 120.0);
361        assert!(plan.estimated_output_size > 0);
362        assert!(plan.estimated_encoding_time > 0.0);
363    }
364
365    #[test]
366    fn test_offline_workflow_plan() {
367        let planner = WorkflowPlanner::new();
368
369        let inputs = vec![MediaInfo {
370            path: "test.mov".into(),
371            file_size: 1_000_000_000,
372            duration: 600.0,
373            resolution: (1920, 1080),
374            frame_rate: 25.0,
375        }];
376
377        let settings = ProxyGenerationSettings::quarter_res_h264();
378        let plan = planner
379            .plan_offline_workflow(&inputs, settings, 7200.0)
380            .expect("should succeed in test");
381
382        assert_eq!(plan.phases.len(), 3);
383        assert!(plan.total_workflow_time > 0.0);
384    }
385
386    #[test]
387    fn test_storage_estimate() {
388        let planner = WorkflowPlanner::new();
389
390        let inputs = vec![MediaInfo {
391            path: "test.mov".into(),
392            file_size: 1_000_000_000,
393            duration: 600.0,
394            resolution: (1920, 1080),
395            frame_rate: 25.0,
396        }];
397
398        let settings = ProxyGenerationSettings::quarter_res_h264();
399        let estimate = planner.estimate_storage(&inputs, &settings, true);
400
401        assert_eq!(estimate.original_size, 1_000_000_000);
402        assert!(estimate.proxy_size > 0);
403        assert!(estimate.working_storage > 0);
404    }
405
406    #[test]
407    fn test_format_duration() {
408        assert_eq!(format_duration(30.0), "30s");
409        assert_eq!(format_duration(90.0), "1m 30s");
410        assert_eq!(format_duration(3665.0), "1h 1m 5s");
411    }
412
413    #[test]
414    fn test_calculate_recommended_jobs() {
415        let jobs = calculate_recommended_jobs(10);
416        assert!(jobs > 0);
417        assert!(jobs <= 10);
418    }
419}