Skip to main content

vidsage_ffmpeg/
lib.rs

1//! VidSage FFmpeg - FFmpeg integration for VidSage
2
3use chrono::{Duration as ChronoDuration, Utc};
4use std::path::Path;
5use std::process::{Command, Stdio};
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::process::Command as TokioCommand;
8use tracing::{debug, info};
9use vidsage_core::video::metadata::{VideoFormat, VideoQuality};
10use vidsage_core::video::processor::CompressionLevel;
11use vidsage_core::video::VideoOutput;
12use vidsage_core::{
13    CoreError, ProcessOptions, ProcessingStatus, Result, VideoMetadata, VideoProcessor,
14};
15use which::which;
16
17/// FFmpeg processor implementation
18pub struct FFmpegProcessor {
19    ffmpeg_path: String,
20    ffprobe_path: String,
21}
22
23impl FFmpegProcessor {
24    /// Create a new FFmpegProcessor instance
25    pub fn new() -> Result<Self> {
26        // Check if FFmpeg is installed
27        let ffmpeg_path = which("ffmpeg")
28            .map_err(|e| CoreError::VideoProcessingError(format!("FFmpeg not found: {}", e)))?;
29
30        // Check if ffprobe is installed
31        let ffprobe_path = which("ffprobe")
32            .map_err(|e| CoreError::VideoProcessingError(format!("ffprobe not found: {}", e)))?;
33
34        Ok(Self {
35            ffmpeg_path: ffmpeg_path.to_string_lossy().to_string(),
36            ffprobe_path: ffprobe_path.to_string_lossy().to_string(),
37        })
38    }
39
40    /// Extract metadata from a video file using ffprobe
41    async fn extract_metadata_with_ffprobe(&self, input: &Path) -> Result<VideoMetadata> {
42        info!("Extracting metadata from: {:?}", input);
43
44        // Build ffprobe command to get metadata in JSON format
45        let output = Command::new(&self.ffprobe_path)
46            .args([
47                "-v",
48                "quiet",
49                "-print_format",
50                "json",
51                "-show_format",
52                "-show_streams",
53                input.to_str().unwrap(),
54            ])
55            .output()
56            .map_err(|e| {
57                CoreError::VideoProcessingError(format!("Failed to run ffprobe: {}", e))
58            })?;
59
60        if !output.status.success() {
61            return Err(CoreError::VideoProcessingError(
62                String::from_utf8_lossy(&output.stderr).to_string(),
63            ));
64        }
65
66        // Parse JSON output
67        let json: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
68            CoreError::VideoProcessingError(format!("Failed to parse ffprobe output: {}", e))
69        })?;
70
71        // Extract video stream information
72        let video_stream = json["streams"]
73            .as_array()
74            .ok_or_else(|| {
75                CoreError::VideoProcessingError("No streams found in video".to_string())
76            })?
77            .iter()
78            .find(|stream| stream["codec_type"].as_str() == Some("video"))
79            .ok_or_else(|| CoreError::VideoProcessingError("No video stream found".to_string()))?;
80
81        // Extract format information
82        let format = json["format"].as_object().ok_or_else(|| {
83            CoreError::VideoProcessingError("No format information found".to_string())
84        })?;
85
86        // Parse duration
87        let duration_str = format["duration"].as_str().unwrap_or("0");
88        let duration_secs = duration_str.parse::<f64>().map_err(|e| {
89            CoreError::VideoProcessingError(format!("Failed to parse duration: {}", e))
90        })?;
91        let duration = ChronoDuration::seconds(duration_secs as i64);
92
93        // Parse resolution
94        let width = video_stream["width"].as_i64().unwrap_or(0) as u32;
95        let height = video_stream["height"].as_i64().unwrap_or(0) as u32;
96
97        // Parse file size
98        let file_size = format["size"].as_i64().unwrap_or(0) as u64;
99
100        // Parse bitrate
101        let bitrate_str = format["bit_rate"].as_str().unwrap_or("0");
102        let bitrate = bitrate_str.parse::<u32>().map_err(|e| {
103            CoreError::VideoProcessingError(format!("Failed to parse bitrate: {}", e))
104        })? / 1000; // Convert to kbps
105
106        // Parse frame rate
107        let frame_rate_str = video_stream["r_frame_rate"].as_str().unwrap_or("30/1");
108        let frame_rate = parse_frame_rate(frame_rate_str).map_err(|e| {
109            CoreError::VideoProcessingError(format!("Failed to parse frame rate: {}", e))
110        })?;
111
112        // Parse video codec
113        let video_codec = video_stream["codec_name"]
114            .as_str()
115            .unwrap_or("unknown")
116            .to_string();
117
118        // Parse audio codec
119        let audio_codec = json["streams"]
120            .as_array()
121            .unwrap_or(&vec![])
122            .iter()
123            .find(|stream| stream["codec_type"].as_str() == Some("audio"))
124            .and_then(|stream| stream["codec_name"].as_str())
125            .unwrap_or("unknown")
126            .to_string();
127
128        // Parse number of audio tracks
129        let audio_tracks = json["streams"]
130            .as_array()
131            .unwrap_or(&vec![])
132            .iter()
133            .filter(|stream| stream["codec_type"].as_str() == Some("audio"))
134            .count() as u32;
135
136        // Parse audio bitrate
137        let audio_bitrate = json["streams"]
138            .as_array()
139            .unwrap_or(&vec![])
140            .iter()
141            .find(|stream| stream["codec_type"].as_str() == Some("audio"))
142            .and_then(|stream| stream["bit_rate"].as_str())
143            .unwrap_or("0")
144            .parse::<u32>()
145            .unwrap_or(0)
146            / 1000; // Convert to kbps
147
148        // Determine video format from file extension
149        let format = Path::new(input)
150            .extension()
151            .and_then(|ext| ext.to_str())
152            .map(|ext| {
153                let ext = ext.to_lowercase();
154                match ext.as_str() {
155                    "mp4" => VideoFormat::MP4,
156                    "webm" => VideoFormat::WebM,
157                    "avi" => VideoFormat::AVI,
158                    "mov" => VideoFormat::MOV,
159                    "mkv" => VideoFormat::MKV,
160                    "flv" => VideoFormat::FLV,
161                    _ => VideoFormat::Other,
162                }
163            })
164            .unwrap_or(VideoFormat::Other);
165
166        // Create VideoMetadata instance
167        let metadata = VideoMetadata {
168            id: vidsage_core::utils::helpers::generate_id(),
169            title: Path::new(input)
170                .file_stem()
171                .and_then(|stem| stem.to_str())
172                .unwrap_or("Unknown Video")
173                .to_string(),
174            duration,
175            resolution: (width, height),
176            format,
177            file_size,
178            bitrate,
179            frame_rate,
180            audio_tracks,
181            audio_bitrate,
182            video_codec,
183            audio_codec,
184            created_at: Utc::now(),
185            updated_at: Utc::now(),
186        };
187
188        debug!("Extracted metadata: {:?}", metadata);
189        Ok(metadata)
190    }
191
192    /// Build FFmpeg command for video processing
193    fn build_process_command(
194        &self,
195        input: &Path,
196        output: &Path,
197        options: &ProcessOptions,
198    ) -> Vec<String> {
199        let mut args = vec![
200            "-i".to_string(),
201            input.to_str().unwrap().to_string(),
202            "-y".to_string(), // Overwrite output file if it exists
203        ];
204
205        // Set video codec based on output format
206        match options.format {
207            VideoFormat::MP4 => args.extend(vec!["-c:v".to_string(), "libx264".to_string()]),
208            VideoFormat::WebM => args.extend(vec!["-c:v".to_string(), "libvpx-vp9".to_string()]),
209            _ => args.extend(vec!["-c:v".to_string(), "copy".to_string()]),
210        }
211
212        // Set video quality
213        match options.quality {
214            VideoQuality::Low => args.extend(vec!["-crf".to_string(), "30".to_string()]),
215            VideoQuality::Medium => args.extend(vec!["-crf".to_string(), "23".to_string()]),
216            VideoQuality::High => args.extend(vec!["-crf".to_string(), "17".to_string()]),
217            VideoQuality::Ultra => args.extend(vec!["-crf".to_string(), "12".to_string()]),
218            VideoQuality::Custom(bitrate) => args.extend(vec![
219                "-b:v".to_string(),
220                format!("{}k", bitrate).to_string(),
221            ]),
222        }
223
224        // Set compression level
225        match options.compression {
226            CompressionLevel::Low => {
227                args.extend(vec!["-preset".to_string(), "ultrafast".to_string()])
228            },
229            CompressionLevel::Medium => {
230                args.extend(vec!["-preset".to_string(), "medium".to_string()])
231            },
232            CompressionLevel::High => args.extend(vec!["-preset".to_string(), "slow".to_string()]),
233            CompressionLevel::Ultra => {
234                args.extend(vec!["-preset".to_string(), "veryslow".to_string()])
235            },
236            CompressionLevel::Custom(_) => {
237                args.extend(vec!["-preset".to_string(), "medium".to_string()])
238            },
239        }
240
241        // Set output resolution if specified
242        if let Some(resolution) = options.resolution {
243            args.extend(vec![
244                "-s".to_string(),
245                format!("{}x{}", resolution.0, resolution.1).to_string(),
246            ]);
247        }
248
249        // Set frame rate if specified
250        if let Some(frame_rate) = options.frame_rate {
251            args.extend(vec!["-r".to_string(), frame_rate.to_string()]);
252        }
253
254        // Set number of threads if specified
255        if let Some(threads) = options.threads {
256            args.extend(vec!["-threads".to_string(), threads.to_string()]);
257        }
258
259        // Add output file
260        args.push(output.to_str().unwrap().to_string());
261
262        args
263    }
264}
265
266#[async_trait::async_trait]
267impl VideoProcessor for FFmpegProcessor {
268    async fn extract_metadata(&self, input: &Path) -> Result<VideoMetadata> {
269        self.extract_metadata_with_ffprobe(input).await
270    }
271
272    async fn process_video(&self, input: &Path, options: ProcessOptions) -> Result<VideoOutput> {
273        info!("Processing video: {:?} with options: {:?}", input, options);
274
275        // Create output directory if it doesn't exist
276        let output_dir = input.parent().unwrap_or(Path::new("."));
277        std::fs::create_dir_all(output_dir).map_err(|e| {
278            CoreError::VideoProcessingError(format!("Failed to create output directory: {}", e))
279        })?;
280
281        // Generate output file path
282        let output_ext = match options.format {
283            VideoFormat::MP4 => "mp4",
284            VideoFormat::WebM => "webm",
285            VideoFormat::AVI => "avi",
286            VideoFormat::MOV => "mov",
287            VideoFormat::MKV => "mkv",
288            VideoFormat::FLV => "flv",
289            VideoFormat::Other => "mp4",
290        };
291
292        let output_file_name = format!(
293            "{}-processed.{}",
294            input.file_stem().unwrap_or_default().to_str().unwrap(),
295            output_ext
296        );
297        let output_path = output_dir.join(output_file_name);
298
299        // Build FFmpeg command
300        let args = self.build_process_command(input, &output_path, &options);
301        debug!("FFmpeg command: {} {:?}", self.ffmpeg_path, args);
302
303        // Start FFmpeg process
304        let mut cmd = TokioCommand::new(&self.ffmpeg_path)
305            .args(args)
306            .stdout(Stdio::piped())
307            .stderr(Stdio::piped())
308            .spawn()
309            .map_err(|e| {
310                CoreError::VideoProcessingError(format!("Failed to start FFmpeg: {}", e))
311            })?;
312
313        // Read stderr to track progress
314        let stderr = cmd.stderr.take().unwrap();
315        let mut reader = BufReader::new(stderr).lines();
316
317        // Wait for process to complete
318        let output = tokio::spawn(async move {
319            while let Some(line) = reader.next_line().await.unwrap() {
320                debug!("FFmpeg stderr: {}", line);
321            }
322            cmd.wait().await
323        })
324        .await
325        .unwrap()
326        .map_err(|e| CoreError::VideoProcessingError(format!("FFmpeg process failed: {}", e)))?;
327
328        if !output.success() {
329            return Err(CoreError::VideoProcessingError(format!(
330                "FFmpeg processing failed with exit code: {}",
331                output.code().unwrap_or(-1)
332            )));
333        }
334
335        // Extract metadata from processed video
336        let metadata = self.extract_metadata_with_ffprobe(&output_path).await?;
337
338        // Create VideoOutput instance
339        let output = VideoOutput {
340            metadata,
341            processed_path: output_path.clone(),
342            status: ProcessingStatus::Completed,
343            processing_time: 0.0, // TODO: Calculate actual processing time
344            original_path: input.to_path_buf(),
345            audio_path: None,         // TODO: Implement audio extraction
346            extracted_metadata: None, // Already extracted metadata
347        };
348
349        info!("Video processing completed successfully: {:?}", output_path);
350        Ok(output)
351    }
352
353    async fn get_status(&self, _job_id: &str) -> Result<ProcessingStatus> {
354        // TODO: Implement job status tracking
355        Ok(ProcessingStatus::Completed)
356    }
357
358    async fn cancel_job(&self, _job_id: &str) -> Result<bool> {
359        // TODO: Implement job cancellation
360        Ok(true)
361    }
362}
363
364/// Parse frame rate from FFprobe output
365fn parse_frame_rate(frame_rate_str: &str) -> Result<f64> {
366    if frame_rate_str.contains('/') {
367        let parts: Vec<&str> = frame_rate_str.split('/').collect();
368        if parts.len() == 2 {
369            let numerator = parts[0].parse::<f64>().map_err(|e| {
370                CoreError::VideoProcessingError(format!("Invalid frame rate: {}", e))
371            })?;
372            let denominator = parts[1].parse::<f64>().map_err(|e| {
373                CoreError::VideoProcessingError(format!("Invalid frame rate: {}", e))
374            })?;
375            if denominator == 0.0 {
376                return Ok(30.0); // Default to 30fps if denominator is 0
377            }
378            return Ok(numerator / denominator);
379        }
380    }
381
382    frame_rate_str
383        .parse::<f64>()
384        .map_err(|e| CoreError::VideoProcessingError(format!("Invalid frame rate: {}", e)))
385        .or(Ok(30.0))
386}
387
388/// Check if FFmpeg is installed
389pub fn is_ffmpeg_installed() -> bool {
390    which("ffmpeg").is_ok() && which("ffprobe").is_ok()
391}
392
393/// Get FFmpeg version
394pub fn get_ffmpeg_version() -> Option<String> {
395    let output = Command::new("ffmpeg").args(["-version"]).output().ok()?;
396
397    if output.status.success() {
398        let stdout = String::from_utf8_lossy(&output.stdout);
399        stdout.lines().next().map(|line| line.to_string())
400    } else {
401        None
402    }
403}