1use adk_rust_mcp_common::auth::AuthProvider;
7use adk_rust_mcp_common::config::Config;
8use adk_rust_mcp_common::error::Error;
9use adk_rust_mcp_common::gcs::{GcsClient, GcsUri};
10use 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
18pub const VALID_ASPECT_RATIOS: &[&str] = &["16:9", "9:16"];
20
21pub const DEFAULT_MODEL: &str = "veo-3.1-generate-preview";
23
24pub const DEFAULT_DURATION_SECONDS: u8 = 8;
26
27pub const SUPPORTED_DURATIONS: &[u8] = &[4, 6, 8];
29
30pub const MIN_DURATION_SECONDS: u8 = 4;
32
33pub const MAX_DURATION_SECONDS: u8 = 8;
35
36pub const DEFAULT_ASPECT_RATIO: &str = "16:9";
38
39pub 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; #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
49pub struct VideoT2vParams {
50 pub prompt: String,
52
53 #[serde(default = "default_model")]
56 pub model: String,
57
58 #[serde(default = "default_aspect_ratio")]
61 pub aspect_ratio: String,
62
63 #[serde(default = "default_duration_seconds")]
65 pub duration_seconds: u8,
66
67 pub output_gcs_uri: String,
70
71 #[serde(default)]
73 pub download_local: bool,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub local_path: Option<String>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub generate_audio: Option<bool>,
82
83 #[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#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
105pub struct VideoI2vParams {
106 pub image: String,
109
110 pub prompt: String,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub last_frame_image: Option<String>,
118
119 #[serde(default = "default_model")]
122 pub model: String,
123
124 #[serde(default = "default_aspect_ratio")]
127 pub aspect_ratio: String,
128
129 #[serde(default = "default_duration_seconds")]
131 pub duration_seconds: u8,
132
133 pub output_gcs_uri: String,
136
137 #[serde(default)]
139 pub download_local: bool,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub local_path: Option<String>,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub seed: Option<i64>,
148}
149
150#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
155pub struct VideoExtendParams {
156 pub video_input: String,
159
160 pub prompt: String,
162
163 #[serde(default = "default_model")]
166 pub model: String,
167
168 #[serde(default = "default_duration_seconds")]
170 pub duration_seconds: u8,
171
172 pub output_gcs_uri: String,
175
176 #[serde(default)]
178 pub download_local: bool,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub local_path: Option<String>,
183
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub seed: Option<i64>,
187}
188
189#[derive(Debug, Clone)]
191pub struct ValidationError {
192 pub field: String,
194 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 pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
211 let mut errors = Vec::new();
212
213 let model = ModelRegistry::resolve_veo(&self.model);
215
216 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 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 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 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 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 !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 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 pub fn get_model(&self) -> Option<&'static VeoModel> {
321 ModelRegistry::resolve_veo(&self.model)
322 }
323}
324
325impl VideoI2vParams {
326 pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
332 let mut errors = Vec::new();
333
334 let model = ModelRegistry::resolve_veo(&self.model);
336
337 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 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 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 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 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 !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 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 pub fn get_model(&self) -> Option<&'static VeoModel> {
439 ModelRegistry::resolve_veo(&self.model)
440 }
441}
442
443impl VideoExtendParams {
444 pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
450 let mut errors = Vec::new();
451
452 let model = ModelRegistry::resolve_veo(&self.model);
454
455 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 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 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 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 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 pub fn get_model(&self) -> Option<&'static VeoModel> {
533 ModelRegistry::resolve_veo(&self.model)
534 }
535}
536
537pub struct VideoHandler {
541 pub config: Config,
543 pub gcs: GcsClient,
545 pub http: reqwest::Client,
547 pub auth: AuthProvider,
549}
550
551impl VideoHandler {
552 #[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 #[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 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 pub fn get_fetch_operation_endpoint(&self, model: &str) -> String {
597 if self.config.is_gemini() {
598 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 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 #[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 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 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 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 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 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 let result = self.poll_lro(&lro_response.name, model.id).await?;
687
688 self.handle_output(result, ¶ms.output_gcs_uri, params.download_local, params.local_path.as_deref()).await
690 }
691
692 #[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 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 let model = params.get_model().ok_or_else(|| {
710 Error::validation(format!("Unknown model: {}", params.model))
711 })?;
712
713 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 let image_data = self.resolve_image_input(¶ms.image).await?;
723
724 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 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, seed: params.seed,
748 last_frame,
749 },
750 };
751
752 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 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 let result = self.poll_lro(&lro_response.name, model.id).await?;
782
783 self.handle_output(result, ¶ms.output_gcs_uri, params.download_local, params.local_path.as_deref()).await
785 }
786
787 #[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 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 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 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 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 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 let result = self.poll_lro(&lro_response.name, model.id).await?;
856
857 self.handle_output(result, ¶ms.output_gcs_uri, params.download_local, params.local_path.as_deref()).await
859 }
860
861 async fn resolve_image_input(&self, image: &str) -> Result<String, Error> {
868 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 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 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 if image.len() > 100 {
899 if BASE64.decode(image).is_ok() {
900 return Ok(image.to_string());
901 }
902 }
903
904 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 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 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 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 let timeout_seconds = (LRO_MAX_ATTEMPTS as u64) * (LRO_MAX_DELAY_MS / 1000);
948 return Err(Error::timeout(timeout_seconds));
949 }
950
951 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
953
954 let response = if self.config.is_gemini() {
956 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 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 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 if let Some(response) = lro_status.response {
1002 info!(operation_name = %operation_name, attempts = attempts, "LRO completed successfully");
1003 let videos = if let Some(vids) = response.videos {
1005 vids
1006 } else if let Some(gemini_resp) = response.generate_video_response {
1007 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 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 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 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 {
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 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 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#[derive(Debug, Serialize)]
1105pub struct VeoT2vRequest {
1106 pub instances: Vec<VeoT2vInstance>,
1108 pub parameters: VeoParameters,
1110}
1111
1112#[derive(Debug, Serialize)]
1114pub struct VeoT2vInstance {
1115 pub prompt: String,
1117}
1118
1119#[derive(Debug, Serialize)]
1121pub struct VeoI2vRequest {
1122 pub instances: Vec<VeoI2vInstance>,
1124 pub parameters: VeoI2vParameters,
1126}
1127
1128#[derive(Debug, Serialize)]
1130pub struct VeoI2vInstance {
1131 pub prompt: String,
1133 pub image: VeoImageInput,
1135}
1136
1137#[derive(Debug, Serialize)]
1139#[serde(rename_all = "camelCase")]
1140pub struct VeoImageInput {
1141 pub bytes_base64_encoded: String,
1143}
1144
1145#[derive(Debug, Serialize)]
1147#[serde(rename_all = "camelCase")]
1148pub struct VeoI2vParameters {
1149 #[serde(skip_serializing_if = "Option::is_none")]
1151 pub aspect_ratio: Option<String>,
1152 #[serde(rename = "storageUri", skip_serializing_if = "String::is_empty")]
1154 pub storage_uri: String,
1155 #[serde(skip_serializing_if = "Option::is_none")]
1157 pub duration_seconds: Option<u8>,
1158 #[serde(skip_serializing_if = "Option::is_none")]
1160 pub generate_audio: Option<bool>,
1161 #[serde(skip_serializing_if = "Option::is_none")]
1163 pub seed: Option<i64>,
1164 #[serde(skip_serializing_if = "Option::is_none")]
1166 pub last_frame: Option<VeoImageInput>,
1167}
1168
1169#[derive(Debug, Serialize)]
1171pub struct VeoExtendRequest {
1172 pub instances: Vec<VeoExtendInstance>,
1174 pub parameters: VeoExtendParameters,
1176}
1177
1178#[derive(Debug, Serialize)]
1180pub struct VeoExtendInstance {
1181 pub prompt: String,
1183 pub video: VeoVideoInput,
1185}
1186
1187#[derive(Debug, Serialize)]
1189#[serde(rename_all = "camelCase")]
1190pub struct VeoVideoInput {
1191 pub gcs_uri: String,
1193 pub mime_type: String,
1195}
1196
1197#[derive(Debug, Serialize)]
1199#[serde(rename_all = "camelCase")]
1200pub struct VeoExtendParameters {
1201 #[serde(rename = "storageUri", skip_serializing_if = "String::is_empty")]
1203 pub storage_uri: String,
1204 #[serde(skip_serializing_if = "Option::is_none")]
1206 pub duration_seconds: Option<u8>,
1207 #[serde(skip_serializing_if = "Option::is_none")]
1209 pub seed: Option<i64>,
1210}
1211
1212#[derive(Debug, Serialize)]
1214#[serde(rename_all = "camelCase")]
1215pub struct VeoParameters {
1216 #[serde(skip_serializing_if = "Option::is_none")]
1218 pub aspect_ratio: Option<String>,
1219 #[serde(rename = "storageUri", skip_serializing_if = "String::is_empty")]
1221 pub storage_uri: String,
1222 #[serde(skip_serializing_if = "Option::is_none")]
1224 pub duration_seconds: Option<u8>,
1225 #[serde(skip_serializing_if = "Option::is_none")]
1227 pub generate_audio: Option<bool>,
1228 #[serde(skip_serializing_if = "Option::is_none")]
1230 pub seed: Option<i64>,
1231}
1232
1233#[derive(Debug, Deserialize)]
1235pub struct LroResponse {
1236 pub name: String,
1238}
1239
1240#[derive(Debug, Serialize)]
1242#[serde(rename_all = "camelCase")]
1243pub struct FetchOperationRequest {
1244 pub operation_name: String,
1246}
1247
1248#[derive(Debug, Deserialize)]
1250pub struct LroStatusResponse {
1251 pub done: Option<bool>,
1253 pub error: Option<LroError>,
1255 pub response: Option<LroResultResponse>,
1257}
1258
1259#[derive(Debug, Deserialize)]
1261pub struct LroError {
1262 pub code: Option<i32>,
1264 pub message: Option<String>,
1266}
1267
1268#[derive(Debug, Deserialize)]
1270#[serde(rename_all = "camelCase")]
1271pub struct LroResultResponse {
1272 pub videos: Option<Vec<VideoOutput>>,
1274 pub generate_video_response: Option<GeminiVideoResponse>,
1276 #[serde(default)]
1278 pub rai_media_filtered_count: Option<i32>,
1279}
1280
1281#[derive(Debug, Deserialize)]
1283#[serde(rename_all = "camelCase")]
1284pub struct GeminiVideoResponse {
1285 pub generated_samples: Option<Vec<GeminiGeneratedSample>>,
1286}
1287
1288#[derive(Debug, Deserialize)]
1290#[serde(rename_all = "camelCase")]
1291pub struct GeminiGeneratedSample {
1292 pub video: Option<GeminiVideoOutput>,
1293}
1294
1295#[derive(Debug, Deserialize)]
1297#[serde(rename_all = "camelCase")]
1298pub struct GeminiVideoOutput {
1299 pub uri: Option<String>,
1300}
1301
1302#[derive(Debug, Deserialize)]
1304#[serde(rename_all = "camelCase")]
1305pub struct VideoOutput {
1306 pub gcs_uri: Option<String>,
1308 pub mime_type: Option<String>,
1310}
1311
1312#[derive(Debug)]
1318pub struct LroResult {
1319 pub videos: Vec<VideoOutput>,
1321}
1322
1323#[derive(Debug)]
1325pub struct VideoGenerateResult {
1326 pub gcs_uri: String,
1328 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, 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, 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(), 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(), 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(), 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), 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(), 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 #[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(), model: "unknown-model".to_string(), aspect_ratio: "invalid".to_string(), duration_seconds: 100, output_gcs_uri: "/local/path".to_string(), 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 #[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 fn valid_duration_strategy() -> impl Strategy<Value = u8> {
1715 prop_oneof![
1716 Just(4u8),
1717 Just(6u8),
1718 Just(8u8),
1719 ]
1720 }
1721
1722 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), Just(7u8), Just(9u8),
1732 Just(10u8),
1733 ]
1734 }
1735
1736 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 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 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 #[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 #[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 proptest! {
1826 #[test]
1828 fn default_params_applied_correctly(
1829 prompt in valid_prompt_strategy(),
1830 ) {
1831 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 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 #[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, seed: Some(42),
1868 };
1869
1870 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 #[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 proptest! {
1924 #[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 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 #[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 #[test]
1962 fn lro_config_constants_valid(_dummy in Just(())) {
1963 prop_assert!(LRO_INITIAL_DELAY_MS > 0, "Initial delay must be positive");
1965
1966 prop_assert!(LRO_MAX_DELAY_MS >= LRO_INITIAL_DELAY_MS,
1968 "Max delay must be >= initial delay");
1969
1970 prop_assert!(LRO_BACKOFF_MULTIPLIER > 1.0,
1972 "Backoff multiplier must be > 1.0");
1973
1974 prop_assert!(LRO_MAX_ATTEMPTS > 0 && LRO_MAX_ATTEMPTS <= 1000,
1976 "Max attempts should be between 1 and 1000");
1977 }
1978
1979 #[test]
1981 fn total_timeout_reasonable(_dummy in Just(())) {
1982 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 prop_assert!(total_minutes >= 10,
1996 "Total timeout {} minutes should be at least 10 minutes", total_minutes);
1997
1998 prop_assert!(total_minutes <= 120,
2000 "Total timeout {} minutes should not exceed 120 minutes", total_minutes);
2001 }
2002 }
2003}
2004
2005
2006#[cfg(test)]
2009mod api_tests {
2010 use super::*;
2011
2012 #[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 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]
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 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 assert!(json["parameters"].get("storageUri").is_some());
2065 }
2066
2067 #[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 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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
2275 fn test_lro_result_empty() {
2276 let result = LroResult {
2277 videos: vec![],
2278 };
2279
2280 assert!(result.videos.is_empty());
2281 }
2282}