From 0701d718eefdfd01b8ef3a243c59e8384cfa3d4d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 24 May 2026 00:36:07 -0400 Subject: [PATCH] first iteration of pipeline --- .../artcraft_router/src/api/provider.rs | 1 + .../src/errors/provider_error.rs | 9 + .../generate_video_request_builder.rs | 6 + .../generate_video/generate_video_response.rs | 15 + .../grok_api/grok_imagine_video/build.rs | 481 ++++++++++++++++++ .../grok_api/grok_imagine_video/cost.rs | 188 +++++++ .../grok_api/grok_imagine_video/mod.rs | 3 + .../grok_api/grok_imagine_video/request.rs | 106 ++++ .../providers/grok_api/mod.rs | 1 + .../generate_video_v2/providers/mod.rs | 1 + .../video_generation_request.rs | 9 + .../videos/video_status/video_status.rs | 3 +- 12 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/mod.rs diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/provider.rs b/crates/api_clients/artcraft/artcraft_router/src/api/provider.rs index 15d0d970f3..cbfb9b3d99 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/provider.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/provider.rs @@ -7,6 +7,7 @@ pub enum Provider { Artcraft, Fal, GmiCloud, + GrokApi, Muapi, Seedance2Pro, } diff --git a/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs b/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs index 96e9d05b7f..155f0db787 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/errors/provider_error.rs @@ -3,6 +3,7 @@ use std::fmt::{Display, Formatter}; use artcraft_client::error::storyteller_error::StorytellerError; use fal_client::error::fal_error_plus::FalErrorPlus; use gmicloud_client::error::gmicloud_error::GmiCloudError; +use grok_api_client::error::grok_error::GrokError; use muapi_client::error::muapi_error::MuapiError; use seedance2pro_client::error::seedance2pro_error::Seedance2ProError; @@ -11,6 +12,7 @@ pub enum ProviderError { Storyteller(StorytellerError), Fal(FalErrorPlus), GmiCloud(GmiCloudError), + Grok(GrokError), Muapi(MuapiError), Seedance2Pro(Seedance2ProError), } @@ -23,6 +25,7 @@ impl Display for ProviderError { Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e), Self::Fal(e) => write!(f, "Fal provider error: {}", e), Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e), + Self::Grok(e) => write!(f, "Grok provider error: {}", e), Self::Muapi(e) => write!(f, "Muapi provider error: {}", e), Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e), } @@ -47,6 +50,12 @@ impl From for ProviderError { } } +impl From for ProviderError { + fn from(error: GrokError) -> Self { + Self::Grok(error) + } +} + impl From for ProviderError { fn from(error: MuapiError) -> Self { Self::Muapi(error) diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs index 360bbad81d..b37009c462 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs @@ -60,6 +60,7 @@ use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::buil use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0; use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u; use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast; +use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast; use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; @@ -165,6 +166,8 @@ impl GenerateVideoRequestBuilder { // GmiCloud (Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => true, (Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => true, + // Grok + (Provider::GrokApi, CommonVideoModel::GrokImagineVideo) => true, // Kinovi (Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => true, (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true, @@ -189,6 +192,8 @@ impl GenerateVideoRequestBuilder { // GmiCloud (Provider::GmiCloud, CommonVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self), (Provider::GmiCloud, CommonVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self), + // Grok + (Provider::GrokApi, CommonVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self), // Kinovi (Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self), (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self), @@ -203,6 +208,7 @@ impl GenerateVideoRequestBuilder { Provider::Artcraft => self.build_artcraft(), Provider::Fal => self.build_fal(), Provider::GmiCloud => self.unsupported_provider(), // GmiCloud uses build2() only + Provider::GrokApi => self.unsupported_provider(), // Grok uses build2() only Provider::Muapi => self.build_muapi(), Provider::Seedance2Pro => self.build_seedance2pro(), } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs index 80b82fbda9..9b3e2f8d2f 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_response.rs @@ -39,10 +39,18 @@ pub struct GmiCloudVideoResponsePayload { pub request_id: String, } +#[derive(Clone, Debug)] +pub struct GrokVideoResponsePayload { + /// xAI request_id; poll with `grok_api_client::api::requests::videos::video_status` + /// to fetch the rendered video URL once it's ready. + pub request_id: String, +} + #[derive(Clone, Debug)] pub enum GenerateVideoResponse { Artcraft(ArtcraftVideoResponsePayload), GmiCloud(GmiCloudVideoResponsePayload), + Grok(GrokVideoResponsePayload), Muapi(MuapiVideoResponsePayload), Seedance2Pro(Seedance2proVideoResponsePayload), Fal(FalVideoResponsePayload), @@ -77,6 +85,13 @@ impl GenerateVideoResponse { } } + pub fn get_grok_payload(&self) -> Option { + match self { + Self::Grok(p) => Some(p.clone()), + _ => None, + } + } + pub fn get_fal_payload(&self) -> Option { match self { Self::Fal(p) => Some(p.clone()), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/build.rs new file mode 100644 index 0000000000..e522dc7bfb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/build.rs @@ -0,0 +1,481 @@ +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + VideoGenerationRequest as GrokVideoGenerationRequest, + VideoImageSource as GrokVideoImageSource, +}; +use grok_api_client::api::types::video_types::video_aspect_ratio::VideoAspectRatio as GrokAspectRatio; +use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::common_resolution::CommonResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState; +use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; + +/// Builds a Grok Imagine Video request from the generic GenerateVideoRequestBuilder. +/// +/// xAI's grok-imagine-video accepts: +/// - `image` (single source image, image-to-video mode) OR `reference_images` (multi-image reference-to-video). +/// - These two are mutually exclusive per xAI's API; supplying both returns a BadRequest. +/// +/// This binding rejects `end_frame`, `reference_videos`, and `reference_audio` since +/// xAI doesn't support them. +pub fn build_grok_api_grok_imagine_video( + mut builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Plan the simple/scalar fields first. + let prompt = builder.prompt.take().unwrap_or_default(); + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_resolution(builder.resolution.take(), strategy)?; + let duration = builder.duration_seconds.take().map(|d| (d as u32).clamp(1, 15)); + + // start_frame → image-to-video; end_frame isn't supported. + let image = resolve_url_to_image_source(builder.start_frame.take())?; + if let Some(end_frame) = builder.end_frame.take() { + let _ = end_frame; // referenced for the error message below. + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "Grok Imagine Video does not support end-frame keyframes".to_string(), + })); + } + + // reference_images → reference-to-video. Mutually exclusive with `image`. + let reference_images = resolve_url_list_to_image_sources(builder.reference_images.take())?; + if image.is_some() && reference_images.as_ref().is_some_and(|v| !v.is_empty()) { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "start_frame + reference_images", + value: "Grok Imagine Video accepts either `start_frame` (image-to-video) OR \ + `reference_images` (reference-to-video), not both".to_string(), + })); + } + + // Grok doesn't ingest video or audio references. + if let Some(videos) = builder.reference_videos.take() { + let _ = videos; + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_videos", + value: "Grok Imagine Video does not accept reference videos".to_string(), + })); + } + if let Some(audio) = builder.reference_audio.take() { + let _ = audio; + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_audio", + value: "Grok Imagine Video does not accept reference audio".to_string(), + })); + } + + let request = GrokVideoGenerationRequest { + prompt, + model: None, // defaults to grok-imagine-video in the client + image, + reference_images, + aspect_ratio, + duration, + resolution, + user: None, + }; + + let state = GrokApiGrokImagineVideoRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::GrokApiGrokImagineVideo(state), + )) +} + +// ── Field planners ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + // xAI supports exactly: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3. + // Auto / unsupported ratios fall back to the closest match (or None → xAI default 16:9). + match aspect_ratio { + None + | Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto3k) + | Some(CommonAspectRatio::Auto4k) => Ok(None), + + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => { + Ok(Some(GrokAspectRatio::Square)) + } + + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => { + Ok(Some(GrokAspectRatio::Landscape16x9)) + } + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => { + Ok(Some(GrokAspectRatio::Portrait9x16)) + } + + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(GrokAspectRatio::Landscape4x3)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(GrokAspectRatio::Portrait3x4)), + + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(GrokAspectRatio::Landscape3x2)), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(GrokAspectRatio::Portrait2x3)), + + // No exact xAI match — pick the closest cardinal direction. Mild + // information loss, but better than failing the request. + Some(CommonAspectRatio::WideFiveByFour) + | Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(GrokAspectRatio::Landscape16x9)), + Some(CommonAspectRatio::TallFourByFive) + | Some(CommonAspectRatio::TallNineByTwentyOne) => Ok(Some(GrokAspectRatio::Portrait9x16)), + } +} + +fn plan_resolution( + resolution: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + // Grok supports 480p and 720p only (1080p is downsized to 720p per xAI docs). + match resolution { + None => Ok(None), + Some(CommonResolution::FourEightyP) => Ok(Some(GrokResolution::FourEightyP)), + Some(CommonResolution::SevenTwentyP) => Ok(Some(GrokResolution::SevenTwentyP)), + // Higher-than-720p requests get clamped to 720p (Grok's cap). + Some(CommonResolution::TenEightyP) + | Some(CommonResolution::TwoK) + | Some(CommonResolution::ThreeK) + | Some(CommonResolution::FourK) => Ok(Some(GrokResolution::SevenTwentyP)), + // Lower-than-480p requests get bumped to 480p (Grok's floor). + Some(CommonResolution::HalfK) | Some(CommonResolution::OneK) => { + Ok(Some(GrokResolution::FourEightyP)) + } + } +} + +// ── Image source resolvers ── + +fn resolve_url_to_image_source( + image_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::Url(url)) => Ok(Some(GrokVideoImageSource::Url(url))), + Some(ImageRef::MediaFileToken(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "start_frame", + value: "Grok Imagine Video only accepts image URLs, not media file tokens".to_string(), + })) + } + } +} + +fn resolve_url_list_to_image_sources( + list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match list_ref { + None => Ok(None), + Some(ImageListRef::Urls(urls)) => { + Ok(Some(urls.into_iter().map(GrokVideoImageSource::Url).collect())) + } + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "Grok Imagine Video only accepts image URLs, not media file tokens".to_string(), + })) + } + } +} + +// `reference_videos` and `reference_audio` resolvers exist only to consume +// the values for a clean error message; both reject any non-None input. +#[allow(dead_code)] +fn reject_video_refs(_refs: Option) -> Result<(), ArtcraftRouterError> { + Ok(()) +} +#[allow(dead_code)] +fn reject_audio_refs(_refs: Option) -> Result<(), ArtcraftRouterError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::provider::Provider; + use crate::api::video_list_ref::VideoListRef; + use crate::api::audio_list_ref::AudioListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; + + // ── Field passthrough ── + + mod field_conversions { + use super::*; + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); })); + assert_eq!(req.request.prompt, "test prompt"); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); })); + assert_eq!(req.request.duration, Some(8)); + } + + #[test] + fn duration_clamped_to_min() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(0); })); + assert_eq!(req.request.duration, Some(1)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration, Some(15)); + } + + #[test] + fn duration_none_stays_none() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; })); + assert_eq!(req.request.duration, None); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn square() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Square)); + } + + #[test] + fn landscape_16x9() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + } + + #[test] + fn portrait_9x16() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16)); + } + + #[test] + fn landscape_4x3() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideFourByThree); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape4x3)); + } + + #[test] + fn portrait_3x4() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::TallThreeByFour); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait3x4)); + } + + #[test] + fn landscape_3x2() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideThreeByTwo); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape3x2)); + } + + #[test] + fn portrait_2x3() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::TallTwoByThree); })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait2x3)); + } + + #[test] + fn auto_maps_to_none() { + let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Auto); })); + assert_eq!(req.request.aspect_ratio, None); + } + + #[test] + fn unsupported_wide_falls_back_to_16x9() { + let req = unwrap_request(make_builder(|b| { + b.aspect_ratio = Some(CommonAspectRatio::WideTwentyOneByNine); + })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + } + + #[test] + fn unsupported_tall_falls_back_to_9x16() { + let req = unwrap_request(make_builder(|b| { + b.aspect_ratio = Some(CommonAspectRatio::TallNineByTwentyOne); + })); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16)); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + } + + #[test] + fn res_1080p_clamps_to_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + } + + #[test] + fn res_4k_clamps_to_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourK); })); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + } + + #[test] + fn res_1k_bumps_to_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::OneK); })); + assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Image source plumbing ── + + mod image_tests { + use super::*; + + #[test] + fn start_frame_url_becomes_image() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + })); + match req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"), + other => panic!("expected Url variant, got {:?}", other), + } + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn start_frame_media_file_token_rejected() { + let result = build_grok_api_grok_imagine_video(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_urls_passed_through() { + let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(urls.clone())); + })); + let refs = req.request.reference_images.expect("reference_images should be set"); + assert_eq!(refs.len(), 2); + assert!(req.request.image.is_none()); + } + + #[test] + fn reference_image_tokens_rejected() { + let result = build_grok_api_grok_imagine_video(GenerateVideoRequestBuilder { + reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_a".to_string())])), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn start_frame_and_reference_images_together_rejected() { + let result = build_grok_api_grok_imagine_video(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("u".to_string())), + reference_images: Some(ImageListRef::Urls(vec!["v".to_string()])), + ..base_builder() + }); + assert!(result.is_err(), "expected rejection of mutually-exclusive image + reference_images"); + } + } + + // ── Unsupported features ── + + mod unsupported_features { + use super::*; + + #[test] + fn end_frame_rejected() { + let result = build_grok_api_grok_imagine_video(GenerateVideoRequestBuilder { + end_frame: Some(ImageRef::Url("u".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_videos_rejected() { + let result = build_grok_api_grok_imagine_video(GenerateVideoRequestBuilder { + reference_videos: Some(VideoListRef::Urls(vec!["v".to_string()])), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_audio_rejected() { + let result = build_grok_api_grok_imagine_video(GenerateVideoRequestBuilder { + reference_audio: Some(AudioListRef::Urls(vec!["a.wav".to_string()])), + ..base_builder() + }); + assert!(result.is_err()); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: CommonVideoModel::GrokImagineVideo, + provider: Provider::GrokApi, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut b = base_builder(); + f(&mut b); + b + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideoRequestState { + let result = build_grok_api_grok_imagine_video(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GrokApiGrokImagineVideo(s)) => s, + _ => panic!("expected GrokApiGrokImagineVideo request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/cost.rs new file mode 100644 index 0000000000..e6252276b7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/cost.rs @@ -0,0 +1,188 @@ +use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState; + +pub struct GrokApiGrokImagineVideoCostState { + request: GrokApiGrokImagineVideoRequestState, +} + +impl GrokApiGrokImagineVideoCostState { + pub fn from_request(request: &GrokApiGrokImagineVideoRequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // grok_api_client's GrokRequestCostCalculator does the math: + // - Output: resolution × duration (50 mills/s @ 480p, 70 mills/s @ 720p) + // - Input: 2 mills × source image count + // No source-video duration to factor in for `video_generation` — it's + // text/image → video, not edit/extension. + let cost_in_usd_cents = self.request.request.calculate_cost_in_cents(); + + VideoGenerationCostEstimate { + cost_in_credits: None, + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::provider::Provider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // Pricing reference (from grok_api_client/cost.rs, mirrors xAI's table): + // Output: 480p 50 mills/s ($0.05/s) + // 720p 70 mills/s ($0.07/s) + // Input: 2 mills per source image ($0.002/img) + // + // Cost in cents = ceil(mills / 10). + + mod pricing_720p { + use super::*; + + #[test] + fn five_s_no_image_ref() { + // 70 × 5 = 350 mills → 35¢ + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, None, None), 35); + } + + #[test] + fn ten_s_no_image_ref() { + // 70 × 10 = 700 mills → 70¢ + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None), 70); + } + + #[test] + fn fifteen_s_no_image_ref() { + // 70 × 15 = 1050 mills → 105¢ + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, None, None), 105); + } + } + + mod pricing_480p { + use super::*; + + #[test] + fn five_s_no_image_ref() { + // 50 × 5 = 250 mills → 25¢ + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, None, None), 25); + } + + #[test] + fn ten_s_no_image_ref() { + // 50 × 10 = 500 mills → 50¢ + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, None, None), 50); + } + + #[test] + fn fifteen_s_no_image_ref() { + // 50 × 15 = 750 mills → 75¢ + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, None, None), 75); + } + } + + mod image_inputs_add_to_cost { + use super::*; + + #[test] + fn single_start_frame_image_adds_2_mills() { + // 5s @ 720p output 350 mills + 1 input image 2 mills = 352 mills → 36¢ (ceil) + let cents = cost_cents( + Some(CommonResolution::SevenTwentyP), + 5, + Some(ImageRef::Url("https://example.com/a.png".to_string())), + None, + ); + assert_eq!(cents, 36); + } + + #[test] + fn three_reference_images_adds_6_mills() { + // 5s @ 720p output 350 mills + 3 input images 6 mills = 356 mills → 36¢ (ceil) + let cents = cost_cents( + Some(CommonResolution::SevenTwentyP), + 5, + None, + Some(ImageListRef::Urls(vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ])), + ); + assert_eq!(cents, 36); + } + } + + mod relative_pricing { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = cost_cents(Some(CommonResolution::FourEightyP), 10, None, None); + let p720 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None); + assert!(p480 < p720, "480p ({p480}) should be < 720p ({p720})"); + } + + #[test] + fn longer_duration_costs_more() { + let c5 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, None, None); + let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None); + let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, None, None); + assert!(c5 < c10); + assert!(c10 < c15); + } + + #[test] + fn one_k_request_clamps_to_480p_pricing() { + // OneK gets bumped to 480p in plan_resolution, so cost equals 480p cost. + let one_k = cost_cents(Some(CommonResolution::OneK), 10, None, None); + let p480 = cost_cents(Some(CommonResolution::FourEightyP), 10, None, None); + assert_eq!(one_k, p480); + } + + #[test] + fn ten_eighty_p_request_clamps_to_720p_pricing() { + // TenEightyP gets clamped to 720p in plan_resolution. + let p1080 = cost_cents(Some(CommonResolution::TenEightyP), 10, None, None); + let p720 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, None, None); + assert_eq!(p1080, p720); + } + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + let builder = GenerateVideoRequestBuilder { + model: CommonVideoModel::GrokImagineVideo, + provider: Provider::GrokApi, + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(1), + start_frame, + reference_images, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/request.rs new file mode 100644 index 0000000000..2171b740b0 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video/request.rs @@ -0,0 +1,106 @@ +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + video_generation, VideoGenerationArgs, VideoGenerationRequest as GrokVideoGenerationRequest, +}; + +use crate::client::router_grok_api_client::RouterGrokApiClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + GenerateVideoResponse, GrokVideoResponsePayload, +}; + +#[derive(Clone, Debug)] +pub struct GrokApiGrokImagineVideoRequestState { + /// The fully-resolved Grok request body. Doesn't carry the API key — that + /// gets borrowed at send time from the router client. + pub request: GrokVideoGenerationRequest, +} + +impl GrokApiGrokImagineVideoRequestState { + pub async fn send(&self, client: &RouterGrokApiClient) -> Result { + let response = video_generation(VideoGenerationArgs { + api_key: &client.api_key, + request: self.request.clone(), + }) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Grok(err)))?; + + Ok(GenerateVideoResponse::Grok(GrokVideoResponsePayload { + request_id: response.request_id, + })) + } +} + +#[cfg(test)] +mod tests { + use grok_api_client::creds::grok_api_key::GrokApiKey; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::provider::Provider; + use crate::client::router_client::RouterClient; + use crate::client::router_grok_api_client::RouterGrokApiClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_text_to_video_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A glowing crystal rocket launching from Mars.".to_string()), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + resolution: Some(CommonResolution::SevenTwentyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_image_to_video_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog leaps into the lake and splashes around.".to_string()), + start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())), + resolution: Some(CommonResolution::FourEightyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + fn grok_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: CommonVideoModel::GrokImagineVideo, + provider: Provider::GrokApi, + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_grok_client() -> RouterClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/grok_api_key.txt") + .expect("Failed to read Grok API key"); + let api_key = GrokApiKey::new(secret.trim().to_string()); + RouterClient::GrokApi(RouterGrokApiClient::new(api_key)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_grok_client(); + let draft_or_request = builder.build2().expect("build2 should succeed"); + let request = match draft_or_request { + VideoGenerationDraftOrRequest::Request(r) => r, + _ => panic!("expected Request variant (Grok skips draft)"), + }; + let response = request.send_request(&client).await.expect("send_request should succeed"); + match &response { + GenerateVideoResponse::Grok(p) => println!("grok request_id={}", p.request_id), + other => println!("unexpected response: {:?}", other), + } + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/mod.rs new file mode 100644 index 0000000000..7343465cec --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/mod.rs @@ -0,0 +1 @@ +pub mod grok_imagine_video; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/mod.rs index 56e6b0e6dc..df80e57fd2 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/mod.rs @@ -1,3 +1,4 @@ pub mod artcraft; pub mod gmicloud; +pub mod grok_api; pub mod kinovi; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs index d3452e2318..5ae4ebfe38 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs @@ -25,6 +25,8 @@ use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::cos use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState; use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState; use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState; +use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::cost::GrokApiGrokImagineVideoCostState; +use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState; use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState; use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState; @@ -45,6 +47,7 @@ pub enum VideoGenerationRequest { ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState), GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState), GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState), + GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState), KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState), KinoviSeedance2p0(KinoviSeedance2p0RequestState), KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState), @@ -65,6 +68,7 @@ impl VideoGenerationRequest { Self::ArtcraftSeedance2p0BytePlusFast(_) => Provider::Artcraft, Self::GmiCloudSeedance2p0Ultra(_) => Provider::GmiCloud, Self::GmiCloudSeedance2p0UltraFast(_) => Provider::GmiCloud, + Self::GrokApiGrokImagineVideo(_) => Provider::GrokApi, Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro, Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro, Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro, @@ -85,6 +89,7 @@ impl VideoGenerationRequest { VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()), VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()), VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()), VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()), VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()), VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()), @@ -139,6 +144,10 @@ impl VideoGenerationRequest { let client_ref = client.get_gmicloud_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::GrokApiGrokImagineVideo(request) => { + let client_ref = client.get_grok_api_client_ref()?; + request.send(client_ref).await + }, VideoGenerationRequest::KinoviHappyHorse1p0(request) => { let client_ref = client.get_seedance2pro_client_ref()?; request.send(client_ref).await diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/video_status.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/video_status.rs index be55dd83bc..bf11467625 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/video_status.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_status/video_status.rs @@ -347,7 +347,8 @@ mod tests { let result = video_status(VideoStatusArgs { api_key: &api_key, request: VideoStatusRequest { - request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(), + //request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(), + request_id: "4eea215a-9d21-9261-be1a-324409ec22c5".to_string(), }, }).await;