From 5fada6b30f707ba95bc34b3fbf0e446ff696d846 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 10 Apr 2026 19:48:47 -0400 Subject: [PATCH] veo2 text-to-image was missing --- Cargo.lock | 1 + .../artcraft_client/src/endpoints/mod.rs | 1 + .../src/endpoints/omni_gen/generate/mod.rs | 1 + .../endpoints/omni_gen/generate/video/mod.rs | 1 + .../omni_gen/generate/video/omni_gen_video.rs | 21 + .../src/endpoints/omni_gen/mod.rs | 1 + .../artcraft/artcraft_router/Cargo.toml | 1 + .../artcraft/generate_video_artcraft_veo_2.rs | 73 +++- .../execute/fal/generate_video_fal_veo_2.rs | 45 ++- .../plan_generate_video_artcraft_veo_2.rs | 8 +- .../plan/fal/plan_generate_video_fal_veo_2.rs | 39 +- .../video/text/http_veo_2_text_to_video.rs | 34 ++ .../src/requests/http/video/text/mod.rs | 1 + .../enqueue_veo_2_text_to_video_webhook.rs | 103 +++++ .../src/requests/webhook/video/text/mod.rs | 1 + .../generate/video/tests/test_veo_2.rs | 361 ++++++++++-------- 16 files changed, 486 insertions(+), 206 deletions(-) create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/omni_gen_video.rs create mode 100644 crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/mod.rs create mode 100644 crates/api_clients/fal_client/src/requests/http/video/text/http_veo_2_text_to_video.rs create mode 100644 crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_2_text_to_video_webhook.rs diff --git a/Cargo.lock b/Cargo.lock index eae56f4fd8..a1ba3f7d7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,6 +770,7 @@ dependencies = [ "anyhow", "artcraft_api_defs", "artcraft_client", + "enums", "fal_client", "log", "muapi_client", diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs index 99b031db6d..7c92178019 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs @@ -2,6 +2,7 @@ pub mod analytics; pub mod credits; pub mod generate; pub mod jobs; +pub mod omni_gen; pub mod media_files; pub mod prompts; pub mod stripe_artcraft; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs new file mode 100644 index 0000000000..b04b953edb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/mod.rs @@ -0,0 +1 @@ +pub mod video; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/mod.rs new file mode 100644 index 0000000000..d4efe9eea8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/mod.rs @@ -0,0 +1 @@ +pub mod omni_gen_video; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/omni_gen_video.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/omni_gen_video.rs new file mode 100644 index 0000000000..f6014de1fe --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/generate/video/omni_gen_video.rs @@ -0,0 +1,21 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_json_post_request::basic_json_post_request; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse; + +pub const OMNI_GEN_VIDEO_GENERATE_PATH: &str = "/v1/omni_gen/generate/video"; + +pub async fn omni_gen_video_generate( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + request: OmniGenVideoCostAndGenerateRequest, +) -> Result { + Ok(basic_json_post_request( + api_host, + OMNI_GEN_VIDEO_GENERATE_PATH, + maybe_creds, + request, + ).await?) +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/mod.rs new file mode 100644 index 0000000000..57e6a9d225 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/omni_gen/mod.rs @@ -0,0 +1 @@ +pub mod generate; diff --git a/crates/api_clients/artcraft/artcraft_router/Cargo.toml b/crates/api_clients/artcraft/artcraft_router/Cargo.toml index d16a017724..11a86e0d0f 100644 --- a/crates/api_clients/artcraft/artcraft_router/Cargo.toml +++ b/crates/api_clients/artcraft/artcraft_router/Cargo.toml @@ -17,6 +17,7 @@ workspace-build-acceleration.workspace = true # Internal artcraft_api_defs.workspace = true +enums.workspace = true artcraft_client.workspace = true fal_client.workspace = true muapi_client.workspace = true diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/artcraft/generate_video_artcraft_veo_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/artcraft/generate_video_artcraft_veo_2.rs index 2ef0b8fd30..d1daa036a3 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/artcraft/generate_video_artcraft_veo_2.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/artcraft/generate_video_artcraft_veo_2.rs @@ -6,31 +6,72 @@ use crate::generate::generate_video::generate_video_response::{ }; use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_veo_2::PlanArtcraftVeo2; use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::GenerateVeo2ImageToVideoRequest; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; use artcraft_client::endpoints::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video; +use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate; +use enums::common::generation::common_video_model::CommonVideoModel; pub async fn execute_artcraft_veo_2( plan: &PlanArtcraftVeo2<'_>, artcraft_client: &RouterArtcraftClient, ) -> Result { - let request = GenerateVeo2ImageToVideoRequest { - uuid_idempotency_token: plan.idempotency_token.clone(), - media_file_token: plan.start_frame.map(|t| t.to_owned()), - prompt: plan.prompt.map(|p| p.to_string()), - aspect_ratio: plan.aspect_ratio.clone(), - duration: plan.duration.clone(), + let inference_job_token = match plan.start_frame { + Some(media_file_token) => { + // Image-to-video: use the legacy dedicated endpoint. + let request = GenerateVeo2ImageToVideoRequest { + uuid_idempotency_token: plan.idempotency_token.clone(), + media_file_token: Some(media_file_token.to_owned()), + prompt: plan.prompt.map(|p| p.to_string()), + aspect_ratio: plan.aspect_ratio.clone(), + duration: plan.duration.clone(), + }; + + let response = generate_veo_2_image_to_video( + &artcraft_client.api_host, + Some(&artcraft_client.credentials), + request, + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + response.inference_job_token + } + None => { + // Text-to-video: use the omni-gen unified endpoint. + let request = OmniGenVideoCostAndGenerateRequest { + idempotency_token: Some(plan.idempotency_token.clone()), + model: Some(CommonVideoModel::Veo2), + prompt: plan.prompt.map(|p| p.to_string()), + negative_prompt: None, + start_frame_image_media_token: None, + end_frame_image_media_token: None, + reference_image_media_tokens: None, + reference_video_media_tokens: None, + reference_audio_media_tokens: None, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + quality: None, + duration_seconds: None, + video_batch_count: None, + generate_audio: None, + }; + + let response = omni_gen_video_generate( + &artcraft_client.api_host, + Some(&artcraft_client.credentials), + request, + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + response.inference_job_token + } }; - let response = generate_veo_2_image_to_video( - &artcraft_client.api_host, - Some(&artcraft_client.credentials), - request, - ) - .await - .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; - - let all_tokens = vec![response.inference_job_token.clone()]; + let all_tokens = vec![inference_job_token.clone()]; Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload { - inference_job_token: response.inference_job_token, + inference_job_token, all_inference_job_tokens: all_tokens, })) } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_2.rs index b3828e30dd..1379c51549 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_2.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_2.rs @@ -4,28 +4,49 @@ use crate::errors::provider_error::ProviderError; use crate::generate::generate_video::generate_video_response::{ FalVideoResponsePayload, GenerateVideoResponse, }; -use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::PlanFalVeo2; +use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{ + FalVeo2Mode, PlanFalVeo2, +}; use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{ enqueue_veo_2_image_to_video_webhook, Veo2Args, }; +use fal_client::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{ + enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, +}; pub async fn execute_fal_veo_2( plan: &PlanFalVeo2, fal_client: &RouterFalClient, ) -> Result { - let args = Veo2Args { - image_url: plan.start_frame_url.as_str(), - prompt: plan.prompt.as_str(), - api_key: &fal_client.api_key, - duration: plan.duration, - aspect_ratio: plan.aspect_ratio, - webhook_url: fal_client.webhook_url.as_str(), + let webhook_response = match &plan.mode { + FalVeo2Mode::TextToVideo => { + let args = Veo2TextToVideoArgs { + prompt: plan.prompt.as_str(), + negative_prompt: plan.negative_prompt.as_deref(), + api_key: &fal_client.api_key, + duration: plan.duration, + aspect_ratio: plan.aspect_ratio, + webhook_url: fal_client.webhook_url.as_str(), + }; + enqueue_veo_2_text_to_video_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + } + FalVeo2Mode::ImageToVideo { image_url } => { + let args = Veo2Args { + image_url: image_url.as_str(), + prompt: plan.prompt.as_str(), + api_key: &fal_client.api_key, + duration: plan.duration, + aspect_ratio: plan.aspect_ratio, + webhook_url: fal_client.webhook_url.as_str(), + }; + enqueue_veo_2_image_to_video_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))? + } }; - let webhook_response = enqueue_veo_2_image_to_video_webhook(args) - .await - .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; - Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { request_id: webhook_response.request_id, gateway_request_id: webhook_response.gateway_request_id, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/artcraft/plan_generate_video_artcraft_veo_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/artcraft/plan_generate_video_artcraft_veo_2.rs index 981b920641..a5089854df 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/artcraft/plan_generate_video_artcraft_veo_2.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/artcraft/plan_generate_video_artcraft_veo_2.rs @@ -33,12 +33,8 @@ pub fn plan_generate_video_artcraft_veo_2<'a>( // Artcraft cost path. Cost only depends on duration, so URL-form refs are // accepted and dropped. Some(ImageRef::Url(_)) => None, - None => { - return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { - field: "start_frame", - value: "Veo 2 requires a starting frame".to_string(), - })) - } + // No start_frame = text-to-video mode. + None => None, }; if request.end_frame.is_some() { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_2.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_2.rs index 0415652a18..cd3d0b8cd5 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_2.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_2.rs @@ -9,10 +9,19 @@ use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_we Veo2AspectRatio, Veo2Duration, }; +#[derive(Debug, Clone)] +pub enum FalVeo2Mode { + TextToVideo, + ImageToVideo { + image_url: String, + }, +} + #[derive(Debug, Clone)] pub struct PlanFalVeo2 { pub prompt: String, - pub start_frame_url: String, + pub negative_prompt: Option, + pub mode: FalVeo2Mode, pub aspect_ratio: Veo2AspectRatio, pub duration: Veo2Duration, } @@ -22,35 +31,32 @@ pub fn plan_generate_video_fal_veo_2<'a>( ) -> Result, ArtcraftRouterError> { let strategy = request.request_mismatch_mitigation_strategy; - let start_frame_url = resolve_required_image_url(request.start_frame, "start_frame")?; if request.end_frame.is_some() { return Err(unsupported("end_frame", "Veo 2 does not support an ending frame")); } + let mode = match request.start_frame { + Some(ImageRef::Url(url)) => FalVeo2Mode::ImageToVideo { + image_url: url.to_string(), + }, + Some(ImageRef::MediaFileToken(_)) => { + return Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)); + } + None => FalVeo2Mode::TextToVideo, + }; + let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, strategy)?; let duration = plan_duration(request.duration_seconds, strategy)?; Ok(VideoGenerationPlan::FalVeo2(PlanFalVeo2 { prompt: request.prompt.unwrap_or("").to_string(), - start_frame_url, + negative_prompt: request.negative_prompt.map(|s| s.to_string()), + mode, aspect_ratio, duration, })) } -fn resolve_required_image_url( - image_ref: Option>, - field: &'static str, -) -> Result { - match image_ref { - Some(ImageRef::Url(url)) => Ok(url.to_string()), - Some(ImageRef::MediaFileToken(_)) => { - Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) - } - None => Err(unsupported(field, "Veo 2 requires a starting frame")), - } -} - fn plan_aspect_ratio( aspect_ratio: Option, strategy: RequestMismatchMitigationStrategy, @@ -60,6 +66,7 @@ fn plan_aspect_ratio( None | Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto3k) | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto), Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine), diff --git a/crates/api_clients/fal_client/src/requests/http/video/text/http_veo_2_text_to_video.rs b/crates/api_clients/fal_client/src/requests/http/video/text/http_veo_2_text_to_video.rs new file mode 100644 index 0000000000..ee43cb59ae --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/http/video/text/http_veo_2_text_to_video.rs @@ -0,0 +1,34 @@ +use crate::requests::api::fal_request::FalRequest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Veo2TextToVideoInput { + pub prompt: String, + + /// Options: "16:9", "9:16", "1:1" + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Options: "5s", "6s", "7s", "8s" + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2TextToVideoVideoFile { + pub url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Veo2TextToVideoOutput { + pub video: Veo2TextToVideoVideoFile, +} + +pub fn veo_2_text_to_video( + params: Veo2TextToVideoInput, +) -> FalRequest { + FalRequest::new("fal-ai/veo2", params) +} diff --git a/crates/api_clients/fal_client/src/requests/http/video/text/mod.rs b/crates/api_clients/fal_client/src/requests/http/video/text/mod.rs index 123ff79079..ed05b7de5a 100644 --- a/crates/api_clients/fal_client/src/requests/http/video/text/mod.rs +++ b/crates/api_clients/fal_client/src/requests/http/video/text/mod.rs @@ -7,5 +7,6 @@ pub mod http_pixverse_v5_text_to_video; pub mod http_seedance_1p5_pro_text_to_video; pub mod http_sora_2_pro_text_to_video; pub mod http_sora_2_text_to_video; +pub mod http_veo_2_text_to_video; pub mod http_veo_3p1_fast_text_to_video; pub mod http_veo_3p1_text_to_video; diff --git a/crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_2_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_2_text_to_video_webhook.rs new file mode 100644 index 0000000000..fe09736fd0 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_2_text_to_video_webhook.rs @@ -0,0 +1,103 @@ +use crate::creds::fal_api_key::FalApiKey; +use crate::error::classify_fal_error::classify_fal_error; +use crate::error::fal_error_plus::FalErrorPlus; +use crate::requests::api::webhook_response::WebhookResponse; +use crate::requests::http::video::text::http_veo_2_text_to_video::{ + veo_2_text_to_video, Veo2TextToVideoInput, +}; +use crate::requests::traits::fal_request_cost_calculator_trait::{ + FalRequestCostCalculator, UsdCents, +}; +// Re-use the same duration/aspect-ratio enums as image-to-video. +pub use crate::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{ + Veo2AspectRatio, Veo2Duration, +}; +use reqwest::IntoUrl; + +pub struct Veo2TextToVideoArgs<'a, V: IntoUrl> { + pub prompt: &'a str, + pub negative_prompt: Option<&'a str>, + pub duration: Veo2Duration, + pub aspect_ratio: Veo2AspectRatio, + pub webhook_url: V, + pub api_key: &'a FalApiKey, +} + +impl FalRequestCostCalculator for Veo2TextToVideoArgs<'_, V> { + fn calculate_cost_in_cents(&self) -> UsdCents { + // Same pricing as image-to-video: + // "For 5s video your request will cost $2.50. + // For every additional second you will be charged $0.50." + match self.duration { + Veo2Duration::Default => 250, + Veo2Duration::FiveSeconds => 250, + Veo2Duration::SixSeconds => 300, + Veo2Duration::SevenSeconds => 350, + Veo2Duration::EightSeconds => 400, + } + } +} + +/// Veo 2 Text-to-Video +/// https://fal.ai/models/fal-ai/veo2 +pub async fn enqueue_veo_2_text_to_video_webhook( + args: Veo2TextToVideoArgs<'_, V>, +) -> Result { + let duration = match args.duration { + Veo2Duration::Default => None, + Veo2Duration::FiveSeconds => Some("5s".to_string()), + Veo2Duration::SixSeconds => Some("6s".to_string()), + Veo2Duration::SevenSeconds => Some("7s".to_string()), + Veo2Duration::EightSeconds => Some("8s".to_string()), + }; + + let aspect_ratio = match args.aspect_ratio { + Veo2AspectRatio::Auto => None, // Let the API default (16:9) + Veo2AspectRatio::AutoPreferPortrait => Some("9:16".to_string()), + Veo2AspectRatio::WideSixteenNine => Some("16:9".to_string()), + Veo2AspectRatio::TallNineSixteen => Some("9:16".to_string()), + }; + + let request = Veo2TextToVideoInput { + prompt: args.prompt.to_string(), + aspect_ratio, + duration, + negative_prompt: args.negative_prompt.map(|s| s.to_string()), + }; + + let result = veo_2_text_to_video(request) + .with_api_key(&args.api_key.0) + .queue_webhook(args.webhook_url) + .await; + + result.map_err(|err| classify_fal_error(err)) +} + +#[cfg(test)] +mod tests { + use crate::creds::fal_api_key::FalApiKey; + use crate::requests::webhook::video::text::enqueue_veo_2_text_to_video_webhook::{ + enqueue_veo_2_text_to_video_webhook, Veo2TextToVideoArgs, Veo2AspectRatio, Veo2Duration, + }; + use errors::AnyhowResult; + use std::fs::read_to_string; + + #[tokio::test] + #[ignore] + async fn test_veo_2_text_to_video() -> AnyhowResult<()> { + let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; + let api_key = FalApiKey::from_str(&secret); + + let args = Veo2TextToVideoArgs { + prompt: "a drone shot of a coastal sunset with waves crashing on rocks", + negative_prompt: None, + api_key: &api_key, + duration: Veo2Duration::FiveSeconds, + aspect_ratio: Veo2AspectRatio::WideSixteenNine, + webhook_url: "https://example.com/webhook", + }; + + let _result = enqueue_veo_2_text_to_video_webhook(args).await?; + Ok(()) + } +} diff --git a/crates/api_clients/fal_client/src/requests/webhook/video/text/mod.rs b/crates/api_clients/fal_client/src/requests/webhook/video/text/mod.rs index ddc7d595df..971105f6e2 100644 --- a/crates/api_clients/fal_client/src/requests/webhook/video/text/mod.rs +++ b/crates/api_clients/fal_client/src/requests/webhook/video/text/mod.rs @@ -7,5 +7,6 @@ pub mod enqueue_pixverse_v5_text_to_video_webhook; pub mod enqueue_seedance_1p5_pro_text_to_video_webhook; pub mod enqueue_sora_2_pro_text_to_video_webhook; pub mod enqueue_sora_2_text_to_video_webhook; +pub mod enqueue_veo_2_text_to_video_webhook; pub mod enqueue_veo_3p1_fast_text_to_video_webhook; pub mod enqueue_veo_3p1_text_to_video_webhook; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_2.rs index 77d1dfb070..5a0cc91af7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_2.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_2.rs @@ -1,7 +1,6 @@ //! Tests for omni-gen video against the Veo 2 model. //! -//! Veo 2 is image-to-video only — a start_frame is always required. -//! There is no text-to-video mode. +//! Veo 2 supports both text-to-video and image-to-video (via start_frame). //! //! Pricing: $2.50 for the first 5 seconds, +$0.50 per additional second. //! 5s → 250¢, 6s → 300¢, 7s → 350¢, 8s → 400¢ @@ -61,66 +60,51 @@ mod tests { (token, map) } - fn distill( + fn distill_text(request: &OmniGenVideoCostAndGenerateRequest) -> DistilledVideoRequest { + let empty: HashMap = HashMap::new(); + distill_video_request(request, Some(&empty)) + .expect("distill_video_request should succeed for Veo 2 (text)") + } + + fn distill_image( request: &OmniGenVideoCostAndGenerateRequest, hydration: &HashMap, ) -> DistilledVideoRequest { distill_video_request(request, Some(hydration)) - .expect("distill_video_request should succeed for Veo 2") + .expect("distill_video_request should succeed for Veo 2 (image)") } // ──────────────────────────────────────────────────────────────────────────── // COST // ──────────────────────────────────────────────────────────────────────────── - // - // Pricing: 5s = 250¢, +50¢ per additional second above 5. - // Default duration = 5s = 250¢. mod cost { use super::*; - mod image { + mod text { use super::*; fn cost(duration: Option) -> u64 { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, duration, Some(start)); - distill(&request, &map).cost.cost_in_usd_cents.unwrap() - } - - // ── Duration-based pricing ──────────────────────────────────────────── - - #[test] - fn default_duration_costs_250_cents() { - assert_eq!(cost(None), 250); + let request = make_request(Some("p"), None, duration, None); + distill_text(&request).cost.cost_in_usd_cents.unwrap() } #[test] - fn five_seconds_costs_250_cents() { - assert_eq!(cost(Some(5)), 250); - } + fn default_duration_costs_250() { assert_eq!(cost(None), 250); } #[test] - fn six_seconds_costs_300_cents() { - assert_eq!(cost(Some(6)), 300); - } + fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); } #[test] - fn seven_seconds_costs_350_cents() { - assert_eq!(cost(Some(7)), 350); - } + fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); } #[test] - fn eight_seconds_costs_400_cents() { - assert_eq!(cost(Some(8)), 400); - } + fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); } #[test] - fn duration_above_8_clamps_to_400_cents() { - // PayMoreUpgrade clamps over-max to 8s. - assert_eq!(cost(Some(15)), 400); - } + fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); } - // ── Cost is independent of aspect ratio ─────────────────────────────── + #[test] + fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); } #[test] fn cost_independent_of_aspect_ratio() { @@ -132,32 +116,14 @@ mod tests { Some(CommonAspectRatio::Square), ]; for ar in ars { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), ar, None, Some(start)); - assert_eq!( - distill(&request, &map).cost.cost_in_usd_cents.unwrap(), 250, - "expected 250¢ regardless of aspect ratio {:?}", ar, - ); + let request = make_request(Some("p"), ar, None, None); + assert_eq!(distill_text(&request).cost.cost_in_usd_cents.unwrap(), 250); } } - // ── Cost is independent of prompt ───────────────────────────────────── - - #[test] - fn cost_independent_of_prompt() { - let (s1, m1) = fake_token("mf_start0000000000000000000000"); - let (s2, m2) = fake_token("mf_start0000000000000000000000"); - let a = distill(&make_request(Some("a cat"), None, None, Some(s1)), &m1); - let b = distill(&make_request(None, None, None, Some(s2)), &m2); - assert_eq!(a.cost.cost_in_usd_cents, b.cost.cost_in_usd_cents); - } - - // ── Metadata flags ──────────────────────────────────────────────────── - #[test] fn cost_metadata_flags_are_default() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let d = distill(&make_request(Some("p"), None, None, Some(start)), &map); + let d = distill_text(&make_request(Some("p"), None, None, None)); assert!(!d.cost.is_free); assert!(!d.cost.is_unlimited); assert!(!d.cost.is_rate_limited); @@ -165,9 +131,46 @@ mod tests { } } - mod text { - // Veo 2 is image-to-video only; there is no text-to-video mode. - // (start_frame is required and the plan errors without it.) + mod image { + use super::*; + + fn cost(duration: Option) -> u64 { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), None, duration, Some(start)); + distill_image(&request, &map).cost.cost_in_usd_cents.unwrap() + } + + #[test] + fn default_duration_costs_250() { assert_eq!(cost(None), 250); } + + #[test] + fn five_seconds_costs_250() { assert_eq!(cost(Some(5)), 250); } + + #[test] + fn six_seconds_costs_300() { assert_eq!(cost(Some(6)), 300); } + + #[test] + fn seven_seconds_costs_350() { assert_eq!(cost(Some(7)), 350); } + + #[test] + fn eight_seconds_costs_400() { assert_eq!(cost(Some(8)), 400); } + + #[test] + fn duration_above_8_clamps_to_400() { assert_eq!(cost(Some(15)), 400); } + + #[test] + fn image_and_text_cost_match() { + for dur in [5u16, 6, 7, 8] { + let text = distill_text(&make_request(Some("p"), None, Some(dur), None)) + .cost.cost_in_usd_cents.unwrap(); + let (start, map) = fake_token("mf_start0000000000000000000000"); + let img = distill_image( + &make_request(Some("p"), None, Some(dur), Some(start)), + &map, + ).cost.cost_in_usd_cents.unwrap(); + assert_eq!(text, img, "text/image cost diverged at dur={}", dur); + } + } } } @@ -178,57 +181,45 @@ mod tests { use super::*; use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; - use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::PlanFalVeo2; + use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_2::{ + FalVeo2Mode, PlanFalVeo2, + }; use fal_client::requests::webhook::video::image::enqueue_veo_2_image_to_video_webhook::{ Veo2AspectRatio, Veo2Duration, }; - fn with_plan( + fn with_text_plan( request: &OmniGenVideoCostAndGenerateRequest, - hydration: &HashMap, assertion: F, ) { - let distilled = distill(request, hydration); + let distilled = distill_text(request); match distilled.plan() { VideoGenerationPlan::FalVeo2(plan) => assertion(plan), other => panic!("expected FalVeo2, got {:?}", other), } } - mod image { + fn with_image_plan( + request: &OmniGenVideoCostAndGenerateRequest, + hydration: &HashMap, + assertion: F, + ) { + let distilled = distill_image(request, hydration); + match distilled.plan() { + VideoGenerationPlan::FalVeo2(plan) => assertion(plan), + other => panic!("expected FalVeo2, got {:?}", other), + } + } + + mod text { use super::*; - // ── Start frame hydration ───────────────────────────────────────────── + // ── Mode detection ──────────────────────────────────────────────────── #[test] - fn start_frame_url_matches_hydration_map() { - let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000"); - let expected_url = "https://cdn.example.com/frames/my_frame.png"; - let mut map = HashMap::new(); - map.insert(token.clone(), Url::parse(expected_url).unwrap()); - - let request = make_request(Some("p"), None, None, Some(token)); - with_plan(&request, &map, |plan| { - assert_eq!(plan.start_frame_url, expected_url); - }); - } - - #[test] - fn start_frame_from_larger_hydration_map() { - let start = MediaFileToken::new_from_str("mf_start0000000000000000000000"); - let extra1 = MediaFileToken::new_from_str("mf_extra1000000000000000000000000"); - let extra2 = MediaFileToken::new_from_str("mf_extra2000000000000000000000000"); - - let start_url = "https://cdn.example.com/start.png"; - - let mut map = HashMap::new(); - map.insert(start.clone(), Url::parse(start_url).unwrap()); - map.insert(extra1, Url::parse("https://cdn.example.com/extra1.png").unwrap()); - map.insert(extra2, Url::parse("https://cdn.example.com/extra2.png").unwrap()); - - let request = make_request(Some("p"), None, None, Some(start)); - with_plan(&request, &map, |plan| { - assert_eq!(plan.start_frame_url, start_url); + fn text_mode_is_text_to_video() { + with_text_plan(&make_request(Some("p"), None, None, None), |plan| { + assert!(matches!(plan.mode, FalVeo2Mode::TextToVideo)); }); } @@ -236,18 +227,14 @@ mod tests { #[test] fn prompt_is_passed_through() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("a horse on mars"), None, None, Some(start)); - with_plan(&request, &map, |plan| { + with_text_plan(&make_request(Some("a horse on mars"), None, None, None), |plan| { assert_eq!(plan.prompt, "a horse on mars"); }); } #[test] fn missing_prompt_is_empty_string() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(None, None, None, Some(start)); - with_plan(&request, &map, |plan| { + with_text_plan(&make_request(None, None, None, None), |plan| { assert_eq!(plan.prompt, ""); }); } @@ -256,9 +243,7 @@ mod tests { #[test] fn default_aspect_ratio_is_auto() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(start)); - with_plan(&request, &map, |plan| { + with_text_plan(&make_request(Some("p"), None, None, None), |plan| { assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto)); }); } @@ -266,31 +251,25 @@ mod tests { #[test] fn auto_yields_auto() { for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(ar), None, Some(start)); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto), "expected Auto for {:?}", ar); + with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { + assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto)); }); } } #[test] - fn wide_16x9_yields_wide_sixteen_nine() { + fn wide_16x9_yields_wide() { for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(ar), None, Some(start)); - with_plan(&request, &map, |plan| { + with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::WideSixteenNine)); }); } } #[test] - fn tall_9x16_yields_tall_nine_sixteen() { + fn tall_9x16_yields_tall() { for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(ar), None, Some(start)); - with_plan(&request, &map, |plan| { + with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::TallNineSixteen)); }); } @@ -298,23 +277,12 @@ mod tests { #[test] fn unsupported_aspect_ratio_falls_back_to_auto() { - let unsupported = [ - CommonAspectRatio::Square, - CommonAspectRatio::SquareHd, - CommonAspectRatio::WideFourByThree, - CommonAspectRatio::WideFiveByFour, - CommonAspectRatio::WideThreeByTwo, - CommonAspectRatio::WideTwentyOneByNine, - CommonAspectRatio::TallThreeByFour, - CommonAspectRatio::TallFourByFive, - CommonAspectRatio::TallTwoByThree, - CommonAspectRatio::TallNineByTwentyOne, - ]; - for ar in unsupported { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), Some(ar), None, Some(start)); - with_plan(&request, &map, |plan| { - assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto), "expected Auto fallback for {:?}", ar); + for ar in [ + CommonAspectRatio::Square, CommonAspectRatio::SquareHd, + CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, + ] { + with_text_plan(&make_request(Some("p"), Some(ar), None, None), |plan| { + assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::Auto)); }); } } @@ -323,62 +291,143 @@ mod tests { #[test] fn default_duration_is_default() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, None, Some(start)); - with_plan(&request, &map, |plan| { + with_text_plan(&make_request(Some("p"), None, None, None), |plan| { assert!(matches!(plan.duration, Veo2Duration::Default)); }); } #[test] - fn duration_5_yields_five_seconds() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(5), Some(start)); - with_plan(&request, &map, |plan| { + fn duration_5_yields_five() { + with_text_plan(&make_request(Some("p"), None, Some(5), None), |plan| { assert!(matches!(plan.duration, Veo2Duration::FiveSeconds)); }); } #[test] - fn duration_6_yields_six_seconds() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(6), Some(start)); - with_plan(&request, &map, |plan| { + fn duration_6_yields_six() { + with_text_plan(&make_request(Some("p"), None, Some(6), None), |plan| { assert!(matches!(plan.duration, Veo2Duration::SixSeconds)); }); } #[test] - fn duration_7_yields_seven_seconds() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(7), Some(start)); - with_plan(&request, &map, |plan| { + fn duration_7_yields_seven() { + with_text_plan(&make_request(Some("p"), None, Some(7), None), |plan| { assert!(matches!(plan.duration, Veo2Duration::SevenSeconds)); }); } #[test] - fn duration_8_yields_eight_seconds() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(8), Some(start)); - with_plan(&request, &map, |plan| { + fn duration_8_yields_eight() { + with_text_plan(&make_request(Some("p"), None, Some(8), None), |plan| { assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); }); } #[test] fn duration_above_8_clamps_to_eight() { - let (start, map) = fake_token("mf_start0000000000000000000000"); - let request = make_request(Some("p"), None, Some(20), Some(start)); - with_plan(&request, &map, |plan| { + with_text_plan(&make_request(Some("p"), None, Some(20), None), |plan| { assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); }); } } - mod text { - // Veo 2 is image-to-video only; there is no text-to-video mode. - // (start_frame is required and the plan errors without it.) + mod image { + use super::*; + + // ── Mode detection / start frame hydration ──────────────────────────── + + #[test] + fn start_frame_triggers_image_to_video() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + match &plan.mode { + FalVeo2Mode::ImageToVideo { image_url } => { + assert!(image_url.starts_with("https://fake.example.com/")); + } + other => panic!("expected ImageToVideo, got {:?}", other), + } + }); + } + + #[test] + fn start_frame_url_matches_hydration_map_exactly() { + let token = MediaFileToken::new_from_str("mf_myframe0000000000000000000000"); + let expected_url = "https://cdn.example.com/frames/my_frame.png"; + let mut map = HashMap::new(); + map.insert(token.clone(), Url::parse(expected_url).unwrap()); + + let request = make_request(Some("p"), None, None, Some(token)); + with_image_plan(&request, &map, |plan| { + match &plan.mode { + FalVeo2Mode::ImageToVideo { image_url } => { + assert_eq!(image_url, expected_url); + } + other => panic!("expected ImageToVideo, got {:?}", other), + } + }); + } + + // ── Prompt passthrough in image mode ────────────────────────────────── + + #[test] + fn prompt_is_passed_through_in_image_mode() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("a horse on mars"), None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert_eq!(plan.prompt, "a horse on mars"); + }); + } + + // ── Aspect ratio in image mode ──────────────────────────────────────── + + #[test] + fn aspect_ratio_passes_through_in_image_mode() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.aspect_ratio, Veo2AspectRatio::WideSixteenNine)); + }); + } + + // ── Duration pass-throughs in image mode ────────────────────────────── + + #[test] + fn duration_5_in_image_mode() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), None, Some(5), Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.duration, Veo2Duration::FiveSeconds)); + }); + } + + #[test] + fn duration_8_in_image_mode() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), None, Some(8), Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); + }); + } + + #[test] + fn duration_above_8_clamps_in_image_mode() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), None, Some(20), Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.duration, Veo2Duration::EightSeconds)); + }); + } + + #[test] + fn default_duration_in_image_mode() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.duration, Veo2Duration::Default)); + }); + } } } }