first iteration of pipeline

This commit is contained in:
Brandon Thomas
2026-05-24 00:36:07 -04:00
parent 086c43b059
commit 0701d718ee
12 changed files with 822 additions and 1 deletions
@@ -7,6 +7,7 @@ pub enum Provider {
Artcraft,
Fal,
GmiCloud,
GrokApi,
Muapi,
Seedance2Pro,
}
@@ -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<GmiCloudError> for ProviderError {
}
}
impl From<GrokError> for ProviderError {
fn from(error: GrokError) -> Self {
Self::Grok(error)
}
}
impl From<MuapiError> for ProviderError {
fn from(error: MuapiError) -> Self {
Self::Muapi(error)
@@ -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(),
}
@@ -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<GrokVideoResponsePayload> {
match self {
Self::Grok(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_fal_payload(&self) -> Option<FalVideoResponsePayload> {
match self {
Self::Fal(p) => Some(p.clone()),
@@ -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<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
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<CommonAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GrokAspectRatio>, 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<CommonResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GrokResolution>, 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<ImageRef>,
) -> Result<Option<GrokVideoImageSource>, 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<ImageListRef>,
) -> Result<Option<Vec<GrokVideoImageSource>>, 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<VideoListRef>) -> Result<(), ArtcraftRouterError> {
Ok(())
}
#[allow(dead_code)]
fn reject_audio_refs(_refs: Option<AudioListRef>) -> 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"),
}
}
}
@@ -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<CommonResolution>,
duration_seconds: u16,
start_frame: Option<ImageRef>,
reference_images: Option<ImageListRef>,
) -> 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()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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<GenerateVideoResponse, ArtcraftRouterError> {
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
}
}
@@ -0,0 +1 @@
pub mod grok_imagine_video;
@@ -1,3 +1,4 @@
pub mod artcraft;
pub mod gmicloud;
pub mod grok_api;
pub mod kinovi;
@@ -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
@@ -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;