From 0d7168c506137ee659ec899c855d6861d146b19e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 31 May 2026 12:21:54 -0400 Subject: [PATCH] grok imagine video 1.5 preview (#1590) --- .../src/api/router_video_model.rs | 3 + .../generate_video_request_builder.rs | 4 + .../artcraft/grok_imagine_video_1p5/build.rs | 181 +++++ .../artcraft/grok_imagine_video_1p5/cost.rs | 546 +++++++++++++++ .../artcraft/grok_imagine_video_1p5/mod.rs | 3 + .../grok_imagine_video_1p5/request.rs | 17 + .../providers/artcraft/mod.rs | 1 + .../grok_api/grok_imagine_video_1p5/build.rs | 650 ++++++++++++++++++ .../grok_api/grok_imagine_video_1p5/cost.rs | 308 +++++++++ .../grok_api/grok_imagine_video_1p5/mod.rs | 3 + .../grok_imagine_video_1p5/request.rs | 152 ++++ .../providers/grok_api/mod.rs | 1 + .../video_generation_request.rs | 18 + .../requests/videos/video_generation/cost.rs | 192 +++++- .../videos/video_status/video_status.rs | 18 +- .../src/api/types/video_types/video_model.rs | 98 ++- ...router_video_request_to_artcraft_prompt.rs | 1 + .../inference_model_type.rs | 9 + .../common/generation/common_model_type.rs | 13 +- .../common/generation/common_video_model.rs | 8 + .../src/configs/omni_gen/video_models.rs | 38 + .../video/estimate_video_cost_handler.rs | 1 + .../video/pipeline_v2/run_pipeline_v2.rs | 1 + 23 files changed, 2247 insertions(+), 19 deletions(-) create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/request.rs diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs index 22763e99f7..3926a3b739 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_video_model.rs @@ -11,6 +11,9 @@ pub enum RouterVideoModel { #[serde(rename = "grok_imagine_video")] GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "kling_1p6_pro")] Kling16Pro, 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 d62ac51500..8bf9cc2234 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 @@ -10,6 +10,7 @@ use crate::api::video_list_ref::VideoListRef; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::build::build_artcraft_grok_imagine_video; +use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::build::build_artcraft_grok_imagine_video_1p5; use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::build::build_artcraft_happy_horse_1p0; use crate::generate::generate_video_v2::providers::artcraft::kling_1_6_pro::build::build_artcraft_kling_1_6_pro; use crate::generate::generate_video_v2::providers::artcraft::kling_2_1_master::build::build_artcraft_kling_2_1_master; @@ -42,6 +43,7 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build:: 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::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5; use crate::generate::generate_video_v2::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro; use crate::generate::generate_video_v2::providers::fal::kling_2_1_master::build::build_fal_kling_2_1_master; use crate::generate::generate_video_v2::providers::fal::kling_2_1_pro::build::build_fal_kling_2_1_pro; @@ -151,6 +153,7 @@ impl GenerateVideoRequestBuilder { match (self.provider, self.model) { // Artcraft (RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self), + (RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self), (RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self), (RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self), (RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self), @@ -200,6 +203,7 @@ impl GenerateVideoRequestBuilder { (RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self), // Grok (RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self), + (RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self), // Kinovi (RouterProvider::Seedance2Pro, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self), (RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/build.rs new file mode 100644 index 0000000000..8a50cddf96 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/build.rs @@ -0,0 +1,181 @@ +use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video_v2::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState; +use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; + +/// Build an ArtCraft-routed Grok Imagine Video 1.5 preview request. +/// +/// Same constraints as the v1 variant — xAI v1.5 still supports only 480p +/// and 720p and no 21:9 ultra-wide. The on-wire model identifier is +/// `grok-imagine-video-1.5-preview`; the cost calculator in +/// [`super::cost`] keys off that to apply the v1.5 pricing tier (with a 5% +/// ArtCraft markup on top of the upstream rates). +pub fn build_artcraft_grok_imagine_video_1p5( + builder: GenerateVideoRequestBuilder, +) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::GrokImagineVideo1p5, + SupportedResolutions::Fast, + UltraWideSupport::Unsupported, + )?; + let state = ArtcraftGrokImagineVideo1p5RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(state), + )) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_resolution::RouterResolution; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + 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; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn model_is_grok_imagine_video_1p5() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::GrokImagineVideo1p5))); + } + + #[test] + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + // SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output). + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + mod media_token_tests { + use super::*; + + #[test] + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); + } + + #[test] + fn url_start_frame_rejected() { + // ArtCraft's omni endpoint requires media tokens — URLs are rejected + // (unlike the direct Grok provider, which is URL-only). + let result = build_artcraft_grok_imagine_video_1p5(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); + } + + #[test] + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); + } + + #[test] + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftGrokImagineVideo1p5RequestState { + let result = build_artcraft_grok_imagine_video_1p5(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(state) + ) => state, + _ => panic!("expected ArtcraftGrokImagineVideo1p5 request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/cost.rs new file mode 100644 index 0000000000..d46edff61d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/cost.rs @@ -0,0 +1,546 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use grok_api_client::api::requests::videos::video_generation::video_generation::{ + VideoGenerationRequest as GrokVideoGenerationRequest, + VideoImageSource as GrokVideoImageSource, +}; +use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator; +use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVideoModel; +use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState; + +// ── Markup ── +// +// ArtCraft adds a modest 5% markup over the underlying xAI v1.5 pricing. The +// markup is applied at cent granularity with ceiling rounding so the final +// charge always covers the upstream cost (we never bill the user less than +// the cost of the API call we forwarded). +const MARKUP_NUMERATOR: u64 = 105; +const MARKUP_DENOMINATOR: u64 = 100; + +pub struct ArtcraftGrokImagineVideo1p5CostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub input_image_count: u64, +} + +impl ArtcraftGrokImagineVideo1p5CostState { + pub fn from_request(request: &ArtcraftGrokImagineVideo1p5RequestState) -> Self { + let resolution = request.request.resolution.unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(8); + let batch_count = request.request.video_batch_count.unwrap_or(1); + + // Mirror grok_api_client's input-image counting: start_frame + reference_images. + let input_image_count = (request.request.start_frame_image_media_token.is_some() as u64) + + (request.request.reference_image_media_tokens + .as_ref() + .map(|v| v.len() as u64) + .unwrap_or(0)); + + Self { resolution, duration_seconds, batch_count, input_image_count } + } + + /// Base cost in cents for a single video at this state's resolution, + /// duration, and input-image count. Computed via grok_api_client's + /// official cost calculator (with the model pinned to the v1.5 preview), + /// so this number tracks upstream pricing automatically and never drifts + /// from what xAI bills. + /// + /// Does NOT include the per-batch multiplier and does NOT include the + /// ArtCraft markup. Used by tests to assert parity with grok_api_client. + pub fn base_cost_in_cents_for_one_video(&self) -> u64 { + self.build_equivalent_grok_request().calculate_cost_in_cents() + } + + /// Total base cost (across the batch) in cents, before markup. + /// Each video in the batch costs [`Self::base_cost_in_cents_for_one_video`]. + pub fn base_cost_in_cents_for_batch(&self) -> u64 { + self.base_cost_in_cents_for_one_video() + .saturating_mul(self.batch_count as u64) + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let base = self.base_cost_in_cents_for_batch(); + // 5% markup, ceil so the user is always charged enough to cover the + // upstream grok cost regardless of rounding. + let usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + + VideoGenerationCostEstimate { + cost_in_credits: Some(usd_cents), + cost_in_usd_cents: Some(usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } + + /// Build a `GrokVideoGenerationRequest` whose pricing-relevant fields + /// exactly mirror this cost state, so the grok_api_client calculator + /// produces the same number ArtCraft would owe for one video. Non-pricing + /// fields (prompt, aspect_ratio, user) are left empty — they don't affect + /// cost. + fn build_equivalent_grok_request(&self) -> GrokVideoGenerationRequest { + let resolution = Some(match self.resolution { + CommonResolution::FourEightyP => GrokResolution::FourEightyP, + // Grok Imagine caps at 720p (and our build step downgrades higher + // tiers via SupportedResolutions::Fast); price anything else as 720p. + _ => GrokResolution::SevenTwentyP, + }); + + // Grok's calculator only counts the TOTAL number of input images — it + // doesn't care how they're split between `image` and `reference_images` + // — so we put one in `image` (if any) and the rest in `reference_images`. + let image = (self.input_image_count >= 1) + .then(|| GrokVideoImageSource::Url(String::new())); + let reference_images = if self.input_image_count >= 2 { + Some( + (1..self.input_image_count) + .map(|_| GrokVideoImageSource::Url(String::new())) + .collect() + ) + } else { None }; + + GrokVideoGenerationRequest { + prompt: String::new(), + model: Some(GrokVideoModel::GrokImagineVideo1p5Preview), + image, + reference_images, + aspect_ratio: None, + duration: Some(self.duration_seconds as u32), + resolution, + user: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // ── Base cost parity with grok_api_client ───────────────────────────── + // + // The base cost (before ArtCraft markup, batch=1) must equal exactly what + // grok_api_client's calculator returns for an equivalent direct-Grok + // request. This protects against the artcraft and grok-api implementations + // silently drifting apart. + + mod grok_api_parity { + use super::*; + + // (resolution, duration, image_count) + // + // Durations are confined to [4, 15] because the ArtCraft pipeline clamps + // outside that range (see `plan_duration` in artcraft/build_common.rs). + const PARITY_CASES: &[(Option, u16, u64)] = &[ + // 480p variations + (Some(RouterResolution::FourEightyP), 4, 0), + (Some(RouterResolution::FourEightyP), 5, 0), + (Some(RouterResolution::FourEightyP), 8, 0), + (Some(RouterResolution::FourEightyP), 15, 0), + (Some(RouterResolution::FourEightyP), 5, 1), + (Some(RouterResolution::FourEightyP), 5, 3), + // 720p variations + (Some(RouterResolution::SevenTwentyP), 4, 0), + (Some(RouterResolution::SevenTwentyP), 5, 0), + (Some(RouterResolution::SevenTwentyP), 8, 0), + (Some(RouterResolution::SevenTwentyP), 15, 0), + (Some(RouterResolution::SevenTwentyP), 5, 1), + (Some(RouterResolution::SevenTwentyP), 5, 3), + // None defaults to 720p in our `from_request`, which matches the + // assumption Grok's calculator makes when fed a 720p request. + (None, 5, 0), + (None, 5, 2), + ]; + + #[test] + fn base_cost_for_one_video_matches_grok_api_client() { + for &(router_res, duration, image_count) in PARITY_CASES { + let artcraft = artcraft_state(router_res, duration, image_count, 1); + let grok_cents = grok_cents(router_res, duration, image_count); + + assert_eq!( + artcraft.base_cost_in_cents_for_one_video(), + grok_cents, + "parity mismatch at res={router_res:?} dur={duration} images={image_count}: \ + artcraft_base={} grok={}", + artcraft.base_cost_in_cents_for_one_video(), + grok_cents, + ); + } + } + + #[test] + fn batch_one_base_total_equals_one_video_base() { + let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1); + assert_eq!(s.base_cost_in_cents_for_batch(), s.base_cost_in_cents_for_one_video()); + } + + #[test] + fn batch_scales_base_linearly() { + // The ArtCraft pipeline only accepts batch counts of 1, 2, or 4 (see + // `plan_batch_count` in artcraft/build_common.rs); anything else gets + // clamped, so we can only meaningfully test those values. + let per_one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1) + .base_cost_in_cents_for_one_video(); + for batch in [1u16, 2, 4] { + let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, batch); + assert_eq!( + s.base_cost_in_cents_for_batch(), + per_one * batch as u64, + "batch={batch}", + ); + } + } + + /// Builder-driven parity — exercises the full pipeline (build2 → + /// estimate_cost) on both providers and compares ArtCraft's base cost + /// (final / 1.05, accounting for ceiling) against GrokApi's direct + /// cost. + /// + /// We verify the markup rather than the base here because the public + /// `estimate_cost()` returns the marked-up number; the explicit + /// `base_cost_in_cents_*` API is tested above. + #[test] + fn end_to_end_markup_is_at_least_five_percent_over_grok() { + let cases: &[(Option, u16, bool, usize)] = &[ + (Some(RouterResolution::FourEightyP), 5, false, 0), + (Some(RouterResolution::FourEightyP), 10, true, 0), + (Some(RouterResolution::SevenTwentyP), 5, false, 0), + (Some(RouterResolution::SevenTwentyP), 5, false, 3), + (Some(RouterResolution::SevenTwentyP), 8, true, 0), + (Some(RouterResolution::SevenTwentyP),15, false, 0), + ]; + for &(res, duration, has_start, refs) in cases { + let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs); + let grok_cents = grok_cents(res, duration, has_start as u64 + refs as u64); + let want_minimum = grok_cents.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + assert_eq!( + artcraft_cents, want_minimum, + "res={res:?} dur={duration} start={has_start} refs={refs}: \ + artcraft={artcraft_cents}¢ vs ceil(grok({grok_cents})×1.05)={want_minimum}¢", + ); + } + } + } + + // ── Markup correctness ───────────────────────────────────────────────── + + mod markup { + use super::*; + + #[test] + fn estimate_cost_equals_ceil_of_base_times_1p05() { + for &(res, dur, imgs, batch) in MARKUP_CASES { + let state = artcraft_state(res, dur, imgs, batch); + let base = state.base_cost_in_cents_for_batch(); + let want = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR); + let got = state.estimate_cost().cost_in_usd_cents.unwrap(); + assert_eq!( + got, want, + "res={res:?} dur={dur} imgs={imgs} batch={batch} base={base}", + ); + } + } + + #[test] + fn final_cost_is_always_at_least_base() { + for &(res, dur, imgs, batch) in MARKUP_CASES { + let state = artcraft_state(res, dur, imgs, batch); + let base = state.base_cost_in_cents_for_batch(); + let final_ = state.estimate_cost().cost_in_usd_cents.unwrap(); + assert!( + final_ >= base, + "final {final_}¢ < base {base}¢ at res={res:?} dur={dur} imgs={imgs} batch={batch}", + ); + } + } + + #[test] + fn known_value_at_720p_5s_batch_1() { + // grok v1.5 720p × 5s = 700 mills = 70¢ + // 70 × 1.05 = 73.5 → ceil = 74¢ + let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1); + assert_eq!(state.base_cost_in_cents_for_one_video(), 70); + assert_eq!(state.estimate_cost().cost_in_usd_cents.unwrap(), 74); + } + + #[test] + fn known_value_at_480p_5s_batch_1() { + // grok v1.5 480p × 5s = 400 mills = 40¢ + // 40 × 1.05 = 42 → ceil = 42¢ + let state = artcraft_state(Some(RouterResolution::FourEightyP), 5, 0, 1); + assert_eq!(state.base_cost_in_cents_for_one_video(), 40); + assert_eq!(state.estimate_cost().cost_in_usd_cents.unwrap(), 42); + } + + #[test] + fn known_value_at_720p_5s_batch_2() { + // base for batch 2 = 70 × 2 = 140¢ + // 140 × 1.05 = 147 → ceil 147¢ + let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 2); + assert_eq!(state.base_cost_in_cents_for_batch(), 140); + assert_eq!(state.estimate_cost().cost_in_usd_cents.unwrap(), 147); + } + + // (resolution, duration, image_count, batch) + const MARKUP_CASES: &[(Option, u16, u64, u16)] = &[ + (Some(RouterResolution::FourEightyP), 5, 0, 1), + (Some(RouterResolution::FourEightyP), 5, 0, 2), + (Some(RouterResolution::FourEightyP), 5, 0, 4), + (Some(RouterResolution::FourEightyP), 8, 0, 1), + (Some(RouterResolution::FourEightyP), 15, 0, 1), + (Some(RouterResolution::FourEightyP), 5, 1, 1), + (Some(RouterResolution::FourEightyP), 5, 3, 1), + (Some(RouterResolution::SevenTwentyP), 5, 0, 1), + (Some(RouterResolution::SevenTwentyP), 5, 0, 2), + (Some(RouterResolution::SevenTwentyP), 5, 0, 4), + (Some(RouterResolution::SevenTwentyP), 8, 0, 1), + (Some(RouterResolution::SevenTwentyP), 15, 0, 1), + (Some(RouterResolution::SevenTwentyP), 5, 1, 1), + (Some(RouterResolution::SevenTwentyP), 5, 3, 1), + (None, 5, 0, 1), + ]; + } + + // ── Relative pricing / monotonicity sanity ───────────────────────────── + + mod monotonicity { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = artcraft_state(Some(RouterResolution::FourEightyP), 5, 0, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + let p720 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + assert!(p480 < p720, "480p ({p480}¢) should be < 720p ({p720}¢)"); + } + + #[test] + fn longer_duration_costs_more() { + let c5 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + let c10 = artcraft_state(Some(RouterResolution::SevenTwentyP), 10, 0, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + let c15 = artcraft_state(Some(RouterResolution::SevenTwentyP), 15, 0, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + assert!(c5 < c10); + assert!(c10 < c15); + } + + #[test] + fn more_images_cost_more_or_equal() { + // Each image is +10 mills = +1¢ before markup, so 1+ image always + // strictly increases final cost (the markup ceil pushes us up enough). + let zero = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + let three = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 3, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + assert!(zero < three, "zero={zero}¢ vs three={three}¢"); + } + + #[test] + fn more_batch_costs_more() { + let b1 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1) + .estimate_cost().cost_in_usd_cents.unwrap(); + let b4 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 4) + .estimate_cost().cost_in_usd_cents.unwrap(); + assert!(b1 < b4); + } + } + + // ── End-to-end through the public builder API ────────────────────────── + + mod end_to_end { + use super::*; + + #[test] + fn credits_equal_usd_cents() { + // ArtCraft credits == USD cents, as in the v1 module. + let resolutions = [ + None, + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP), // clamps to 720p + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + let cost = build_cost_via_builder(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents at res={res:?} dur={dur}s batch={batch}", + ); + } + } + } + } + + #[test] + fn ten_eighty_p_request_clamps_to_720p_pricing() { + let p1080 = build_cost_via_builder(Some(RouterResolution::TenEightyP), 10, 1) + .cost_in_usd_cents.unwrap(); + let p720 = build_cost_via_builder(Some(RouterResolution::SevenTwentyP), 10, 1) + .cost_in_usd_cents.unwrap(); + assert_eq!(p1080, p720); + } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + fn artcraft_state( + resolution: Option, + duration_seconds: u16, + input_image_count: u64, + batch_count: u16, + ) -> ArtcraftGrokImagineVideo1p5CostState { + // Build a state directly from a synthesized request — avoids the full + // build2() pipeline so we can construct arbitrary image counts without + // worrying about builder validation rules. + let state = unwrap_request_via_builder(resolution, duration_seconds, input_image_count, batch_count); + ArtcraftGrokImagineVideo1p5CostState::from_request(&state) + } + + fn unwrap_request_via_builder( + resolution: Option, + duration_seconds: u16, + input_image_count: u64, + batch_count: u16, + ) -> ArtcraftGrokImagineVideo1p5RequestState { + // Split input_image_count into start_frame (1 if any) + reference_images (the rest), + // mirroring how `from_request` counts source images. + let start_frame = if input_image_count >= 1 { + Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))) + } else { None }; + let reference_images = if input_image_count >= 2 { + Some(ImageListRef::MediaFileTokens( + (1..input_image_count).map(|i| MediaFileToken::new(format!("mf_ref_{i}"))).collect(), + )) + } else { None }; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(batch_count), + start_frame, + reference_images, + ..Default::default() + }; + let result = builder.build2().expect("build2 should succeed"); + match result { + crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest::Request( + crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(s) + ) => s, + _ => panic!("expected ArtcraftGrokImagineVideo1p5 request"), + } + } + + fn artcraft_end_to_end_cents( + resolution: Option, + duration_seconds: u16, + batch_count: u16, + has_start_frame: bool, + num_reference_images: usize, + ) -> u64 { + let start_frame = if has_start_frame { + Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))) + } else { None }; + let reference_images = if num_reference_images > 0 { + Some(ImageListRef::MediaFileTokens( + (0..num_reference_images) + .map(|i| MediaFileToken::new(format!("mf_ref_{i}"))) + .collect(), + )) + } else { None }; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(batch_count), + start_frame, + reference_images, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + .cost_in_usd_cents + .unwrap() + } + + fn build_cost_via_builder( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + /// Reference value: what GrokApi's own pipeline would charge for one + /// equivalent video. Used by parity tests. + /// + /// The Grok build step prefers `start_frame` over `reference_images` when + /// both are supplied and drops the latter (see grok_api/grok_imagine_video_1p5/build.rs). + /// To keep parity with the ArtCraft input-image *count*, we put all source + /// images in `reference_images` only — the cost calculator counts the total + /// either way. + fn grok_cents( + resolution: Option, + duration_seconds: u16, + image_count: u64, + ) -> u64 { + let reference_images = if image_count >= 1 { + Some(ImageListRef::Urls( + (0..image_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(), + )) + } else { None }; + + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::GrokApi, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(1), + start_frame: None, + reference_images, + ..Default::default() + }; + builder.build2() + .expect("build2 (grokapi) should succeed") + .estimate_cost() + .expect("estimate_cost (grokapi) should succeed") + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/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/artcraft/grok_imagine_video_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/request.rs new file mode 100644 index 0000000000..0187fe7910 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/grok_imagine_video_1p5/request.rs @@ -0,0 +1,17 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; +use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftGrokImagineVideo1p5RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftGrokImagineVideo1p5RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs index ab417b22b4..ed10944726 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod build_common; #[cfg(test)] mod parity_tests; pub mod grok_imagine_video; +pub mod grok_imagine_video_1p5; pub mod happy_horse_1p0; pub mod kling_1_6_pro; pub mod kling_2_1_master; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/build.rs new file mode 100644 index 0000000000..5e20bfe543 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/build.rs @@ -0,0 +1,650 @@ +use log::info; + +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_model::VideoModel as GrokVideoModel; +use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +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::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState; +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 1.5 preview request from the generic +/// GenerateVideoRequestBuilder. Mirrors the v1 builder's capabilities and +/// graceful-drop policy (the 1.5 model accepts the same wire shape as v1); +/// the only material differences are the `model` identifier on the wire and +/// the steeper pricing in the cost calculator (see `cost.rs`). +/// +/// xAI's grok-imagine-video-1.5-preview 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. +/// - Aspect ratio, resolution (480p/720p), duration (1–15s), prompt. +/// +/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`, +/// `reference_audio`, and `MediaFileToken`-style image refs) are silently +/// dropped with an info-level log. +pub fn build_grok_api_grok_imagine_video_1p5( + mut builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Scalar fields. + 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)); + + log_and_drop_end_frame(builder.end_frame.take()); + log_and_drop_reference_videos(builder.reference_videos.take()); + log_and_drop_reference_audio(builder.reference_audio.take()); + + let start_frame = resolve_url_to_image_source(builder.start_frame.take()); + let reference_images_supplied = builder.reference_images.take(); + + // xAI's v1.5 model explicitly does NOT accept `reference_images` (it only + // supports text-to-video and single-image-to-video). If the caller supplies + // reference images and no start_frame, promote the first reference image + // into `image` (image-to-video) and drop the rest. + let (image, reference_images) = match (start_frame, reference_images_supplied) { + (Some(img), Some(refs)) => { + info!( + "grok_imagine_video_1p5: both start_frame and reference_images supplied — \ + preferring start_frame (image-to-video). Dropping {} reference image(s).", + count_image_list_ref(&refs), + ); + (Some(img), None) + } + (Some(img), None) => (Some(img), None), + (None, Some(refs)) => promote_first_reference_to_image(refs), + (None, None) => (None, None), + }; + + let request = GrokVideoGenerationRequest { + prompt, + // Pin to the 1.5 preview identifier so the grok_api_client cost + // calculator picks the v1.5 pricing tier (see VideoModel::pricing_tier). + model: Some(GrokVideoModel::GrokImagineVideo1p5Preview), + image, + reference_images, + aspect_ratio, + duration, + resolution, + user: None, + }; + + let state = GrokApiGrokImagineVideo1p5RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::GrokApiGrokImagineVideo1p5(state), + )) +} + +// ── Field planners ── + +fn plan_aspect_ratio( + aspect_ratio: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Option { + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => None, + + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => { + Some(GrokAspectRatio::Square) + } + + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => { + Some(GrokAspectRatio::Landscape16x9) + } + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => { + Some(GrokAspectRatio::Portrait9x16) + } + + Some(RouterAspectRatio::WideFourByThree) => Some(GrokAspectRatio::Landscape4x3), + Some(RouterAspectRatio::TallThreeByFour) => Some(GrokAspectRatio::Portrait3x4), + + Some(RouterAspectRatio::WideThreeByTwo) => Some(GrokAspectRatio::Landscape3x2), + Some(RouterAspectRatio::TallTwoByThree) => Some(GrokAspectRatio::Portrait2x3), + + Some(RouterAspectRatio::WideFiveByFour) + | Some(RouterAspectRatio::WideTwentyOneByNine) => Some(GrokAspectRatio::Landscape16x9), + Some(RouterAspectRatio::TallFourByFive) + | Some(RouterAspectRatio::TallNineByTwentyOne) => Some(GrokAspectRatio::Portrait9x16), + } +} + +fn plan_resolution( + resolution: Option, + _strategy: RequestMismatchMitigationStrategy, +) -> Option { + match resolution { + None => None, + Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP), + Some(RouterResolution::TenEightyP) + | Some(RouterResolution::TwoK) + | Some(RouterResolution::ThreeK) + | Some(RouterResolution::FourK) => Some(GrokResolution::SevenTwentyP), + Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => { + Some(GrokResolution::FourEightyP) + } + } +} + +// ── Image source resolvers (silent drops for unsupported variants) ── + +fn resolve_url_to_image_source(image_ref: Option) -> Option { + match image_ref { + None => None, + Some(ImageRef::Url(url)) => Some(GrokVideoImageSource::Url(url)), + Some(ImageRef::MediaFileToken(token)) => { + info!( + "grok_imagine_video_1p5: dropping MediaFileToken {:?} for start_frame — \ + Grok only accepts public URLs. Resolve the token to a URL upstream if needed.", + token, + ); + None + } + } +} + +/// xAI's v1.5 model rejects `reference_images` entirely. When a caller has +/// only supplied reference images (no start_frame), promote the first one +/// to `image` so they at least get image-to-video, and drop the remainder +/// with a log so it's visible in operator traces. +fn promote_first_reference_to_image( + list_ref: ImageListRef, +) -> (Option, Option>) { + match list_ref { + ImageListRef::Urls(urls) if urls.is_empty() => (None, None), + ImageListRef::Urls(mut urls) => { + let first_url = urls.remove(0); + let dropped = urls.len(); + if dropped > 0 { + info!( + "grok_imagine_video_1p5: v1.5 doesn't accept reference_images; promoting \ + the first reference image to start_frame (image-to-video). Dropping {} \ + additional reference image(s).", + dropped, + ); + } else { + info!( + "grok_imagine_video_1p5: v1.5 doesn't accept reference_images; promoting \ + the single reference image to start_frame (image-to-video).", + ); + } + (Some(GrokVideoImageSource::Url(first_url)), None) + } + ImageListRef::MediaFileTokens(tokens) => { + info!( + "grok_imagine_video_1p5: dropping {} MediaFileToken reference image(s) — \ + Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.", + tokens.len(), + ); + (None, None) + } + } +} + +fn count_image_list_ref(refs: &ImageListRef) -> usize { + match refs { + ImageListRef::Urls(v) => v.len(), + ImageListRef::MediaFileTokens(v) => v.len(), + } +} + +// ── Drop-with-log helpers for fields Grok doesn't accept at all ── + +fn log_and_drop_end_frame(end_frame: Option) { + if end_frame.is_some() { + info!("grok_imagine_video_1p5: dropping end_frame — Grok doesn't support end-frame keyframes."); + } +} + +fn log_and_drop_reference_videos(refs: Option) { + if let Some(refs) = refs { + let count = match refs { + VideoListRef::Urls(v) => v.len(), + VideoListRef::MediaFileTokens(v) => v.len(), + }; + info!( + "grok_imagine_video_1p5: dropping {} reference video(s) — Grok doesn't accept reference videos.", + count, + ); + } +} + +fn log_and_drop_reference_audio(refs: Option) { + if let Some(refs) = refs { + let count = match refs { + AudioListRef::Urls(v) => v.len(), + AudioListRef::MediaFileTokens(v) => v.len(), + }; + info!( + "grok_imagine_video_1p5: dropping {} reference audio clip(s) — Grok doesn't accept reference audio.", + count, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + 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; + + // ── Wire-shape: the built request always carries the 1.5 preview model id ── + + mod model_id { + use super::*; + + #[test] + fn built_request_pins_grok_imagine_video_1p5_preview() { + let req = unwrap_request(make_builder(|_| {})); + assert_eq!( + req.request.model, + Some(GrokVideoModel::GrokImagineVideo1p5Preview), + ); + } + + #[test] + fn built_request_serializes_canonical_wire_name() { + // The on-wire model string is the canonical name; the v1.5 cost + // calculator in grok_api_client keys off this string. + let req = unwrap_request(make_builder(|_| {})); + let model = req.request.model.as_ref().expect("model should be set"); + assert_eq!(model.as_str(), "grok-imagine-video-1.5-preview"); + } + } + + // ── 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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterAspectRatio::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(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::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(RouterResolution::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(RouterResolution::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(RouterResolution::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 (graceful — never errors) ── + + 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_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))); + })); + assert!(req.request.image.is_none()); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn multiple_reference_image_urls_promote_first_and_drop_rest() { + // xAI's v1.5 model rejects `reference_images`. The builder promotes the + // first reference to `image` and drops the remainder. + let urls = vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(urls.clone())); + })); + assert!( + req.request.reference_images.is_none(), + "reference_images must be None (xAI v1.5 rejects them)", + ); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/a.png"), + other => panic!("expected first reference image promoted to `image`, got {:?}", other), + } + } + + #[test] + fn single_reference_image_url_promotes_to_image() { + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/only.png".to_string(), + ])); + })); + assert!(req.request.reference_images.is_none()); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/only.png"), + other => panic!("expected single reference promoted to `image`, got {:?}", other), + } + } + + /// Replicates the production failure from 2026-05-31: storyteller-web + /// received `reference_image_media_tokens: [..., ...]` and routed it to + /// the v1.5 model, which rejected the request with + /// `reference_images is not supported for this model`. After the + /// promote-first fix, the resolved URLs go into `image` instead. + #[test] + fn reproduces_2026_05_31_two_ref_image_urls_no_start_frame() { + let req = unwrap_request(make_builder(|b| { + // Two URLs (the storyteller-web pipeline resolves media tokens to URLs + // before calling build2() on the GrokApi provider). + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://pub.example.com/media/a.png".to_string(), + "https://pub.example.com/media/b.png".to_string(), + ])); + b.start_frame = None; + b.resolution = Some(RouterResolution::SevenTwentyP); + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + b.duration_seconds = Some(5); + })); + // The fix: no `reference_images` ever reach xAI for v1.5. + assert!( + req.request.reference_images.is_none(), + "v1.5 must never send reference_images to xAI", + ); + // The first reference is preserved as image-to-video so the user still + // gets a sensible result. + assert!(matches!(req.request.image, Some(GrokVideoImageSource::Url(_)))); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + assert_eq!(req.request.duration, Some(5)); + assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview)); + } + + #[test] + fn reference_image_tokens_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new("mf_a".to_string()), + MediaFileToken::new("mf_b".to_string()), + ])); + })); + assert!(req.request.reference_images.is_none()); + assert!(req.request.image.is_none()); + } + + #[test] + fn start_frame_and_reference_images_together_prefers_start_frame() { + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref1.png".to_string(), + "https://example.com/ref2.png".to_string(), + ])); + })); + match &req.request.image { + Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"), + other => panic!("expected start_frame URL, got {:?}", other), + } + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn empty_reference_image_url_list_becomes_none() { + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::Urls(vec![])); + })); + assert!(req.request.reference_images.is_none()); + } + } + + // ── Unsupported fields are silently dropped, not rejected ── + + mod unsupported_fields_graceful { + use super::*; + + #[test] + fn end_frame_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + })); + assert!(req.request.image.is_none()); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn reference_videos_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + })); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn reference_audio_silently_dropped() { + let req = unwrap_request(make_builder(|b| { + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()])); + })); + assert!(req.request.reference_images.is_none()); + } + + #[test] + fn kitchen_sink_with_every_unsupported_field_still_succeeds() { + let req = unwrap_request(make_builder(|b| { + b.prompt = Some("kitchen sink".to_string()); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string())); + b.reference_images = Some(ImageListRef::Urls(vec![ + "https://example.com/ref.png".to_string(), + ])); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()])); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()])); + b.resolution = Some(RouterResolution::SevenTwentyP); + b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); + b.duration_seconds = Some(8); + })); + assert_eq!(req.request.prompt, "kitchen sink"); + assert!(matches!( + req.request.image, + Some(GrokVideoImageSource::Url(_)) + )); + assert!(req.request.reference_images.is_none()); + assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP)); + assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9)); + assert_eq!(req.request.duration, Some(8)); + // Still pinned to the 1.5 model regardless of kitchen-sink inputs. + assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview)); + } + } + + // ── Helpers ── + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::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) -> GrokApiGrokImagineVideo1p5RequestState { + let result = build_grok_api_grok_imagine_video_1p5(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GrokApiGrokImagineVideo1p5(s)) => s, + _ => panic!("expected GrokApiGrokImagineVideo1p5 request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/cost.rs new file mode 100644 index 0000000000..1f6dab4f2f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/cost.rs @@ -0,0 +1,308 @@ +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_1p5::request::GrokApiGrokImagineVideo1p5RequestState; + +pub struct GrokApiGrokImagineVideo1p5CostState { + request: GrokApiGrokImagineVideo1p5RequestState, +} + +impl GrokApiGrokImagineVideo1p5CostState { + pub fn from_request(request: &GrokApiGrokImagineVideo1p5RequestState) -> Self { + Self { request: request.clone() } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // grok_api_client's calculator picks the v1.5 pricing tier from the + // request's `model` field (see VideoModel::pricing_tier). The build step + // sets `model = Some(GrokImagineVideo1p5Preview)`, so this returns the + // v1.5 rates: + // Output: 80 mills/s @ 480p, 140 mills/s @ 720p + // Input: 10 mills per source image + 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::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // v1.5 pricing reference (from grok_api_client/video_generation/cost.rs): + // Output: 480p 80 mills/s ($0.08/s), 720p 140 mills/s ($0.14/s) + // Input: 10 mills per source image ($0.01/img) + // + // Cost in cents = ceil(mills / 10). + + // ── 720p pricing ── + + mod pricing_720p { + use super::*; + + #[test] + fn five_s_no_image_ref() { + // 140 × 5 = 700 mills → 70¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None), 70); + } + + #[test] + fn ten_s_no_image_ref() { + // 140 × 10 = 1400 mills → 140¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None), 140); + } + + #[test] + fn fifteen_s_no_image_ref() { + // 140 × 15 = 2100 mills → 210¢ + assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None), 210); + } + } + + // ── 480p pricing ── + + mod pricing_480p { + use super::*; + + #[test] + fn five_s_no_image_ref() { + // 80 × 5 = 400 mills → 40¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, None, None), 40); + } + + #[test] + fn ten_s_no_image_ref() { + // 80 × 10 = 800 mills → 80¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, None, None), 80); + } + + #[test] + fn fifteen_s_no_image_ref() { + // 80 × 15 = 1200 mills → 120¢ + assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, None, None), 120); + } + } + + // ── Image input pricing (10 mills/image, NOT 2 mills as in v1) ── + + mod image_inputs_add_to_cost { + use super::*; + + #[test] + fn single_start_frame_image_adds_10_mills() { + // 5s @ 720p output 700 mills + 1 input image 10 mills = 710 mills → 71¢ + let cents = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + Some(ImageRef::Url("https://example.com/a.png".to_string())), + None, + ); + assert_eq!(cents, 71); + } + + #[test] + fn three_reference_images_adds_30_mills() { + // 5s @ 720p output 700 mills + 3 input images 30 mills = 730 mills → 73¢ + let cents = cost_cents( + Some(RouterResolution::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, 73); + } + + #[test] + fn image_input_count_scales_linearly() { + // Fix 5s @ 720p (700 mills base); vary ref image count 1..=5. + for n in 1u64..=5 { + let images: Vec = (0..n) + .map(|i| format!("https://example.com/{i}.png")) + .collect(); + let cents = cost_cents( + Some(RouterResolution::SevenTwentyP), + 5, + None, + Some(ImageListRef::Urls(images)), + ); + let expected_mills = 700 + 10 * n; + let expected_cents = expected_mills.div_ceil(10); + assert_eq!(cents, expected_cents, "n={n}"); + } + } + } + + // ── Relative pricing sanity ── + + mod relative_pricing { + use super::*; + + #[test] + fn higher_resolution_costs_more() { + let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None); + let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + assert!(p480 < p720, "480p ({p480}) should be < 720p ({p720})"); + } + + #[test] + fn longer_duration_costs_more() { + let c5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None); + let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None); + assert!(c5 < c10); + assert!(c10 < c15); + } + + #[test] + fn one_k_request_clamps_to_480p_pricing() { + let one_k = cost_cents(Some(RouterResolution::OneK), 10, None, None); + let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None); + assert_eq!(one_k, p480); + } + + #[test] + fn ten_eighty_p_request_clamps_to_720p_pricing() { + let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None); + let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None); + assert_eq!(p1080, p720); + } + } + + // ── Cross-tier sanity: 1.5 must cost more than v1 at same settings ── + + mod cross_tier { + use super::*; + + #[test] + fn v1p5_costs_more_than_v1_at_720p() { + let v1p5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None); + let v1 = v1_cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None); + assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}"); + } + + #[test] + fn v1p5_costs_more_than_v1_at_480p() { + let v1p5 = cost_cents(Some(RouterResolution::FourEightyP), 5, None, None); + let v1 = v1_cost_cents(Some(RouterResolution::FourEightyP), 5, None, None); + assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}"); + } + } + + // ── Exhaustive matrix ── + + mod exhaustive_matrix { + use super::*; + + // (resolution, duration_s, has_image, ref_count, expected_cents) + // + // Mills math: + // 480p output: 80 mills/s 720p output: 140 mills/s + // per input image: 10 mills + // cents = ceil(mills / 10) + const CASES: &[(RouterResolution, u16, bool, usize, u64)] = &[ + // 480p text-only + (RouterResolution::FourEightyP, 1, false, 0, 8), // 80 + (RouterResolution::FourEightyP, 5, false, 0, 40), // 400 + (RouterResolution::FourEightyP, 8, false, 0, 64), // 640 + (RouterResolution::FourEightyP, 15, false, 0, 120), // 1200 + // 720p text-only + (RouterResolution::SevenTwentyP, 1, false, 0, 14), // 140 + (RouterResolution::SevenTwentyP, 5, false, 0, 70), // 700 + (RouterResolution::SevenTwentyP, 8, false, 0, 112), // 1120 + (RouterResolution::SevenTwentyP,15, false, 0, 210), // 2100 + // 480p with image-to-video (1 input image, +10 mills) + (RouterResolution::FourEightyP, 5, true, 0, 41), // 410 + (RouterResolution::FourEightyP, 10, true, 0, 81), // 810 + // 720p with image-to-video (1 input image, +10 mills) + (RouterResolution::SevenTwentyP, 5, true, 0, 71), // 710 + (RouterResolution::SevenTwentyP,10, true, 0, 141), // 1410 + // 720p reference-to-video (no start frame, N reference images) + (RouterResolution::SevenTwentyP, 5, false, 1, 71), // 710 + (RouterResolution::SevenTwentyP, 5, false, 2, 72), // 720 + (RouterResolution::SevenTwentyP, 5, false, 3, 73), // 730 + ]; + + #[test] + fn all_matrix_cases() { + for &(res, duration, has_image, ref_count, expected_cents) in CASES { + let start_frame = if has_image { + Some(ImageRef::Url("https://example.com/start.png".to_string())) + } else { None }; + let reference_images = if ref_count > 0 { + Some(ImageListRef::Urls( + (0..ref_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(), + )) + } else { None }; + let cents = cost_cents(Some(res), duration, start_frame, reference_images); + assert_eq!( + cents, expected_cents, + "res={res:?} dur={duration} has_image={has_image} ref_count={ref_count}", + ); + } + } + } + + // ── Helpers ── + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + build_for(RouterVideoModel::GrokImagineVideo1p5, resolution, duration_seconds, start_frame, reference_images) + } + + fn v1_cost_cents( + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + build_for(RouterVideoModel::GrokImagineVideo, resolution, duration_seconds, start_frame, reference_images) + } + + fn build_for( + model: RouterVideoModel, + resolution: Option, + duration_seconds: u16, + start_frame: Option, + reference_images: Option, + ) -> u64 { + let builder = GenerateVideoRequestBuilder { + model, + provider: RouterProvider::GrokApi, + aspect_ratio: Some(RouterAspectRatio::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_1p5/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/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_1p5/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_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/request.rs new file mode 100644 index 0000000000..743eaff7b4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/grok_api/grok_imagine_video_1p5/request.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; + +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 GrokApiGrokImagineVideo1p5RequestState { + /// The fully-resolved Grok request body, with `model` set to + /// `VideoModel::GrokImagineVideo1p5Preview`. Doesn't carry the API key — + /// that gets borrowed at send time from the router client. + pub request: GrokVideoGenerationRequest, +} + +impl GrokApiGrokImagineVideo1p5RequestState { + pub async fn send(&self, client: &RouterGrokApiClient) -> Result { + let outbound_request = Arc::new(self.request.clone()); + + 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, + maybe_outbound_request: Some(outbound_request), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use grok_api_client::api::types::video_types::video_model::VideoModel; + use grok_api_client::creds::grok_api_key::GrokApiKey; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + 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; + + // ── Wire-shape sanity: the built request always carries the 1.5 model id ── + + #[test] + fn built_request_targets_grok_imagine_video_1p5_preview() { + let builder = GenerateVideoRequestBuilder { + prompt: Some("test".to_string()), + resolution: Some(RouterResolution::SevenTwentyP), + ..grok_builder() + }; + let request = unwrap_request(builder); + assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5Preview)); + } + + // ── Live API tests (manual) ── + + #[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(RouterAspectRatio::WideSixteenByNine), + resolution: Some(RouterResolution::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(RouterResolution::FourEightyP), + 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_720p() { + 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(RouterResolution::SevenTwentyP), + duration_seconds: Some(5), + ..grok_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Grok(_))); + } + + fn grok_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::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)) + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideo1p5RequestState { + let result = builder.build2().expect("build2 should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest::GrokApiGrokImagineVideo1p5(s) + ) => s, + _ => panic!("expected GrokApiGrokImagineVideo1p5 request"), + } + } + + 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 index 7343465cec..3368e9b140 100644 --- 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 @@ -1 +1,2 @@ pub mod grok_imagine_video; +pub mod grok_imagine_video_1p5; 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 ce04aa8247..1b517af03f 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 @@ -5,6 +5,8 @@ use crate::generate::generate_video::generate_video_response::GenerateVideoRespo use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::cost::ArtcraftGrokImagineVideoCostState; use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState; +use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::cost::ArtcraftGrokImagineVideo1p5CostState; +use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState; use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::cost::ArtcraftHappyHorse1p0CostState; use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState; use crate::generate::generate_video_v2::providers::artcraft::kling_1_6_pro::cost::ArtcraftKling16ProCostState; @@ -97,6 +99,8 @@ use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g 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::grok_api::grok_imagine_video_1p5::cost::GrokApiGrokImagineVideo1p5CostState; +use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState; 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; @@ -107,6 +111,7 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re #[derive(Clone, Debug)] pub enum VideoGenerationRequest { ArtcraftGrokImagineVideo(ArtcraftGrokImagineVideoRequestState), + ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState), ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState), ArtcraftKling16Pro(ArtcraftKling16ProRequestState), ArtcraftKling21Master(ArtcraftKling21MasterRequestState), @@ -153,6 +158,7 @@ pub enum VideoGenerationRequest { GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState), GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState), GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState), + GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState), KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState), KinoviSeedance2p0(KinoviSeedance2p0RequestState), KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState), @@ -163,6 +169,7 @@ impl VideoGenerationRequest { pub fn get_provider(&self) -> RouterProvider { match self { Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft, + Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft, Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft, Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft, Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft, @@ -209,6 +216,7 @@ impl VideoGenerationRequest { Self::GmiCloudSeedance2p0Ultra(_) => RouterProvider::GmiCloud, Self::GmiCloudSeedance2p0UltraFast(_) => RouterProvider::GmiCloud, Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi, + Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi, Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro, Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro, Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro, @@ -219,6 +227,7 @@ impl VideoGenerationRequest { pub fn estimate_cost(&self) -> Result { match self { VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => Ok(ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()), @@ -265,6 +274,7 @@ impl VideoGenerationRequest { 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::GrokApiGrokImagineVideo1p5(request) => Ok(GrokApiGrokImagineVideo1p5CostState::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()), @@ -279,6 +289,10 @@ impl VideoGenerationRequest { let client_ref = client.get_artcraft_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => { let client_ref = client.get_artcraft_client_ref()?; request.send(client_ref).await @@ -463,6 +477,10 @@ impl VideoGenerationRequest { let client_ref = client.get_grok_api_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::GrokApiGrokImagineVideo1p5(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_generation/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs index 987fab6956..d0cffed360 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs @@ -1,27 +1,39 @@ use crate::api::requests::videos::video_generation::video_generation::VideoGenerationRequest; use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::video_types::video_model::{VideoModel, VideoModelPricingTier}; use crate::api::types::video_types::video_resolution::VideoResolution; -// xAI pricing per : +// xAI pricing per and +// : // -// grok-imagine-video +// grok-imagine-video (v1) // Output: 480p $0.05/sec, 720p $0.07/sec // Input: $0.01/sec (source video) + $0.002/img (source images) // +// grok-imagine-video-1.5-preview (v1.5) +// Output: 480p $0.08/sec, 720p $0.14/sec +// Input: $0.01/img (source images) +// // In mills (1¢ = 10 mills): // -// Output: 480p 50 mills/sec, 720p 70 mills/sec -// Input: 10 mills/sec (video), 2 mills/img (per source image) +// v1 output: 480p 50 mills/sec, 720p 70 mills/sec +// v1 input: 10 mills/sec (video), 2 mills/img +// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec +// v1.5 input: 10 mills/img // // video_generation has NO input video — only optional input images. xAI -// defaults: `duration` = 8 seconds, `resolution` = "480p". +// defaults: `duration` = 8 seconds, `resolution` = "480p" (the server side; +// our local default below is 720p — see DEFAULT comments in the impl). /// xAI default duration for video_generation when `duration` is omitted. pub(crate) const DEFAULT_VIDEO_DURATION_SECONDS: u32 = 8; -/// Per-source-image input rate for video endpoints, in mills. +/// Per-source-image input rate (v1 tier), in mills. pub(crate) const INPUT_MILLS_PER_IMAGE: UsdMills = 2; +/// Per-source-image input rate (v1.5 tier), in mills. +const INPUT_MILLS_PER_IMAGE_V1P5: UsdMills = 10; + /// Per-source-second input rate (when a source video is supplied) for video /// endpoints, in mills. Not used by `video_generation` (which has no source /// video) but referenced by `video_edit` and `video_extension`. @@ -31,8 +43,12 @@ impl GrokRequestCostCalculator for VideoGenerationRequest { fn calculate_cost_in_mills(&self) -> UsdMills { let duration = self.duration.unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS) as u64; let resolution = self.resolution.unwrap_or(VideoResolution::SevenTwentyP); + let tier = self.model + .as_ref() + .map(VideoModel::pricing_tier) + .unwrap_or(VideoModelPricingTier::V1); - let output_mills = output_mills_per_second(resolution) * duration; + let output_mills = output_mills_per_second_for_tier(resolution, tier) * duration; // Source image count = `image` (image-to-video) + len(`reference_images`) // (reference-to-video). These are mutually exclusive at call time but the @@ -41,17 +57,38 @@ impl GrokRequestCostCalculator for VideoGenerationRequest { (self.image.is_some() as u64) + (self.reference_images.as_ref().map(|v| v.len() as u64).unwrap_or(0)); - let input_mills = INPUT_MILLS_PER_IMAGE * source_image_count; + let input_mills = input_mills_per_image(tier) * source_image_count; output_mills + input_mills } } -/// Output rate in mills per second of generated video, by resolution. +/// Output rate in mills per second of generated video at v1 rates, by +/// resolution. Kept for cross-module use by `video_edit` and +/// `video_extension`, which both assume v1 pricing and do not currently take +/// a model into account in their cost calculators. pub(crate) fn output_mills_per_second(resolution: VideoResolution) -> UsdMills { - match resolution { - VideoResolution::FourEightyP => 50, - VideoResolution::SevenTwentyP => 70, + output_mills_per_second_for_tier(resolution, VideoModelPricingTier::V1) +} + +/// Output rate in mills per second of generated video, by resolution AND +/// pricing tier. Used by `video_generation`. +fn output_mills_per_second_for_tier( + resolution: VideoResolution, + tier: VideoModelPricingTier, +) -> UsdMills { + match (tier, resolution) { + (VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50, + (VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70, + (VideoModelPricingTier::V1p5Preview, VideoResolution::FourEightyP) => 80, + (VideoModelPricingTier::V1p5Preview, VideoResolution::SevenTwentyP) => 140, + } +} + +fn input_mills_per_image(tier: VideoModelPricingTier) -> UsdMills { + match tier { + VideoModelPricingTier::V1 => INPUT_MILLS_PER_IMAGE, + VideoModelPricingTier::V1p5Preview => INPUT_MILLS_PER_IMAGE_V1P5, } } @@ -236,8 +273,9 @@ mod tests { } #[test] - fn cost_is_independent_of_model_variant() { - // All `model` enum variants map to the same pricing (only one video model). + fn cost_within_v1_tier_is_independent_of_model_variant() { + // Within the v1 pricing tier (default + unrecognized Custom strings), + // model variant must not affect cost. let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); let base_cost = base.calculate_cost_in_mills(); for m in [ @@ -250,6 +288,132 @@ mod tests { } } + // ── grok-imagine-video-1.5-preview pricing ── + // + // v1.5 rates per the model's docs page: + // 480p: 80 mills/sec, 720p: 140 mills/sec, 10 mills per source image. + + mod v1p5_preview { + use super::*; + + fn make_v1p5_request( + duration: Option, + resolution: Option, + image: Option, + reference_images: Option>, + ) -> VideoGenerationRequest { + let mut req = make_request(duration, resolution, image, reference_images); + req.model = Some(VideoModel::GrokImagineVideo1p5Preview); + req + } + + #[test] + fn five_seconds_480p_text_only() { + // 80 × 5 = 400 mills = 40¢ + let req = make_v1p5_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 400); + assert_eq!(req.calculate_cost_in_cents(), 40); + } + + #[test] + fn five_seconds_720p_text_only() { + // 140 × 5 = 700 mills = 70¢ + let req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 700); + assert_eq!(req.calculate_cost_in_cents(), 70); + } + + #[test] + fn five_seconds_720p_one_source_image() { + // 140 × 5 + 10 × 1 = 710 mills = 71¢ + let req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + assert_eq!(req.calculate_cost_in_mills(), 710); + assert_eq!(req.calculate_cost_in_cents(), 71); + } + + #[test] + fn five_seconds_480p_three_reference_images() { + // 80 × 5 + 10 × 3 = 430 mills = 43¢ + let req = make_v1p5_request( + Some(5), Some(VideoResolution::FourEightyP), + None, Some(vec![url_source(), url_source(), url_source()]), + ); + assert_eq!(req.calculate_cost_in_mills(), 430); + assert_eq!(req.calculate_cost_in_cents(), 43); + } + + #[test] + fn dated_alias_via_custom_is_billed_as_v1p5() { + // xAI accepts the dated alias as a synonym; cost must match the + // canonical 1.5 variant exactly. + let canonical = { + let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + req.model = Some(VideoModel::GrokImagineVideo1p5Preview); + req.calculate_cost_in_mills() + }; + let via_alias = { + let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + req.model = Some(VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string())); + req.calculate_cost_in_mills() + }; + assert_eq!(canonical, via_alias); + } + + #[test] + fn custom_with_canonical_1p5_name_is_billed_as_v1p5() { + let via_custom = { + let mut req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); + req.model = Some(VideoModel::Custom("grok-imagine-video-1.5-preview".to_string())); + req.calculate_cost_in_mills() + }; + let via_enum = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None) + .calculate_cost_in_mills(); + assert_eq!(via_custom, via_enum); + } + + #[test] + fn v1p5_costs_more_than_v1_at_same_settings() { + let v1 = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + let v1p5 = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}"); + } + + // (duration, resolution, has_image, ref_count, expected_mills) + const V1P5_CASES: &[(u32, VideoResolution, bool, usize, u64)] = &[ + // 480p text-only + ( 1, VideoResolution::FourEightyP, false, 0, 80), + ( 8, VideoResolution::FourEightyP, false, 0, 640), + (15, VideoResolution::FourEightyP, false, 0, 1200), + // 720p text-only + ( 1, VideoResolution::SevenTwentyP, false, 0, 140), + ( 8, VideoResolution::SevenTwentyP, false, 0, 1120), + (15, VideoResolution::SevenTwentyP, false, 0, 2100), + // 480p with image-to-video + ( 5, VideoResolution::FourEightyP, true, 0, 410), + (10, VideoResolution::FourEightyP, true, 0, 810), + // 720p with image-to-video + ( 5, VideoResolution::SevenTwentyP, true, 0, 710), + // 720p with reference images + ( 5, VideoResolution::SevenTwentyP, false, 2, 720), + ( 5, VideoResolution::SevenTwentyP, false, 3, 730), + ]; + + #[test] + fn v1p5_matrix() { + for &(duration, res, has_image, ref_count, expected) in V1P5_CASES { + let image = if has_image { Some(url_source()) } else { None }; + let refs = if ref_count > 0 { + Some((0..ref_count).map(|_| url_source()).collect()) + } else { None }; + let req = make_v1p5_request(Some(duration), Some(res), image, refs); + assert_eq!( + req.calculate_cost_in_mills(), expected, + "duration={duration} res={res:?} has_image={has_image} ref_count={ref_count}", + ); + } + } + } + // ── Exhaustive matrix ── mod exhaustive_matrix { 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 bf11467625..b527156d03 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 @@ -169,6 +169,21 @@ mod tests { assert!(result.video.is_none()); } + #[test] + fn pending_response_with_v1p5_model_passes_model_string_through() { + // The `model` field is opaque Option; the v1.5 preview model + // identifier (and its dated alias) flow through unchanged. + let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-preview" }"#; + let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap(); + let result = classify_status_field(&parsed).unwrap(); + assert_eq!(result.model.as_deref(), Some("grok-imagine-video-1.5-preview")); + + let json_alias = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-2026-05-30" }"#; + let parsed_alias: VideoStatusResponseBody = serde_json::from_str(json_alias).unwrap(); + let result_alias = classify_status_field(&parsed_alias).unwrap(); + assert_eq!(result_alias.model.as_deref(), Some("grok-imagine-video-1.5-2026-05-30")); + } + #[test] fn done_response_classifies_as_done_with_video() { let json = r#"{ @@ -291,7 +306,8 @@ mod tests { let result = video_status(VideoStatusArgs { api_key: &api_key, request: VideoStatusRequest { - request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(), + //request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(), + request_id: "ce681bd0-133d-9cf3-975c-422d292d4e8e".to_string(), }, }).await; diff --git a/crates/api_clients/grok_api_client/src/api/types/video_types/video_model.rs b/crates/api_clients/grok_api_client/src/api/types/video_types/video_model.rs index 54d07e4b41..06ab80f102 100644 --- a/crates/api_clients/grok_api_client/src/api/types/video_types/video_model.rs +++ b/crates/api_clients/grok_api_client/src/api/types/video_types/video_model.rs @@ -13,10 +13,16 @@ /// - #[derive(Clone, Debug, PartialEq, Eq)] pub enum VideoModel { - /// `grok-imagine-video` — the (currently sole) video model. Used by + /// `grok-imagine-video` — the original Imagine video model. Used by /// generation, editing, and extension endpoints. GrokImagineVideo, + /// `grok-imagine-video-1.5-preview` — the 1.5 preview model. xAI also + /// accepts the dated alias `grok-imagine-video-1.5-2026-05-30`, which + /// resolves to the same model server-side; serialize via this variant for + /// the canonical name. + GrokImagineVideo1p5Preview, + /// Escape hatch for model identifiers not yet enumerated here. Custom(String), } @@ -25,10 +31,40 @@ impl VideoModel { /// Wire representation — the exact string xAI expects in the `"model"` field. pub fn as_str(&self) -> &str { match self { - Self::GrokImagineVideo => "grok-imagine-video", - Self::Custom(s) => s.as_str(), + Self::GrokImagineVideo => "grok-imagine-video", + Self::GrokImagineVideo1p5Preview => "grok-imagine-video-1.5-preview", + Self::Custom(s) => s.as_str(), } } + + /// Pricing tier this model bills under. Includes recognition of the dated + /// alias `grok-imagine-video-1.5-2026-05-30` passed via [`Self::Custom`], + /// which xAI treats as a synonym for `grok-imagine-video-1.5-preview` and + /// therefore charges at the 1.5 rates. + pub fn pricing_tier(&self) -> VideoModelPricingTier { + match self { + Self::GrokImagineVideo => VideoModelPricingTier::V1, + Self::GrokImagineVideo1p5Preview => VideoModelPricingTier::V1p5Preview, + Self::Custom(s) => match s.as_str() { + "grok-imagine-video-1.5-preview" | "grok-imagine-video-1.5-2026-05-30" => { + VideoModelPricingTier::V1p5Preview + } + _ => VideoModelPricingTier::V1, + }, + } + } +} + +/// Pricing tier identifier used by the per-request cost calculators. xAI +/// publishes different per-second and per-image rates for each tier — see +/// `video_generation/cost.rs`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VideoModelPricingTier { + /// `grok-imagine-video` and any unrecognized identifier that defaults + /// to v1 rates. + V1, + /// `grok-imagine-video-1.5-preview` (and its dated alias). + V1p5Preview, } // Serialize as the wire string ("grok-imagine-video" or the Custom inner @@ -50,6 +86,18 @@ mod tests { assert_eq!(VideoModel::GrokImagineVideo.as_str(), "grok-imagine-video"); } + #[test] + fn one_point_five_preview_serializes_canonical_name() { + assert_eq!( + VideoModel::GrokImagineVideo1p5Preview.as_str(), + "grok-imagine-video-1.5-preview", + ); + assert_eq!( + serde_json::to_string(&VideoModel::GrokImagineVideo1p5Preview).unwrap(), + "\"grok-imagine-video-1.5-preview\"", + ); + } + #[test] fn custom_model_passes_through() { let m = VideoModel::Custom("grok-imagine-video-future".to_string()); @@ -62,4 +110,48 @@ mod tests { let m = VideoModel::Custom("grok-imagine-video-v2".to_string()); assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-video-v2\""); } + + // ── Pricing tier classification ── + + mod pricing_tier { + use super::*; + + #[test] + fn grok_imagine_video_is_v1() { + assert_eq!(VideoModel::GrokImagineVideo.pricing_tier(), VideoModelPricingTier::V1); + } + + #[test] + fn one_point_five_preview_enum_is_v1p5() { + assert_eq!( + VideoModel::GrokImagineVideo1p5Preview.pricing_tier(), + VideoModelPricingTier::V1p5Preview, + ); + } + + #[test] + fn custom_with_canonical_1p5_name_is_v1p5() { + let m = VideoModel::Custom("grok-imagine-video-1.5-preview".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview); + } + + #[test] + fn custom_with_dated_1p5_alias_is_v1p5() { + // xAI treats this alias as a synonym for the 1.5 preview model. + let m = VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview); + } + + #[test] + fn custom_unknown_defaults_to_v1() { + let m = VideoModel::Custom("grok-imagine-video-future".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1); + } + + #[test] + fn custom_with_v1_name_is_v1() { + let m = VideoModel::Custom("grok-imagine-video".to_string()); + assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1); + } + } } diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs index 4a17b52cae..1196095c29 100644 --- a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs +++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs @@ -77,6 +77,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option Some(CommonModelType::PreviewModel), RouterVideoModel::PreviewModelFast => Some(CommonModelType::PreviewModelFast), RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo), + RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5), } } diff --git a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs index 384c993f95..8899a91074 100644 --- a/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs +++ b/crates/schema/public/enums/src/by_table/generic_inference_jobs/inference_model_type.rs @@ -149,6 +149,8 @@ pub enum InferenceModelType { GrokVideo, #[serde(rename = "grok_imagine_video")] GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, #[serde(rename = "kling_1p6_pro")] Kling16Pro, #[serde(rename = "kling_2p1_pro")] @@ -286,6 +288,7 @@ impl InferenceModelType { // Video models Self::GrokVideo => "grok_video", Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", Self::Kling16Pro => "kling_1p6_pro", Self::Kling21Pro => "kling_2p1_pro", Self::Kling21Master => "kling_2p1_master", @@ -384,6 +387,7 @@ impl InferenceModelType { // Video models "grok_video" => Ok(Self::GrokVideo), "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), "kling_1p6_pro" => Ok(Self::Kling16Pro), "kling_2p1_pro" => Ok(Self::Kling21Pro), "kling_2p1_master" => Ok(Self::Kling21Master), @@ -486,6 +490,7 @@ impl InferenceModelType { // Video models Self::GrokVideo, Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, Self::Kling16Pro, Self::Kling21Pro, Self::Kling21Master, @@ -571,6 +576,7 @@ impl InferenceModelType { // Video models CommonModelType::GrokVideo => Self::GrokVideo, CommonModelType::GrokImagineVideo => Self::GrokImagineVideo, + CommonModelType::GrokImagineVideo1p5 => Self::GrokImagineVideo1p5, CommonModelType::Kling16Pro => Self::Kling16Pro, CommonModelType::Kling21Pro => Self::Kling21Pro, CommonModelType::Kling21Master => Self::Kling21Master, @@ -679,6 +685,7 @@ mod tests { // Video models assert_serialization(InferenceModelType::GrokVideo, "grok_video"); assert_serialization(InferenceModelType::GrokImagineVideo, "grok_imagine_video"); + assert_serialization(InferenceModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5"); assert_serialization(InferenceModelType::Kling16Pro, "kling_1p6_pro"); assert_serialization(InferenceModelType::Kling21Pro, "kling_2p1_pro"); assert_serialization(InferenceModelType::Kling21Master, "kling_2p1_master"); @@ -776,6 +783,7 @@ mod tests { // Video models assert_eq!(InferenceModelType::GrokVideo.to_str(), "grok_video"); assert_eq!(InferenceModelType::GrokImagineVideo.to_str(), "grok_imagine_video"); + assert_eq!(InferenceModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5"); assert_eq!(InferenceModelType::Kling16Pro.to_str(), "kling_1p6_pro"); assert_eq!(InferenceModelType::Kling21Pro.to_str(), "kling_2p1_pro"); assert_eq!(InferenceModelType::Kling21Master.to_str(), "kling_2p1_master"); @@ -873,6 +881,7 @@ mod tests { // Video models assert_eq!(InferenceModelType::from_str("grok_video").unwrap(), InferenceModelType::GrokVideo); assert_eq!(InferenceModelType::from_str("grok_imagine_video").unwrap(), InferenceModelType::GrokImagineVideo); + assert_eq!(InferenceModelType::from_str("grok_imagine_video_1p5").unwrap(), InferenceModelType::GrokImagineVideo1p5); assert_eq!(InferenceModelType::from_str("kling_1p6_pro").unwrap(), InferenceModelType::Kling16Pro); assert_eq!(InferenceModelType::from_str("kling_2p1_pro").unwrap(), InferenceModelType::Kling21Pro); assert_eq!(InferenceModelType::from_str("kling_2p1_master").unwrap(), InferenceModelType::Kling21Master); diff --git a/crates/schema/public/enums/src/common/generation/common_model_type.rs b/crates/schema/public/enums/src/common/generation/common_model_type.rs index e9e49a6aeb..bcbfb45ec1 100644 --- a/crates/schema/public/enums/src/common/generation/common_model_type.rs +++ b/crates/schema/public/enums/src/common/generation/common_model_type.rs @@ -117,6 +117,9 @@ pub enum CommonModelType { /// No version is specified. #[serde(rename = "grok_imagine_video")] GrokImagineVideo, + /// xAI's Grok Imagine video 1.5 preview model (grok-imagine-video-1.5-preview). + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, #[serde(rename = "kling_1p6_pro")] Kling16Pro, #[serde(rename = "kling_2p1_pro")] @@ -238,6 +241,7 @@ impl CommonModelType { // Video models Self::GrokVideo => "grok_video", Self::GrokImagineVideo => "grok_imagine_video", + Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5", Self::Kling16Pro => "kling_1p6_pro", Self::Kling21Pro => "kling_2p1_pro", Self::Kling21Master => "kling_2p1_master", @@ -319,6 +323,7 @@ impl CommonModelType { // Video models "grok_video" => Ok(Self::GrokVideo), "grok_imagine_video" => Ok(Self::GrokImagineVideo), + "grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5), "kling_1p6_pro" => Ok(Self::Kling16Pro), "kling_2p1_pro" => Ok(Self::Kling21Pro), "kling_2p1_master" => Ok(Self::Kling21Master), @@ -404,6 +409,7 @@ impl CommonModelType { // Video models Self::GrokVideo, Self::GrokImagineVideo, + Self::GrokImagineVideo1p5, Self::Kling16Pro, Self::Kling21Pro, Self::Kling21Master, @@ -486,6 +492,7 @@ impl CommonModelType { // Video models Self::GrokVideo => CommonModelClass::Video, Self::GrokImagineVideo => CommonModelClass::Video, + Self::GrokImagineVideo1p5 => CommonModelClass::Video, Self::Kling16Pro => CommonModelClass::Video, Self::Kling21Pro => CommonModelClass::Video, Self::Kling21Master => CommonModelClass::Video, @@ -577,6 +584,7 @@ mod tests { // Video models assert_serialization(CommonModelType::GrokVideo, "grok_video"); assert_serialization(CommonModelType::GrokImagineVideo, "grok_imagine_video"); + assert_serialization(CommonModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5"); assert_serialization(CommonModelType::Kling16Pro, "kling_1p6_pro"); assert_serialization(CommonModelType::Kling21Pro, "kling_2p1_pro"); assert_serialization(CommonModelType::Kling21Master, "kling_2p1_master"); @@ -655,6 +663,7 @@ mod tests { // Video models assert_eq!(CommonModelType::GrokVideo.to_str(), "grok_video"); assert_eq!(CommonModelType::GrokImagineVideo.to_str(), "grok_imagine_video"); + assert_eq!(CommonModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5"); assert_eq!(CommonModelType::Kling16Pro.to_str(), "kling_1p6_pro"); assert_eq!(CommonModelType::Kling21Pro.to_str(), "kling_2p1_pro"); assert_eq!(CommonModelType::Kling21Master.to_str(), "kling_2p1_master"); @@ -733,6 +742,7 @@ mod tests { // Video models assert_eq!(CommonModelType::from_str("grok_video").unwrap(), CommonModelType::GrokVideo); assert_eq!(CommonModelType::from_str("grok_imagine_video").unwrap(), CommonModelType::GrokImagineVideo); + assert_eq!(CommonModelType::from_str("grok_imagine_video_1p5").unwrap(), CommonModelType::GrokImagineVideo1p5); assert_eq!(CommonModelType::from_str("kling_1p6_pro").unwrap(), CommonModelType::Kling16Pro); assert_eq!(CommonModelType::from_str("kling_2p1_pro").unwrap(), CommonModelType::Kling21Pro); assert_eq!(CommonModelType::from_str("kling_2p1_master").unwrap(), CommonModelType::Kling21Master); @@ -775,7 +785,7 @@ mod tests { #[test] fn all_variants() { let mut variants = CommonModelType::all_variants(); - assert_eq!(variants.len(), 69); + assert_eq!(variants.len(), 70); // Image models assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell)); @@ -813,6 +823,7 @@ mod tests { // Video models assert_eq!(variants.pop_first(), Some(CommonModelType::GrokVideo)); assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo)); + assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo1p5)); assert_eq!(variants.pop_first(), Some(CommonModelType::Kling16Pro)); assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Pro)); assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Master)); diff --git a/crates/schema/public/enums/src/common/generation/common_video_model.rs b/crates/schema/public/enums/src/common/generation/common_video_model.rs index 22aabece76..f3c86f7947 100644 --- a/crates/schema/public/enums/src/common/generation/common_video_model.rs +++ b/crates/schema/public/enums/src/common/generation/common_video_model.rs @@ -14,6 +14,9 @@ pub enum CommonVideoModel { #[serde(rename = "grok_imagine_video")] GrokImagineVideo, + #[serde(rename = "grok_imagine_video_1p5")] + GrokImagineVideo1p5, + #[serde(rename = "kling_1p6_pro")] Kling16Pro, @@ -106,6 +109,7 @@ impl CommonVideoModel { match self { Self::GrokVideo => CommonModelType::GrokVideo, Self::GrokImagineVideo => CommonModelType::GrokImagineVideo, + Self::GrokImagineVideo1p5 => CommonModelType::GrokImagineVideo1p5, Self::Kling16Pro => CommonModelType::Kling16Pro, Self::Kling21Pro => CommonModelType::Kling21Pro, Self::Kling21Master => CommonModelType::Kling21Master, @@ -147,6 +151,7 @@ mod tests { fn test_serialization() { assert_serialization(CommonVideoModel::GrokVideo, "grok_video"); assert_serialization(CommonVideoModel::GrokImagineVideo, "grok_imagine_video"); + assert_serialization(CommonVideoModel::GrokImagineVideo1p5, "grok_imagine_video_1p5"); assert_serialization(CommonVideoModel::Kling16Pro, "kling_1p6_pro"); assert_serialization(CommonVideoModel::Kling21Pro, "kling_2p1_pro"); assert_serialization(CommonVideoModel::Kling21Master, "kling_2p1_master"); @@ -181,6 +186,7 @@ mod tests { let cases = [ ("grok_video", CommonVideoModel::GrokVideo), ("grok_imagine_video", CommonVideoModel::GrokImagineVideo), + ("grok_imagine_video_1p5", CommonVideoModel::GrokImagineVideo1p5), ("kling_1p6_pro", CommonVideoModel::Kling16Pro), ("kling_2p1_pro", CommonVideoModel::Kling21Pro), ("kling_2p1_master", CommonVideoModel::Kling21Master), @@ -222,6 +228,7 @@ mod tests { let all = [ CommonVideoModel::GrokVideo, CommonVideoModel::GrokImagineVideo, + CommonVideoModel::GrokImagineVideo1p5, CommonVideoModel::Kling16Pro, CommonVideoModel::Kling21Pro, CommonVideoModel::Kling21Master, @@ -262,6 +269,7 @@ mod tests { let models = [ (CommonVideoModel::GrokVideo, CommonModelType::GrokVideo), (CommonVideoModel::GrokImagineVideo, CommonModelType::GrokImagineVideo), + (CommonVideoModel::GrokImagineVideo1p5, CommonModelType::GrokImagineVideo1p5), (CommonVideoModel::Kling16Pro, CommonModelType::Kling16Pro), (CommonVideoModel::Kling21Pro, CommonModelType::Kling21Pro), (CommonVideoModel::Kling21Master, CommonModelType::Kling21Master), diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs index 7efe5ddffb..e92c94ed70 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models.rs @@ -55,6 +55,44 @@ fn build_omni_gen_video_models() -> Vec { ..Default::default() }); + models.push(OmniGenVideoModelDetails { + model: CommonVideoModel::GrokImagineVideo1p5, + model_creator: Some(ModelCreator::Grok), + full_name: Some("Grok Imagine 1.5 Preview".to_string()), + extra_info: Some("Fast and high quality".to_string()), + extra_info_short: Some("Fast and high quality".to_string()), + text_prompt_supported: Some(true), + starting_keyframe_supported: Some(true), + ending_keyframe_supported: Some(false), + image_references_supported: Some(false), + //image_references_max: Some(7), + //video_references_supported: Some(false), + //video_references_max: Some(3), + //video_references_max_total_duration_seconds: Some(15), + aspect_ratio_options: Some(vec![ + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::WideFourByThree, + CommonAspectRatio::WideThreeByTwo, + CommonAspectRatio::Square, + CommonAspectRatio::TallTwoByThree, + CommonAspectRatio::TallThreeByFour, + CommonAspectRatio::TallNineBySixteen, + ]), + aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine), + resolution_options: Some(vec![ + CommonResolution::FourEightyP, + CommonResolution::SevenTwentyP, + ]), + resolution_default: Some(CommonResolution::SevenTwentyP), + //batch_size_options: Some(vec![1, 2, 4]), + //batch_size_default: Some(1), + duration_seconds_min: Some(1), + duration_seconds_max: Some(15), + duration_seconds_max_with_image_references: Some(10), + duration_seconds_default: Some(8), + ..Default::default() + }); + models.push(OmniGenVideoModelDetails { model: CommonVideoModel::HappyHorse1p0, model_creator: Some(ModelCreator::Alibaba), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs index ead61c1ff8..8a2964f0ec 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/cost_estimate/video/estimate_video_cost_handler.rs @@ -131,6 +131,7 @@ fn map_video_model(model: CommonVideoModel) -> Result RouterVideoModel::GrokVideo, CommonVideoModel::GrokImagineVideo => RouterVideoModel::GrokImagineVideo, + CommonVideoModel::GrokImagineVideo1p5 => RouterVideoModel::GrokImagineVideo1p5, CommonVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro, CommonVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro, CommonVideoModel::Kling21Master => RouterVideoModel::Kling21Master, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs index 0c95d53c20..c5919ff8be 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs @@ -65,6 +65,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result RouterProvider::GmiCloud, //RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud, RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi, + RouterVideoModel::GrokImagineVideo1p5 => RouterProvider::GrokApi, _ => RouterProvider::Fal, };