1use 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
18pub const DEFAULT_BITRATE: &str = "192k";
24
25pub const DEFAULT_GIF_FPS: u8 = 10;
27
28pub const DEFAULT_VOLUME: f32 = 1.0;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MediaInfo {
38 pub duration: f64,
40 pub format: String,
42 pub streams: Vec<StreamInfo>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct StreamInfo {
49 pub index: u32,
51 pub codec_type: String,
53 pub codec_name: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub width: Option<u32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub height: Option<u32>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub sample_rate: Option<u32>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub channels: Option<u32>,
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
75pub struct GetMediaInfoParams {
76 pub input: String,
78}
79
80#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
82pub struct ConvertAudioParams {
83 pub input: String,
85 pub output: String,
87 #[serde(default = "default_bitrate")]
89 pub bitrate: String,
90}
91
92fn default_bitrate() -> String {
93 DEFAULT_BITRATE.to_string()
94}
95
96#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
98pub struct VideoToGifParams {
99 pub input: String,
101 pub output: String,
103 #[serde(default = "default_fps")]
105 pub fps: u8,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub width: Option<u32>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub start_time: Option<f64>,
112 #[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#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
123pub struct CombineAvParams {
124 pub video_input: String,
126 pub audio_input: String,
128 pub output: String,
130}
131
132#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
134pub struct OverlayImageParams {
135 pub video_input: String,
137 pub image_input: String,
139 pub output: String,
141 #[serde(default)]
143 pub x: i32,
144 #[serde(default)]
146 pub y: i32,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub scale: Option<f32>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub start_time: Option<f64>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub duration: Option<f64>,
156}
157
158#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
160pub struct ConcatenateParams {
161 pub inputs: Vec<String>,
163 pub output: String,
165}
166
167#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
169pub struct AdjustVolumeParams {
170 pub input: String,
172 pub output: String,
174 pub volume: String,
176}
177
178#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
180pub struct LayerAudioParams {
181 pub inputs: Vec<AudioLayer>,
183 pub output: String,
185}
186
187#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
189pub struct AudioLayer {
190 pub path: String,
192 #[serde(default)]
194 pub offset_seconds: f64,
195 #[serde(default = "default_volume")]
197 pub volume: f32,
198}
199
200fn default_volume() -> f32 {
201 DEFAULT_VOLUME
202}
203
204#[derive(Debug, Clone)]
210pub struct ValidationError {
211 pub field: String,
213 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#[derive(Debug, Clone, PartialEq)]
225pub enum VolumeValue {
226 Multiplier(f64),
228 Decibels(f64),
230}
231
232impl VolumeValue {
233 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 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 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 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 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
321pub struct AVToolHandler {
327 pub config: Config,
329 pub gcs: GcsClient,
331 temp_dir: PathBuf,
333}
334
335impl AVToolHandler {
336 #[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 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 #[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 pub fn is_gcs_uri(path: &str) -> bool {
374 path.starts_with("gs://")
375 }
376
377 #[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 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 Ok(PathBuf::from(path))
400 }
401 }
402
403 #[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 let gcs_uri = GcsUri::parse(output)?;
411 let data = tokio::fs::read(local_path).await?;
412
413 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 if local_path != Path::new(output) {
423 tokio::fs::copy(local_path, output).await?;
424 }
425 Ok(output.to_string())
426 }
427 }
428
429 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 fn temp_output_path(&self, extension: &str) -> PathBuf {
450 self.temp_dir.join(format!("{}.{}", Uuid::new_v4(), extension))
451 }
452
453 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 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"]) .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 #[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(¶ms.input).await?;
516
517 let json = self.run_ffprobe(&local_input).await?;
518
519 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 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 if Self::is_gcs_uri(¶ms.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 #[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(¶ms.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", ¶ms.bitrate,
581 &output_str,
582 ]).await?;
583
584 let result = self.handle_output(&temp_output, ¶ms.output).await?;
585
586 if Self::is_gcs_uri(¶ms.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 #[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(¶ms.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 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 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 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, ¶ms.output).await?;
637
638 if Self::is_gcs_uri(¶ms.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 #[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(¶ms.video_input).await?;
652 let local_audio = self.resolve_input(¶ms.audio_input).await?;
653
654 let ext = Path::new(¶ms.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, ¶ms.output).await?;
677
678 if Self::is_gcs_uri(¶ms.video_input) {
680 let _ = tokio::fs::remove_file(&local_video).await;
681 }
682 if Self::is_gcs_uri(¶ms.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 #[instrument(level = "info", skip(self))]
693 pub async fn overlay_image(&self, params: OverlayImageParams) -> Result<String, Error> {
694 let local_video = self.resolve_input(¶ms.video_input).await?;
695 let local_image = self.resolve_input(¶ms.image_input).await?;
696
697 let ext = Path::new(¶ms.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 let mut filter_parts = Vec::new();
709
710 if let Some(scale) = params.scale {
712 filter_parts.push(format!("[1:v]scale=iw*{}:ih*{}[img]", scale, scale));
713 }
714
715 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 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, ¶ms.output).await?;
742
743 if Self::is_gcs_uri(¶ms.video_input) {
745 let _ = tokio::fs::remove_file(&local_video).await;
746 }
747 if Self::is_gcs_uri(¶ms.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 #[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 let mut local_inputs = Vec::new();
765 for input in ¶ms.inputs {
766 local_inputs.push(self.resolve_input(input).await?);
767 }
768
769 let ext = Path::new(¶ms.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 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, ¶ms.output).await?;
795
796 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 #[instrument(level = "info", skip(self))]
811 pub async fn adjust_volume(&self, params: AdjustVolumeParams) -> Result<String, Error> {
812 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(¶ms.input).await?;
819
820 let ext = Path::new(¶ms.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, ¶ms.output).await?;
837
838 if Self::is_gcs_uri(¶ms.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 #[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 let mut local_inputs = Vec::new();
857 for layer in ¶ms.inputs {
858 local_inputs.push(self.resolve_input(&layer.path).await?);
859 }
860
861 let ext = Path::new(¶ms.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 let mut args = Vec::new();
869
870 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 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 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 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, ¶ms.output).await?;
922
923 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#[cfg(test)]
942mod tests {
943 use super::*;
944
945 #[test]
950 fn test_ffmpeg_error_contains_stderr_output() {
951 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 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 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 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 #[test]
997 fn test_media_info_parsing_video_stream() {
998 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 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 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 assert_eq!(info.streams[0].codec_type, "video");
1081 assert_eq!(info.streams[0].width, Some(3840));
1082
1083 assert_eq!(info.streams[1].codec_type, "audio");
1085 assert_eq!(info.streams[1].channels, Some(6));
1086
1087 assert_eq!(info.streams[2].codec_type, "subtitle");
1089 }
1090
1091 #[test]
1092 fn test_media_info_json_output_format() {
1093 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 assert!(json.is_object());
1114 assert!(json["duration"].is_f64());
1115 assert!(json["format"].is_string());
1116 assert!(json["streams"].is_array());
1117
1118 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 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 #[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 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()); }
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 #[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 #[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 #[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 #[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 #[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 assert_eq!(params.inputs.len(), 1);
1353 }
1354
1355 #[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 let layer = AudioLayer {
1386 path: "audio.wav".to_string(),
1387 offset_seconds: -1.0,
1388 volume: 1.0,
1389 };
1390
1391 assert_eq!(layer.offset_seconds, -1.0);
1393 }
1394
1395 #[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 #[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#[cfg(test)]
1454mod property_tests {
1455 use super::*;
1456 use proptest::prelude::*;
1457
1458 fn valid_multiplier_strategy() -> impl Strategy<Value = f64> {
1468 (0.0f64..=10.0f64)
1469 }
1470
1471 fn valid_db_strategy() -> impl Strategy<Value = f64> {
1473 (-60.0f64..=60.0f64)
1474 }
1475
1476 proptest! {
1477 #[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 prop_assert!(
1493 (parsed - value).abs() < 0.0001,
1494 "Parsed value {} should match input {}",
1495 parsed,
1496 value
1497 );
1498 }
1499 }
1500
1501 #[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 #[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 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 #[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 prop_assert_eq!(
1561 result_with.ok(),
1562 result_without.ok(),
1563 "Whitespace should not affect parsing"
1564 );
1565 }
1566
1567 #[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 #[test]
1582 fn invalid_strings_rejected(s in "[a-zA-Z]{1,10}") {
1583 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 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 #[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 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 #[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 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 fn valid_object_strategy() -> impl Strategy<Value = String> {
1650 "[a-zA-Z0-9/_.-]{1,50}".prop_map(|s| s.to_string())
1651 }
1652
1653 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 #[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 #[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 #[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 #[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 proptest! {
1725 #[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 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 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 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 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 #[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 #[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 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}