1use 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
17pub struct FFmpegProcessor {
19 ffmpeg_path: String,
20 ffprobe_path: String,
21}
22
23impl FFmpegProcessor {
24 pub fn new() -> Result<Self> {
26 let ffmpeg_path = which("ffmpeg")
28 .map_err(|e| CoreError::VideoProcessingError(format!("FFmpeg not found: {}", e)))?;
29
30 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 async fn extract_metadata_with_ffprobe(&self, input: &Path) -> Result<VideoMetadata> {
42 info!("Extracting metadata from: {:?}", input);
43
44 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 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 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 let format = json["format"].as_object().ok_or_else(|| {
83 CoreError::VideoProcessingError("No format information found".to_string())
84 })?;
85
86 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 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 let file_size = format["size"].as_i64().unwrap_or(0) as u64;
99
100 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; 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 let video_codec = video_stream["codec_name"]
114 .as_str()
115 .unwrap_or("unknown")
116 .to_string();
117
118 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 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 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; 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 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 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(), ];
204
205 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 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 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 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 if let Some(frame_rate) = options.frame_rate {
251 args.extend(vec!["-r".to_string(), frame_rate.to_string()]);
252 }
253
254 if let Some(threads) = options.threads {
256 args.extend(vec!["-threads".to_string(), threads.to_string()]);
257 }
258
259 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 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 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 let args = self.build_process_command(input, &output_path, &options);
301 debug!("FFmpeg command: {} {:?}", self.ffmpeg_path, args);
302
303 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 let stderr = cmd.stderr.take().unwrap();
315 let mut reader = BufReader::new(stderr).lines();
316
317 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 let metadata = self.extract_metadata_with_ffprobe(&output_path).await?;
337
338 let output = VideoOutput {
340 metadata,
341 processed_path: output_path.clone(),
342 status: ProcessingStatus::Completed,
343 processing_time: 0.0, original_path: input.to_path_buf(),
345 audio_path: None, extracted_metadata: None, };
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 Ok(ProcessingStatus::Completed)
356 }
357
358 async fn cancel_job(&self, _job_id: &str) -> Result<bool> {
359 Ok(true)
361 }
362}
363
364fn 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); }
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
388pub fn is_ffmpeg_installed() -> bool {
390 which("ffmpeg").is_ok() && which("ffprobe").is_ok()
391}
392
393pub 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}