Skip to main content

adk_rust_mcp_avtool/
handler.rs

1//! AVTool handler for audio/video processing using FFmpeg.
2//!
3//! This module provides the `AVToolHandler` struct and parameter types for
4//! FFmpeg-based media processing operations.
5
6use adk_rust_mcp_common::auth::AuthProvider;
7use adk_rust_mcp_common::config::Config;
8use adk_rust_mcp_common::error::Error;
9use adk_rust_mcp_common::gcs::{GcsClient, GcsUri};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13use std::process::Stdio;
14use tokio::process::Command;
15use tracing::{debug, info, instrument};
16use uuid::Uuid;
17
18// =============================================================================
19// Constants
20// =============================================================================
21
22/// Default audio bitrate for MP3 conversion.
23pub const DEFAULT_BITRATE: &str = "192k";
24
25/// Default FPS for GIF conversion.
26pub const DEFAULT_GIF_FPS: u8 = 10;
27
28/// Default volume multiplier.
29pub const DEFAULT_VOLUME: f32 = 1.0;
30
31// =============================================================================
32// Output Types
33// =============================================================================
34
35/// Media file information returned by ffprobe.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MediaInfo {
38    /// Duration in seconds.
39    pub duration: f64,
40    /// Container format name.
41    pub format: String,
42    /// List of streams in the file.
43    pub streams: Vec<StreamInfo>,
44}
45
46/// Information about a single stream in a media file.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct StreamInfo {
49    /// Stream index.
50    pub index: u32,
51    /// Codec type (video, audio, subtitle, etc.).
52    pub codec_type: String,
53    /// Codec name.
54    pub codec_name: String,
55    /// Video width (if video stream).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub width: Option<u32>,
58    /// Video height (if video stream).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub height: Option<u32>,
61    /// Audio sample rate (if audio stream).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub sample_rate: Option<u32>,
64    /// Number of audio channels (if audio stream).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub channels: Option<u32>,
67}
68
69// =============================================================================
70// Parameter Types
71// =============================================================================
72
73/// Parameters for getting media file information.
74#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
75pub struct GetMediaInfoParams {
76    /// Input file path (local path or GCS URI).
77    pub input: String,
78}
79
80/// Parameters for converting WAV to MP3.
81#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
82pub struct ConvertAudioParams {
83    /// Input WAV file path (local path or GCS URI).
84    pub input: String,
85    /// Output MP3 file path (local path or GCS URI).
86    pub output: String,
87    /// Audio bitrate (e.g., "128k", "192k", "320k"). Default: "192k".
88    #[serde(default = "default_bitrate")]
89    pub bitrate: String,
90}
91
92fn default_bitrate() -> String {
93    DEFAULT_BITRATE.to_string()
94}
95
96/// Parameters for converting video to GIF.
97#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
98pub struct VideoToGifParams {
99    /// Input video file path (local path or GCS URI).
100    pub input: String,
101    /// Output GIF file path (local path or GCS URI).
102    pub output: String,
103    /// Frames per second for the GIF. Default: 10.
104    #[serde(default = "default_fps")]
105    pub fps: u8,
106    /// Output width in pixels (height auto-calculated to maintain aspect ratio).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub width: Option<u32>,
109    /// Start time in seconds.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub start_time: Option<f64>,
112    /// Duration in seconds.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub duration: Option<f64>,
115}
116
117fn default_fps() -> u8 {
118    DEFAULT_GIF_FPS
119}
120
121/// Parameters for combining audio and video.
122#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
123pub struct CombineAvParams {
124    /// Input video file path (local path or GCS URI).
125    pub video_input: String,
126    /// Input audio file path (local path or GCS URI).
127    pub audio_input: String,
128    /// Output file path (local path or GCS URI).
129    pub output: String,
130}
131
132/// Parameters for overlaying an image on video.
133#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
134pub struct OverlayImageParams {
135    /// Input video file path (local path or GCS URI).
136    pub video_input: String,
137    /// Input image file path (local path or GCS URI).
138    pub image_input: String,
139    /// Output file path (local path or GCS URI).
140    pub output: String,
141    /// X position of the overlay (from left). Default: 0.
142    #[serde(default)]
143    pub x: i32,
144    /// Y position of the overlay (from top). Default: 0.
145    #[serde(default)]
146    pub y: i32,
147    /// Scale factor for the image (e.g., 0.5 for half size).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub scale: Option<f32>,
150    /// Start time in seconds when overlay appears.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub start_time: Option<f64>,
153    /// Duration in seconds for the overlay.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub duration: Option<f64>,
156}
157
158/// Parameters for concatenating media files.
159#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
160pub struct ConcatenateParams {
161    /// List of input file paths (local paths or GCS URIs).
162    pub inputs: Vec<String>,
163    /// Output file path (local path or GCS URI).
164    pub output: String,
165}
166
167/// Parameters for adjusting audio volume.
168#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
169pub struct AdjustVolumeParams {
170    /// Input audio file path (local path or GCS URI).
171    pub input: String,
172    /// Output audio file path (local path or GCS URI).
173    pub output: String,
174    /// Volume adjustment: numeric multiplier (e.g., "0.5", "2.0") or dB string (e.g., "-3dB", "+6dB").
175    pub volume: String,
176}
177
178/// Parameters for layering multiple audio files.
179#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
180pub struct LayerAudioParams {
181    /// List of audio layers to mix.
182    pub inputs: Vec<AudioLayer>,
183    /// Output file path (local path or GCS URI).
184    pub output: String,
185}
186
187/// A single audio layer for mixing.
188#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
189pub struct AudioLayer {
190    /// Input audio file path (local path or GCS URI).
191    pub path: String,
192    /// Offset in seconds from the start. Default: 0.0.
193    #[serde(default)]
194    pub offset_seconds: f64,
195    /// Volume multiplier for this layer. Default: 1.0.
196    #[serde(default = "default_volume")]
197    pub volume: f32,
198}
199
200fn default_volume() -> f32 {
201    DEFAULT_VOLUME
202}
203
204// =============================================================================
205// Validation
206// =============================================================================
207
208/// Validation error details.
209#[derive(Debug, Clone)]
210pub struct ValidationError {
211    /// The field that failed validation.
212    pub field: String,
213    /// Description of the validation failure.
214    pub message: String,
215}
216
217impl std::fmt::Display for ValidationError {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(f, "{}: {}", self.field, self.message)
220    }
221}
222
223/// Parsed volume value.
224#[derive(Debug, Clone, PartialEq)]
225pub enum VolumeValue {
226    /// Numeric multiplier (e.g., 0.5, 2.0).
227    Multiplier(f64),
228    /// Decibel adjustment (e.g., -3.0, +6.0).
229    Decibels(f64),
230}
231
232impl VolumeValue {
233    /// Parse a volume string into a VolumeValue.
234    ///
235    /// Accepts:
236    /// - Numeric multipliers: "0.5", "2.0", "1"
237    /// - Decibel strings: "-3dB", "+6dB", "0dB"
238    pub fn parse(s: &str) -> Result<Self, String> {
239        let s = s.trim();
240        
241        if s.is_empty() {
242            return Err("Volume string cannot be empty".to_string());
243        }
244        
245        // Check for dB suffix (case-insensitive)
246        let lower = s.to_lowercase();
247        if lower.ends_with("db") {
248            let num_part = &s[..s.len() - 2].trim();
249            let db_value: f64 = num_part.parse().map_err(|_| {
250                format!("Invalid dB value '{}'. Expected format: '-3dB', '+6dB'", s)
251            })?;
252            return Ok(VolumeValue::Decibels(db_value));
253        }
254        
255        // Try to parse as numeric multiplier
256        let multiplier: f64 = s.parse().map_err(|_| {
257            format!(
258                "Invalid volume '{}'. Expected numeric multiplier (e.g., '0.5', '2.0') or dB string (e.g., '-3dB', '+6dB')",
259                s
260            )
261        })?;
262        
263        if multiplier < 0.0 {
264            return Err(format!(
265                "Volume multiplier cannot be negative: {}. Use dB notation for attenuation (e.g., '-3dB')",
266                multiplier
267            ));
268        }
269        
270        Ok(VolumeValue::Multiplier(multiplier))
271    }
272    
273    /// Convert to FFmpeg volume filter value.
274    pub fn to_ffmpeg_value(&self) -> String {
275        match self {
276            VolumeValue::Multiplier(m) => format!("{}", m),
277            VolumeValue::Decibels(db) => format!("{}dB", db),
278        }
279    }
280}
281
282impl AdjustVolumeParams {
283    /// Validate the volume parameter.
284    pub fn validate(&self) -> Result<VolumeValue, Vec<ValidationError>> {
285        let mut errors = Vec::new();
286        
287        if self.input.trim().is_empty() {
288            errors.push(ValidationError {
289                field: "input".to_string(),
290                message: "Input path cannot be empty".to_string(),
291            });
292        }
293        
294        if self.output.trim().is_empty() {
295            errors.push(ValidationError {
296                field: "output".to_string(),
297                message: "Output path cannot be empty".to_string(),
298            });
299        }
300        
301        let volume = match VolumeValue::parse(&self.volume) {
302            Ok(v) => Some(v),
303            Err(e) => {
304                errors.push(ValidationError {
305                    field: "volume".to_string(),
306                    message: e,
307                });
308                None
309            }
310        };
311        
312        if errors.is_empty() {
313            Ok(volume.unwrap())
314        } else {
315            Err(errors)
316        }
317    }
318}
319
320
321// =============================================================================
322// AVToolHandler
323// =============================================================================
324
325/// AVTool handler for FFmpeg-based media processing.
326pub struct AVToolHandler {
327    /// Application configuration.
328    pub config: Config,
329    /// GCS client for storage operations.
330    pub gcs: GcsClient,
331    /// Temporary directory for downloaded files.
332    temp_dir: PathBuf,
333}
334
335impl AVToolHandler {
336    /// Create a new AVToolHandler with the given configuration.
337    ///
338    /// # Errors
339    /// Returns an error if GCS client initialization fails.
340    #[instrument(level = "debug", name = "avtool_handler_new", skip_all)]
341    pub async fn new(config: Config) -> Result<Self, Error> {
342        debug!("Initializing AVToolHandler");
343
344        let auth = AuthProvider::new().await?;
345        let gcs = GcsClient::with_auth(auth);
346        
347        // Create temp directory for downloaded files
348        let temp_dir = std::env::temp_dir().join("adk-rust-mcp-avtool");
349        tokio::fs::create_dir_all(&temp_dir).await?;
350
351        Ok(Self {
352            config,
353            gcs,
354            temp_dir,
355        })
356    }
357
358    /// Create a new AVToolHandler with provided dependencies (for testing).
359    #[cfg(test)]
360    pub fn with_deps(config: Config, gcs: GcsClient, temp_dir: PathBuf) -> Self {
361        Self {
362            config,
363            gcs,
364            temp_dir,
365        }
366    }
367
368    // =========================================================================
369    // Path Resolution Helpers
370    // =========================================================================
371
372    /// Check if a path is a GCS URI.
373    pub fn is_gcs_uri(path: &str) -> bool {
374        path.starts_with("gs://")
375    }
376
377    /// Resolve an input path, downloading from GCS if necessary.
378    ///
379    /// Returns the local path to use for FFmpeg operations.
380    #[instrument(level = "debug", skip(self))]
381    pub async fn resolve_input(&self, path: &str) -> Result<PathBuf, Error> {
382        if Self::is_gcs_uri(path) {
383            // Download from GCS to temp file
384            let gcs_uri = GcsUri::parse(path)?;
385            let filename = Path::new(&gcs_uri.object)
386                .file_name()
387                .and_then(|n| n.to_str())
388                .unwrap_or("input");
389            
390            let local_path = self.temp_dir.join(format!("{}_{}", Uuid::new_v4(), filename));
391            
392            debug!(gcs_uri = %path, local_path = %local_path.display(), "Downloading from GCS");
393            let data = self.gcs.download(&gcs_uri).await?;
394            tokio::fs::write(&local_path, &data).await?;
395            
396            Ok(local_path)
397        } else {
398            // Local path, use as-is
399            Ok(PathBuf::from(path))
400        }
401    }
402
403    /// Handle output, uploading to GCS if the output path is a GCS URI.
404    ///
405    /// Returns the final output path (GCS URI or local path).
406    #[instrument(level = "debug", skip(self))]
407    pub async fn handle_output(&self, local_path: &Path, output: &str) -> Result<String, Error> {
408        if Self::is_gcs_uri(output) {
409            // Upload to GCS
410            let gcs_uri = GcsUri::parse(output)?;
411            let data = tokio::fs::read(local_path).await?;
412            
413            // Determine content type from extension
414            let content_type = Self::content_type_from_extension(local_path);
415            
416            debug!(local_path = %local_path.display(), gcs_uri = %output, "Uploading to GCS");
417            self.gcs.upload(&gcs_uri, &data, content_type).await?;
418            
419            Ok(output.to_string())
420        } else {
421            // Local path - if different from local_path, copy the file
422            if local_path != Path::new(output) {
423                tokio::fs::copy(local_path, output).await?;
424            }
425            Ok(output.to_string())
426        }
427    }
428
429    /// Get content type from file extension.
430    fn content_type_from_extension(path: &Path) -> &'static str {
431        match path.extension().and_then(|e| e.to_str()) {
432            Some("mp3") => "audio/mpeg",
433            Some("wav") => "audio/wav",
434            Some("mp4") => "video/mp4",
435            Some("webm") => "video/webm",
436            Some("gif") => "image/gif",
437            Some("png") => "image/png",
438            Some("jpg") | Some("jpeg") => "image/jpeg",
439            Some("mkv") => "video/x-matroska",
440            Some("avi") => "video/x-msvideo",
441            Some("mov") => "video/quicktime",
442            Some("ogg") => "audio/ogg",
443            Some("flac") => "audio/flac",
444            _ => "application/octet-stream",
445        }
446    }
447
448    /// Generate a temporary output path.
449    fn temp_output_path(&self, extension: &str) -> PathBuf {
450        self.temp_dir.join(format!("{}.{}", Uuid::new_v4(), extension))
451    }
452
453    // =========================================================================
454    // FFmpeg/FFprobe Execution
455    // =========================================================================
456
457    /// Execute ffprobe and return parsed JSON output.
458    async fn run_ffprobe(&self, input: &Path) -> Result<serde_json::Value, Error> {
459        let output = Command::new("ffprobe")
460            .args([
461                "-v", "quiet",
462                "-print_format", "json",
463                "-show_format",
464                "-show_streams",
465            ])
466            .arg(input)
467            .stdout(Stdio::piped())
468            .stderr(Stdio::piped())
469            .output()
470            .await?;
471
472        if !output.status.success() {
473            let stderr = String::from_utf8_lossy(&output.stderr);
474            return Err(Error::ffmpeg(format!(
475                "ffprobe failed for '{}': {}",
476                input.display(),
477                stderr
478            )));
479        }
480
481        let json: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
482            Error::ffmpeg(format!("Failed to parse ffprobe output: {}", e))
483        })?;
484
485        Ok(json)
486    }
487
488    /// Execute ffmpeg with the given arguments.
489    async fn run_ffmpeg(&self, args: &[&str]) -> Result<(), Error> {
490        debug!(args = ?args, "Running ffmpeg");
491        
492        let output = Command::new("ffmpeg")
493            .args(["-y"]) // Overwrite output files
494            .args(args)
495            .stdout(Stdio::piped())
496            .stderr(Stdio::piped())
497            .output()
498            .await?;
499
500        if !output.status.success() {
501            let stderr = String::from_utf8_lossy(&output.stderr);
502            return Err(Error::ffmpeg(format!("ffmpeg failed: {}", stderr)));
503        }
504
505        Ok(())
506    }
507
508    // =========================================================================
509    // Tool Implementations
510    // =========================================================================
511
512    /// Get media file information using ffprobe.
513    #[instrument(level = "info", skip(self))]
514    pub async fn get_media_info(&self, params: GetMediaInfoParams) -> Result<MediaInfo, Error> {
515        let local_input = self.resolve_input(&params.input).await?;
516        
517        let json = self.run_ffprobe(&local_input).await?;
518        
519        // Parse format info
520        let format = json.get("format").ok_or_else(|| {
521            Error::ffmpeg("ffprobe output missing 'format' field")
522        })?;
523        
524        let duration: f64 = format
525            .get("duration")
526            .and_then(|d| d.as_str())
527            .and_then(|s| s.parse().ok())
528            .unwrap_or(0.0);
529        
530        let format_name = format
531            .get("format_name")
532            .and_then(|f| f.as_str())
533            .unwrap_or("unknown")
534            .to_string();
535        
536        // Parse streams
537        let streams_json = json.get("streams").and_then(|s| s.as_array());
538        let streams: Vec<StreamInfo> = streams_json
539            .map(|arr| {
540                arr.iter()
541                    .map(|s| StreamInfo {
542                        index: s.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32,
543                        codec_type: s.get("codec_type").and_then(|c| c.as_str()).unwrap_or("unknown").to_string(),
544                        codec_name: s.get("codec_name").and_then(|c| c.as_str()).unwrap_or("unknown").to_string(),
545                        width: s.get("width").and_then(|w| w.as_u64()).map(|w| w as u32),
546                        height: s.get("height").and_then(|h| h.as_u64()).map(|h| h as u32),
547                        sample_rate: s.get("sample_rate").and_then(|r| r.as_str()).and_then(|s| s.parse().ok()),
548                        channels: s.get("channels").and_then(|c| c.as_u64()).map(|c| c as u32),
549                    })
550                    .collect()
551            })
552            .unwrap_or_default();
553        
554        // Clean up temp file if we downloaded from GCS
555        if Self::is_gcs_uri(&params.input) {
556            let _ = tokio::fs::remove_file(&local_input).await;
557        }
558        
559        info!(duration, format = %format_name, streams = streams.len(), "Got media info");
560        
561        Ok(MediaInfo {
562            duration,
563            format: format_name,
564            streams,
565        })
566    }
567
568    /// Convert WAV to MP3.
569    #[instrument(level = "info", skip(self))]
570    pub async fn convert_wav_to_mp3(&self, params: ConvertAudioParams) -> Result<String, Error> {
571        let local_input = self.resolve_input(&params.input).await?;
572        let temp_output = self.temp_output_path("mp3");
573        
574        let input_str = local_input.to_string_lossy();
575        let output_str = temp_output.to_string_lossy();
576        
577        self.run_ffmpeg(&[
578            "-i", &input_str,
579            "-codec:a", "libmp3lame",
580            "-b:a", &params.bitrate,
581            &output_str,
582        ]).await?;
583        
584        let result = self.handle_output(&temp_output, &params.output).await?;
585        
586        // Clean up temp files
587        if Self::is_gcs_uri(&params.input) {
588            let _ = tokio::fs::remove_file(&local_input).await;
589        }
590        let _ = tokio::fs::remove_file(&temp_output).await;
591        
592        info!(output = %result, "Converted WAV to MP3");
593        Ok(result)
594    }
595
596    /// Convert video to GIF.
597    #[instrument(level = "info", skip(self))]
598    pub async fn video_to_gif(&self, params: VideoToGifParams) -> Result<String, Error> {
599        let local_input = self.resolve_input(&params.input).await?;
600        let temp_output = self.temp_output_path("gif");
601        
602        let input_str = local_input.to_string_lossy();
603        let output_str = temp_output.to_string_lossy();
604        
605        // Build filter string
606        let mut filters = vec![format!("fps={}", params.fps)];
607        if let Some(width) = params.width {
608            filters.push(format!("scale={}:-1:flags=lanczos", width));
609        }
610        let filter_str = filters.join(",");
611        
612        let mut args: Vec<String> = Vec::new();
613        
614        // Add start time if specified
615        if let Some(start) = params.start_time {
616            args.push("-ss".to_string());
617            args.push(format!("{}", start));
618        }
619        
620        args.push("-i".to_string());
621        args.push(input_str.to_string());
622        
623        // Add duration if specified
624        if let Some(duration) = params.duration {
625            args.push("-t".to_string());
626            args.push(format!("{}", duration));
627        }
628        
629        args.push("-vf".to_string());
630        args.push(filter_str);
631        args.push(output_str.to_string());
632        
633        let args_refs: Vec<&str> = args.iter().map(String::as_str).collect();
634        self.run_ffmpeg(&args_refs).await?;
635        
636        let result = self.handle_output(&temp_output, &params.output).await?;
637        
638        // Clean up temp files
639        if Self::is_gcs_uri(&params.input) {
640            let _ = tokio::fs::remove_file(&local_input).await;
641        }
642        let _ = tokio::fs::remove_file(&temp_output).await;
643        
644        info!(output = %result, "Converted video to GIF");
645        Ok(result)
646    }
647
648    /// Combine audio and video.
649    #[instrument(level = "info", skip(self))]
650    pub async fn combine_audio_video(&self, params: CombineAvParams) -> Result<String, Error> {
651        let local_video = self.resolve_input(&params.video_input).await?;
652        let local_audio = self.resolve_input(&params.audio_input).await?;
653        
654        // Determine output extension from output path
655        let ext = Path::new(&params.output)
656            .extension()
657            .and_then(|e| e.to_str())
658            .unwrap_or("mp4");
659        let temp_output = self.temp_output_path(ext);
660        
661        let video_str = local_video.to_string_lossy();
662        let audio_str = local_audio.to_string_lossy();
663        let output_str = temp_output.to_string_lossy();
664        
665        self.run_ffmpeg(&[
666            "-i", &video_str,
667            "-i", &audio_str,
668            "-c:v", "copy",
669            "-c:a", "aac",
670            "-map", "0:v:0",
671            "-map", "1:a:0",
672            "-shortest",
673            &output_str,
674        ]).await?;
675        
676        let result = self.handle_output(&temp_output, &params.output).await?;
677        
678        // Clean up temp files
679        if Self::is_gcs_uri(&params.video_input) {
680            let _ = tokio::fs::remove_file(&local_video).await;
681        }
682        if Self::is_gcs_uri(&params.audio_input) {
683            let _ = tokio::fs::remove_file(&local_audio).await;
684        }
685        let _ = tokio::fs::remove_file(&temp_output).await;
686        
687        info!(output = %result, "Combined audio and video");
688        Ok(result)
689    }
690
691    /// Overlay image on video.
692    #[instrument(level = "info", skip(self))]
693    pub async fn overlay_image(&self, params: OverlayImageParams) -> Result<String, Error> {
694        let local_video = self.resolve_input(&params.video_input).await?;
695        let local_image = self.resolve_input(&params.image_input).await?;
696        
697        let ext = Path::new(&params.output)
698            .extension()
699            .and_then(|e| e.to_str())
700            .unwrap_or("mp4");
701        let temp_output = self.temp_output_path(ext);
702        
703        let video_str = local_video.to_string_lossy();
704        let image_str = local_image.to_string_lossy();
705        let output_str = temp_output.to_string_lossy();
706        
707        // Build filter complex
708        let mut filter_parts = Vec::new();
709        
710        // Scale image if specified
711        if let Some(scale) = params.scale {
712            filter_parts.push(format!("[1:v]scale=iw*{}:ih*{}[img]", scale, scale));
713        }
714        
715        // Build overlay filter with position and timing
716        let img_ref = if params.scale.is_some() { "[img]" } else { "[1:v]" };
717        let mut overlay = format!("[0:v]{}overlay={}:{}", img_ref, params.x, params.y);
718        
719        // Add enable expression for timing
720        if params.start_time.is_some() || params.duration.is_some() {
721            let start = params.start_time.unwrap_or(0.0);
722            let enable = if let Some(dur) = params.duration {
723                format!(":enable='between(t,{},{})'", start, start + dur)
724            } else {
725                format!(":enable='gte(t,{})'", start)
726            };
727            overlay.push_str(&enable);
728        }
729        
730        filter_parts.push(overlay);
731        let filter_complex = filter_parts.join(";");
732        
733        self.run_ffmpeg(&[
734            "-i", &video_str,
735            "-i", &image_str,
736            "-filter_complex", &filter_complex,
737            "-c:a", "copy",
738            &output_str,
739        ]).await?;
740        
741        let result = self.handle_output(&temp_output, &params.output).await?;
742        
743        // Clean up temp files
744        if Self::is_gcs_uri(&params.video_input) {
745            let _ = tokio::fs::remove_file(&local_video).await;
746        }
747        if Self::is_gcs_uri(&params.image_input) {
748            let _ = tokio::fs::remove_file(&local_image).await;
749        }
750        let _ = tokio::fs::remove_file(&temp_output).await;
751        
752        info!(output = %result, "Overlaid image on video");
753        Ok(result)
754    }
755
756    /// Concatenate media files.
757    #[instrument(level = "info", skip(self))]
758    pub async fn concatenate(&self, params: ConcatenateParams) -> Result<String, Error> {
759        if params.inputs.is_empty() {
760            return Err(Error::validation("At least one input file is required"));
761        }
762        
763        // Resolve all inputs
764        let mut local_inputs = Vec::new();
765        for input in &params.inputs {
766            local_inputs.push(self.resolve_input(input).await?);
767        }
768        
769        let ext = Path::new(&params.output)
770            .extension()
771            .and_then(|e| e.to_str())
772            .unwrap_or("mp4");
773        let temp_output = self.temp_output_path(ext);
774        
775        // Create concat file list
776        let concat_file = self.temp_dir.join(format!("{}_concat.txt", Uuid::new_v4()));
777        let concat_content: String = local_inputs
778            .iter()
779            .map(|p| format!("file '{}'\n", p.display()))
780            .collect();
781        tokio::fs::write(&concat_file, &concat_content).await?;
782        
783        let concat_str = concat_file.to_string_lossy();
784        let output_str = temp_output.to_string_lossy();
785        
786        self.run_ffmpeg(&[
787            "-f", "concat",
788            "-safe", "0",
789            "-i", &concat_str,
790            "-c", "copy",
791            &output_str,
792        ]).await?;
793        
794        let result = self.handle_output(&temp_output, &params.output).await?;
795        
796        // Clean up temp files
797        for (i, input) in params.inputs.iter().enumerate() {
798            if Self::is_gcs_uri(input) {
799                let _ = tokio::fs::remove_file(&local_inputs[i]).await;
800            }
801        }
802        let _ = tokio::fs::remove_file(&concat_file).await;
803        let _ = tokio::fs::remove_file(&temp_output).await;
804        
805        info!(output = %result, count = params.inputs.len(), "Concatenated media files");
806        Ok(result)
807    }
808
809    /// Adjust audio volume.
810    #[instrument(level = "info", skip(self))]
811    pub async fn adjust_volume(&self, params: AdjustVolumeParams) -> Result<String, Error> {
812        // Validate and parse volume
813        let volume = params.validate().map_err(|errors| {
814            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
815            Error::validation(messages.join("; "))
816        })?;
817        
818        let local_input = self.resolve_input(&params.input).await?;
819        
820        let ext = Path::new(&params.output)
821            .extension()
822            .and_then(|e| e.to_str())
823            .unwrap_or("wav");
824        let temp_output = self.temp_output_path(ext);
825        
826        let input_str = local_input.to_string_lossy();
827        let output_str = temp_output.to_string_lossy();
828        let volume_filter = format!("volume={}", volume.to_ffmpeg_value());
829        
830        self.run_ffmpeg(&[
831            "-i", &input_str,
832            "-af", &volume_filter,
833            &output_str,
834        ]).await?;
835        
836        let result = self.handle_output(&temp_output, &params.output).await?;
837        
838        // Clean up temp files
839        if Self::is_gcs_uri(&params.input) {
840            let _ = tokio::fs::remove_file(&local_input).await;
841        }
842        let _ = tokio::fs::remove_file(&temp_output).await;
843        
844        info!(output = %result, volume = ?volume, "Adjusted audio volume");
845        Ok(result)
846    }
847
848    /// Layer multiple audio files.
849    #[instrument(level = "info", skip(self))]
850    pub async fn layer_audio(&self, params: LayerAudioParams) -> Result<String, Error> {
851        if params.inputs.is_empty() {
852            return Err(Error::validation("At least one audio layer is required"));
853        }
854        
855        // Resolve all inputs
856        let mut local_inputs = Vec::new();
857        for layer in &params.inputs {
858            local_inputs.push(self.resolve_input(&layer.path).await?);
859        }
860        
861        let ext = Path::new(&params.output)
862            .extension()
863            .and_then(|e| e.to_str())
864            .unwrap_or("wav");
865        let temp_output = self.temp_output_path(ext);
866        
867        // Build ffmpeg command with amix filter
868        let mut args = Vec::new();
869        
870        // Add all inputs
871        for local_input in &local_inputs {
872            args.push("-i".to_string());
873            args.push(local_input.to_string_lossy().to_string());
874        }
875        
876        // Build filter complex for mixing with delays and volumes
877        let mut filter_parts = Vec::new();
878        let mut mix_inputs = Vec::new();
879        
880        for (i, layer) in params.inputs.iter().enumerate() {
881            let label = format!("a{}", i);
882            let mut filter = format!("[{}:a]", i);
883            
884            // Add delay if offset > 0
885            if layer.offset_seconds > 0.0 {
886                let delay_ms = (layer.offset_seconds * 1000.0) as i64;
887                filter.push_str(&format!("adelay={}|{}", delay_ms, delay_ms));
888                if layer.volume != 1.0 {
889                    filter.push_str(&format!(",volume={}", layer.volume));
890                }
891            } else if layer.volume != 1.0 {
892                filter.push_str(&format!("volume={}", layer.volume));
893            } else {
894                filter.push_str("anull");
895            }
896            
897            filter.push_str(&format!("[{}]", label));
898            filter_parts.push(filter);
899            mix_inputs.push(format!("[{}]", label));
900        }
901        
902        // Add amix filter
903        let mix_filter = format!(
904            "{}amix=inputs={}:duration=longest",
905            mix_inputs.join(""),
906            params.inputs.len()
907        );
908        filter_parts.push(mix_filter);
909        
910        let filter_complex = filter_parts.join(";");
911        
912        args.extend([
913            "-filter_complex".to_string(),
914            filter_complex,
915            temp_output.to_string_lossy().to_string(),
916        ]);
917        
918        let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
919        self.run_ffmpeg(&args_refs).await?;
920        
921        let result = self.handle_output(&temp_output, &params.output).await?;
922        
923        // Clean up temp files
924        for (i, layer) in params.inputs.iter().enumerate() {
925            if Self::is_gcs_uri(&layer.path) {
926                let _ = tokio::fs::remove_file(&local_inputs[i]).await;
927            }
928        }
929        let _ = tokio::fs::remove_file(&temp_output).await;
930        
931        info!(output = %result, layers = params.inputs.len(), "Layered audio files");
932        Ok(result)
933    }
934}
935
936
937// =============================================================================
938// Unit Tests
939// =============================================================================
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944
945    // =========================================================================
946    // FFmpeg Error Handling Tests (Requirements 9.19, 9.20)
947    // =========================================================================
948
949    #[test]
950    fn test_ffmpeg_error_contains_stderr_output() {
951        // Verify that FFmpeg errors include the stderr output for debugging
952        let stderr_output = "Invalid input file: file not found";
953        let err = Error::ffmpeg(format!("ffmpeg failed: {}", stderr_output));
954        let msg = err.to_string();
955        
956        assert!(msg.contains("FFmpeg"), "Error should mention FFmpeg");
957        assert!(msg.contains("Invalid input file"), "Error should contain stderr output");
958    }
959
960    #[test]
961    fn test_ffprobe_error_contains_file_path() {
962        // Verify that FFprobe errors include the file path for context
963        let file_path = "/path/to/nonexistent.mp4";
964        let err = Error::ffmpeg(format!("ffprobe failed for '{}': No such file or directory", file_path));
965        let msg = err.to_string();
966        
967        assert!(msg.contains("ffprobe"), "Error should mention ffprobe");
968        assert!(msg.contains(file_path), "Error should contain file path");
969    }
970
971    #[test]
972    fn test_ffmpeg_error_preserves_codec_errors() {
973        // Verify that codec-related errors are preserved
974        let codec_error = "Unknown encoder 'libx265'";
975        let err = Error::ffmpeg(format!("ffmpeg failed: {}", codec_error));
976        let msg = err.to_string();
977        
978        assert!(msg.contains("libx265"), "Error should preserve codec name");
979        assert!(msg.contains("Unknown encoder"), "Error should preserve error type");
980    }
981
982    #[test]
983    fn test_ffmpeg_error_preserves_format_errors() {
984        // Verify that format-related errors are preserved
985        let format_error = "Invalid data found when processing input";
986        let err = Error::ffmpeg(format!("ffmpeg failed: {}", format_error));
987        let msg = err.to_string();
988        
989        assert!(msg.contains("Invalid data"), "Error should preserve format error");
990    }
991
992    // =========================================================================
993    // Media Info Extraction Tests (Requirement 9.11)
994    // =========================================================================
995
996    #[test]
997    fn test_media_info_parsing_video_stream() {
998        // Test parsing of video stream information
999        let stream = StreamInfo {
1000            index: 0,
1001            codec_type: "video".to_string(),
1002            codec_name: "h264".to_string(),
1003            width: Some(1920),
1004            height: Some(1080),
1005            sample_rate: None,
1006            channels: None,
1007        };
1008        
1009        assert_eq!(stream.codec_type, "video");
1010        assert_eq!(stream.codec_name, "h264");
1011        assert_eq!(stream.width, Some(1920));
1012        assert_eq!(stream.height, Some(1080));
1013        assert!(stream.sample_rate.is_none());
1014        assert!(stream.channels.is_none());
1015    }
1016
1017    #[test]
1018    fn test_media_info_parsing_audio_stream() {
1019        // Test parsing of audio stream information
1020        let stream = StreamInfo {
1021            index: 1,
1022            codec_type: "audio".to_string(),
1023            codec_name: "aac".to_string(),
1024            width: None,
1025            height: None,
1026            sample_rate: Some(48000),
1027            channels: Some(2),
1028        };
1029        
1030        assert_eq!(stream.codec_type, "audio");
1031        assert_eq!(stream.codec_name, "aac");
1032        assert!(stream.width.is_none());
1033        assert!(stream.height.is_none());
1034        assert_eq!(stream.sample_rate, Some(48000));
1035        assert_eq!(stream.channels, Some(2));
1036    }
1037
1038    #[test]
1039    fn test_media_info_complete_structure() {
1040        // Test complete MediaInfo structure with multiple streams
1041        let info = MediaInfo {
1042            duration: 120.5,
1043            format: "matroska,webm".to_string(),
1044            streams: vec![
1045                StreamInfo {
1046                    index: 0,
1047                    codec_type: "video".to_string(),
1048                    codec_name: "vp9".to_string(),
1049                    width: Some(3840),
1050                    height: Some(2160),
1051                    sample_rate: None,
1052                    channels: None,
1053                },
1054                StreamInfo {
1055                    index: 1,
1056                    codec_type: "audio".to_string(),
1057                    codec_name: "opus".to_string(),
1058                    width: None,
1059                    height: None,
1060                    sample_rate: Some(48000),
1061                    channels: Some(6),
1062                },
1063                StreamInfo {
1064                    index: 2,
1065                    codec_type: "subtitle".to_string(),
1066                    codec_name: "subrip".to_string(),
1067                    width: None,
1068                    height: None,
1069                    sample_rate: None,
1070                    channels: None,
1071                },
1072            ],
1073        };
1074        
1075        assert_eq!(info.duration, 120.5);
1076        assert_eq!(info.format, "matroska,webm");
1077        assert_eq!(info.streams.len(), 3);
1078        
1079        // Verify video stream
1080        assert_eq!(info.streams[0].codec_type, "video");
1081        assert_eq!(info.streams[0].width, Some(3840));
1082        
1083        // Verify audio stream
1084        assert_eq!(info.streams[1].codec_type, "audio");
1085        assert_eq!(info.streams[1].channels, Some(6));
1086        
1087        // Verify subtitle stream
1088        assert_eq!(info.streams[2].codec_type, "subtitle");
1089    }
1090
1091    #[test]
1092    fn test_media_info_json_output_format() {
1093        // Test that MediaInfo serializes to proper JSON format
1094        let info = MediaInfo {
1095            duration: 60.0,
1096            format: "mp4".to_string(),
1097            streams: vec![
1098                StreamInfo {
1099                    index: 0,
1100                    codec_type: "video".to_string(),
1101                    codec_name: "h264".to_string(),
1102                    width: Some(1280),
1103                    height: Some(720),
1104                    sample_rate: None,
1105                    channels: None,
1106                },
1107            ],
1108        };
1109        
1110        let json = serde_json::to_value(&info).unwrap();
1111        
1112        // Verify JSON structure
1113        assert!(json.is_object());
1114        assert!(json["duration"].is_f64());
1115        assert!(json["format"].is_string());
1116        assert!(json["streams"].is_array());
1117        
1118        // Verify values
1119        assert_eq!(json["duration"].as_f64().unwrap(), 60.0);
1120        assert_eq!(json["format"].as_str().unwrap(), "mp4");
1121        assert_eq!(json["streams"].as_array().unwrap().len(), 1);
1122    }
1123
1124    #[test]
1125    fn test_media_info_empty_streams() {
1126        // Test MediaInfo with no streams (edge case)
1127        let info = MediaInfo {
1128            duration: 0.0,
1129            format: "unknown".to_string(),
1130            streams: vec![],
1131        };
1132        
1133        let json = serde_json::to_string(&info).unwrap();
1134        let parsed: MediaInfo = serde_json::from_str(&json).unwrap();
1135        
1136        assert_eq!(parsed.duration, 0.0);
1137        assert_eq!(parsed.format, "unknown");
1138        assert!(parsed.streams.is_empty());
1139    }
1140
1141    // =========================================================================
1142    // VolumeValue Tests
1143    // =========================================================================
1144
1145    #[test]
1146    fn test_volume_parse_multiplier() {
1147        assert_eq!(VolumeValue::parse("0.5").unwrap(), VolumeValue::Multiplier(0.5));
1148        assert_eq!(VolumeValue::parse("1.0").unwrap(), VolumeValue::Multiplier(1.0));
1149        assert_eq!(VolumeValue::parse("2.0").unwrap(), VolumeValue::Multiplier(2.0));
1150        assert_eq!(VolumeValue::parse("1").unwrap(), VolumeValue::Multiplier(1.0));
1151        assert_eq!(VolumeValue::parse("0").unwrap(), VolumeValue::Multiplier(0.0));
1152    }
1153
1154    #[test]
1155    fn test_volume_parse_decibels() {
1156        assert_eq!(VolumeValue::parse("-3dB").unwrap(), VolumeValue::Decibels(-3.0));
1157        assert_eq!(VolumeValue::parse("+6dB").unwrap(), VolumeValue::Decibels(6.0));
1158        assert_eq!(VolumeValue::parse("0dB").unwrap(), VolumeValue::Decibels(0.0));
1159        assert_eq!(VolumeValue::parse("-10.5dB").unwrap(), VolumeValue::Decibels(-10.5));
1160        // Case insensitive
1161        assert_eq!(VolumeValue::parse("-3DB").unwrap(), VolumeValue::Decibels(-3.0));
1162        assert_eq!(VolumeValue::parse("-3db").unwrap(), VolumeValue::Decibels(-3.0));
1163    }
1164
1165    #[test]
1166    fn test_volume_parse_with_whitespace() {
1167        assert_eq!(VolumeValue::parse("  0.5  ").unwrap(), VolumeValue::Multiplier(0.5));
1168        assert_eq!(VolumeValue::parse("  -3dB  ").unwrap(), VolumeValue::Decibels(-3.0));
1169    }
1170
1171    #[test]
1172    fn test_volume_parse_invalid() {
1173        assert!(VolumeValue::parse("").is_err());
1174        assert!(VolumeValue::parse("abc").is_err());
1175        assert!(VolumeValue::parse("dB").is_err());
1176        assert!(VolumeValue::parse("-3").is_err()); // Negative multiplier not allowed
1177    }
1178
1179    #[test]
1180    fn test_volume_to_ffmpeg_value() {
1181        assert_eq!(VolumeValue::Multiplier(0.5).to_ffmpeg_value(), "0.5");
1182        assert_eq!(VolumeValue::Multiplier(2.0).to_ffmpeg_value(), "2");
1183        assert_eq!(VolumeValue::Decibels(-3.0).to_ffmpeg_value(), "-3dB");
1184        assert_eq!(VolumeValue::Decibels(6.0).to_ffmpeg_value(), "6dB");
1185    }
1186
1187    // =========================================================================
1188    // Parameter Validation Tests
1189    // =========================================================================
1190
1191    #[test]
1192    fn test_adjust_volume_params_valid() {
1193        let params = AdjustVolumeParams {
1194            input: "input.wav".to_string(),
1195            output: "output.wav".to_string(),
1196            volume: "0.5".to_string(),
1197        };
1198        assert!(params.validate().is_ok());
1199    }
1200
1201    #[test]
1202    fn test_adjust_volume_params_invalid_volume() {
1203        let params = AdjustVolumeParams {
1204            input: "input.wav".to_string(),
1205            output: "output.wav".to_string(),
1206            volume: "invalid".to_string(),
1207        };
1208        let result = params.validate();
1209        assert!(result.is_err());
1210        let errors = result.unwrap_err();
1211        assert!(errors.iter().any(|e| e.field == "volume"));
1212    }
1213
1214    #[test]
1215    fn test_adjust_volume_params_empty_input() {
1216        let params = AdjustVolumeParams {
1217            input: "".to_string(),
1218            output: "output.wav".to_string(),
1219            volume: "0.5".to_string(),
1220        };
1221        let result = params.validate();
1222        assert!(result.is_err());
1223        let errors = result.unwrap_err();
1224        assert!(errors.iter().any(|e| e.field == "input"));
1225    }
1226
1227    // =========================================================================
1228    // GCS URI Detection Tests
1229    // =========================================================================
1230
1231    #[test]
1232    fn test_is_gcs_uri() {
1233        assert!(AVToolHandler::is_gcs_uri("gs://bucket/path/file.mp4"));
1234        assert!(AVToolHandler::is_gcs_uri("gs://my-bucket/file.wav"));
1235        assert!(!AVToolHandler::is_gcs_uri("/local/path/file.mp4"));
1236        assert!(!AVToolHandler::is_gcs_uri("./relative/path.wav"));
1237        assert!(!AVToolHandler::is_gcs_uri("file.mp3"));
1238        assert!(!AVToolHandler::is_gcs_uri("s3://bucket/file.mp4"));
1239    }
1240
1241    // =========================================================================
1242    // Content Type Tests
1243    // =========================================================================
1244
1245    #[test]
1246    fn test_content_type_from_extension() {
1247        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file.mp3")), "audio/mpeg");
1248        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file.wav")), "audio/wav");
1249        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file.mp4")), "video/mp4");
1250        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file.gif")), "image/gif");
1251        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file.png")), "image/png");
1252        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file.jpg")), "image/jpeg");
1253        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file.unknown")), "application/octet-stream");
1254        assert_eq!(AVToolHandler::content_type_from_extension(Path::new("file")), "application/octet-stream");
1255    }
1256
1257    // =========================================================================
1258    // Serialization Tests
1259    // =========================================================================
1260
1261    #[test]
1262    fn test_media_info_serialization() {
1263        let info = MediaInfo {
1264            duration: 10.5,
1265            format: "mp4".to_string(),
1266            streams: vec![
1267                StreamInfo {
1268                    index: 0,
1269                    codec_type: "video".to_string(),
1270                    codec_name: "h264".to_string(),
1271                    width: Some(1920),
1272                    height: Some(1080),
1273                    sample_rate: None,
1274                    channels: None,
1275                },
1276                StreamInfo {
1277                    index: 1,
1278                    codec_type: "audio".to_string(),
1279                    codec_name: "aac".to_string(),
1280                    width: None,
1281                    height: None,
1282                    sample_rate: Some(44100),
1283                    channels: Some(2),
1284                },
1285            ],
1286        };
1287
1288        let json = serde_json::to_string(&info).unwrap();
1289        let deserialized: MediaInfo = serde_json::from_str(&json).unwrap();
1290        
1291        assert_eq!(deserialized.duration, 10.5);
1292        assert_eq!(deserialized.format, "mp4");
1293        assert_eq!(deserialized.streams.len(), 2);
1294    }
1295
1296    #[test]
1297    fn test_convert_audio_params_defaults() {
1298        let params: ConvertAudioParams = serde_json::from_str(r#"{
1299            "input": "input.wav",
1300            "output": "output.mp3"
1301        }"#).unwrap();
1302        
1303        assert_eq!(params.bitrate, DEFAULT_BITRATE);
1304    }
1305
1306    #[test]
1307    fn test_video_to_gif_params_defaults() {
1308        let params: VideoToGifParams = serde_json::from_str(r#"{
1309            "input": "input.mp4",
1310            "output": "output.gif"
1311        }"#).unwrap();
1312        
1313        assert_eq!(params.fps, DEFAULT_GIF_FPS);
1314        assert!(params.width.is_none());
1315        assert!(params.start_time.is_none());
1316        assert!(params.duration.is_none());
1317    }
1318
1319    #[test]
1320    fn test_audio_layer_defaults() {
1321        let layer: AudioLayer = serde_json::from_str(r#"{
1322            "path": "audio.wav"
1323        }"#).unwrap();
1324        
1325        assert_eq!(layer.offset_seconds, 0.0);
1326        assert_eq!(layer.volume, DEFAULT_VOLUME);
1327    }
1328
1329    // =========================================================================
1330    // Concatenate Validation Tests
1331    // =========================================================================
1332
1333    #[test]
1334    fn test_concatenate_params_valid() {
1335        let params = ConcatenateParams {
1336            inputs: vec!["file1.mp4".to_string(), "file2.mp4".to_string()],
1337            output: "output.mp4".to_string(),
1338        };
1339        
1340        assert!(!params.inputs.is_empty());
1341        assert_eq!(params.inputs.len(), 2);
1342    }
1343
1344    #[test]
1345    fn test_concatenate_params_single_input() {
1346        let params = ConcatenateParams {
1347            inputs: vec!["file1.mp4".to_string()],
1348            output: "output.mp4".to_string(),
1349        };
1350        
1351        // Single input is valid (though not very useful)
1352        assert_eq!(params.inputs.len(), 1);
1353    }
1354
1355    // =========================================================================
1356    // Layer Audio Validation Tests
1357    // =========================================================================
1358
1359    #[test]
1360    fn test_layer_audio_params_valid() {
1361        let params = LayerAudioParams {
1362            inputs: vec![
1363                AudioLayer {
1364                    path: "audio1.wav".to_string(),
1365                    offset_seconds: 0.0,
1366                    volume: 1.0,
1367                },
1368                AudioLayer {
1369                    path: "audio2.wav".to_string(),
1370                    offset_seconds: 2.5,
1371                    volume: 0.8,
1372                },
1373            ],
1374            output: "mixed.wav".to_string(),
1375        };
1376        
1377        assert_eq!(params.inputs.len(), 2);
1378        assert_eq!(params.inputs[1].offset_seconds, 2.5);
1379        assert_eq!(params.inputs[1].volume, 0.8);
1380    }
1381
1382    #[test]
1383    fn test_layer_audio_with_negative_offset() {
1384        // Negative offset should be allowed (for pre-delay effects)
1385        let layer = AudioLayer {
1386            path: "audio.wav".to_string(),
1387            offset_seconds: -1.0,
1388            volume: 1.0,
1389        };
1390        
1391        // The struct allows negative values, validation happens at runtime
1392        assert_eq!(layer.offset_seconds, -1.0);
1393    }
1394
1395    // =========================================================================
1396    // Overlay Image Params Tests
1397    // =========================================================================
1398
1399    #[test]
1400    fn test_overlay_image_params_defaults() {
1401        let params: OverlayImageParams = serde_json::from_str(r#"{
1402            "video_input": "video.mp4",
1403            "image_input": "overlay.png",
1404            "output": "output.mp4"
1405        }"#).unwrap();
1406        
1407        assert_eq!(params.x, 0);
1408        assert_eq!(params.y, 0);
1409        assert!(params.scale.is_none());
1410        assert!(params.start_time.is_none());
1411        assert!(params.duration.is_none());
1412    }
1413
1414    #[test]
1415    fn test_overlay_image_params_with_position() {
1416        let params: OverlayImageParams = serde_json::from_str(r#"{
1417            "video_input": "video.mp4",
1418            "image_input": "overlay.png",
1419            "output": "output.mp4",
1420            "x": 100,
1421            "y": 50,
1422            "scale": 0.5
1423        }"#).unwrap();
1424        
1425        assert_eq!(params.x, 100);
1426        assert_eq!(params.y, 50);
1427        assert_eq!(params.scale, Some(0.5));
1428    }
1429
1430    // =========================================================================
1431    // Combine AV Params Tests
1432    // =========================================================================
1433
1434    #[test]
1435    fn test_combine_av_params_valid() {
1436        let params: CombineAvParams = serde_json::from_str(r#"{
1437            "video_input": "video.mp4",
1438            "audio_input": "audio.wav",
1439            "output": "combined.mp4"
1440        }"#).unwrap();
1441        
1442        assert_eq!(params.video_input, "video.mp4");
1443        assert_eq!(params.audio_input, "audio.wav");
1444        assert_eq!(params.output, "combined.mp4");
1445    }
1446}
1447
1448
1449// =============================================================================
1450// Property-Based Tests
1451// =============================================================================
1452
1453#[cfg(test)]
1454mod property_tests {
1455    use super::*;
1456    use proptest::prelude::*;
1457
1458    // Feature: rust-mcp-genmedia, Property 15: Volume String Parsing
1459    // **Validates: Requirements 9.17**
1460    //
1461    // For any volume parameter string, it SHALL be parsed as either:
1462    // (a) a numeric multiplier (e.g., "0.5", "2.0"), or
1463    // (b) a dB adjustment (e.g., "-3dB", "+6dB").
1464    // Invalid formats SHALL be rejected with a descriptive error.
1465
1466    /// Strategy to generate valid numeric multipliers (non-negative floats)
1467    fn valid_multiplier_strategy() -> impl Strategy<Value = f64> {
1468        (0.0f64..=10.0f64)
1469    }
1470
1471    /// Strategy to generate valid dB values (can be negative or positive)
1472    fn valid_db_strategy() -> impl Strategy<Value = f64> {
1473        (-60.0f64..=60.0f64)
1474    }
1475
1476    proptest! {
1477        /// Property 15: Valid numeric multipliers should parse successfully
1478        #[test]
1479        fn valid_multiplier_parses_correctly(value in valid_multiplier_strategy()) {
1480            let input = format!("{}", value);
1481            let result = VolumeValue::parse(&input);
1482            
1483            prop_assert!(
1484                result.is_ok(),
1485                "Valid multiplier '{}' should parse successfully, got error: {:?}",
1486                input,
1487                result.err()
1488            );
1489            
1490            if let Ok(VolumeValue::Multiplier(parsed)) = result {
1491                // Allow for floating point precision differences
1492                prop_assert!(
1493                    (parsed - value).abs() < 0.0001,
1494                    "Parsed value {} should match input {}",
1495                    parsed,
1496                    value
1497                );
1498            }
1499        }
1500
1501        /// Property 15: Valid dB strings should parse successfully
1502        #[test]
1503        fn valid_db_parses_correctly(value in valid_db_strategy()) {
1504            let input = format!("{}dB", value);
1505            let result = VolumeValue::parse(&input);
1506            
1507            prop_assert!(
1508                result.is_ok(),
1509                "Valid dB string '{}' should parse successfully, got error: {:?}",
1510                input,
1511                result.err()
1512            );
1513            
1514            if let Ok(VolumeValue::Decibels(parsed)) = result {
1515                prop_assert!(
1516                    (parsed - value).abs() < 0.0001,
1517                    "Parsed dB value {} should match input {}",
1518                    parsed,
1519                    value
1520                );
1521            }
1522        }
1523
1524        /// Property 15: dB parsing should be case-insensitive
1525        #[test]
1526        fn db_parsing_case_insensitive(value in valid_db_strategy()) {
1527            let lower = format!("{}db", value);
1528            let upper = format!("{}DB", value);
1529            let mixed = format!("{}dB", value);
1530            
1531            let result_lower = VolumeValue::parse(&lower);
1532            let result_upper = VolumeValue::parse(&upper);
1533            let result_mixed = VolumeValue::parse(&mixed);
1534            
1535            prop_assert!(result_lower.is_ok(), "Lowercase 'db' should parse");
1536            prop_assert!(result_upper.is_ok(), "Uppercase 'DB' should parse");
1537            prop_assert!(result_mixed.is_ok(), "Mixed case 'dB' should parse");
1538            
1539            // All should produce the same value
1540            if let (Ok(VolumeValue::Decibels(v1)), Ok(VolumeValue::Decibels(v2)), Ok(VolumeValue::Decibels(v3))) = 
1541                (result_lower, result_upper, result_mixed) {
1542                prop_assert!((v1 - v2).abs() < 0.0001);
1543                prop_assert!((v2 - v3).abs() < 0.0001);
1544            }
1545        }
1546
1547        /// Property 15: Whitespace should be trimmed
1548        #[test]
1549        fn whitespace_is_trimmed(value in valid_multiplier_strategy()) {
1550            let with_spaces = format!("  {}  ", value);
1551            let without_spaces = format!("{}", value);
1552            
1553            let result_with = VolumeValue::parse(&with_spaces);
1554            let result_without = VolumeValue::parse(&without_spaces);
1555            
1556            prop_assert!(result_with.is_ok(), "Should parse with whitespace");
1557            prop_assert!(result_without.is_ok(), "Should parse without whitespace");
1558            
1559            // Both should produce the same value
1560            prop_assert_eq!(
1561                result_with.ok(),
1562                result_without.ok(),
1563                "Whitespace should not affect parsing"
1564            );
1565        }
1566
1567        /// Property 15: Negative multipliers should be rejected
1568        #[test]
1569        fn negative_multiplier_rejected(value in -100.0f64..-0.001f64) {
1570            let input = format!("{}", value);
1571            let result = VolumeValue::parse(&input);
1572            
1573            prop_assert!(
1574                result.is_err(),
1575                "Negative multiplier '{}' should be rejected",
1576                input
1577            );
1578        }
1579
1580        /// Property 15: Invalid strings should be rejected with descriptive error
1581        #[test]
1582        fn invalid_strings_rejected(s in "[a-zA-Z]{1,10}") {
1583            // Skip strings that end with "db" (case insensitive) as they might be valid
1584            if !s.to_lowercase().ends_with("db") {
1585                let result = VolumeValue::parse(&s);
1586                
1587                prop_assert!(
1588                    result.is_err(),
1589                    "Invalid string '{}' should be rejected",
1590                    s
1591                );
1592                
1593                // Error message should be descriptive
1594                if let Err(msg) = result {
1595                    prop_assert!(
1596                        msg.contains("Invalid") || msg.contains("Expected"),
1597                        "Error message should be descriptive: {}",
1598                        msg
1599                    );
1600                }
1601            }
1602        }
1603
1604        /// Property 15: FFmpeg value round-trip for multipliers
1605        #[test]
1606        fn multiplier_ffmpeg_roundtrip(value in valid_multiplier_strategy()) {
1607            let volume = VolumeValue::Multiplier(value);
1608            let ffmpeg_str = volume.to_ffmpeg_value();
1609            
1610            // The FFmpeg value should be parseable back
1611            let reparsed: f64 = ffmpeg_str.parse().expect("FFmpeg value should be parseable");
1612            
1613            prop_assert!(
1614                (reparsed - value).abs() < 0.0001,
1615                "FFmpeg value '{}' should round-trip to {}",
1616                ffmpeg_str,
1617                value
1618            );
1619        }
1620
1621        /// Property 15: FFmpeg value format for dB
1622        #[test]
1623        fn db_ffmpeg_format(value in valid_db_strategy()) {
1624            let volume = VolumeValue::Decibels(value);
1625            let ffmpeg_str = volume.to_ffmpeg_value();
1626            
1627            prop_assert!(
1628                ffmpeg_str.ends_with("dB"),
1629                "dB FFmpeg value '{}' should end with 'dB'",
1630                ffmpeg_str
1631            );
1632        }
1633    }
1634
1635    // Feature: rust-mcp-genmedia, Property 13: GCS Path Resolution
1636    // **Validates: Requirements 9.9, 9.10**
1637    //
1638    // For any input or output path that is a GCS URI (starts with `gs://`),
1639    // the AVTool_Server SHALL download inputs to a temporary location before
1640    // processing and upload outputs after processing. For any local path,
1641    // no GCS operations SHALL occur.
1642
1643    /// Strategy to generate valid GCS bucket names
1644    fn valid_bucket_strategy() -> impl Strategy<Value = String> {
1645        "[a-z][a-z0-9-]{2,20}".prop_map(|s| s.to_string())
1646    }
1647
1648    /// Strategy to generate valid GCS object paths
1649    fn valid_object_strategy() -> impl Strategy<Value = String> {
1650        "[a-zA-Z0-9/_.-]{1,50}".prop_map(|s| s.to_string())
1651    }
1652
1653    /// Strategy to generate valid local paths
1654    fn valid_local_path_strategy() -> impl Strategy<Value = String> {
1655        prop_oneof![
1656            Just("/tmp/file.mp4".to_string()),
1657            Just("./relative/path.wav".to_string()),
1658            Just("file.mp3".to_string()),
1659            "[a-zA-Z0-9/_.-]{1,30}".prop_map(|s| format!("/tmp/{}", s)),
1660        ]
1661    }
1662
1663    proptest! {
1664        /// Property 13: GCS URIs should be correctly identified
1665        #[test]
1666        fn gcs_uri_correctly_identified(
1667            bucket in valid_bucket_strategy(),
1668            object in valid_object_strategy()
1669        ) {
1670            let gcs_uri = format!("gs://{}/{}", bucket, object);
1671            
1672            prop_assert!(
1673                AVToolHandler::is_gcs_uri(&gcs_uri),
1674                "GCS URI '{}' should be identified as GCS",
1675                gcs_uri
1676            );
1677        }
1678
1679        /// Property 13: Local paths should not be identified as GCS URIs
1680        #[test]
1681        fn local_path_not_gcs(path in valid_local_path_strategy()) {
1682            prop_assert!(
1683                !AVToolHandler::is_gcs_uri(&path),
1684                "Local path '{}' should not be identified as GCS",
1685                path
1686            );
1687        }
1688
1689        /// Property 13: S3 URIs should not be identified as GCS URIs
1690        #[test]
1691        fn s3_uri_not_gcs(
1692            bucket in valid_bucket_strategy(),
1693            object in valid_object_strategy()
1694        ) {
1695            let s3_uri = format!("s3://{}/{}", bucket, object);
1696            
1697            prop_assert!(
1698                !AVToolHandler::is_gcs_uri(&s3_uri),
1699                "S3 URI '{}' should not be identified as GCS",
1700                s3_uri
1701            );
1702        }
1703
1704        /// Property 13: HTTP URLs should not be identified as GCS URIs
1705        #[test]
1706        fn http_url_not_gcs(domain in "[a-z]{3,10}\\.[a-z]{2,3}", path in "[a-z/]{1,20}") {
1707            let http_url = format!("https://{}/{}", domain, path);
1708            
1709            prop_assert!(
1710                !AVToolHandler::is_gcs_uri(&http_url),
1711                "HTTP URL '{}' should not be identified as GCS",
1712                http_url
1713            );
1714        }
1715    }
1716
1717    // Feature: rust-mcp-genmedia, Property 14: Media Info Output Completeness
1718    // **Validates: Requirements 9.11**
1719    //
1720    // For any valid media file, ffmpeg_get_media_info SHALL return a JSON object
1721    // containing at minimum: duration (number), format (string), and streams (array).
1722    // Each stream SHALL contain codec_type and codec_name.
1723
1724    proptest! {
1725        /// Property 14: MediaInfo serialization always includes required fields
1726        #[test]
1727        fn media_info_has_required_fields(
1728            duration in 0.0f64..=3600.0f64,
1729            format in "[a-z0-9]{1,10}",
1730            num_streams in 0usize..=5usize
1731        ) {
1732            let streams: Vec<StreamInfo> = (0..num_streams)
1733                .map(|i| StreamInfo {
1734                    index: i as u32,
1735                    codec_type: if i % 2 == 0 { "video".to_string() } else { "audio".to_string() },
1736                    codec_name: format!("codec_{}", i),
1737                    width: if i % 2 == 0 { Some(1920) } else { None },
1738                    height: if i % 2 == 0 { Some(1080) } else { None },
1739                    sample_rate: if i % 2 == 1 { Some(44100) } else { None },
1740                    channels: if i % 2 == 1 { Some(2) } else { None },
1741                })
1742                .collect();
1743            
1744            let info = MediaInfo {
1745                duration,
1746                format: format.clone(),
1747                streams,
1748            };
1749            
1750            // Serialize to JSON
1751            let json_str = serde_json::to_string(&info).expect("Should serialize");
1752            let json: serde_json::Value = serde_json::from_str(&json_str).expect("Should parse");
1753            
1754            // Verify required fields exist
1755            prop_assert!(json.get("duration").is_some(), "Should have duration field");
1756            prop_assert!(json.get("format").is_some(), "Should have format field");
1757            prop_assert!(json.get("streams").is_some(), "Should have streams field");
1758            
1759            // Verify types
1760            prop_assert!(json["duration"].is_f64(), "duration should be a number");
1761            prop_assert!(json["format"].is_string(), "format should be a string");
1762            prop_assert!(json["streams"].is_array(), "streams should be an array");
1763            
1764            // Verify stream contents
1765            if let Some(streams_arr) = json["streams"].as_array() {
1766                prop_assert_eq!(streams_arr.len(), num_streams, "Should have correct number of streams");
1767                
1768                for stream in streams_arr {
1769                    prop_assert!(
1770                        stream.get("codec_type").is_some(),
1771                        "Each stream should have codec_type"
1772                    );
1773                    prop_assert!(
1774                        stream.get("codec_name").is_some(),
1775                        "Each stream should have codec_name"
1776                    );
1777                    prop_assert!(
1778                        stream["codec_type"].is_string(),
1779                        "codec_type should be a string"
1780                    );
1781                    prop_assert!(
1782                        stream["codec_name"].is_string(),
1783                        "codec_name should be a string"
1784                    );
1785                }
1786            }
1787        }
1788
1789        /// Property 14: MediaInfo round-trip serialization preserves data
1790        #[test]
1791        fn media_info_roundtrip(
1792            duration in 0.0f64..=3600.0f64,
1793            format in "[a-z0-9]{1,10}"
1794        ) {
1795            let original = MediaInfo {
1796                duration,
1797                format: format.clone(),
1798                streams: vec![
1799                    StreamInfo {
1800                        index: 0,
1801                        codec_type: "video".to_string(),
1802                        codec_name: "h264".to_string(),
1803                        width: Some(1920),
1804                        height: Some(1080),
1805                        sample_rate: None,
1806                        channels: None,
1807                    },
1808                ],
1809            };
1810            
1811            let json_str = serde_json::to_string(&original).expect("Should serialize");
1812            let deserialized: MediaInfo = serde_json::from_str(&json_str).expect("Should deserialize");
1813            
1814            prop_assert!(
1815                (deserialized.duration - duration).abs() < 0.0001,
1816                "Duration should round-trip"
1817            );
1818            prop_assert_eq!(deserialized.format, format, "Format should round-trip");
1819            prop_assert_eq!(deserialized.streams.len(), 1, "Streams should round-trip");
1820        }
1821
1822        /// Property 14: StreamInfo optional fields are properly serialized
1823        #[test]
1824        fn stream_info_optional_fields(
1825            has_width in proptest::bool::ANY,
1826            has_height in proptest::bool::ANY,
1827            has_sample_rate in proptest::bool::ANY,
1828            has_channels in proptest::bool::ANY
1829        ) {
1830            let stream = StreamInfo {
1831                index: 0,
1832                codec_type: "video".to_string(),
1833                codec_name: "h264".to_string(),
1834                width: if has_width { Some(1920) } else { None },
1835                height: if has_height { Some(1080) } else { None },
1836                sample_rate: if has_sample_rate { Some(44100) } else { None },
1837                channels: if has_channels { Some(2) } else { None },
1838            };
1839            
1840            let json_str = serde_json::to_string(&stream).expect("Should serialize");
1841            let json: serde_json::Value = serde_json::from_str(&json_str).expect("Should parse");
1842            
1843            // Optional fields should only be present if they have values
1844            prop_assert_eq!(
1845                json.get("width").is_some(),
1846                has_width,
1847                "width presence should match"
1848            );
1849            prop_assert_eq!(
1850                json.get("height").is_some(),
1851                has_height,
1852                "height presence should match"
1853            );
1854            prop_assert_eq!(
1855                json.get("sample_rate").is_some(),
1856                has_sample_rate,
1857                "sample_rate presence should match"
1858            );
1859            prop_assert_eq!(
1860                json.get("channels").is_some(),
1861                has_channels,
1862                "channels presence should match"
1863            );
1864        }
1865    }
1866}