Skip to main content

adk_rust_mcp_video/
handler.rs

1//! Video generation handler for the MCP Video server.
2//!
3//! This module provides the `VideoHandler` struct and parameter types for
4//! video generation using Google's Vertex AI Veo API.
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 adk_rust_mcp_common::models::{ModelRegistry, VeoModel, VEO_MODELS};
11use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15use std::time::Duration;
16use tracing::{debug, info, instrument};
17
18/// Valid aspect ratios for video generation.
19pub const VALID_ASPECT_RATIOS: &[&str] = &["16:9", "9:16"];
20
21/// Default model for video generation.
22pub const DEFAULT_MODEL: &str = "veo-3.1-generate-preview";
23
24/// Default duration in seconds (must be one of the supported values: 4, 6, 8).
25pub const DEFAULT_DURATION_SECONDS: u8 = 8;
26
27/// Supported durations in seconds (for fallback validation when model is unknown).
28pub const SUPPORTED_DURATIONS: &[u8] = &[4, 6, 8];
29
30/// Minimum duration in seconds (for fallback validation).
31pub const MIN_DURATION_SECONDS: u8 = 4;
32
33/// Maximum duration in seconds (for fallback validation).
34pub const MAX_DURATION_SECONDS: u8 = 8;
35
36/// Default aspect ratio.
37pub const DEFAULT_ASPECT_RATIO: &str = "16:9";
38
39/// LRO polling configuration
40pub const LRO_INITIAL_DELAY_MS: u64 = 5000;
41pub const LRO_MAX_DELAY_MS: u64 = 60000;
42pub const LRO_BACKOFF_MULTIPLIER: f64 = 1.5;
43pub const LRO_MAX_ATTEMPTS: u32 = 120; // ~30 minutes max with backoff
44
45/// Text-to-video generation parameters.
46///
47/// These parameters control the video generation process via the Vertex AI Veo API.
48#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
49pub struct VideoT2vParams {
50    /// Text prompt describing the video to generate.
51    pub prompt: String,
52
53    /// Model to use for generation.
54    /// Defaults to "veo-3.0-generate-preview".
55    #[serde(default = "default_model")]
56    pub model: String,
57
58    /// Aspect ratio for the generated video.
59    /// Valid values: "16:9", "9:16".
60    #[serde(default = "default_aspect_ratio")]
61    pub aspect_ratio: String,
62
63    /// Duration of the video in seconds (5-8 depending on model).
64    #[serde(default = "default_duration_seconds")]
65    pub duration_seconds: u8,
66
67    /// GCS URI for output (required by Veo API).
68    /// Format: gs://bucket/path/to/output.mp4
69    pub output_gcs_uri: String,
70
71    /// Whether to also download the video locally after generation.
72    #[serde(default)]
73    pub download_local: bool,
74
75    /// Local path to save the video if download_local is true.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub local_path: Option<String>,
78
79    /// Whether to generate audio (only supported on Veo 3.x models).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub generate_audio: Option<bool>,
82
83    /// Random seed for reproducible generation.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub seed: Option<i64>,
86}
87
88fn default_model() -> String {
89    DEFAULT_MODEL.to_string()
90}
91
92fn default_aspect_ratio() -> String {
93    DEFAULT_ASPECT_RATIO.to_string()
94}
95
96fn default_duration_seconds() -> u8 {
97    DEFAULT_DURATION_SECONDS
98}
99
100/// Image-to-video generation parameters.
101///
102/// These parameters control the image-to-video generation process via the Vertex AI Veo API.
103/// Supports both single-image I2V and interpolation (first + last frame).
104#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
105pub struct VideoI2vParams {
106    /// Source image for video generation (first frame for interpolation).
107    /// Can be base64 data, local file path, or GCS URI.
108    pub image: String,
109
110    /// Text prompt describing the desired video motion.
111    pub prompt: String,
112
113    /// Last frame image for interpolation mode.
114    /// If provided, generates a video interpolating between `image` and `last_frame_image`.
115    /// Can be base64 data, local file path, or GCS URI.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub last_frame_image: Option<String>,
118
119    /// Model to use for generation.
120    /// Defaults to "veo-3.0-generate-preview".
121    #[serde(default = "default_model")]
122    pub model: String,
123
124    /// Aspect ratio for the generated video.
125    /// Valid values: "16:9", "9:16".
126    #[serde(default = "default_aspect_ratio")]
127    pub aspect_ratio: String,
128
129    /// Duration of the video in seconds (5-8 depending on model).
130    #[serde(default = "default_duration_seconds")]
131    pub duration_seconds: u8,
132
133    /// GCS URI for output (required by Veo API).
134    /// Format: gs://bucket/path/to/output.mp4
135    pub output_gcs_uri: String,
136
137    /// Whether to also download the video locally after generation.
138    #[serde(default)]
139    pub download_local: bool,
140
141    /// Local path to save the video if download_local is true.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub local_path: Option<String>,
144
145    /// Random seed for reproducible generation.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub seed: Option<i64>,
148}
149
150/// Video extension parameters.
151///
152/// These parameters control the video extension process via the Vertex AI Veo API.
153/// Extends an existing video by generating additional frames.
154#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
155pub struct VideoExtendParams {
156    /// GCS URI of the video to extend.
157    /// Format: gs://bucket/path/to/input.mp4
158    pub video_input: String,
159
160    /// Text prompt describing the desired continuation.
161    pub prompt: String,
162
163    /// Model to use for generation.
164    /// Defaults to "veo-3.0-generate-preview".
165    #[serde(default = "default_model")]
166    pub model: String,
167
168    /// Duration of the extension in seconds (5-8 depending on model).
169    #[serde(default = "default_duration_seconds")]
170    pub duration_seconds: u8,
171
172    /// GCS URI for output (required by Veo API).
173    /// Format: gs://bucket/path/to/output.mp4
174    pub output_gcs_uri: String,
175
176    /// Whether to also download the video locally after generation.
177    #[serde(default)]
178    pub download_local: bool,
179
180    /// Local path to save the video if download_local is true.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub local_path: Option<String>,
183
184    /// Random seed for reproducible generation.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub seed: Option<i64>,
187}
188
189/// Validation error details for video generation parameters.
190#[derive(Debug, Clone)]
191pub struct ValidationError {
192    /// The field that failed validation.
193    pub field: String,
194    /// Description of the validation failure.
195    pub message: String,
196}
197
198impl std::fmt::Display for ValidationError {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        write!(f, "{}: {}", self.field, self.message)
201    }
202}
203
204impl VideoT2vParams {
205    /// Validate the parameters against the model constraints.
206    ///
207    /// # Returns
208    /// - `Ok(())` if all parameters are valid
209    /// - `Err(Vec<ValidationError>)` with all validation errors
210    pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
211        let mut errors = Vec::new();
212
213        // Resolve the model to get constraints
214        let model = ModelRegistry::resolve_veo(&self.model);
215
216        // Validate model exists
217        if model.is_none() {
218            errors.push(ValidationError {
219                field: "model".to_string(),
220                message: format!(
221                    "Unknown model '{}'. Valid models: {}",
222                    self.model,
223                    VEO_MODELS
224                        .iter()
225                        .map(|m| m.id)
226                        .collect::<Vec<_>>()
227                        .join(", ")
228                ),
229            });
230        }
231
232        // Validate prompt is not empty
233        if self.prompt.trim().is_empty() {
234            errors.push(ValidationError {
235                field: "prompt".to_string(),
236                message: "Prompt cannot be empty".to_string(),
237            });
238        }
239
240        // Validate aspect ratio
241        if let Some(model) = model {
242            if !model.supported_aspect_ratios.contains(&self.aspect_ratio.as_str()) {
243                errors.push(ValidationError {
244                    field: "aspect_ratio".to_string(),
245                    message: format!(
246                        "Invalid aspect ratio '{}'. Valid options for {}: {}",
247                        self.aspect_ratio,
248                        model.id,
249                        model.supported_aspect_ratios.join(", ")
250                    ),
251                });
252            }
253
254            // Validate duration_seconds against model's supported durations
255            if !model.supported_durations.contains(&self.duration_seconds) {
256                let durations_str: Vec<String> = model.supported_durations.iter().map(|d| d.to_string()).collect();
257                errors.push(ValidationError {
258                    field: "duration_seconds".to_string(),
259                    message: format!(
260                        "duration_seconds must be one of [{}] for model {}, got {}",
261                        durations_str.join(", "), model.id, self.duration_seconds
262                    ),
263                });
264            }
265
266            // Validate generate_audio is only used with Veo 3.x models
267            if self.generate_audio.is_some() && !model.supports_audio {
268                errors.push(ValidationError {
269                    field: "generate_audio".to_string(),
270                    message: format!(
271                        "generate_audio is only supported on Veo 3.x models, not {}",
272                        model.id
273                    ),
274                });
275            }
276        } else {
277            // If model is unknown, validate against common constraints
278            if !VALID_ASPECT_RATIOS.contains(&self.aspect_ratio.as_str()) {
279                errors.push(ValidationError {
280                    field: "aspect_ratio".to_string(),
281                    message: format!(
282                        "Invalid aspect ratio '{}'. Valid options: {}",
283                        self.aspect_ratio,
284                        VALID_ASPECT_RATIOS.join(", ")
285                    ),
286                });
287            }
288
289            if !SUPPORTED_DURATIONS.contains(&self.duration_seconds) {
290                let durations_str: Vec<String> = SUPPORTED_DURATIONS.iter().map(|d| d.to_string()).collect();
291                errors.push(ValidationError {
292                    field: "duration_seconds".to_string(),
293                    message: format!(
294                        "duration_seconds must be one of [{}], got {}",
295                        durations_str.join(", "), self.duration_seconds
296                    ),
297                });
298            }
299        }
300
301        // Validate output_gcs_uri is a valid GCS URI
302        if !self.output_gcs_uri.starts_with("gs://") {
303            errors.push(ValidationError {
304                field: "output_gcs_uri".to_string(),
305                message: format!(
306                    "output_gcs_uri must be a GCS URI starting with 'gs://', got '{}'",
307                    self.output_gcs_uri
308                ),
309            });
310        }
311
312        if errors.is_empty() {
313            Ok(())
314        } else {
315            Err(errors)
316        }
317    }
318
319    /// Get the resolved model definition.
320    pub fn get_model(&self) -> Option<&'static VeoModel> {
321        ModelRegistry::resolve_veo(&self.model)
322    }
323}
324
325impl VideoI2vParams {
326    /// Validate the parameters against the model constraints.
327    ///
328    /// # Returns
329    /// - `Ok(())` if all parameters are valid
330    /// - `Err(Vec<ValidationError>)` with all validation errors
331    pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
332        let mut errors = Vec::new();
333
334        // Resolve the model to get constraints
335        let model = ModelRegistry::resolve_veo(&self.model);
336
337        // Validate model exists
338        if model.is_none() {
339            errors.push(ValidationError {
340                field: "model".to_string(),
341                message: format!(
342                    "Unknown model '{}'. Valid models: {}",
343                    self.model,
344                    VEO_MODELS
345                        .iter()
346                        .map(|m| m.id)
347                        .collect::<Vec<_>>()
348                        .join(", ")
349                ),
350            });
351        }
352
353        // Validate image is not empty
354        if self.image.trim().is_empty() {
355            errors.push(ValidationError {
356                field: "image".to_string(),
357                message: "Image cannot be empty".to_string(),
358            });
359        }
360
361        // Validate prompt is not empty
362        if self.prompt.trim().is_empty() {
363            errors.push(ValidationError {
364                field: "prompt".to_string(),
365                message: "Prompt cannot be empty".to_string(),
366            });
367        }
368
369        // Validate aspect ratio
370        if let Some(model) = model {
371            if !model.supported_aspect_ratios.contains(&self.aspect_ratio.as_str()) {
372                errors.push(ValidationError {
373                    field: "aspect_ratio".to_string(),
374                    message: format!(
375                        "Invalid aspect ratio '{}'. Valid options for {}: {}",
376                        self.aspect_ratio,
377                        model.id,
378                        model.supported_aspect_ratios.join(", ")
379                    ),
380                });
381            }
382
383            // Validate duration_seconds against model's supported durations
384            if !model.supported_durations.contains(&self.duration_seconds) {
385                let durations_str: Vec<String> = model.supported_durations.iter().map(|d| d.to_string()).collect();
386                errors.push(ValidationError {
387                    field: "duration_seconds".to_string(),
388                    message: format!(
389                        "duration_seconds must be one of [{}] for model {}, got {}",
390                        durations_str.join(", "), model.id, self.duration_seconds
391                    ),
392                });
393            }
394        } else {
395            // If model is unknown, validate against common constraints
396            if !VALID_ASPECT_RATIOS.contains(&self.aspect_ratio.as_str()) {
397                errors.push(ValidationError {
398                    field: "aspect_ratio".to_string(),
399                    message: format!(
400                        "Invalid aspect ratio '{}'. Valid options: {}",
401                        self.aspect_ratio,
402                        VALID_ASPECT_RATIOS.join(", ")
403                    ),
404                });
405            }
406
407            if !SUPPORTED_DURATIONS.contains(&self.duration_seconds) {
408                let durations_str: Vec<String> = SUPPORTED_DURATIONS.iter().map(|d| d.to_string()).collect();
409                errors.push(ValidationError {
410                    field: "duration_seconds".to_string(),
411                    message: format!(
412                        "duration_seconds must be one of [{}], got {}",
413                        durations_str.join(", "), self.duration_seconds
414                    ),
415                });
416            }
417        }
418
419        // Validate output_gcs_uri is a valid GCS URI
420        if !self.output_gcs_uri.starts_with("gs://") {
421            errors.push(ValidationError {
422                field: "output_gcs_uri".to_string(),
423                message: format!(
424                    "output_gcs_uri must be a GCS URI starting with 'gs://', got '{}'",
425                    self.output_gcs_uri
426                ),
427            });
428        }
429
430        if errors.is_empty() {
431            Ok(())
432        } else {
433            Err(errors)
434        }
435    }
436
437    /// Get the resolved model definition.
438    pub fn get_model(&self) -> Option<&'static VeoModel> {
439        ModelRegistry::resolve_veo(&self.model)
440    }
441}
442
443impl VideoExtendParams {
444    /// Validate the parameters against the model constraints.
445    ///
446    /// # Returns
447    /// - `Ok(())` if all parameters are valid
448    /// - `Err(Vec<ValidationError>)` with all validation errors
449    pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
450        let mut errors = Vec::new();
451
452        // Resolve the model to get constraints
453        let model = ModelRegistry::resolve_veo(&self.model);
454
455        // Validate model exists
456        if model.is_none() {
457            errors.push(ValidationError {
458                field: "model".to_string(),
459                message: format!(
460                    "Unknown model '{}'. Valid models: {}",
461                    self.model,
462                    VEO_MODELS
463                        .iter()
464                        .map(|m| m.id)
465                        .collect::<Vec<_>>()
466                        .join(", ")
467                ),
468            });
469        }
470
471        // Validate video_input is a valid GCS URI
472        if !self.video_input.starts_with("gs://") {
473            errors.push(ValidationError {
474                field: "video_input".to_string(),
475                message: format!(
476                    "video_input must be a GCS URI starting with 'gs://', got '{}'",
477                    self.video_input
478                ),
479            });
480        }
481
482        // Validate prompt is not empty
483        if self.prompt.trim().is_empty() {
484            errors.push(ValidationError {
485                field: "prompt".to_string(),
486                message: "Prompt cannot be empty".to_string(),
487            });
488        }
489
490        // Validate duration_seconds against model's supported durations
491        if let Some(model) = model {
492            if !model.supported_durations.contains(&self.duration_seconds) {
493                let durations_str: Vec<String> = model.supported_durations.iter().map(|d| d.to_string()).collect();
494                errors.push(ValidationError {
495                    field: "duration_seconds".to_string(),
496                    message: format!(
497                        "duration_seconds must be one of [{}] for model {}, got {}",
498                        durations_str.join(", "), model.id, self.duration_seconds
499                    ),
500                });
501            }
502        } else if !SUPPORTED_DURATIONS.contains(&self.duration_seconds) {
503            let durations_str: Vec<String> = SUPPORTED_DURATIONS.iter().map(|d| d.to_string()).collect();
504            errors.push(ValidationError {
505                field: "duration_seconds".to_string(),
506                message: format!(
507                    "duration_seconds must be one of [{}], got {}",
508                    durations_str.join(", "), self.duration_seconds
509                ),
510            });
511        }
512
513        // Validate output_gcs_uri is a valid GCS URI
514        if !self.output_gcs_uri.starts_with("gs://") {
515            errors.push(ValidationError {
516                field: "output_gcs_uri".to_string(),
517                message: format!(
518                    "output_gcs_uri must be a GCS URI starting with 'gs://', got '{}'",
519                    self.output_gcs_uri
520                ),
521            });
522        }
523
524        if errors.is_empty() {
525            Ok(())
526        } else {
527            Err(errors)
528        }
529    }
530
531    /// Get the resolved model definition.
532    pub fn get_model(&self) -> Option<&'static VeoModel> {
533        ModelRegistry::resolve_veo(&self.model)
534    }
535}
536
537/// Video generation handler.
538///
539/// Handles video generation requests using the Vertex AI Veo API.
540pub struct VideoHandler {
541    /// Application configuration.
542    pub config: Config,
543    /// GCS client for storage operations.
544    pub gcs: GcsClient,
545    /// HTTP client for API requests.
546    pub http: reqwest::Client,
547    /// Authentication provider.
548    pub auth: AuthProvider,
549}
550
551impl VideoHandler {
552    /// Create a new VideoHandler with the given configuration.
553    ///
554    /// # Errors
555    /// Returns an error if GCS client or auth provider initialization fails.
556    #[instrument(level = "debug", name = "video_handler_new", skip_all)]
557    pub async fn new(config: Config) -> Result<Self, Error> {
558        debug!("Initializing VideoHandler");
559
560        let auth = AuthProvider::new().await?;
561        let gcs = GcsClient::with_auth(AuthProvider::new().await?);
562        let http = reqwest::Client::new();
563
564        Ok(Self {
565            config,
566            gcs,
567            http,
568            auth,
569        })
570    }
571
572    /// Create a new VideoHandler with provided dependencies (for testing).
573    #[cfg(test)]
574    pub fn with_deps(config: Config, gcs: GcsClient, http: reqwest::Client, auth: AuthProvider) -> Self {
575        Self {
576            config,
577            gcs,
578            http,
579            auth,
580        }
581    }
582
583    /// Get the Vertex AI Veo API endpoint for generating videos.
584    pub fn get_generate_endpoint(&self, model: &str) -> String {
585        if self.config.is_gemini() {
586            format!("{}/models/{}:predictLongRunning", self.config.gemini_base_url(), model)
587        } else {
588            format!(
589                "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/{}:predictLongRunning",
590                self.config.location, self.config.project_id, self.config.location, model
591            )
592        }
593    }
594
595    /// Get the endpoint for fetching LRO status.
596    pub fn get_fetch_operation_endpoint(&self, model: &str) -> String {
597        if self.config.is_gemini() {
598            // Gemini uses GET on the operation name directly — endpoint not used the same way
599            // The operation_name IS the full path, so we just prepend the base URL
600            format!("{}", self.config.gemini_base_url())
601        } else {
602            format!(
603                "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/{}:fetchPredictOperation",
604                self.config.location, self.config.project_id, self.config.location, model
605            )
606        }
607    }
608
609    /// Add auth headers to a request based on provider.
610    async fn add_auth(&self, builder: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder, Error> {
611        if self.config.is_gemini() {
612            let key = self.config.gemini_api_key.as_deref().unwrap_or_default();
613            Ok(builder.header("x-goog-api-key", key))
614        } else {
615            let token = self.auth.get_token(&["https://www.googleapis.com/auth/cloud-platform"]).await?;
616            Ok(builder.header("Authorization", format!("Bearer {}", token)))
617        }
618    }
619
620    /// Generate video from a text prompt.
621    ///
622    /// # Arguments
623    /// * `params` - Video generation parameters
624    ///
625    /// # Returns
626    /// * `Ok(VideoGenerateResult)` - Generated video with GCS URI and optional local path
627    /// * `Err(Error)` - If validation fails, API call fails, or output handling fails
628    #[instrument(level = "info", name = "generate_video_t2v", skip(self, params), fields(model = %params.model, aspect_ratio = %params.aspect_ratio))]
629    pub async fn generate_video_t2v(&self, params: VideoT2vParams) -> Result<VideoGenerateResult, Error> {
630        // Validate parameters
631        params.validate().map_err(|errors| {
632            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
633            Error::validation(messages.join("; "))
634        })?;
635
636        // Resolve the model to get the canonical ID
637        let model = params.get_model().ok_or_else(|| {
638            Error::validation(format!("Unknown model: {}", params.model))
639        })?;
640
641        info!(model_id = model.id, "Generating video with Veo API (text-to-video)");
642
643        // Build the API request
644        let request = VeoT2vRequest {
645            instances: vec![VeoT2vInstance {
646                prompt: params.prompt.clone(),
647            }],
648            parameters: VeoParameters {
649                aspect_ratio: Some(params.aspect_ratio.clone()),
650                storage_uri: if self.config.is_gemini() { String::new() } else { params.output_gcs_uri.clone() },
651                duration_seconds: Some(params.duration_seconds),
652                generate_audio: if model.supports_audio { params.generate_audio } else { None },
653                seed: params.seed,
654            },
655        };
656
657        // Make API request to start LRO
658        let endpoint = self.get_generate_endpoint(model.id);
659        debug!(endpoint = %endpoint, "Calling Veo API");
660
661        let builder = self.http
662            .post(&endpoint)
663            .header("Content-Type", "application/json")
664            .json(&request);
665        let builder = self.add_auth(builder).await?;
666
667        let response = builder
668            .send()
669            .await
670            .map_err(|e| Error::api(&endpoint, 0, format!("Request failed: {}", e)))?;
671
672        let status = response.status();
673        if !status.is_success() {
674            let body = response.text().await.unwrap_or_default();
675            return Err(Error::api(&endpoint, status.as_u16(), body));
676        }
677
678        // Parse LRO response
679        let lro_response: LroResponse = response.json().await.map_err(|e| {
680            Error::api(&endpoint, status.as_u16(), format!("Failed to parse LRO response: {}", e))
681        })?;
682
683        info!(operation_name = %lro_response.name, "Started video generation LRO");
684
685        // Poll for completion
686        let result = self.poll_lro(&lro_response.name, model.id).await?;
687
688        // Handle output
689        self.handle_output(result, &params.output_gcs_uri, params.download_local, params.local_path.as_deref()).await
690    }
691
692    /// Generate video from an image.
693    ///
694    /// # Arguments
695    /// * `params` - Image-to-video generation parameters
696    ///
697    /// # Returns
698    /// * `Ok(VideoGenerateResult)` - Generated video with GCS URI and optional local path
699    /// * `Err(Error)` - If validation fails, API call fails, or output handling fails
700    #[instrument(level = "info", name = "generate_video_i2v", skip(self, params), fields(model = %params.model, aspect_ratio = %params.aspect_ratio))]
701    pub async fn generate_video_i2v(&self, params: VideoI2vParams) -> Result<VideoGenerateResult, Error> {
702        // Validate parameters
703        params.validate().map_err(|errors| {
704            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
705            Error::validation(messages.join("; "))
706        })?;
707
708        // Resolve the model to get the canonical ID
709        let model = params.get_model().ok_or_else(|| {
710            Error::validation(format!("Unknown model: {}", params.model))
711        })?;
712
713        // Determine mode: interpolation or standard I2V
714        let is_interpolation = params.last_frame_image.is_some();
715        if is_interpolation {
716            info!(model_id = model.id, "Generating video with Veo API (interpolation mode)");
717        } else {
718            info!(model_id = model.id, "Generating video with Veo API (image-to-video)");
719        }
720
721        // Resolve the image input (first frame)
722        let image_data = self.resolve_image_input(&params.image).await?;
723
724        // Resolve last frame if provided (interpolation mode)
725        let last_frame = if let Some(ref last_frame_path) = params.last_frame_image {
726            let last_frame_data = self.resolve_image_input(last_frame_path).await?;
727            Some(VeoImageInput {
728                bytes_base64_encoded: last_frame_data,
729            })
730        } else {
731            None
732        };
733
734        // Build the API request
735        let request = VeoI2vRequest {
736            instances: vec![VeoI2vInstance {
737                prompt: params.prompt.clone(),
738                image: VeoImageInput {
739                    bytes_base64_encoded: image_data,
740                },
741            }],
742            parameters: VeoI2vParameters {
743                aspect_ratio: Some(params.aspect_ratio.clone()),
744                storage_uri: if self.config.is_gemini() { String::new() } else { params.output_gcs_uri.clone() },
745                duration_seconds: Some(params.duration_seconds),
746                generate_audio: None, // I2V doesn't support audio generation
747                seed: params.seed,
748                last_frame,
749            },
750        };
751
752        // Make API request to start LRO
753        let endpoint = self.get_generate_endpoint(model.id);
754        debug!(endpoint = %endpoint, "Calling Veo API");
755
756        let builder = self.http
757            .post(&endpoint)
758            .header("Content-Type", "application/json")
759            .json(&request);
760        let builder = self.add_auth(builder).await?;
761
762        let response = builder
763            .send()
764            .await
765            .map_err(|e| Error::api(&endpoint, 0, format!("Request failed: {}", e)))?;
766
767        let status = response.status();
768        if !status.is_success() {
769            let body = response.text().await.unwrap_or_default();
770            return Err(Error::api(&endpoint, status.as_u16(), body));
771        }
772
773        // Parse LRO response
774        let lro_response: LroResponse = response.json().await.map_err(|e| {
775            Error::api(&endpoint, status.as_u16(), format!("Failed to parse LRO response: {}", e))
776        })?;
777
778        info!(operation_name = %lro_response.name, "Started video generation LRO");
779
780        // Poll for completion
781        let result = self.poll_lro(&lro_response.name, model.id).await?;
782
783        // Handle output
784        self.handle_output(result, &params.output_gcs_uri, params.download_local, params.local_path.as_deref()).await
785    }
786
787    /// Extend an existing video.
788    ///
789    /// # Arguments
790    /// * `params` - Video extension parameters
791    ///
792    /// # Returns
793    /// * `Ok(VideoGenerateResult)` - Extended video with GCS URI and optional local path
794    /// * `Err(Error)` - If validation fails, API call fails, or output handling fails
795    #[instrument(level = "info", name = "extend_video", skip(self, params), fields(model = %params.model))]
796    pub async fn extend_video(&self, params: VideoExtendParams) -> Result<VideoGenerateResult, Error> {
797        // Validate parameters
798        params.validate().map_err(|errors| {
799            let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
800            Error::validation(messages.join("; "))
801        })?;
802
803        // Resolve the model to get the canonical ID
804        let model = params.get_model().ok_or_else(|| {
805            Error::validation(format!("Unknown model: {}", params.model))
806        })?;
807
808        info!(model_id = model.id, "Extending video with Veo API");
809
810        // Build the API request
811        let request = VeoExtendRequest {
812            instances: vec![VeoExtendInstance {
813                prompt: params.prompt.clone(),
814                video: VeoVideoInput {
815                    gcs_uri: params.video_input.clone(),
816                    mime_type: "video/mp4".to_string(),
817                },
818            }],
819            parameters: VeoExtendParameters {
820                storage_uri: if self.config.is_gemini() { String::new() } else { params.output_gcs_uri.clone() },
821                duration_seconds: Some(params.duration_seconds),
822                seed: params.seed,
823            },
824        };
825
826        // Make API request to start LRO
827        let endpoint = self.get_generate_endpoint(model.id);
828        debug!(endpoint = %endpoint, "Calling Veo API");
829
830        let builder = self.http
831            .post(&endpoint)
832            .header("Content-Type", "application/json")
833            .json(&request);
834        let builder = self.add_auth(builder).await?;
835
836        let response = builder
837            .send()
838            .await
839            .map_err(|e| Error::api(&endpoint, 0, format!("Request failed: {}", e)))?;
840
841        let status = response.status();
842        if !status.is_success() {
843            let body = response.text().await.unwrap_or_default();
844            return Err(Error::api(&endpoint, status.as_u16(), body));
845        }
846
847        // Parse LRO response
848        let lro_response: LroResponse = response.json().await.map_err(|e| {
849            Error::api(&endpoint, status.as_u16(), format!("Failed to parse LRO response: {}", e))
850        })?;
851
852        info!(operation_name = %lro_response.name, "Started video extension LRO");
853
854        // Poll for completion
855        let result = self.poll_lro(&lro_response.name, model.id).await?;
856
857        // Handle output
858        self.handle_output(result, &params.output_gcs_uri, params.download_local, params.local_path.as_deref()).await
859    }
860
861    /// Resolve image input to base64 data.
862    ///
863    /// Handles three input formats:
864    /// - Base64 data (already encoded)
865    /// - Local file path
866    /// - GCS URI
867    async fn resolve_image_input(&self, image: &str) -> Result<String, Error> {
868        // Check if it's a GCS URI first (explicit protocol)
869        if image.starts_with("gs://") {
870            let uri = GcsUri::parse(image)?;
871            let data = self.gcs.download(&uri).await?;
872            return Ok(BASE64.encode(&data));
873        }
874
875        // Check if it looks like a file path:
876        // - Starts with / (absolute path)
877        // - Starts with ./ or ../ (relative path)
878        // - Contains common path patterns like file extensions with preceding path separators
879        // - Is short enough to be a reasonable path (base64 images are typically very long)
880        let looks_like_path = image.starts_with('/')
881            || image.starts_with("./")
882            || image.starts_with("../")
883            || image.starts_with("~/")
884            || (image.len() < 500 && image.contains('/') && Self::has_file_extension(image));
885
886        if looks_like_path {
887            // Treat as local file path
888            let path = Path::new(image);
889            if !path.exists() {
890                return Err(Error::validation(format!("Image file not found: {}", image)));
891            }
892            let data = tokio::fs::read(path).await?;
893            return Ok(BASE64.encode(&data));
894        }
895
896        // Try to validate as base64 - if it decodes successfully, it's base64
897        // This handles the case where base64 contains '/' characters
898        if image.len() > 100 {
899            if BASE64.decode(image).is_ok() {
900                return Ok(image.to_string());
901            }
902        }
903
904        // Last resort: try as file path (might be a relative path without ./)
905        let path = Path::new(image);
906        if path.exists() {
907            let data = tokio::fs::read(path).await?;
908            return Ok(BASE64.encode(&data));
909        }
910
911        // If nothing worked and it's long, assume it's base64 (might be malformed)
912        if image.len() > 100 {
913            return Ok(image.to_string());
914        }
915
916        Err(Error::validation(format!(
917            "Image input '{}' is not a valid file path, GCS URI, or base64 data",
918            if image.len() > 50 { &image[..50] } else { image }
919        )))
920    }
921
922    /// Check if a string ends with a common image file extension.
923    fn has_file_extension(s: &str) -> bool {
924        let lower = s.to_lowercase();
925        lower.ends_with(".png")
926            || lower.ends_with(".jpg")
927            || lower.ends_with(".jpeg")
928            || lower.ends_with(".gif")
929            || lower.ends_with(".webp")
930            || lower.ends_with(".bmp")
931            || lower.ends_with(".tiff")
932            || lower.ends_with(".tif")
933    }
934
935    /// Poll a long-running operation until completion.
936    ///
937    /// Uses exponential backoff with configurable parameters.
938    /// Uses the fetchPredictOperation endpoint which requires the operation name in the request body.
939    pub async fn poll_lro(&self, operation_name: &str, model: &str) -> Result<LroResult, Error> {
940        let mut delay_ms = LRO_INITIAL_DELAY_MS;
941        let mut attempts = 0;
942
943        loop {
944            attempts += 1;
945            if attempts > LRO_MAX_ATTEMPTS {
946                // Calculate approximate timeout in seconds
947                let timeout_seconds = (LRO_MAX_ATTEMPTS as u64) * (LRO_MAX_DELAY_MS / 1000);
948                return Err(Error::timeout(timeout_seconds));
949            }
950
951            // Wait before polling
952            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
953
954            // Poll the operation
955            let response = if self.config.is_gemini() {
956                // Gemini: GET {base_url}/{operation_name}
957                let endpoint = format!("{}/{}", self.config.gemini_base_url(), operation_name);
958                debug!(endpoint = %endpoint, attempt = attempts, "Polling LRO (Gemini)");
959                let builder = self.http.get(&endpoint);
960                let builder = self.add_auth(builder).await?;
961                builder.send().await
962                    .map_err(|e| Error::api(&endpoint, 0, format!("Poll request failed: {}", e)))?
963            } else {
964                // Vertex: POST fetchPredictOperation with operation name in body
965                let endpoint = self.get_fetch_operation_endpoint(model);
966                debug!(endpoint = %endpoint, attempt = attempts, "Polling LRO (Vertex)");
967                let fetch_request = FetchOperationRequest {
968                    operation_name: operation_name.to_string(),
969                };
970                let builder = self.http
971                    .post(&endpoint)
972                    .header("Content-Type", "application/json")
973                    .json(&fetch_request);
974                let builder = self.add_auth(builder).await?;
975                builder.send().await
976                    .map_err(|e| Error::api(&endpoint, 0, format!("Poll request failed: {}", e)))?
977            };
978
979            let poll_endpoint = format!("poll:{}", operation_name);
980            let status = response.status();
981            if !status.is_success() {
982                let body = response.text().await.unwrap_or_default();
983                return Err(Error::api(&poll_endpoint, status.as_u16(), body));
984            }
985
986            let lro_status: LroStatusResponse = response.json().await.map_err(|e| {
987                Error::api(&poll_endpoint, status.as_u16(), format!("Failed to parse LRO status: {}", e))
988            })?;
989
990            if lro_status.done.unwrap_or(false) {
991                // Check for error
992                if let Some(error) = lro_status.error {
993                    return Err(Error::api(
994                        &poll_endpoint,
995                        error.code.unwrap_or(500) as u16,
996                        error.message.unwrap_or_else(|| "Unknown error".to_string()),
997                    ));
998                }
999
1000                // Return the result
1001                if let Some(response) = lro_status.response {
1002                    info!(operation_name = %operation_name, attempts = attempts, "LRO completed successfully");
1003                    // Handle both Vertex (videos) and Gemini (generateVideoResponse) formats
1004                    let videos = if let Some(vids) = response.videos {
1005                        vids
1006                    } else if let Some(gemini_resp) = response.generate_video_response {
1007                        // Convert Gemini format to VideoOutput
1008                        gemini_resp.generated_samples.unwrap_or_default()
1009                            .into_iter()
1010                            .filter_map(|s| s.video.map(|v| VideoOutput {
1011                                gcs_uri: v.uri,
1012                                mime_type: Some("video/mp4".to_string()),
1013                            }))
1014                            .collect()
1015                    } else {
1016                        Vec::new()
1017                    };
1018                    return Ok(LroResult { videos });
1019                }
1020
1021                return Err(Error::api(&poll_endpoint, 200, "LRO completed but no response found"));
1022            }
1023
1024            // Increase delay with exponential backoff
1025            delay_ms = ((delay_ms as f64) * LRO_BACKOFF_MULTIPLIER) as u64;
1026            delay_ms = delay_ms.min(LRO_MAX_DELAY_MS);
1027
1028            debug!(
1029                operation_name = %operation_name,
1030                attempt = attempts,
1031                next_delay_ms = delay_ms,
1032                "LRO still in progress"
1033            );
1034        }
1035    }
1036
1037    /// Handle output of generated video.
1038    async fn handle_output(
1039        &self,
1040        result: LroResult,
1041        output_gcs_uri: &str,
1042        download_local: bool,
1043        local_path: Option<&str>,
1044    ) -> Result<VideoGenerateResult, Error> {
1045        // Get the first generated video
1046        let video = result.videos.first().ok_or_else(|| {
1047            Error::api("", 200, "No video generated")
1048        })?;
1049
1050        let video_uri = video.gcs_uri.clone()
1051            .unwrap_or_else(|| output_gcs_uri.to_string());
1052
1053        info!(video_uri = %video_uri, "Video generated successfully");
1054
1055        // If download_local is requested, download the video
1056        if download_local {
1057            let local_file = if let Some(path) = local_path {
1058                path.to_string()
1059            } else {
1060                "output.mp4".to_string()
1061            };
1062
1063            if video_uri.starts_with("gs://") {
1064                // Download from GCS
1065                let uri = GcsUri::parse(&video_uri)?;
1066                let data = self.gcs.download(&uri).await?;
1067                tokio::fs::write(&local_file, &data).await?;
1068            } else {
1069                // Direct HTTP download (Gemini API returns download URLs)
1070                let builder = self.http.get(&video_uri);
1071                let builder = self.add_auth(builder).await?;
1072                let response = builder.send().await
1073                    .map_err(|e| Error::api(&video_uri, 0, format!("Download failed: {}", e)))?;
1074                if !response.status().is_success() {
1075                    let status = response.status().as_u16();
1076                    let body = response.text().await.unwrap_or_default();
1077                    return Err(Error::api(&video_uri, status, body));
1078                }
1079                let data = response.bytes().await
1080                    .map_err(|e| Error::api(&video_uri, 0, format!("Download read failed: {}", e)))?;
1081                tokio::fs::write(&local_file, &data).await?;
1082            }
1083
1084            info!(local_file = %local_file, "Video downloaded locally");
1085
1086            return Ok(VideoGenerateResult {
1087                gcs_uri: video_uri,
1088                local_path: Some(local_file),
1089            });
1090        }
1091
1092        Ok(VideoGenerateResult {
1093            gcs_uri: video_uri,
1094            local_path: None,
1095        })
1096    }
1097}
1098
1099// =============================================================================
1100// API Request/Response Types
1101// =============================================================================
1102
1103/// Vertex AI Veo API request for text-to-video.
1104#[derive(Debug, Serialize)]
1105pub struct VeoT2vRequest {
1106    /// Input instances (prompts)
1107    pub instances: Vec<VeoT2vInstance>,
1108    /// Generation parameters
1109    pub parameters: VeoParameters,
1110}
1111
1112/// Veo API instance for text-to-video.
1113#[derive(Debug, Serialize)]
1114pub struct VeoT2vInstance {
1115    /// Text prompt describing the video
1116    pub prompt: String,
1117}
1118
1119/// Vertex AI Veo API request for image-to-video.
1120#[derive(Debug, Serialize)]
1121pub struct VeoI2vRequest {
1122    /// Input instances (image + prompt)
1123    pub instances: Vec<VeoI2vInstance>,
1124    /// Generation parameters
1125    pub parameters: VeoI2vParameters,
1126}
1127
1128/// Veo API instance for image-to-video.
1129#[derive(Debug, Serialize)]
1130pub struct VeoI2vInstance {
1131    /// Text prompt describing the desired motion
1132    pub prompt: String,
1133    /// Source image (first frame)
1134    pub image: VeoImageInput,
1135}
1136
1137/// Veo image input.
1138#[derive(Debug, Serialize)]
1139#[serde(rename_all = "camelCase")]
1140pub struct VeoImageInput {
1141    /// Base64-encoded image data
1142    pub bytes_base64_encoded: String,
1143}
1144
1145/// Veo API parameters for I2V (includes last_frame for interpolation).
1146#[derive(Debug, Serialize)]
1147#[serde(rename_all = "camelCase")]
1148pub struct VeoI2vParameters {
1149    /// Aspect ratio
1150    #[serde(skip_serializing_if = "Option::is_none")]
1151    pub aspect_ratio: Option<String>,
1152    /// GCS URI for output (API expects "storageUri")
1153    #[serde(rename = "storageUri", skip_serializing_if = "String::is_empty")]
1154    pub storage_uri: String,
1155    /// Duration in seconds
1156    #[serde(skip_serializing_if = "Option::is_none")]
1157    pub duration_seconds: Option<u8>,
1158    /// Whether to generate audio (Veo 3.x only)
1159    #[serde(skip_serializing_if = "Option::is_none")]
1160    pub generate_audio: Option<bool>,
1161    /// Random seed for reproducibility
1162    #[serde(skip_serializing_if = "Option::is_none")]
1163    pub seed: Option<i64>,
1164    /// Last frame for interpolation mode
1165    #[serde(skip_serializing_if = "Option::is_none")]
1166    pub last_frame: Option<VeoImageInput>,
1167}
1168
1169/// Vertex AI Veo API request for video extension.
1170#[derive(Debug, Serialize)]
1171pub struct VeoExtendRequest {
1172    /// Input instances (video + prompt)
1173    pub instances: Vec<VeoExtendInstance>,
1174    /// Generation parameters
1175    pub parameters: VeoExtendParameters,
1176}
1177
1178/// Veo API instance for video extension.
1179#[derive(Debug, Serialize)]
1180pub struct VeoExtendInstance {
1181    /// Text prompt describing the desired continuation
1182    pub prompt: String,
1183    /// Source video to extend
1184    pub video: VeoVideoInput,
1185}
1186
1187/// Veo video input.
1188#[derive(Debug, Serialize)]
1189#[serde(rename_all = "camelCase")]
1190pub struct VeoVideoInput {
1191    /// GCS URI of the video
1192    pub gcs_uri: String,
1193    /// MIME type of the video
1194    pub mime_type: String,
1195}
1196
1197/// Veo API parameters for video extension.
1198#[derive(Debug, Serialize)]
1199#[serde(rename_all = "camelCase")]
1200pub struct VeoExtendParameters {
1201    /// GCS URI for output (API expects "storageUri")
1202    #[serde(rename = "storageUri", skip_serializing_if = "String::is_empty")]
1203    pub storage_uri: String,
1204    /// Duration in seconds
1205    #[serde(skip_serializing_if = "Option::is_none")]
1206    pub duration_seconds: Option<u8>,
1207    /// Random seed for reproducibility
1208    #[serde(skip_serializing_if = "Option::is_none")]
1209    pub seed: Option<i64>,
1210}
1211
1212/// Veo API parameters.
1213#[derive(Debug, Serialize)]
1214#[serde(rename_all = "camelCase")]
1215pub struct VeoParameters {
1216    /// Aspect ratio
1217    #[serde(skip_serializing_if = "Option::is_none")]
1218    pub aspect_ratio: Option<String>,
1219    /// GCS URI for output (API expects "storageUri")
1220    #[serde(rename = "storageUri", skip_serializing_if = "String::is_empty")]
1221    pub storage_uri: String,
1222    /// Duration in seconds
1223    #[serde(skip_serializing_if = "Option::is_none")]
1224    pub duration_seconds: Option<u8>,
1225    /// Whether to generate audio (Veo 3.x only)
1226    #[serde(skip_serializing_if = "Option::is_none")]
1227    pub generate_audio: Option<bool>,
1228    /// Random seed for reproducibility
1229    #[serde(skip_serializing_if = "Option::is_none")]
1230    pub seed: Option<i64>,
1231}
1232
1233/// Long-running operation response.
1234#[derive(Debug, Deserialize)]
1235pub struct LroResponse {
1236    /// Operation name for polling
1237    pub name: String,
1238}
1239
1240/// Request to fetch operation status.
1241#[derive(Debug, Serialize)]
1242#[serde(rename_all = "camelCase")]
1243pub struct FetchOperationRequest {
1244    /// The operation name to fetch
1245    pub operation_name: String,
1246}
1247
1248/// Long-running operation status response.
1249#[derive(Debug, Deserialize)]
1250pub struct LroStatusResponse {
1251    /// Whether the operation is complete
1252    pub done: Option<bool>,
1253    /// Error if the operation failed
1254    pub error: Option<LroError>,
1255    /// Response if the operation succeeded
1256    pub response: Option<LroResultResponse>,
1257}
1258
1259/// LRO error details.
1260#[derive(Debug, Deserialize)]
1261pub struct LroError {
1262    /// Error code
1263    pub code: Option<i32>,
1264    /// Error message
1265    pub message: Option<String>,
1266}
1267
1268/// LRO result response — supports both Vertex AI and Gemini API formats.
1269#[derive(Debug, Deserialize)]
1270#[serde(rename_all = "camelCase")]
1271pub struct LroResultResponse {
1272    /// Vertex AI format: "videos" array
1273    pub videos: Option<Vec<VideoOutput>>,
1274    /// Gemini API format: "generateVideoResponse"
1275    pub generate_video_response: Option<GeminiVideoResponse>,
1276    /// Count of videos filtered by RAI policies
1277    #[serde(default)]
1278    pub rai_media_filtered_count: Option<i32>,
1279}
1280
1281/// Gemini API video response format.
1282#[derive(Debug, Deserialize)]
1283#[serde(rename_all = "camelCase")]
1284pub struct GeminiVideoResponse {
1285    pub generated_samples: Option<Vec<GeminiGeneratedSample>>,
1286}
1287
1288/// Gemini API generated sample.
1289#[derive(Debug, Deserialize)]
1290#[serde(rename_all = "camelCase")]
1291pub struct GeminiGeneratedSample {
1292    pub video: Option<GeminiVideoOutput>,
1293}
1294
1295/// Gemini API video output.
1296#[derive(Debug, Deserialize)]
1297#[serde(rename_all = "camelCase")]
1298pub struct GeminiVideoOutput {
1299    pub uri: Option<String>,
1300}
1301
1302/// Video output from Veo API (Vertex AI format).
1303#[derive(Debug, Deserialize)]
1304#[serde(rename_all = "camelCase")]
1305pub struct VideoOutput {
1306    /// GCS URI of the generated video
1307    pub gcs_uri: Option<String>,
1308    /// MIME type of the video
1309    pub mime_type: Option<String>,
1310}
1311
1312// =============================================================================
1313// Result Types
1314// =============================================================================
1315
1316/// Internal LRO result.
1317#[derive(Debug)]
1318pub struct LroResult {
1319    /// Generated videos
1320    pub videos: Vec<VideoOutput>,
1321}
1322
1323/// Result of video generation.
1324#[derive(Debug)]
1325pub struct VideoGenerateResult {
1326    /// GCS URI of the generated video
1327    pub gcs_uri: String,
1328    /// Local file path if downloaded
1329    pub local_path: Option<String>,
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335
1336    #[test]
1337    fn test_default_t2v_params() {
1338        let params: VideoT2vParams = serde_json::from_str(r#"{
1339            "prompt": "A cat walking",
1340            "output_gcs_uri": "gs://bucket/output.mp4"
1341        }"#).unwrap();
1342        assert_eq!(params.model, DEFAULT_MODEL);
1343        assert_eq!(params.aspect_ratio, DEFAULT_ASPECT_RATIO);
1344        assert_eq!(params.duration_seconds, DEFAULT_DURATION_SECONDS);
1345        assert!(!params.download_local);
1346        assert!(params.generate_audio.is_none());
1347        assert!(params.seed.is_none());
1348    }
1349
1350    #[test]
1351    fn test_valid_t2v_params() {
1352        let params = VideoT2vParams {
1353            prompt: "A beautiful sunset over mountains".to_string(),
1354            model: "veo-3".to_string(),
1355            aspect_ratio: "16:9".to_string(),
1356            duration_seconds: 6,
1357            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1358            download_local: false,
1359            local_path: None,
1360            generate_audio: Some(true),
1361            seed: Some(42),
1362        };
1363
1364        assert!(params.validate().is_ok());
1365    }
1366
1367    #[test]
1368    fn test_invalid_duration_too_low() {
1369        let params = VideoT2vParams {
1370            prompt: "A cat".to_string(),
1371            model: DEFAULT_MODEL.to_string(),
1372            aspect_ratio: "16:9".to_string(),
1373            duration_seconds: 3, // Below minimum
1374            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1375            download_local: false,
1376            local_path: None,
1377            generate_audio: None,
1378            seed: None,
1379        };
1380
1381        let result = params.validate();
1382        assert!(result.is_err());
1383        let errors = result.unwrap_err();
1384        assert!(errors.iter().any(|e| e.field == "duration_seconds"));
1385    }
1386
1387    #[test]
1388    fn test_invalid_duration_too_high() {
1389        let params = VideoT2vParams {
1390            prompt: "A cat".to_string(),
1391            model: DEFAULT_MODEL.to_string(),
1392            aspect_ratio: "16:9".to_string(),
1393            duration_seconds: 15, // Above maximum
1394            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1395            download_local: false,
1396            local_path: None,
1397            generate_audio: None,
1398            seed: None,
1399        };
1400
1401        let result = params.validate();
1402        assert!(result.is_err());
1403        let errors = result.unwrap_err();
1404        assert!(errors.iter().any(|e| e.field == "duration_seconds"));
1405    }
1406
1407    #[test]
1408    fn test_invalid_aspect_ratio() {
1409        let params = VideoT2vParams {
1410            prompt: "A cat".to_string(),
1411            model: DEFAULT_MODEL.to_string(),
1412            aspect_ratio: "4:3".to_string(), // Not valid for Veo
1413            duration_seconds: 6,
1414            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1415            download_local: false,
1416            local_path: None,
1417            generate_audio: None,
1418            seed: None,
1419        };
1420
1421        let result = params.validate();
1422        assert!(result.is_err());
1423        let errors = result.unwrap_err();
1424        assert!(errors.iter().any(|e| e.field == "aspect_ratio"));
1425    }
1426
1427    #[test]
1428    fn test_invalid_model() {
1429        let params = VideoT2vParams {
1430            prompt: "A cat".to_string(),
1431            model: "unknown-model".to_string(),
1432            aspect_ratio: "16:9".to_string(),
1433            duration_seconds: 6,
1434            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1435            download_local: false,
1436            local_path: None,
1437            generate_audio: None,
1438            seed: None,
1439        };
1440
1441        let result = params.validate();
1442        assert!(result.is_err());
1443        let errors = result.unwrap_err();
1444        assert!(errors.iter().any(|e| e.field == "model"));
1445    }
1446
1447    #[test]
1448    fn test_empty_prompt() {
1449        let params = VideoT2vParams {
1450            prompt: "   ".to_string(),
1451            model: DEFAULT_MODEL.to_string(),
1452            aspect_ratio: "16:9".to_string(),
1453            duration_seconds: 6,
1454            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1455            download_local: false,
1456            local_path: None,
1457            generate_audio: None,
1458            seed: None,
1459        };
1460
1461        let result = params.validate();
1462        assert!(result.is_err());
1463        let errors = result.unwrap_err();
1464        assert!(errors.iter().any(|e| e.field == "prompt"));
1465    }
1466
1467    #[test]
1468    fn test_invalid_gcs_uri() {
1469        let params = VideoT2vParams {
1470            prompt: "A cat".to_string(),
1471            model: DEFAULT_MODEL.to_string(),
1472            aspect_ratio: "16:9".to_string(),
1473            duration_seconds: 6,
1474            output_gcs_uri: "/local/path/output.mp4".to_string(), // Not a GCS URI
1475            download_local: false,
1476            local_path: None,
1477            generate_audio: None,
1478            seed: None,
1479        };
1480
1481        let result = params.validate();
1482        assert!(result.is_err());
1483        let errors = result.unwrap_err();
1484        assert!(errors.iter().any(|e| e.field == "output_gcs_uri"));
1485    }
1486
1487    #[test]
1488    fn test_generate_audio_on_veo2_fails() {
1489        let params = VideoT2vParams {
1490            prompt: "A cat".to_string(),
1491            model: "veo-2".to_string(), // Veo 2 doesn't support audio
1492            aspect_ratio: "16:9".to_string(),
1493            duration_seconds: 6,
1494            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1495            download_local: false,
1496            local_path: None,
1497            generate_audio: Some(true), // Should fail
1498            seed: None,
1499        };
1500
1501        let result = params.validate();
1502        assert!(result.is_err());
1503        let errors = result.unwrap_err();
1504        assert!(errors.iter().any(|e| e.field == "generate_audio"));
1505    }
1506
1507    #[test]
1508    fn test_generate_audio_on_veo3_succeeds() {
1509        let params = VideoT2vParams {
1510            prompt: "A cat".to_string(),
1511            model: "veo-3".to_string(), // Veo 3 supports audio
1512            aspect_ratio: "16:9".to_string(),
1513            duration_seconds: 6,
1514            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1515            download_local: false,
1516            local_path: None,
1517            generate_audio: Some(true),
1518            seed: None,
1519        };
1520
1521        assert!(params.validate().is_ok());
1522    }
1523
1524    #[test]
1525    fn test_all_valid_aspect_ratios() {
1526        for ratio in VALID_ASPECT_RATIOS {
1527            let params = VideoT2vParams {
1528                prompt: "A cat".to_string(),
1529                model: DEFAULT_MODEL.to_string(),
1530                aspect_ratio: ratio.to_string(),
1531                duration_seconds: 6,
1532                output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1533                download_local: false,
1534                local_path: None,
1535                generate_audio: None,
1536                seed: None,
1537            };
1538            assert!(params.validate().is_ok(), "Aspect ratio {} should be valid", ratio);
1539        }
1540    }
1541
1542    #[test]
1543    fn test_all_valid_durations() {
1544        for dur in SUPPORTED_DURATIONS {
1545            let params = VideoT2vParams {
1546                prompt: "A cat".to_string(),
1547                model: DEFAULT_MODEL.to_string(),
1548                aspect_ratio: "16:9".to_string(),
1549                duration_seconds: *dur,
1550                output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1551                download_local: false,
1552                local_path: None,
1553                generate_audio: None,
1554                seed: None,
1555            };
1556            assert!(params.validate().is_ok(), "Duration {} should be valid", dur);
1557        }
1558    }
1559
1560    #[test]
1561    fn test_get_model() {
1562        let params = VideoT2vParams {
1563            prompt: "A cat".to_string(),
1564            model: "veo-3".to_string(),
1565            aspect_ratio: "16:9".to_string(),
1566            duration_seconds: 6,
1567            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1568            download_local: false,
1569            local_path: None,
1570            generate_audio: None,
1571            seed: None,
1572        };
1573
1574        let model = params.get_model();
1575        assert!(model.is_some());
1576        assert_eq!(model.unwrap().id, "veo-3.0-generate-preview");
1577    }
1578
1579    // I2V tests
1580    #[test]
1581    fn test_default_i2v_params() {
1582        let params: VideoI2vParams = serde_json::from_str(r#"{
1583            "image": "base64data",
1584            "prompt": "A cat walking",
1585            "output_gcs_uri": "gs://bucket/output.mp4"
1586        }"#).unwrap();
1587        assert_eq!(params.model, DEFAULT_MODEL);
1588        assert_eq!(params.aspect_ratio, DEFAULT_ASPECT_RATIO);
1589        assert_eq!(params.duration_seconds, DEFAULT_DURATION_SECONDS);
1590        assert!(!params.download_local);
1591    }
1592
1593    #[test]
1594    fn test_valid_i2v_params() {
1595        let params = VideoI2vParams {
1596            image: "base64imagedata".to_string(),
1597            prompt: "The cat starts walking".to_string(),
1598            last_frame_image: None,
1599            model: "veo-3".to_string(),
1600            aspect_ratio: "16:9".to_string(),
1601            duration_seconds: 6,
1602            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1603            download_local: false,
1604            local_path: None,
1605            seed: Some(42),
1606        };
1607
1608        assert!(params.validate().is_ok());
1609    }
1610
1611    #[test]
1612    fn test_i2v_empty_image() {
1613        let params = VideoI2vParams {
1614            image: "   ".to_string(),
1615            prompt: "The cat starts walking".to_string(),
1616            last_frame_image: None,
1617            model: DEFAULT_MODEL.to_string(),
1618            aspect_ratio: "16:9".to_string(),
1619            duration_seconds: 6,
1620            output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1621            download_local: false,
1622            local_path: None,
1623            seed: None,
1624        };
1625
1626        let result = params.validate();
1627        assert!(result.is_err());
1628        let errors = result.unwrap_err();
1629        assert!(errors.iter().any(|e| e.field == "image"));
1630    }
1631
1632    #[test]
1633    fn test_validation_error_display() {
1634        let error = ValidationError {
1635            field: "prompt".to_string(),
1636            message: "cannot be empty".to_string(),
1637        };
1638
1639        let display = format!("{}", error);
1640        assert_eq!(display, "prompt: cannot be empty");
1641    }
1642
1643    #[test]
1644    fn test_validation_multiple_errors() {
1645        let params = VideoT2vParams {
1646            prompt: "   ".to_string(), // Empty prompt
1647            model: "unknown-model".to_string(), // Invalid model
1648            aspect_ratio: "invalid".to_string(), // Invalid aspect ratio
1649            duration_seconds: 100, // Out of range
1650            output_gcs_uri: "/local/path".to_string(), // Invalid GCS URI
1651            download_local: false,
1652            local_path: None,
1653            generate_audio: None,
1654            seed: None,
1655        };
1656
1657        let result = params.validate();
1658        assert!(result.is_err());
1659        
1660        let errors = result.unwrap_err();
1661        assert!(errors.len() >= 3, "Expected at least 3 validation errors, got {}", errors.len());
1662    }
1663
1664    // Tests for base64 detection (P2 fix)
1665    #[test]
1666    fn test_has_file_extension_png() {
1667        assert!(VideoHandler::has_file_extension("image.png"));
1668        assert!(VideoHandler::has_file_extension("path/to/image.PNG"));
1669    }
1670
1671    #[test]
1672    fn test_has_file_extension_jpg() {
1673        assert!(VideoHandler::has_file_extension("photo.jpg"));
1674        assert!(VideoHandler::has_file_extension("photo.jpeg"));
1675        assert!(VideoHandler::has_file_extension("photo.JPEG"));
1676    }
1677
1678    #[test]
1679    fn test_has_file_extension_other_formats() {
1680        assert!(VideoHandler::has_file_extension("image.gif"));
1681        assert!(VideoHandler::has_file_extension("image.webp"));
1682        assert!(VideoHandler::has_file_extension("image.bmp"));
1683        assert!(VideoHandler::has_file_extension("image.tiff"));
1684        assert!(VideoHandler::has_file_extension("image.tif"));
1685    }
1686
1687    #[test]
1688    fn test_has_file_extension_no_extension() {
1689        assert!(!VideoHandler::has_file_extension("noextension"));
1690        assert!(!VideoHandler::has_file_extension("path/to/file"));
1691    }
1692
1693    #[test]
1694    fn test_has_file_extension_wrong_extension() {
1695        assert!(!VideoHandler::has_file_extension("file.txt"));
1696        assert!(!VideoHandler::has_file_extension("file.mp4"));
1697        assert!(!VideoHandler::has_file_extension("file.pdf"));
1698    }
1699}
1700
1701
1702#[cfg(test)]
1703mod property_tests {
1704    use super::*;
1705    use proptest::prelude::*;
1706
1707    // Feature: rust-mcp-genmedia, Property 8: Numeric Parameter Range Validation (duration_seconds)
1708    // **Validates: Requirements 5.4, 5.6**
1709    //
1710    // For any numeric parameter with defined bounds (duration_seconds 4, 6, 8),
1711    // values outside the valid set SHALL be rejected with a validation error.
1712
1713    /// Strategy to generate valid duration_seconds values (4, 6, 8)
1714    fn valid_duration_strategy() -> impl Strategy<Value = u8> {
1715        prop_oneof![
1716            Just(4u8),
1717            Just(6u8),
1718            Just(8u8),
1719        ]
1720    }
1721
1722    /// Strategy to generate invalid duration_seconds values (not in [4, 6, 8])
1723    fn invalid_duration_strategy() -> impl Strategy<Value = u8> {
1724        prop_oneof![
1725            Just(0u8),
1726            Just(1u8),
1727            Just(2u8),
1728            Just(3u8),
1729            Just(5u8),  // 5 is not supported
1730            Just(7u8),  // 7 is not supported
1731            Just(9u8),
1732            Just(10u8),
1733        ]
1734    }
1735
1736    /// Strategy to generate valid aspect ratios
1737    fn valid_aspect_ratio_strategy() -> impl Strategy<Value = &'static str> {
1738        prop_oneof![
1739            Just("16:9"),
1740            Just("9:16"),
1741        ]
1742    }
1743
1744    /// Strategy to generate valid prompts (non-empty)
1745    fn valid_prompt_strategy() -> impl Strategy<Value = String> {
1746        "[a-zA-Z0-9 ]{1,100}".prop_map(|s| s.trim().to_string())
1747            .prop_filter("Must not be empty", |s| !s.trim().is_empty())
1748    }
1749
1750    /// Strategy to generate valid GCS URIs
1751    fn valid_gcs_uri_strategy() -> impl Strategy<Value = String> {
1752        "[a-z0-9-]{3,20}".prop_map(|bucket| format!("gs://{}/output.mp4", bucket))
1753    }
1754
1755    proptest! {
1756        /// Property 8: Valid duration_seconds values (5-8) should pass validation
1757        #[test]
1758        fn valid_duration_passes_validation(
1759            dur in valid_duration_strategy(),
1760            prompt in valid_prompt_strategy(),
1761            gcs_uri in valid_gcs_uri_strategy(),
1762        ) {
1763            let params = VideoT2vParams {
1764                prompt,
1765                model: DEFAULT_MODEL.to_string(),
1766                aspect_ratio: DEFAULT_ASPECT_RATIO.to_string(),
1767                duration_seconds: dur,
1768                output_gcs_uri: gcs_uri,
1769                download_local: false,
1770                local_path: None,
1771                generate_audio: None,
1772                seed: None,
1773            };
1774
1775            let result = params.validate();
1776            prop_assert!(
1777                result.is_ok(),
1778                "duration_seconds {} should be valid, but got errors: {:?}",
1779                dur,
1780                result.err()
1781            );
1782        }
1783
1784        /// Property 8: Invalid duration_seconds values (< 5 or > 8) should fail validation
1785        #[test]
1786        fn invalid_duration_fails_validation(
1787            dur in invalid_duration_strategy(),
1788            prompt in valid_prompt_strategy(),
1789            gcs_uri in valid_gcs_uri_strategy(),
1790        ) {
1791            let params = VideoT2vParams {
1792                prompt,
1793                model: DEFAULT_MODEL.to_string(),
1794                aspect_ratio: DEFAULT_ASPECT_RATIO.to_string(),
1795                duration_seconds: dur,
1796                output_gcs_uri: gcs_uri,
1797                download_local: false,
1798                local_path: None,
1799                generate_audio: None,
1800                seed: None,
1801            };
1802
1803            let result = params.validate();
1804            prop_assert!(
1805                result.is_err(),
1806                "duration_seconds {} should be invalid",
1807                dur
1808            );
1809
1810            let errors = result.unwrap_err();
1811            prop_assert!(
1812                errors.iter().any(|e| e.field == "duration_seconds"),
1813                "Should have a duration_seconds validation error for value {}",
1814                dur
1815            );
1816        }
1817    }
1818
1819    // Feature: rust-mcp-genmedia, Property 9: Default Parameter Application
1820    // **Validates: Requirements 5.4, 5.6**
1821    //
1822    // When optional parameters are not provided, the system SHALL apply
1823    // documented default values consistently.
1824
1825    proptest! {
1826        /// Property 9: Default parameters should be applied when not specified
1827        #[test]
1828        fn default_params_applied_correctly(
1829            prompt in valid_prompt_strategy(),
1830        ) {
1831            // Parse JSON with only required fields
1832            let json = format!(r#"{{
1833                "prompt": "{}",
1834                "output_gcs_uri": "gs://bucket/output.mp4"
1835            }}"#, prompt.replace('"', "\\\""));
1836            
1837            let params: Result<VideoT2vParams, _> = serde_json::from_str(&json);
1838            prop_assert!(params.is_ok(), "Should parse successfully");
1839            
1840            let params = params.unwrap();
1841            
1842            // Verify defaults are applied
1843            prop_assert_eq!(params.model, DEFAULT_MODEL, "Default model should be applied");
1844            prop_assert_eq!(params.aspect_ratio, DEFAULT_ASPECT_RATIO, "Default aspect ratio should be applied");
1845            prop_assert_eq!(params.duration_seconds, DEFAULT_DURATION_SECONDS, "Default duration should be applied");
1846            prop_assert!(!params.download_local, "download_local should default to false");
1847            prop_assert!(params.generate_audio.is_none(), "generate_audio should default to None");
1848            prop_assert!(params.seed.is_none(), "seed should default to None");
1849        }
1850
1851        /// Property 9: Explicitly provided parameters should override defaults
1852        #[test]
1853        fn explicit_params_override_defaults(
1854            dur in valid_duration_strategy(),
1855            ratio in valid_aspect_ratio_strategy(),
1856            prompt in valid_prompt_strategy(),
1857        ) {
1858            let params = VideoT2vParams {
1859                prompt: prompt.clone(),
1860                model: "veo-2".to_string(),
1861                aspect_ratio: ratio.to_string(),
1862                duration_seconds: dur,
1863                output_gcs_uri: "gs://bucket/output.mp4".to_string(),
1864                download_local: true,
1865                local_path: Some("/tmp/video.mp4".to_string()),
1866                generate_audio: None, // Veo 2 doesn't support audio
1867                seed: Some(42),
1868            };
1869
1870            // Verify explicit values are preserved
1871            prop_assert_eq!(params.model, "veo-2");
1872            prop_assert_eq!(params.aspect_ratio, ratio);
1873            prop_assert_eq!(params.duration_seconds, dur);
1874            prop_assert!(params.download_local);
1875            prop_assert_eq!(params.local_path, Some("/tmp/video.mp4".to_string()));
1876            prop_assert_eq!(params.seed, Some(42));
1877        }
1878
1879        /// Property: Combination of valid parameters should always pass validation
1880        #[test]
1881        fn valid_params_combination_passes(
1882            dur in valid_duration_strategy(),
1883            ratio in valid_aspect_ratio_strategy(),
1884            prompt in valid_prompt_strategy(),
1885            gcs_uri in valid_gcs_uri_strategy(),
1886        ) {
1887            let params = VideoT2vParams {
1888                prompt,
1889                model: DEFAULT_MODEL.to_string(),
1890                aspect_ratio: ratio.to_string(),
1891                duration_seconds: dur,
1892                output_gcs_uri: gcs_uri,
1893                download_local: false,
1894                local_path: None,
1895                generate_audio: None,
1896                seed: None,
1897            };
1898
1899            let result = params.validate();
1900            prop_assert!(
1901                result.is_ok(),
1902                "Valid params (dur={}, ratio='{}') should pass, but got: {:?}",
1903                dur,
1904                ratio,
1905                result.err()
1906            );
1907        }
1908    }
1909}
1910
1911
1912#[cfg(test)]
1913mod lro_property_tests {
1914    use super::*;
1915    use proptest::prelude::*;
1916
1917    // Feature: rust-mcp-genmedia, Property 11: Long-Running Operation Polling
1918    // **Validates: Requirements 5.16**
1919    //
1920    // The LRO polling mechanism SHALL use exponential backoff with configurable
1921    // parameters and SHALL timeout after a maximum number of attempts.
1922
1923    proptest! {
1924        /// Property 11: Exponential backoff delay increases correctly
1925        #[test]
1926        fn exponential_backoff_increases_delay(
1927            initial_delay in 1000u64..10000u64,
1928            multiplier in 1.1f64..3.0f64,
1929            iterations in 1usize..10usize,
1930        ) {
1931            let mut delay = initial_delay;
1932            let mut prev_delay = 0u64;
1933            
1934            for _ in 0..iterations {
1935                // Each iteration should increase the delay
1936                prop_assert!(delay > prev_delay || prev_delay == 0, 
1937                    "Delay should increase: {} > {}", delay, prev_delay);
1938                
1939                prev_delay = delay;
1940                delay = ((delay as f64) * multiplier) as u64;
1941            }
1942        }
1943
1944        /// Property 11: Backoff delay is capped at maximum
1945        #[test]
1946        fn backoff_delay_capped_at_max(
1947            iterations in 1usize..200usize,
1948        ) {
1949            let mut delay_ms = LRO_INITIAL_DELAY_MS;
1950            
1951            for _ in 0..iterations {
1952                delay_ms = ((delay_ms as f64) * LRO_BACKOFF_MULTIPLIER) as u64;
1953                delay_ms = delay_ms.min(LRO_MAX_DELAY_MS);
1954                
1955                prop_assert!(delay_ms <= LRO_MAX_DELAY_MS,
1956                    "Delay {} should not exceed max {}", delay_ms, LRO_MAX_DELAY_MS);
1957            }
1958        }
1959
1960        /// Property 11: LRO configuration constants are valid
1961        #[test]
1962        fn lro_config_constants_valid(_dummy in Just(())) {
1963            // Initial delay should be positive
1964            prop_assert!(LRO_INITIAL_DELAY_MS > 0, "Initial delay must be positive");
1965            
1966            // Max delay should be >= initial delay
1967            prop_assert!(LRO_MAX_DELAY_MS >= LRO_INITIAL_DELAY_MS, 
1968                "Max delay must be >= initial delay");
1969            
1970            // Backoff multiplier should be > 1.0
1971            prop_assert!(LRO_BACKOFF_MULTIPLIER > 1.0, 
1972                "Backoff multiplier must be > 1.0");
1973            
1974            // Max attempts should be reasonable (allow for long operations)
1975            prop_assert!(LRO_MAX_ATTEMPTS > 0 && LRO_MAX_ATTEMPTS <= 1000,
1976                "Max attempts should be between 1 and 1000");
1977        }
1978
1979        /// Property 11: Total timeout is reasonable for video generation
1980        #[test]
1981        fn total_timeout_reasonable(_dummy in Just(())) {
1982            // Calculate approximate total timeout
1983            let mut total_ms = 0u64;
1984            let mut delay_ms = LRO_INITIAL_DELAY_MS;
1985            
1986            for _ in 0..LRO_MAX_ATTEMPTS {
1987                total_ms += delay_ms;
1988                delay_ms = ((delay_ms as f64) * LRO_BACKOFF_MULTIPLIER) as u64;
1989                delay_ms = delay_ms.min(LRO_MAX_DELAY_MS);
1990            }
1991            
1992            let total_minutes = total_ms / 60000;
1993            
1994            // Video generation can take several minutes, so timeout should be at least 10 minutes
1995            prop_assert!(total_minutes >= 10, 
1996                "Total timeout {} minutes should be at least 10 minutes", total_minutes);
1997            
1998            // But not more than 2 hours (reasonable upper bound)
1999            prop_assert!(total_minutes <= 120,
2000                "Total timeout {} minutes should not exceed 120 minutes", total_minutes);
2001        }
2002    }
2003}
2004
2005
2006/// Unit tests for API interactions and error handling.
2007/// These tests verify the handler's behavior with mocked API responses.
2008#[cfg(test)]
2009mod api_tests {
2010    use super::*;
2011
2012    /// Test that VeoT2vRequest serializes correctly for the API.
2013    #[test]
2014    fn test_veo_t2v_request_serialization() {
2015        let request = VeoT2vRequest {
2016            instances: vec![VeoT2vInstance {
2017                prompt: "A cat walking in a garden".to_string(),
2018            }],
2019            parameters: VeoParameters {
2020                aspect_ratio: Some("16:9".to_string()),
2021                storage_uri: "gs://bucket/output.mp4".to_string(),
2022                duration_seconds: Some(6),
2023                generate_audio: Some(true),
2024                seed: Some(42),
2025            },
2026        };
2027
2028        let json = serde_json::to_value(&request).unwrap();
2029        
2030        // Verify structure
2031        assert!(json["instances"].is_array());
2032        assert_eq!(json["instances"][0]["prompt"], "A cat walking in a garden");
2033        assert_eq!(json["parameters"]["aspectRatio"], "16:9");
2034        assert_eq!(json["parameters"]["storageUri"], "gs://bucket/output.mp4");
2035        assert_eq!(json["parameters"]["durationSeconds"], 6);
2036        assert_eq!(json["parameters"]["generateAudio"], true);
2037        assert_eq!(json["parameters"]["seed"], 42);
2038    }
2039
2040    /// Test that VeoT2vRequest serializes without optional fields when not provided.
2041    #[test]
2042    fn test_veo_t2v_request_serialization_minimal() {
2043        let request = VeoT2vRequest {
2044            instances: vec![VeoT2vInstance {
2045                prompt: "A cat".to_string(),
2046            }],
2047            parameters: VeoParameters {
2048                aspect_ratio: None,
2049                storage_uri: "gs://bucket/output.mp4".to_string(),
2050                duration_seconds: None,
2051                generate_audio: None,
2052                seed: None,
2053            },
2054        };
2055
2056        let json = serde_json::to_value(&request).unwrap();
2057        
2058        // Verify optional fields are not present
2059        assert!(json["parameters"].get("aspectRatio").is_none());
2060        assert!(json["parameters"].get("durationSeconds").is_none());
2061        assert!(json["parameters"].get("generateAudio").is_none());
2062        assert!(json["parameters"].get("seed").is_none());
2063        // Required field should be present
2064        assert!(json["parameters"].get("storageUri").is_some());
2065    }
2066
2067    /// Test that VeoI2vRequest serializes correctly for the API.
2068    #[test]
2069    fn test_veo_i2v_request_serialization() {
2070        let request = VeoI2vRequest {
2071            instances: vec![VeoI2vInstance {
2072                prompt: "The cat starts walking".to_string(),
2073                image: VeoImageInput {
2074                    bytes_base64_encoded: "base64imagedata".to_string(),
2075                },
2076            }],
2077            parameters: VeoI2vParameters {
2078                aspect_ratio: Some("9:16".to_string()),
2079                storage_uri: "gs://bucket/output.mp4".to_string(),
2080                duration_seconds: Some(6),
2081                generate_audio: None,
2082                seed: None,
2083                last_frame: None,
2084            },
2085        };
2086
2087        let json = serde_json::to_value(&request).unwrap();
2088        
2089        // Verify structure
2090        assert!(json["instances"].is_array());
2091        assert_eq!(json["instances"][0]["prompt"], "The cat starts walking");
2092        assert_eq!(json["instances"][0]["image"]["bytesBase64Encoded"], "base64imagedata");
2093        assert_eq!(json["parameters"]["aspectRatio"], "9:16");
2094    }
2095
2096    /// Test that LroResponse deserializes correctly.
2097    #[test]
2098    fn test_lro_response_deserialization() {
2099        let json = r#"{
2100            "name": "projects/123/locations/us-central1/operations/abc123"
2101        }"#;
2102
2103        let response: LroResponse = serde_json::from_str(json).unwrap();
2104        
2105        assert_eq!(response.name, "projects/123/locations/us-central1/operations/abc123");
2106    }
2107
2108    /// Test that LroStatusResponse deserializes when not done.
2109    #[test]
2110    fn test_lro_status_not_done() {
2111        let json = r#"{
2112            "done": false
2113        }"#;
2114
2115        let response: LroStatusResponse = serde_json::from_str(json).unwrap();
2116        
2117        assert_eq!(response.done, Some(false));
2118        assert!(response.error.is_none());
2119        assert!(response.response.is_none());
2120    }
2121
2122    /// Test that LroStatusResponse deserializes when done with success.
2123    #[test]
2124    fn test_lro_status_done_success() {
2125        let json = r#"{
2126            "done": true,
2127            "response": {
2128                "videos": [
2129                    {
2130                        "gcsUri": "gs://bucket/output.mp4",
2131                        "mimeType": "video/mp4"
2132                    }
2133                ]
2134            }
2135        }"#;
2136
2137        let response: LroStatusResponse = serde_json::from_str(json).unwrap();
2138        
2139        assert_eq!(response.done, Some(true));
2140        assert!(response.error.is_none());
2141        assert!(response.response.is_some());
2142        
2143        let result = response.response.unwrap();
2144        assert!(result.videos.is_some());
2145        let videos = result.videos.unwrap();
2146        assert_eq!(videos.len(), 1);
2147        assert_eq!(videos[0].gcs_uri, Some("gs://bucket/output.mp4".to_string()));
2148        assert_eq!(videos[0].mime_type, Some("video/mp4".to_string()));
2149    }
2150
2151    /// Test that LroStatusResponse deserializes when done with error.
2152    #[test]
2153    fn test_lro_status_done_error() {
2154        let json = r#"{
2155            "done": true,
2156            "error": {
2157                "code": 400,
2158                "message": "Invalid prompt"
2159            }
2160        }"#;
2161
2162        let response: LroStatusResponse = serde_json::from_str(json).unwrap();
2163        
2164        assert_eq!(response.done, Some(true));
2165        assert!(response.error.is_some());
2166        
2167        let error = response.error.unwrap();
2168        assert_eq!(error.code, Some(400));
2169        assert_eq!(error.message, Some("Invalid prompt".to_string()));
2170    }
2171
2172    /// Test endpoint URL construction for generate.
2173    #[test]
2174    fn test_get_generate_endpoint() {
2175        let config = Config {
2176            project_id: "my-project".to_string(),
2177            location: "us-central1".to_string(),
2178            gcs_bucket: None,
2179            port: 8080,
2180        ..Default::default()
2181        };
2182
2183        let expected_url = format!(
2184            "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/{}:predictLongRunning",
2185            config.location,
2186            config.project_id,
2187            config.location,
2188            "veo-3.0-generate-preview"
2189        );
2190
2191        assert!(expected_url.contains("us-central1-aiplatform.googleapis.com"));
2192        assert!(expected_url.contains("my-project"));
2193        assert!(expected_url.contains("veo-3.0-generate-preview"));
2194        assert!(expected_url.ends_with(":predictLongRunning"));
2195    }
2196
2197    /// Test endpoint URL construction for fetch operation (LRO polling).
2198    #[test]
2199    fn test_get_fetch_operation_endpoint() {
2200        let config = Config {
2201            project_id: "my-project".to_string(),
2202            location: "us-central1".to_string(),
2203            gcs_bucket: None,
2204            port: 8080,
2205        ..Default::default()
2206        };
2207
2208        let model = "veo-3.0-generate-preview";
2209        let expected_url = format!(
2210            "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/{}:fetchPredictOperation",
2211            config.location,
2212            config.project_id,
2213            config.location,
2214            model
2215        );
2216
2217        assert!(expected_url.contains("us-central1-aiplatform.googleapis.com"));
2218        assert!(expected_url.contains("my-project"));
2219        assert!(expected_url.contains(model));
2220        assert!(expected_url.ends_with(":fetchPredictOperation"));
2221    }
2222
2223    /// Test FetchOperationRequest serialization.
2224    #[test]
2225    fn test_fetch_operation_request_serialization() {
2226        let request = FetchOperationRequest {
2227            operation_name: "projects/my-project/locations/us-central1/publishers/google/models/veo-3.0-generate-preview/operations/abc123".to_string(),
2228        };
2229
2230        let json = serde_json::to_value(&request).unwrap();
2231        
2232        assert_eq!(json["operationName"], "projects/my-project/locations/us-central1/publishers/google/models/veo-3.0-generate-preview/operations/abc123");
2233    }
2234
2235    /// Test VideoGenerateResult structure.
2236    #[test]
2237    fn test_video_generate_result_gcs_only() {
2238        let result = VideoGenerateResult {
2239            gcs_uri: "gs://bucket/output.mp4".to_string(),
2240            local_path: None,
2241        };
2242
2243        assert_eq!(result.gcs_uri, "gs://bucket/output.mp4");
2244        assert!(result.local_path.is_none());
2245    }
2246
2247    /// Test VideoGenerateResult with local path.
2248    #[test]
2249    fn test_video_generate_result_with_local() {
2250        let result = VideoGenerateResult {
2251            gcs_uri: "gs://bucket/output.mp4".to_string(),
2252            local_path: Some("/tmp/output.mp4".to_string()),
2253        };
2254
2255        assert_eq!(result.gcs_uri, "gs://bucket/output.mp4");
2256        assert_eq!(result.local_path, Some("/tmp/output.mp4".to_string()));
2257    }
2258
2259    /// Test LroResult structure.
2260    #[test]
2261    fn test_lro_result() {
2262        let result = LroResult {
2263            videos: vec![VideoOutput {
2264                gcs_uri: Some("gs://bucket/output.mp4".to_string()),
2265                mime_type: Some("video/mp4".to_string()),
2266            }],
2267        };
2268
2269        assert_eq!(result.videos.len(), 1);
2270        assert!(result.videos[0].gcs_uri.is_some());
2271    }
2272
2273    /// Test LroResult with empty videos.
2274    #[test]
2275    fn test_lro_result_empty() {
2276        let result = LroResult {
2277            videos: vec![],
2278        };
2279
2280        assert!(result.videos.is_empty());
2281    }
2282}