From 9307b4edede46ab8fc29c8f885cdac143e68febb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 11 Apr 2026 15:58:19 -0400 Subject: [PATCH] veo 3 --- .../video/generate_veo_3_image_to_video.rs | 4 + .../execute/fal/generate_video_fal_veo_3.rs | 71 ++++++++-- .../plan/fal/plan_generate_video_fal_veo_3.rs | 126 ++++++++++++----- .../enqueue_veo_3_image_to_video_webhook.rs | 80 +++++------ .../enqueue_veo_3_text_to_video_webhook.rs | 84 +++++++----- .../generate_veo_3_image_to_video_handler.rs | 27 ++-- .../generate/video/tests/test_veo_3.rs | 128 +++++++++++++----- 7 files changed, 363 insertions(+), 157 deletions(-) diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_image_to_video.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_image_to_video.rs index 5daabd4d5d..7b462da839 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_image_to_video.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/generate_veo_3_image_to_video.rs @@ -42,6 +42,10 @@ pub struct GenerateVeo3ImageToVideoRequest { pub enum GenerateVeo3AspectRatio { WideSixteenNine, // 16:9 TallNineSixteen, // 9:16 + + /// NB: DO NOT REMOVE. This is an unsupported resolution for Veo 3, but we can't + /// impact existing callers by removing it. The handler will convert this to a + /// supported resolution or 400 the request instead. Square, // 1:1 } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_3.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_3.rs index fe70526c89..245413cf22 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_3.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/execute/fal/generate_video_fal_veo_3.rs @@ -5,15 +5,68 @@ use crate::generate::generate_video::generate_video_response::{ FalVideoResponsePayload, GenerateVideoResponse, }; use crate::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{ - FalVeo3Mode, PlanFalVeo3, + FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution, + FalVeo3T2vAspectRatio, PlanFalVeo3, }; use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{ - enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, + enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration, + Veo3I2vResolution, }; use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{ - enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, + enqueue_veo_3_text_to_video_webhook, Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution, + Veo3TextToVideoArgs, }; +fn to_i2v_duration(d: FalVeo3Duration) -> Veo3I2vDuration { + match d { + FalVeo3Duration::Default => Veo3I2vDuration::Default, + FalVeo3Duration::FourSeconds => Veo3I2vDuration::FourSeconds, + FalVeo3Duration::SixSeconds => Veo3I2vDuration::SixSeconds, + FalVeo3Duration::EightSeconds => Veo3I2vDuration::EightSeconds, + } +} + +fn to_t2v_duration(d: FalVeo3Duration) -> Veo3T2vDuration { + match d { + FalVeo3Duration::Default => Veo3T2vDuration::Default, + FalVeo3Duration::FourSeconds => Veo3T2vDuration::FourSeconds, + FalVeo3Duration::SixSeconds => Veo3T2vDuration::SixSeconds, + FalVeo3Duration::EightSeconds => Veo3T2vDuration::EightSeconds, + } +} + +fn to_i2v_resolution(r: FalVeo3Resolution) -> Veo3I2vResolution { + match r { + FalVeo3Resolution::Default => Veo3I2vResolution::Default, + FalVeo3Resolution::SevenTwentyP => Veo3I2vResolution::SevenTwentyP, + FalVeo3Resolution::TenEightyP => Veo3I2vResolution::TenEightyP, + } +} + +fn to_t2v_resolution(r: FalVeo3Resolution) -> Veo3T2vResolution { + match r { + FalVeo3Resolution::Default => Veo3T2vResolution::Default, + FalVeo3Resolution::SevenTwentyP => Veo3T2vResolution::SevenTwentyP, + FalVeo3Resolution::TenEightyP => Veo3T2vResolution::TenEightyP, + } +} + +fn to_t2v_aspect_ratio(ar: FalVeo3T2vAspectRatio) -> Veo3T2vAspectRatio { + match ar { + FalVeo3T2vAspectRatio::Default => Veo3T2vAspectRatio::Default, + FalVeo3T2vAspectRatio::WideSixteenNine => Veo3T2vAspectRatio::WideSixteenNine, + FalVeo3T2vAspectRatio::TallNineSixteen => Veo3T2vAspectRatio::TallNineSixteen, + } +} + +fn to_i2v_aspect_ratio(ar: FalVeo3I2vAspectRatio) -> Veo3I2vAspectRatio { + match ar { + FalVeo3I2vAspectRatio::Auto => Veo3I2vAspectRatio::Auto, + FalVeo3I2vAspectRatio::WideSixteenNine => Veo3I2vAspectRatio::WideSixteenNine, + FalVeo3I2vAspectRatio::TallNineSixteen => Veo3I2vAspectRatio::TallNineSixteen, + } +} + pub async fn execute_fal_veo_3( plan: &PlanFalVeo3, fal_client: &RouterFalClient, @@ -24,9 +77,9 @@ pub async fn execute_fal_veo_3( 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.unwrap_or(Veo3AspectRatio::Default), - resolution: plan.resolution, + duration: to_t2v_duration(plan.duration), + aspect_ratio: plan.t2v_aspect_ratio.map(to_t2v_aspect_ratio).unwrap_or(Veo3T2vAspectRatio::Default), + resolution: to_t2v_resolution(plan.resolution), generate_audio: plan.generate_audio, webhook_url: fal_client.webhook_url.as_str(), }; @@ -38,9 +91,9 @@ pub async fn execute_fal_veo_3( let args = Veo3Args { image_url: image_url.as_str(), prompt: plan.prompt.as_str(), - duration: plan.duration, - aspect_ratio: plan.aspect_ratio.unwrap_or(Veo3AspectRatio::Default), - resolution: plan.resolution, + duration: to_i2v_duration(plan.duration), + aspect_ratio: plan.i2v_aspect_ratio.map(to_i2v_aspect_ratio).unwrap_or(Veo3I2vAspectRatio::Auto), + resolution: to_i2v_resolution(plan.resolution), generate_audio: plan.generate_audio, api_key: &fal_client.api_key, webhook_url: fal_client.webhook_url.as_str(), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_3.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_3.rs index ff22cf4030..7573b88c1c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_3.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/plan/fal/plan_generate_video_fal_veo_3.rs @@ -7,7 +7,10 @@ use crate::errors::client_error::ClientError; use crate::generate::generate_video::generate_video_request::GenerateVideoRequest; use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan; use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{ - Veo3AspectRatio, Veo3Duration, Veo3Resolution, + Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution, +}; +use fal_client::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{ + Veo3T2vAspectRatio, Veo3T2vDuration, Veo3T2vResolution, }; #[derive(Debug, Clone)] @@ -18,16 +21,51 @@ pub enum FalVeo3Mode { }, } +/// Router-level duration shared between both modes. +#[derive(Debug, Clone, Copy)] +pub enum FalVeo3Duration { + Default, + FourSeconds, + SixSeconds, + EightSeconds, +} + +/// Router-level resolution shared between both modes. +#[derive(Debug, Clone, Copy)] +pub enum FalVeo3Resolution { + Default, + SevenTwentyP, + TenEightyP, +} + +/// Router-level aspect ratio for text-to-video only (no Auto, no Square). +#[derive(Debug, Clone, Copy)] +pub enum FalVeo3T2vAspectRatio { + Default, + WideSixteenNine, + TallNineSixteen, +} + +/// Router-level aspect ratio for image-to-video only (has Auto, no Square). +#[derive(Debug, Clone, Copy)] +pub enum FalVeo3I2vAspectRatio { + Auto, + WideSixteenNine, + TallNineSixteen, +} + #[derive(Debug, Clone)] pub struct PlanFalVeo3 { pub prompt: String, pub negative_prompt: Option, pub mode: FalVeo3Mode, /// Only set for text-to-video. Image-to-video inherits the source frame's - /// aspect ratio. - pub aspect_ratio: Option, - pub resolution: Veo3Resolution, - pub duration: Veo3Duration, + /// aspect ratio and uses `i2v_aspect_ratio` instead. + pub t2v_aspect_ratio: Option, + /// Only set for image-to-video. + pub i2v_aspect_ratio: Option, + pub resolution: FalVeo3Resolution, + pub duration: FalVeo3Duration, pub generate_audio: bool, } @@ -50,11 +88,13 @@ pub fn plan_generate_video_fal_veo_3<'a>( None => FalVeo3Mode::TextToVideo, }; - // Aspect ratio only applies to text-to-video; image-to-video inherits - // the source frame's aspect ratio. - let aspect_ratio = match &mode { - FalVeo3Mode::TextToVideo => Some(plan_aspect_ratio(request.aspect_ratio, strategy)?), - FalVeo3Mode::ImageToVideo { .. } => None, + let (t2v_aspect_ratio, i2v_aspect_ratio) = match &mode { + FalVeo3Mode::TextToVideo => { + (Some(plan_t2v_aspect_ratio(request.aspect_ratio, strategy)?), None) + } + FalVeo3Mode::ImageToVideo { .. } => { + (None, Some(plan_i2v_aspect_ratio(request.aspect_ratio, strategy)?)) + } }; let resolution = plan_resolution(request.resolution, strategy)?; let duration = plan_duration(request.duration_seconds, strategy)?; @@ -64,34 +104,58 @@ pub fn plan_generate_video_fal_veo_3<'a>( prompt: request.prompt.unwrap_or("").to_string(), negative_prompt: request.negative_prompt.map(|s| s.to_string()), mode, - aspect_ratio, + t2v_aspect_ratio, + i2v_aspect_ratio, resolution, duration, generate_audio, })) } -fn plan_aspect_ratio( +/// Text-to-video: only 16:9 and 9:16 (no Auto, no Square). +fn plan_t2v_aspect_ratio( aspect_ratio: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result { - use Veo3AspectRatio as Ar; +) -> Result { + use FalVeo3T2vAspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::Default), + + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen), + + // Everything else (Auto*, Square, unsupported) → Default (16:9). + Some(_) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap()))) + } + _ => Ok(Ar::Default), + }, + } +} + +/// Image-to-video: Auto, 16:9, 9:16 (no Square). +fn plan_i2v_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use FalVeo3I2vAspectRatio as Ar; match aspect_ratio { None | Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) - | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Default), + | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Auto), - Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Ar::Square), Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::WideSixteenNine), Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::TallNineSixteen), - Some(unsupported_ar) => match strategy { + // Everything else (Square, unsupported) → Auto. + Some(_) => match strategy { RequestMismatchMitigationStrategy::ErrorOut => { - Err(unsupported("aspect_ratio", &format!("{:?}", unsupported_ar))) + Err(unsupported("aspect_ratio", &format!("{:?}", aspect_ratio.unwrap()))) } - _ => Ok(Ar::Default), + _ => Ok(Ar::Auto), }, } } @@ -99,8 +163,8 @@ fn plan_aspect_ratio( fn plan_resolution( resolution: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result { - use Veo3Resolution as R; +) -> Result { + use FalVeo3Resolution as R; match resolution { None => Ok(R::Default), Some(CommonResolution::SevenTwentyP) => Ok(R::SevenTwentyP), @@ -118,18 +182,18 @@ fn plan_resolution( fn plan_duration( duration_seconds: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result { +) -> Result { match duration_seconds { - None => Ok(Veo3Duration::Default), - Some(4) => Ok(Veo3Duration::FourSeconds), - Some(6) => Ok(Veo3Duration::SixSeconds), - Some(8) => Ok(Veo3Duration::EightSeconds), + None => Ok(FalVeo3Duration::Default), + Some(4) => Ok(FalVeo3Duration::FourSeconds), + Some(6) => Ok(FalVeo3Duration::SixSeconds), + Some(8) => Ok(FalVeo3Duration::EightSeconds), Some(other) => match strategy { RequestMismatchMitigationStrategy::ErrorOut => { Err(unsupported("duration_seconds", &format!("{}", other))) } - RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Veo3Duration::EightSeconds), - RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Veo3Duration::FourSeconds), + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(FalVeo3Duration::EightSeconds), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(FalVeo3Duration::FourSeconds), }, } } @@ -144,9 +208,9 @@ fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError { impl PlanFalVeo3 { pub fn duration_seconds_for_cost(&self) -> u64 { match self.duration { - Veo3Duration::Default | Veo3Duration::EightSeconds => 8, - Veo3Duration::SixSeconds => 6, - Veo3Duration::FourSeconds => 4, + FalVeo3Duration::Default | FalVeo3Duration::EightSeconds => 8, + FalVeo3Duration::SixSeconds => 6, + FalVeo3Duration::FourSeconds => 4, } } } diff --git a/crates/api_clients/fal_client/src/requests/webhook/video/image/enqueue_veo_3_image_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests/webhook/video/image/enqueue_veo_3_image_to_video_webhook.rs index 05c5ed1672..082ccf432d 100644 --- a/crates/api_clients/fal_client/src/requests/webhook/video/image/enqueue_veo_3_image_to_video_webhook.rs +++ b/crates/api_clients/fal_client/src/requests/webhook/video/image/enqueue_veo_3_image_to_video_webhook.rs @@ -9,32 +9,35 @@ use reqwest::IntoUrl; pub struct Veo3Args<'a, U: IntoUrl, V: IntoUrl> { pub image_url: U, pub prompt: &'a str, - pub duration: Veo3Duration, - pub aspect_ratio: Veo3AspectRatio, - pub resolution: Veo3Resolution, + pub duration: Veo3I2vDuration, + pub aspect_ratio: Veo3I2vAspectRatio, + pub resolution: Veo3I2vResolution, pub generate_audio: bool, pub api_key: &'a FalApiKey, pub webhook_url: V, } +/// Duration for Veo 3 image-to-video. Default is 8 seconds. #[derive(Copy, Clone, Debug)] -pub enum Veo3Duration { +pub enum Veo3I2vDuration { Default, // Default is 8 seconds FourSeconds, SixSeconds, EightSeconds, } +/// Aspect ratio for Veo 3 image-to-video. +/// Supports Auto (inherit source), 16:9, and 9:16. No Square. #[derive(Copy, Clone, Debug)] -pub enum Veo3AspectRatio { - Default, - WideSixteenNine, - TallNineSixteen, - Square, +pub enum Veo3I2vAspectRatio { + Auto, + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 } +/// Resolution for Veo 3 image-to-video. #[derive(Copy, Clone, Debug)] -pub enum Veo3Resolution { +pub enum Veo3I2vResolution { Default, SevenTwentyP, TenEightyP, @@ -48,14 +51,14 @@ impl FalRequestCostCalculator for Veo3Args<'_, U, V> { // For example, a 5s video with audio on will cost $2." match (self.duration, self.generate_audio) { - (Veo3Duration::FourSeconds, false) => 80, - (Veo3Duration::SixSeconds, false) => 120, - (Veo3Duration::EightSeconds, false) => 160, - (Veo3Duration::Default, false) => 160, // NB: Default is 8 seconds - (Veo3Duration::FourSeconds, true) => 160, - (Veo3Duration::SixSeconds, true) => 240, - (Veo3Duration::EightSeconds, true) => 320, - (Veo3Duration::Default, true) => 320, // NB: Default is 8 seconds320 + (Veo3I2vDuration::FourSeconds, false) => 80, + (Veo3I2vDuration::SixSeconds, false) => 120, + (Veo3I2vDuration::EightSeconds, false) => 160, + (Veo3I2vDuration::Default, false) => 160, + (Veo3I2vDuration::FourSeconds, true) => 160, + (Veo3I2vDuration::SixSeconds, true) => 240, + (Veo3I2vDuration::EightSeconds, true) => 320, + (Veo3I2vDuration::Default, true) => 320, } } } @@ -67,23 +70,22 @@ pub async fn enqueue_veo_3_image_to_video_webhook( args: Veo3Args<'_, U, V> ) -> Result { let duration = match args.duration { - Veo3Duration::Default => None, - Veo3Duration::FourSeconds => Some("4s".to_string()), - Veo3Duration::SixSeconds => Some("6s".to_string()), - Veo3Duration::EightSeconds => Some("8s".to_string()), + Veo3I2vDuration::Default => None, + Veo3I2vDuration::FourSeconds => Some("4s".to_string()), + Veo3I2vDuration::SixSeconds => Some("6s".to_string()), + Veo3I2vDuration::EightSeconds => Some("8s".to_string()), }; let aspect_ratio = match args.aspect_ratio { - Veo3AspectRatio::Default => None, - Veo3AspectRatio::WideSixteenNine => Some("16:9".to_string()), - Veo3AspectRatio::TallNineSixteen => Some("9:16".to_string()), - Veo3AspectRatio::Square => Some("1:1".to_string()), + Veo3I2vAspectRatio::Auto => Some("auto".to_string()), + Veo3I2vAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Veo3I2vAspectRatio::TallNineSixteen => Some("9:16".to_string()), }; - - let resolution= match args.resolution { - Veo3Resolution::Default => None, - Veo3Resolution::SevenTwentyP => Some("720p".to_string()), - Veo3Resolution::TenEightyP => Some("1080p".to_string()), + + let resolution = match args.resolution { + Veo3I2vResolution::Default => None, + Veo3I2vResolution::SevenTwentyP => Some("720p".to_string()), + Veo3I2vResolution::TenEightyP => Some("1080p".to_string()), }; let image_url = args.image_url.as_str().to_string(); @@ -109,7 +111,10 @@ pub async fn enqueue_veo_3_image_to_video_webhook( #[cfg(test)] mod tests { use crate::creds::fal_api_key::FalApiKey; - use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution}; + use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{ + enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration, + Veo3I2vResolution, + }; use errors::AnyhowResult; use std::fs::read_to_string; use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL; @@ -117,24 +122,21 @@ mod tests { #[tokio::test] #[ignore] async fn test() -> AnyhowResult<()> { - // XXX: Don't commit secrets! let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?; - let api_key = FalApiKey::from_str(&secret); let args = Veo3Args { image_url: TREX_SKELETON_IMAGE_URL, prompt: "the t-rex skeleton starts walking towards the camera and roars", api_key: &api_key, - duration: Veo3Duration::EightSeconds, - aspect_ratio: Veo3AspectRatio::WideSixteenNine, - resolution: Veo3Resolution::TenEightyP, + duration: Veo3I2vDuration::EightSeconds, + aspect_ratio: Veo3I2vAspectRatio::WideSixteenNine, + resolution: Veo3I2vResolution::TenEightyP, generate_audio: true, webhook_url: "https://example.com/webhook", }; - let result = enqueue_veo_3_image_to_video_webhook(args).await?; - + let _result = enqueue_veo_3_image_to_video_webhook(args).await?; Ok(()) } } diff --git a/crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_3_text_to_video_webhook.rs b/crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_3_text_to_video_webhook.rs index 5d72690734..0d30e746a7 100644 --- a/crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_3_text_to_video_webhook.rs +++ b/crates/api_clients/fal_client/src/requests/webhook/video/text/enqueue_veo_3_text_to_video_webhook.rs @@ -8,36 +8,57 @@ use crate::requests::http::video::text::http_veo_3_text_to_video::{ use crate::requests::traits::fal_request_cost_calculator_trait::{ FalRequestCostCalculator, UsdCents, }; -// Re-use the same duration/aspect-ratio/resolution enums as image-to-video. -pub use crate::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{ - Veo3AspectRatio, Veo3Duration, Veo3Resolution, -}; use reqwest::IntoUrl; pub struct Veo3TextToVideoArgs<'a, V: IntoUrl> { pub prompt: &'a str, pub negative_prompt: Option<&'a str>, - pub duration: Veo3Duration, - pub aspect_ratio: Veo3AspectRatio, - pub resolution: Veo3Resolution, + pub duration: Veo3T2vDuration, + pub aspect_ratio: Veo3T2vAspectRatio, + pub resolution: Veo3T2vResolution, pub generate_audio: bool, pub webhook_url: V, pub api_key: &'a FalApiKey, } +/// Duration for Veo 3 text-to-video. Default is 8 seconds. +#[derive(Copy, Clone, Debug)] +pub enum Veo3T2vDuration { + Default, // Default is 8 seconds + FourSeconds, + SixSeconds, + EightSeconds, +} + +/// Aspect ratio for Veo 3 text-to-video. +/// Only 16:9 and 9:16 are supported. No Auto, no Square. +#[derive(Copy, Clone, Debug)] +pub enum Veo3T2vAspectRatio { + Default, // Default is 16:9 + WideSixteenNine, // 16:9 + TallNineSixteen, // 9:16 +} + +/// Resolution for Veo 3 text-to-video. +#[derive(Copy, Clone, Debug)] +pub enum Veo3T2vResolution { + Default, + SevenTwentyP, + TenEightyP, +} + impl FalRequestCostCalculator for Veo3TextToVideoArgs<'_, V> { fn calculate_cost_in_cents(&self) -> UsdCents { - // Same pricing as image-to-video: // $0.20/sec audio off, $0.40/sec audio on. match (self.duration, self.generate_audio) { - (Veo3Duration::FourSeconds, false) => 80, - (Veo3Duration::SixSeconds, false) => 120, - (Veo3Duration::EightSeconds, false) => 160, - (Veo3Duration::Default, false) => 160, - (Veo3Duration::FourSeconds, true) => 160, - (Veo3Duration::SixSeconds, true) => 240, - (Veo3Duration::EightSeconds, true) => 320, - (Veo3Duration::Default, true) => 320, + (Veo3T2vDuration::FourSeconds, false) => 80, + (Veo3T2vDuration::SixSeconds, false) => 120, + (Veo3T2vDuration::EightSeconds, false) => 160, + (Veo3T2vDuration::Default, false) => 160, + (Veo3T2vDuration::FourSeconds, true) => 160, + (Veo3T2vDuration::SixSeconds, true) => 240, + (Veo3T2vDuration::EightSeconds, true) => 320, + (Veo3T2vDuration::Default, true) => 320, } } } @@ -48,23 +69,22 @@ pub async fn enqueue_veo_3_text_to_video_webhook( args: Veo3TextToVideoArgs<'_, V>, ) -> Result { let duration = match args.duration { - Veo3Duration::Default => None, - Veo3Duration::FourSeconds => Some("4s".to_string()), - Veo3Duration::SixSeconds => Some("6s".to_string()), - Veo3Duration::EightSeconds => Some("8s".to_string()), + Veo3T2vDuration::Default => None, + Veo3T2vDuration::FourSeconds => Some("4s".to_string()), + Veo3T2vDuration::SixSeconds => Some("6s".to_string()), + Veo3T2vDuration::EightSeconds => Some("8s".to_string()), }; let aspect_ratio = match args.aspect_ratio { - Veo3AspectRatio::Default => None, - Veo3AspectRatio::WideSixteenNine => Some("16:9".to_string()), - Veo3AspectRatio::TallNineSixteen => Some("9:16".to_string()), - Veo3AspectRatio::Square => Some("1:1".to_string()), + Veo3T2vAspectRatio::Default => None, + Veo3T2vAspectRatio::WideSixteenNine => Some("16:9".to_string()), + Veo3T2vAspectRatio::TallNineSixteen => Some("9:16".to_string()), }; let resolution = match args.resolution { - Veo3Resolution::Default => None, - Veo3Resolution::SevenTwentyP => Some("720p".to_string()), - Veo3Resolution::TenEightyP => Some("1080p".to_string()), + Veo3T2vResolution::Default => None, + Veo3T2vResolution::SevenTwentyP => Some("720p".to_string()), + Veo3T2vResolution::TenEightyP => Some("1080p".to_string()), }; let request = Veo3TextToVideoInput { @@ -88,8 +108,8 @@ pub async fn enqueue_veo_3_text_to_video_webhook( mod tests { use crate::creds::fal_api_key::FalApiKey; use crate::requests::webhook::video::text::enqueue_veo_3_text_to_video_webhook::{ - enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3AspectRatio, Veo3Duration, - Veo3Resolution, + enqueue_veo_3_text_to_video_webhook, Veo3TextToVideoArgs, Veo3T2vAspectRatio, + Veo3T2vDuration, Veo3T2vResolution, }; use errors::AnyhowResult; use std::fs::read_to_string; @@ -104,9 +124,9 @@ mod tests { prompt: "a drone shot of a coastal sunset with waves crashing on rocks", negative_prompt: None, api_key: &api_key, - duration: Veo3Duration::EightSeconds, - aspect_ratio: Veo3AspectRatio::WideSixteenNine, - resolution: Veo3Resolution::TenEightyP, + duration: Veo3T2vDuration::EightSeconds, + aspect_ratio: Veo3T2vAspectRatio::WideSixteenNine, + resolution: Veo3T2vResolution::TenEightyP, generate_audio: true, webhook_url: "https://example.com/webhook", }; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs index 8655d3ceda..5c8df8b3a9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/image/generate_veo_3_image_to_video_handler.rs @@ -14,13 +14,13 @@ use anyhow::anyhow; use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3AspectRatio, GenerateVeo3Duration, GenerateVeo3ImageToVideoRequest, GenerateVeo3ImageToVideoResponse, GenerateVeo3Resolution}; use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use enums::by_table::prompts::prompt_type::PromptType; -use enums::common::generation_provider::GenerationProvider; -use enums::common::generation::common_model_type::CommonModelType; -use enums::common::visibility::Visibility; use enums::common::generation::common_aspect_ratio::CommonAspectRatio; use enums::common::generation::common_generation_mode::CommonGenerationMode; +use enums::common::generation::common_model_type::CommonModelType; +use enums::common::generation_provider::GenerationProvider; +use enums::common::visibility::Visibility; use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; -use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3AspectRatio, Veo3Duration, Veo3Resolution}; +use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{enqueue_veo_3_image_to_video_webhook, Veo3Args, Veo3I2vAspectRatio, Veo3I2vDuration, Veo3I2vResolution}; use http_server_common::request::get_request_ip::get_request_ip; use log::{error, info, warn}; use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue; @@ -143,21 +143,22 @@ pub async fn generate_veo_3_image_to_video_handler( .unwrap_or_else(|| ""); let aspect_ratio = match &request.aspect_ratio { - Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3AspectRatio::WideSixteenNine, - Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3AspectRatio::TallNineSixteen, - Some(GenerateVeo3AspectRatio::Square) => Veo3AspectRatio::Square, - None => Veo3AspectRatio::WideSixteenNine, + Some(GenerateVeo3AspectRatio::WideSixteenNine) => Veo3I2vAspectRatio::WideSixteenNine, + Some(GenerateVeo3AspectRatio::TallNineSixteen) => Veo3I2vAspectRatio::TallNineSixteen, + // Square is not supported by Veo 3 i2v — fall back to Auto. + Some(GenerateVeo3AspectRatio::Square) => Veo3I2vAspectRatio::Auto, + None => Veo3I2vAspectRatio::WideSixteenNine, }; let resolution = match &request.resolution { - Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3Resolution::SevenTwentyP, - Some(GenerateVeo3Resolution::TenEightyP) => Veo3Resolution::TenEightyP, - None => Veo3Resolution::Default, + Some(GenerateVeo3Resolution::SevenTwentyP) => Veo3I2vResolution::SevenTwentyP, + Some(GenerateVeo3Resolution::TenEightyP) => Veo3I2vResolution::TenEightyP, + None => Veo3I2vResolution::Default, }; let duration = match &request.duration { - Some(GenerateVeo3Duration::EightSeconds) => Veo3Duration::EightSeconds, - None => Veo3Duration::EightSeconds, + Some(GenerateVeo3Duration::EightSeconds) => Veo3I2vDuration::EightSeconds, + None => Veo3I2vDuration::EightSeconds, }; let generate_audio = request.generate_audio diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_3.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_3.rs index c21a84e557..1f48305250 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_3.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/test_veo_3.rs @@ -208,10 +208,8 @@ mod tests { use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan; use artcraft_router::generate::generate_video::plan::fal::plan_generate_video_fal_veo_3::{ - FalVeo3Mode, PlanFalVeo3, - }; - use fal_client::requests::webhook::video::image::enqueue_veo_3_image_to_video_webhook::{ - Veo3AspectRatio, Veo3Duration, Veo3Resolution, + FalVeo3Duration, FalVeo3I2vAspectRatio, FalVeo3Mode, FalVeo3Resolution, + FalVeo3T2vAspectRatio, PlanFalVeo3, }; fn with_text_plan( @@ -270,24 +268,15 @@ mod tests { #[test] fn default_aspect_ratio_is_default() { with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo3AspectRatio::Default))); + assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); }); } - #[test] - fn auto_yields_default() { - for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { - with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo3AspectRatio::Default))); - }); - } - } - #[test] fn wide_16x9_yields_wide() { for ar in [CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::Wide] { with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo3AspectRatio::WideSixteenNine))); + assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::WideSixteenNine))); }); } } @@ -296,16 +285,27 @@ mod tests { fn tall_9x16_yields_tall() { for ar in [CommonAspectRatio::TallNineBySixteen, CommonAspectRatio::Tall] { with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo3AspectRatio::TallNineSixteen))); + assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::TallNineSixteen))); }); } } #[test] - fn square_yields_square() { + fn square_falls_back_to_default() { + // Veo 3 t2v has no Square — falls back to Default (16:9). for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] { with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo3AspectRatio::Square))); + assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); + }); + } + } + + #[test] + fn auto_falls_back_to_default() { + // Veo 3 t2v has no Auto — falls back to Default (16:9). + for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { + with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { + assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); }); } } @@ -314,7 +314,7 @@ mod tests { fn unsupported_aspect_ratio_falls_back_to_default() { for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour, CommonAspectRatio::WideFiveByFour] { with_text_plan(&make_request(Some("p"), Some(ar), None, None, None, None), |plan| { - assert!(matches!(plan.aspect_ratio, Some(Veo3AspectRatio::Default))); + assert!(matches!(plan.t2v_aspect_ratio, Some(FalVeo3T2vAspectRatio::Default))); }); } } @@ -324,35 +324,35 @@ mod tests { #[test] fn default_duration_is_default() { with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(matches!(plan.duration, Veo3Duration::Default)); + assert!(matches!(plan.duration, FalVeo3Duration::Default)); }); } #[test] fn duration_4_yields_four() { with_text_plan(&make_request(Some("p"), None, None, Some(4), None, None), |plan| { - assert!(matches!(plan.duration, Veo3Duration::FourSeconds)); + assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds)); }); } #[test] fn duration_6_yields_six() { with_text_plan(&make_request(Some("p"), None, None, Some(6), None, None), |plan| { - assert!(matches!(plan.duration, Veo3Duration::SixSeconds)); + assert!(matches!(plan.duration, FalVeo3Duration::SixSeconds)); }); } #[test] fn duration_8_yields_eight() { with_text_plan(&make_request(Some("p"), None, None, Some(8), None, None), |plan| { - assert!(matches!(plan.duration, Veo3Duration::EightSeconds)); + assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds)); }); } #[test] fn duration_above_8_clamps_to_eight() { with_text_plan(&make_request(Some("p"), None, None, Some(20), None, None), |plan| { - assert!(matches!(plan.duration, Veo3Duration::EightSeconds)); + assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds)); }); } @@ -361,21 +361,21 @@ mod tests { #[test] fn default_resolution_is_default() { with_text_plan(&make_request(Some("p"), None, None, None, None, None), |plan| { - assert!(matches!(plan.resolution, Veo3Resolution::Default)); + assert!(matches!(plan.resolution, FalVeo3Resolution::Default)); }); } #[test] fn seven_twenty_p_yields_seven_twenty_p() { with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::SevenTwentyP), None, None, None), |plan| { - assert!(matches!(plan.resolution, Veo3Resolution::SevenTwentyP)); + assert!(matches!(plan.resolution, FalVeo3Resolution::SevenTwentyP)); }); } #[test] fn ten_eighty_p_yields_ten_eighty_p() { with_text_plan(&make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, None), |plan| { - assert!(matches!(plan.resolution, Veo3Resolution::TenEightyP)); + assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP)); }); } @@ -443,15 +443,77 @@ mod tests { // ── Aspect ratio is None in image mode ──────────────────────────────── + // ── Aspect ratio in image mode: uses i2v (has Auto, no Square) ───────── + #[test] - fn aspect_ratio_is_none_in_image_mode() { + fn t2v_aspect_ratio_is_none_in_image_mode() { let (start, map) = fake_token("mf_start0000000000000000000000"); let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start)); with_image_plan(&request, &map, |plan| { - assert!(plan.aspect_ratio.is_none()); + assert!(plan.t2v_aspect_ratio.is_none()); }); } + #[test] + fn i2v_default_aspect_ratio_is_auto() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), None, None, None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); + }); + } + + #[test] + fn i2v_wide_16x9_yields_wide() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::WideSixteenNine))); + }); + } + + #[test] + fn i2v_tall_9x16_yields_tall() { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::TallNineSixteen))); + }); + } + + #[test] + fn i2v_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, None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); + }); + } + } + + #[test] + fn i2v_square_falls_back_to_auto() { + for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), Some(ar), None, None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); + }); + } + } + + #[test] + fn i2v_unsupported_falls_back_to_auto() { + for ar in [CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour] { + let (start, map) = fake_token("mf_start0000000000000000000000"); + let request = make_request(Some("p"), Some(ar), None, None, None, Some(start)); + with_image_plan(&request, &map, |plan| { + assert!(matches!(plan.i2v_aspect_ratio, Some(FalVeo3I2vAspectRatio::Auto))); + }); + } + } + // ── Prompt passthrough in image mode ────────────────────────────────── #[test] @@ -470,7 +532,7 @@ mod tests { let (start, map) = fake_token("mf_start0000000000000000000000"); let request = make_request(Some("p"), None, None, Some(4), None, Some(start)); with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Veo3Duration::FourSeconds)); + assert!(matches!(plan.duration, FalVeo3Duration::FourSeconds)); }); } @@ -479,7 +541,7 @@ mod tests { let (start, map) = fake_token("mf_start0000000000000000000000"); let request = make_request(Some("p"), None, None, Some(8), None, Some(start)); with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Veo3Duration::EightSeconds)); + assert!(matches!(plan.duration, FalVeo3Duration::EightSeconds)); }); } @@ -488,7 +550,7 @@ mod tests { let (start, map) = fake_token("mf_start0000000000000000000000"); let request = make_request(Some("p"), None, None, None, None, Some(start)); with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.duration, Veo3Duration::Default)); + assert!(matches!(plan.duration, FalVeo3Duration::Default)); }); } @@ -499,7 +561,7 @@ mod tests { let (start, map) = fake_token("mf_start0000000000000000000000"); let request = make_request(Some("p"), None, Some(CommonResolution::TenEightyP), None, None, Some(start)); with_image_plan(&request, &map, |plan| { - assert!(matches!(plan.resolution, Veo3Resolution::TenEightyP)); + assert!(matches!(plan.resolution, FalVeo3Resolution::TenEightyP)); }); }