//! Clip Hand - Video processing and editing capabilities //! //! This hand provides video processing features: //! - Trim: Cut video segments //! - Convert: Format conversion //! - Resize: Resolution changes //! - Thumbnail: Generate thumbnails //! - Concat: Join videos use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::process::Command; use std::sync::Arc; use tokio::sync::RwLock; use zclaw_types::Result; use crate::{Hand, HandConfig, HandContext, HandResult}; /// Video format options #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum VideoFormat { Mp4, Webm, Mov, Avi, Gif, } impl Default for VideoFormat { fn default() -> Self { Self::Mp4 } } /// Video resolution presets #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Resolution { Original, P480, P720, P1080, P4k, Custom { width: u32, height: u32 }, } impl Default for Resolution { fn default() -> Self { Self::Original } } /// Trim configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrimConfig { /// Input video path pub input_path: String, /// Output video path pub output_path: String, /// Start time in seconds #[serde(default)] pub start_time: Option, /// End time in seconds #[serde(default)] pub end_time: Option, /// Duration in seconds (alternative to end_time) #[serde(default)] pub duration: Option, } /// Convert configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConvertConfig { /// Input video path pub input_path: String, /// Output video path pub output_path: String, /// Output format #[serde(default)] pub format: VideoFormat, /// Resolution #[serde(default)] pub resolution: Resolution, /// Video bitrate (e.g., "2M") #[serde(default)] pub video_bitrate: Option, /// Audio bitrate (e.g., "128k") #[serde(default)] pub audio_bitrate: Option, } /// Thumbnail configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThumbnailConfig { /// Input video path pub input_path: String, /// Output image path pub output_path: String, /// Time position in seconds #[serde(default)] pub time: f64, /// Output width #[serde(default)] pub width: Option, /// Output height #[serde(default)] pub height: Option, } /// Concat configuration #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConcatConfig { /// Input video paths pub input_paths: Vec, /// Output video path pub output_path: String, } /// Video info result #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoInfo { pub path: String, pub duration_secs: f64, pub width: u32, pub height: u32, pub fps: f64, pub format: String, pub video_codec: String, pub audio_codec: Option, pub bitrate_kbps: Option, pub file_size_bytes: u64, } /// Clip action types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "action")] pub enum ClipAction { #[serde(rename = "trim")] Trim { config: TrimConfig }, #[serde(rename = "convert")] Convert { config: ConvertConfig }, #[serde(rename = "resize")] Resize { input_path: String, output_path: String, resolution: Resolution }, #[serde(rename = "thumbnail")] Thumbnail { config: ThumbnailConfig }, #[serde(rename = "concat")] Concat { config: ConcatConfig }, #[serde(rename = "info")] Info { path: String }, #[serde(rename = "check_ffmpeg")] CheckFfmpeg, } /// Clip Hand implementation pub struct ClipHand { config: HandConfig, ffmpeg_path: Arc>>, } impl ClipHand { /// Create a new clip hand pub fn new() -> Self { Self { config: HandConfig { id: "clip".to_string(), name: "视频剪辑".to_string(), description: "使用 FFmpeg 进行视频处理和编辑".to_string(), needs_approval: false, dependencies: vec!["ffmpeg".to_string()], input_schema: Some(serde_json::json!({ "type": "object", "oneOf": [ { "properties": { "action": { "const": "trim" }, "config": { "type": "object", "properties": { "inputPath": { "type": "string" }, "outputPath": { "type": "string" }, "startTime": { "type": "number" }, "endTime": { "type": "number" }, "duration": { "type": "number" } }, "required": ["inputPath", "outputPath"] } }, "required": ["action", "config"] }, { "properties": { "action": { "const": "convert" }, "config": { "type": "object", "properties": { "inputPath": { "type": "string" }, "outputPath": { "type": "string" }, "format": { "type": "string", "enum": ["mp4", "webm", "mov", "avi", "gif"] }, "resolution": { "type": "string" } }, "required": ["inputPath", "outputPath"] } }, "required": ["action", "config"] }, { "properties": { "action": { "const": "thumbnail" }, "config": { "type": "object", "properties": { "inputPath": { "type": "string" }, "outputPath": { "type": "string" }, "time": { "type": "number" } }, "required": ["inputPath", "outputPath"] } }, "required": ["action", "config"] }, { "properties": { "action": { "const": "concat" }, "config": { "type": "object", "properties": { "inputPaths": { "type": "array", "items": { "type": "string" } }, "outputPath": { "type": "string" } }, "required": ["inputPaths", "outputPath"] } }, "required": ["action", "config"] }, { "properties": { "action": { "const": "info" }, "path": { "type": "string" } }, "required": ["action", "path"] }, { "properties": { "action": { "const": "check_ffmpeg" } }, "required": ["action"] } ] })), tags: vec!["video".to_string(), "media".to_string(), "editing".to_string()], enabled: true, max_concurrent: 0, timeout_secs: 0, }, ffmpeg_path: Arc::new(RwLock::new(None)), } } /// Find FFmpeg executable async fn find_ffmpeg(&self) -> Option { // Check cached path { let cached = self.ffmpeg_path.read().await; if cached.is_some() { return cached.clone(); } } // Try common locations let candidates = if cfg!(windows) { vec!["ffmpeg.exe", "C:\\ffmpeg\\bin\\ffmpeg.exe", "C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe"] } else { vec!["ffmpeg", "/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg"] }; for candidate in candidates { if Command::new(candidate).arg("-version").output().is_ok() { let mut cached = self.ffmpeg_path.write().await; *cached = Some(candidate.to_string()); return Some(candidate.to_string()); } } None } /// Execute trim operation async fn execute_trim(&self, config: &TrimConfig) -> Result { let ffmpeg = self.find_ffmpeg().await .ok_or_else(|| zclaw_types::ZclawError::HandError("FFmpeg not found. Please install FFmpeg.".to_string()))?; let mut args: Vec = vec!["-i".to_string(), config.input_path.clone()]; // Add start time if let Some(start) = config.start_time { args.push("-ss".to_string()); args.push(start.to_string()); } // Add duration or end time if let Some(duration) = config.duration { args.push("-t".to_string()); args.push(duration.to_string()); } else if let Some(end) = config.end_time { if let Some(start) = config.start_time { args.push("-t".to_string()); args.push((end - start).to_string()); } else { args.push("-to".to_string()); args.push(end.to_string()); } } args.extend_from_slice(&["-c".to_string(), "copy".to_string(), config.output_path.clone()]); let output = Command::new(&ffmpeg) .args(&args) .output() .map_err(|e| zclaw_types::ZclawError::HandError(format!("FFmpeg execution failed: {}", e)))?; if output.status.success() { Ok(json!({ "success": true, "output_path": config.output_path, "message": "Video trimmed successfully" })) } else { let stderr = String::from_utf8_lossy(&output.stderr); Ok(json!({ "success": false, "error": stderr, "message": "Failed to trim video" })) } } /// Execute convert operation async fn execute_convert(&self, config: &ConvertConfig) -> Result { let ffmpeg = self.find_ffmpeg().await .ok_or_else(|| zclaw_types::ZclawError::HandError("FFmpeg not found".to_string()))?; let mut args: Vec = vec!["-i".to_string(), config.input_path.clone()]; // Add resolution if let Resolution::Custom { width, height } = config.resolution { args.push("-vf".to_string()); args.push(format!("scale={}:{}", width, height)); } else { let scale = match &config.resolution { Resolution::P480 => "scale=854:480", Resolution::P720 => "scale=1280:720", Resolution::P1080 => "scale=1920:1080", Resolution::P4k => "scale=3840:2160", _ => "", }; if !scale.is_empty() { args.push("-vf".to_string()); args.push(scale.to_string()); } } // Add bitrates if let Some(ref vbr) = config.video_bitrate { args.push("-b:v".to_string()); args.push(vbr.clone()); } if let Some(ref abr) = config.audio_bitrate { args.push("-b:a".to_string()); args.push(abr.clone()); } args.push(config.output_path.clone()); let output = Command::new(&ffmpeg) .args(&args) .output() .map_err(|e| zclaw_types::ZclawError::HandError(format!("FFmpeg execution failed: {}", e)))?; if output.status.success() { Ok(json!({ "success": true, "output_path": config.output_path, "format": format!("{:?}", config.format), "message": "Video converted successfully" })) } else { let stderr = String::from_utf8_lossy(&output.stderr); Ok(json!({ "success": false, "error": stderr, "message": "Failed to convert video" })) } } /// Execute thumbnail extraction async fn execute_thumbnail(&self, config: &ThumbnailConfig) -> Result { let ffmpeg = self.find_ffmpeg().await .ok_or_else(|| zclaw_types::ZclawError::HandError("FFmpeg not found".to_string()))?; let mut args: Vec = vec![ "-i".to_string(), config.input_path.clone(), "-ss".to_string(), config.time.to_string(), "-vframes".to_string(), "1".to_string(), ]; // Add scale if dimensions specified if let (Some(w), Some(h)) = (config.width, config.height) { args.push("-vf".to_string()); args.push(format!("scale={}:{}", w, h)); } args.push(config.output_path.clone()); let output = Command::new(&ffmpeg) .args(&args) .output() .map_err(|e| zclaw_types::ZclawError::HandError(format!("FFmpeg execution failed: {}", e)))?; if output.status.success() { Ok(json!({ "success": true, "output_path": config.output_path, "time": config.time, "message": "Thumbnail extracted successfully" })) } else { let stderr = String::from_utf8_lossy(&output.stderr); Ok(json!({ "success": false, "error": stderr, "message": "Failed to extract thumbnail" })) } } /// Execute video concatenation async fn execute_concat(&self, config: &ConcatConfig) -> Result { let ffmpeg = self.find_ffmpeg().await .ok_or_else(|| zclaw_types::ZclawError::HandError("FFmpeg not found".to_string()))?; // Create concat file let concat_content: String = config.input_paths.iter() .map(|p| format!("file '{}'", p)) .collect::>() .join("\n"); let temp_file = std::env::temp_dir().join("zclaw_concat.txt"); std::fs::write(&temp_file, &concat_content) .map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to create concat file: {}", e)))?; let args = vec![ "-f", "concat", "-safe", "0", "-i", temp_file.to_str().unwrap(), "-c", "copy", &config.output_path, ]; let output = Command::new(&ffmpeg) .args(&args) .output() .map_err(|e| zclaw_types::ZclawError::HandError(format!("FFmpeg execution failed: {}", e)))?; // Cleanup temp file let _ = std::fs::remove_file(&temp_file); if output.status.success() { Ok(json!({ "success": true, "output_path": config.output_path, "videos_concatenated": config.input_paths.len(), "message": "Videos concatenated successfully" })) } else { let stderr = String::from_utf8_lossy(&output.stderr); Ok(json!({ "success": false, "error": stderr, "message": "Failed to concatenate videos" })) } } /// Get video information async fn execute_info(&self, path: &str) -> Result { let ffprobe = { let ffmpeg = self.find_ffmpeg().await .ok_or_else(|| zclaw_types::ZclawError::HandError("FFmpeg not found".to_string()))?; ffmpeg.replace("ffmpeg", "ffprobe") }; let args = vec![ "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path, ]; let output = Command::new(&ffprobe) .args(&args) .output() .map_err(|e| zclaw_types::ZclawError::HandError(format!("FFprobe execution failed: {}", e)))?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let info: Value = serde_json::from_str(&stdout) .unwrap_or_else(|_| json!({"raw": stdout.to_string()})); Ok(json!({ "success": true, "path": path, "info": info })) } else { let stderr = String::from_utf8_lossy(&output.stderr); Ok(json!({ "success": false, "error": stderr, "message": "Failed to get video info" })) } } /// Check FFmpeg availability async fn check_ffmpeg(&self) -> Result { match self.find_ffmpeg().await { Some(path) => { // Get version info let output = Command::new(&path) .arg("-version") .output() .ok(); let version = output.and_then(|o| { let stdout = String::from_utf8_lossy(&o.stdout); stdout.lines().next().map(|s| s.to_string()) }).unwrap_or_else(|| "Unknown version".to_string()); Ok(json!({ "available": true, "path": path, "version": version })) } None => Ok(json!({ "available": false, "message": "FFmpeg not found. Please install FFmpeg to use video processing features.", "install_url": if cfg!(windows) { "https://ffmpeg.org/download.html#build-windows" } else if cfg!(target_os = "macos") { "brew install ffmpeg" } else { "sudo apt install ffmpeg" } })) } } } impl Default for ClipHand { fn default() -> Self { Self::new() } } #[async_trait] impl Hand for ClipHand { fn config(&self) -> &HandConfig { &self.config } async fn execute(&self, _context: &HandContext, input: Value) -> Result { let action: ClipAction = serde_json::from_value(input.clone()) .map_err(|e| zclaw_types::ZclawError::HandError(format!("Invalid action: {}", e)))?; let start = std::time::Instant::now(); let result = match action { ClipAction::Trim { config } => self.execute_trim(&config).await?, ClipAction::Convert { config } => self.execute_convert(&config).await?, ClipAction::Resize { input_path, output_path, resolution } => { let convert_config = ConvertConfig { input_path, output_path, format: VideoFormat::Mp4, resolution, video_bitrate: None, audio_bitrate: None, }; self.execute_convert(&convert_config).await? } ClipAction::Thumbnail { config } => self.execute_thumbnail(&config).await?, ClipAction::Concat { config } => self.execute_concat(&config).await?, ClipAction::Info { path } => self.execute_info(&path).await?, ClipAction::CheckFfmpeg => self.check_ffmpeg().await?, }; let duration_ms = start.elapsed().as_millis() as u64; Ok(HandResult { success: result["success"].as_bool().unwrap_or(false), output: result, error: None, duration_ms: Some(duration_ms), status: "completed".to_string(), }) } fn needs_approval(&self) -> bool { false } fn check_dependencies(&self) -> Result> { let mut missing = Vec::new(); // Check FFmpeg if Command::new("ffmpeg").arg("-version").output().is_err() { if Command::new("C:\\ffmpeg\\bin\\ffmpeg.exe").arg("-version").output().is_err() { missing.push("FFmpeg not found. Install from https://ffmpeg.org/".to_string()); } } Ok(missing) } fn status(&self) -> crate::HandStatus { // Check if FFmpeg is available if Command::new("ffmpeg").arg("-version").output().is_ok() { crate::HandStatus::Idle } else if Command::new("C:\\ffmpeg\\bin\\ffmpeg.exe").arg("-version").output().is_ok() { crate::HandStatus::Idle } else { crate::HandStatus::Failed } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; // === Config & Defaults === #[test] fn test_hand_config() { let hand = ClipHand::new(); assert_eq!(hand.config().id, "clip"); assert_eq!(hand.config().name, "视频剪辑"); assert!(!hand.config().needs_approval); assert!(hand.config().enabled); assert!(hand.config().tags.contains(&"video".to_string())); assert!(hand.config().input_schema.is_some()); } #[test] fn test_default_impl() { let hand = ClipHand::default(); assert_eq!(hand.config().id, "clip"); } #[test] fn test_needs_approval() { let hand = ClipHand::new(); assert!(!hand.needs_approval()); } #[test] fn test_check_dependencies() { let hand = ClipHand::new(); let deps = hand.check_dependencies().unwrap(); // May or may not find ffmpeg depending on test environment // Just verify it doesn't panic let _ = deps; } // === VideoFormat === #[test] fn test_video_format_default() { assert!(matches!(VideoFormat::default(), VideoFormat::Mp4)); } #[test] fn test_video_format_deserialize() { let fmt: VideoFormat = serde_json::from_value(json!("mp4")).unwrap(); assert!(matches!(fmt, VideoFormat::Mp4)); let fmt: VideoFormat = serde_json::from_value(json!("webm")).unwrap(); assert!(matches!(fmt, VideoFormat::Webm)); let fmt: VideoFormat = serde_json::from_value(json!("gif")).unwrap(); assert!(matches!(fmt, VideoFormat::Gif)); } #[test] fn test_video_format_serialize() { assert_eq!(serde_json::to_value(&VideoFormat::Mp4).unwrap(), "mp4"); assert_eq!(serde_json::to_value(&VideoFormat::Webm).unwrap(), "webm"); } // === Resolution === #[test] fn test_resolution_default() { assert!(matches!(Resolution::default(), Resolution::Original)); } #[test] fn test_resolution_presets() { let r: Resolution = serde_json::from_value(json!("p720")).unwrap(); assert!(matches!(r, Resolution::P720)); let r: Resolution = serde_json::from_value(json!("p1080")).unwrap(); assert!(matches!(r, Resolution::P1080)); let r: Resolution = serde_json::from_value(json!("p4k")).unwrap(); assert!(matches!(r, Resolution::P4k)); } #[test] fn test_resolution_custom() { let r: Resolution = serde_json::from_value(json!({"custom": {"width": 800, "height": 600}})).unwrap(); match r { Resolution::Custom { width, height } => { assert_eq!(width, 800); assert_eq!(height, 600); } _ => panic!("Expected Custom"), } } #[test] fn test_resolution_serialize() { assert_eq!(serde_json::to_value(&Resolution::P720).unwrap(), "p720"); assert_eq!(serde_json::to_value(&Resolution::Original).unwrap(), "original"); } // === TrimConfig === #[test] fn test_trim_config_deserialize() { let config: TrimConfig = serde_json::from_value(json!({ "inputPath": "/input.mp4", "outputPath": "/output.mp4", "startTime": 5.0, "duration": 10.0 })).unwrap(); assert_eq!(config.input_path, "/input.mp4"); assert_eq!(config.output_path, "/output.mp4"); assert_eq!(config.start_time, Some(5.0)); assert_eq!(config.duration, Some(10.0)); assert!(config.end_time.is_none()); } #[test] fn test_trim_config_minimal() { let config: TrimConfig = serde_json::from_value(json!({ "inputPath": "/in.mp4", "outputPath": "/out.mp4" })).unwrap(); assert!(config.start_time.is_none()); assert!(config.end_time.is_none()); assert!(config.duration.is_none()); } // === ConvertConfig === #[test] fn test_convert_config_deserialize() { let config: ConvertConfig = serde_json::from_value(json!({ "inputPath": "/input.avi", "outputPath": "/output.mp4", "format": "mp4", "resolution": "p1080", "videoBitrate": "4M", "audioBitrate": "192k" })).unwrap(); assert_eq!(config.input_path, "/input.avi"); assert!(matches!(config.format, VideoFormat::Mp4)); assert!(matches!(config.resolution, Resolution::P1080)); assert_eq!(config.video_bitrate, Some("4M".to_string())); assert_eq!(config.audio_bitrate, Some("192k".to_string())); } #[test] fn test_convert_config_defaults() { let config: ConvertConfig = serde_json::from_value(json!({ "inputPath": "/in.mp4", "outputPath": "/out.mp4" })).unwrap(); assert!(matches!(config.format, VideoFormat::Mp4)); assert!(matches!(config.resolution, Resolution::Original)); assert!(config.video_bitrate.is_none()); assert!(config.audio_bitrate.is_none()); } // === ThumbnailConfig === #[test] fn test_thumbnail_config_deserialize() { let config: ThumbnailConfig = serde_json::from_value(json!({ "inputPath": "/video.mp4", "outputPath": "/thumb.jpg", "time": 5.0, "width": 320, "height": 240 })).unwrap(); assert_eq!(config.input_path, "/video.mp4"); assert_eq!(config.time, 5.0); assert_eq!(config.width, Some(320)); assert_eq!(config.height, Some(240)); } #[test] fn test_thumbnail_config_defaults() { let config: ThumbnailConfig = serde_json::from_value(json!({ "inputPath": "/v.mp4", "outputPath": "/t.jpg" })).unwrap(); assert_eq!(config.time, 0.0); assert!(config.width.is_none()); assert!(config.height.is_none()); } // === ConcatConfig === #[test] fn test_concat_config_deserialize() { let config: ConcatConfig = serde_json::from_value(json!({ "inputPaths": ["/a.mp4", "/b.mp4"], "outputPath": "/merged.mp4" })).unwrap(); assert_eq!(config.input_paths.len(), 2); assert_eq!(config.output_path, "/merged.mp4"); } // === VideoInfo === #[test] fn test_video_info_deserialize() { let info: VideoInfo = serde_json::from_value(json!({ "path": "/test.mp4", "durationSecs": 120.5, "width": 1920, "height": 1080, "fps": 30.0, "format": "mp4", "videoCodec": "h264", "audioCodec": "aac", "bitrateKbps": 5000, "fileSizeBytes": 75_000_000 })).unwrap(); assert_eq!(info.path, "/test.mp4"); assert_eq!(info.duration_secs, 120.5); assert_eq!(info.width, 1920); assert_eq!(info.fps, 30.0); assert_eq!(info.video_codec, "h264"); assert_eq!(info.audio_codec, Some("aac".to_string())); assert_eq!(info.bitrate_kbps, Some(5000)); assert_eq!(info.file_size_bytes, 75_000_000); } // === ClipAction Deserialization === #[test] fn test_action_trim() { let action: ClipAction = serde_json::from_value(json!({ "action": "trim", "config": { "inputPath": "/in.mp4", "outputPath": "/out.mp4", "startTime": 1.0, "endTime": 5.0 } })).unwrap(); match action { ClipAction::Trim { config } => { assert_eq!(config.input_path, "/in.mp4"); assert_eq!(config.start_time, Some(1.0)); } _ => panic!("Expected Trim"), } } #[test] fn test_action_convert() { let action: ClipAction = serde_json::from_value(json!({ "action": "convert", "config": { "inputPath": "/in.avi", "outputPath": "/out.mp4" } })).unwrap(); assert!(matches!(action, ClipAction::Convert { .. })); } #[test] fn test_action_resize() { let action: ClipAction = serde_json::from_value(json!({ "action": "resize", "input_path": "/in.mp4", "output_path": "/out.mp4", "resolution": "p720" })).unwrap(); match action { ClipAction::Resize { input_path, resolution, .. } => { assert_eq!(input_path, "/in.mp4"); assert!(matches!(resolution, Resolution::P720)); } _ => panic!("Expected Resize"), } } #[test] fn test_action_thumbnail() { let action: ClipAction = serde_json::from_value(json!({ "action": "thumbnail", "config": { "inputPath": "/in.mp4", "outputPath": "/thumb.jpg" } })).unwrap(); assert!(matches!(action, ClipAction::Thumbnail { .. })); } #[test] fn test_action_concat() { let action: ClipAction = serde_json::from_value(json!({ "action": "concat", "config": { "inputPaths": ["/a.mp4", "/b.mp4"], "outputPath": "/out.mp4" } })).unwrap(); assert!(matches!(action, ClipAction::Concat { .. })); } #[test] fn test_action_info() { let action: ClipAction = serde_json::from_value(json!({ "action": "info", "path": "/video.mp4" })).unwrap(); match action { ClipAction::Info { path } => assert_eq!(path, "/video.mp4"), _ => panic!("Expected Info"), } } #[test] fn test_action_check_ffmpeg() { let action: ClipAction = serde_json::from_value(json!({"action": "check_ffmpeg"})).unwrap(); assert!(matches!(action, ClipAction::CheckFfmpeg)); } #[test] fn test_action_invalid() { let result = serde_json::from_value::(json!({"action": "nonexistent"})); assert!(result.is_err()); } // === Hand execute dispatch === #[tokio::test] async fn test_execute_check_ffmpeg() { let hand = ClipHand::new(); let ctx = HandContext::default(); let result = hand.execute(&ctx, json!({"action": "check_ffmpeg"})).await.unwrap(); // Just verify it doesn't crash and returns a valid result assert!(result.output.is_object()); // "available" field should exist assert!(result.output["available"].is_boolean()); } #[tokio::test] async fn test_execute_invalid_action() { let hand = ClipHand::new(); let ctx = HandContext::default(); let result = hand.execute(&ctx, json!({"action": "bogus"})).await; assert!(result.is_err()); } // === Status === #[test] fn test_status() { let hand = ClipHand::new(); let status = hand.status(); // Either Idle (ffmpeg found) or Failed (not found) — just verify it doesn't panic assert!(matches!(status, crate::HandStatus::Idle | crate::HandStatus::Failed)); } // === Roundtrip === #[test] fn test_trim_action_roundtrip() { let json = json!({ "action": "trim", "config": { "inputPath": "/in.mp4", "outputPath": "/out.mp4", "startTime": 2.0, "duration": 5.0 } }); let action: ClipAction = serde_json::from_value(json).unwrap(); let serialized = serde_json::to_value(&action).unwrap(); assert_eq!(serialized["action"], "trim"); assert_eq!(serialized["config"]["inputPath"], "/in.mp4"); assert_eq!(serialized["config"]["startTime"], 2.0); assert_eq!(serialized["config"]["duration"], 5.0); } #[test] fn test_info_action_roundtrip() { let json = json!({"action": "info", "path": "/video.mp4"}); let action: ClipAction = serde_json::from_value(json).unwrap(); let serialized = serde_json::to_value(&action).unwrap(); assert_eq!(serialized["action"], "info"); assert_eq!(serialized["path"], "/video.mp4"); } }