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 35232e3e57..546416c83e 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 @@ -35,6 +35,9 @@ pub enum RouterVideoModel { #[serde(rename = "kling_3p0_pro")] Kling3p0Pro, + #[serde(rename = "minimax_h3")] + MinimaxH3, + #[serde(rename = "seedance_1p0_lite")] Seedance10Lite, @@ -149,4 +152,11 @@ mod tests { assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3"); assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo"); } + + #[test] + fn minimax_h3_serializes() { + // NB: This string must match `CommonVideoModel` — the two enums convert + // via serde string round-trip in the omni handlers. + assert_serde_round_trip(RouterVideoModel::MinimaxH3, "minimax_h3"); + } } 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 4b77ab17d2..c464574012 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 @@ -43,6 +43,7 @@ use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::bui use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1; use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast; use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite; +use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3; use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3; use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo; use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0; @@ -68,6 +69,7 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1; use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast; use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite; +use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3; use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3; use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo; use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast; @@ -180,6 +182,7 @@ impl GenerateVideoRequestBuilder { (RouterProvider::Artcraft, RouterVideoModel::Kling2p6Pro) => build_artcraft_kling_2_6_pro(self), (RouterProvider::Artcraft, RouterVideoModel::Kling3p0Pro) => build_artcraft_kling_3p0_pro(self), (RouterProvider::Artcraft, RouterVideoModel::Kling3p0Standard) => build_artcraft_kling_3p0_standard(self), + (RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self), (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self), (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self), (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self), @@ -214,6 +217,7 @@ impl GenerateVideoRequestBuilder { (RouterProvider::Fal, RouterVideoModel::Kling2p6Pro) => build_fal_kling_2_6_pro(self), (RouterProvider::Fal, RouterVideoModel::Kling3p0Pro) => build_fal_kling_3p0_pro(self), (RouterProvider::Fal, RouterVideoModel::Kling3p0Standard) => build_fal_kling_3p0_standard(self), + (RouterProvider::Fal, RouterVideoModel::MinimaxH3) => build_fal_minimax_h3(self), (RouterProvider::Fal, RouterVideoModel::Seedance10Lite) => build_fal_seedance_1p0_lite(self), (RouterProvider::Fal, RouterVideoModel::Seedance1p5Pro) => build_fal_seedance_1p5_pro(self), (RouterProvider::Fal, RouterVideoModel::Sora2) => build_fal_sora_2(self), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/build.rs new file mode 100644 index 0000000000..2ecf0e7f49 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/build.rs @@ -0,0 +1,25 @@ +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::providers::artcraft::build_common::{ + build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport, +}; +use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_minimax_h3(builder: GenerateVideoRequestBuilder) -> Result { + let state = build_artcraft_minimax_h3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftMinimaxH3(state))) +} + +pub(crate) fn build_artcraft_minimax_h3_state(builder: GenerateVideoRequestBuilder) -> Result { + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::MinimaxH3, + SupportedResolutions::Full, + UltraWideSupport::Supported, + )?; + Ok(ArtcraftMinimaxH3RequestState { request }) +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/cost.rs new file mode 100644 index 0000000000..3da3ecf778 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/cost.rs @@ -0,0 +1,158 @@ +use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + +use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +/// Per-second rates in hundredths of a US cent. +const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_840; +const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 2_990; + +/// The first 5 reference images are included; each additional image bills a +/// flat fee (in hundredths of a US cent). +const INCLUDED_REFERENCE_IMAGES: u64 = 5; +const EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS: u64 = 920; + +#[derive(Clone, Debug)] +pub struct ArtcraftMinimaxH3CostState { + pub duration_seconds: u64, + pub is_2k: bool, + pub reference_image_count: u64, +} + +impl ArtcraftMinimaxH3CostState { + pub fn from_request(request: &ArtcraftMinimaxH3RequestState) -> Self { + Self { + // MiniMax H3 defaults None → 5s. + duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5), + is_2k: is_2k(request.request.resolution), + reference_image_count: request.request.reference_image_media_tokens + .as_ref() + .map_or(0, |tokens| tokens.len() as u64), + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let rate = if self.is_2k { + HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC + } else { + LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC + }; + let extra_images = self.reference_image_count.saturating_sub(INCLUDED_REFERENCE_IMAGES); + // Round up to the next whole cent. + let cost_in_usd_cents = + (rate * self.duration_seconds + extra_images * EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS) + .div_ceil(100); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_usd_cents), + 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, + } + } +} + +/// MiniMax H3 offers 768P and 2K output; 1080p and above land on the 2K tier, +/// while 720p and below render at 768P. The default (unset) resolution is 2K. +fn is_2k(resolution: Option) -> bool { + match resolution { + None => true, // defaults to 2K + Some(CommonResolutionEnum::TenEightyP) + | Some(CommonResolutionEnum::OneK) + | Some(CommonResolutionEnum::TwoK) + | Some(CommonResolutionEnum::ThreeK) + | Some(CommonResolutionEnum::FourK) => true, + Some(CommonResolutionEnum::HalfK) + | Some(CommonResolutionEnum::FourEightyP) + | Some(CommonResolutionEnum::SevenTwentyP) => false, + } +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3_state; + use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState; + + #[test] + fn default_resolution_5s_is_150() { assert_eq!(cost_cents(Some(5), None, 0), 150); } + + #[test] + fn default_duration_is_5s() { assert_eq!(cost_cents(None, None, 0), 150); } + + #[test] + fn low_res_5s_is_92() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::SevenTwentyP), 0), 92); } + + #[test] + fn four_eighty_p_lands_on_low_tier() { + assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP), 0), 92); + } + + #[test] + fn low_res_15s_is_276() { assert_eq!(cost_cents(Some(15), Some(RouterResolution::SevenTwentyP), 0), 276); } + + #[test] + fn high_res_10s_is_299() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP), 0), 299); } + + #[test] + fn odd_duration_rounds_up_to_whole_cents() { + // 7s high res: 2990 × 7 = 20930 hundredth-cents → 210 cents. + assert_eq!(cost_cents(Some(7), None, 0), 210); + } + + #[test] + fn five_reference_images_are_included() { + assert_eq!(cost_cents(Some(5), None, 5), cost_cents(Some(5), None, 0)); + } + + #[test] + fn nine_reference_images_add_the_extra_image_fee() { + // 2990 × 5 + 4 × 920 = 18630 hundredth-cents → 187 cents. + assert_eq!(cost_cents(Some(5), None, 9), 187); + } + + #[test] + fn resolution_classifier_defaults_2k() { + assert!(super::is_2k(None)); + assert!(super::is_2k(Some(CommonResolutionEnum::TenEightyP))); + assert!(!super::is_2k(Some(CommonResolutionEnum::SevenTwentyP))); + assert!(!super::is_2k(Some(CommonResolutionEnum::FourEightyP))); + } + + fn cost_cents( + duration_seconds: Option, + resolution: Option, + reference_image_count: usize, + ) -> u64 { + let reference_images = if reference_image_count == 0 { + None + } else { + Some(ImageListRef::MediaFileTokens( + (0..reference_image_count) + .map(|i| MediaFileToken::new(format!("m_test_{i}"))) + .collect(), + )) + }; + let b = GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Artcraft, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + reference_images, + ..Default::default() + }; + let state = build_artcraft_minimax_h3_state(b).unwrap(); + ArtcraftMinimaxH3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/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/providers/artcraft/minimax_h3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/request.rs new file mode 100644 index 0000000000..2216fbbc8f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/minimax_h3/request.rs @@ -0,0 +1,117 @@ +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::providers::artcraft::request_common::send_artcraft_omni_video_request; + +#[derive(Clone, Debug)] +pub struct ArtcraftMinimaxH3RequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftMinimaxH3RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN; + + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + use crate::client::router_artcraft_client::RouterArtcraftClient; + use crate::client::router_client::RouterClient; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A red kite soaring over coastal cliffs, cinematic.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn keyframe_start_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog stands up and walks toward the lake.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // sends a real generation to the Artcraft backend, incurs cost + async fn reference_images_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("Image 1 is the dog. The dog explores a mossy forest clearing.".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_artcraft_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt") + .expect("Failed to read artcraft cookies"); + let cookies = cookies.trim().to_string(); + let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies) + .expect("Failed to parse cookies") + .expect("No credentials found"); + RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials)) + } + + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_artcraft_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 (Artcraft skips draft)"), + }; + + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Artcraft(p) => { + println!("inference_job_token={:?}", p.inference_job_token); + println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs index e960fcf4c5..f3c93b28e0 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/mod.rs @@ -12,6 +12,7 @@ pub mod kling_2_5_turbo_pro; pub mod kling_2_6_pro; pub mod kling_3p0_pro; pub mod kling_3p0_standard; +pub mod minimax_h3; pub mod preview_model; pub mod preview_model_fast; pub(crate) mod request_common; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/build.rs new file mode 100644 index 0000000000..88e18796a7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/build.rs @@ -0,0 +1,543 @@ +use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest; +use fal_client::requests::api::video::reference::minimax_h3::api::{ + MinimaxH3ReferenceToVideoAspectRatio, MinimaxH3ReferenceToVideoRequest, +}; +use fal_client::requests::api::video::text::minimax_h3::api::{ + MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio, MinimaxH3TextToVideoRequest, +}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::router_aspect_ratio::RouterAspectRatio; +use crate::api::router_resolution::RouterResolution; +use crate::api::video_list_ref::VideoListRef; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::optional_url; +use crate::generate::generate_video::providers::fal::minimax_h3::request::{ + FalMinimaxH3Mode, FalMinimaxH3RequestState, +}; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +const MAX_REFERENCE_IMAGES: usize = 9; +const MAX_REFERENCE_VIDEOS: usize = 3; +const MAX_REFERENCE_AUDIO: usize = 3; +const MAX_REFERENCE_FILES: usize = 12; +const MIN_DURATION_SECONDS: u16 = 5; +const MAX_DURATION_SECONDS: u16 = 15; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum PlanAspectRatio { + TwentyOneByNine, + SixteenByNine, + FourByThree, + Square, + ThreeByFour, + NineBySixteen, +} + +pub fn build_fal_minimax_h3( + builder: GenerateVideoRequestBuilder, +) -> Result { + let state = build_fal_minimax_h3_state(builder)?; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalMinimaxH3(state))) +} + +pub(crate) fn build_fal_minimax_h3_state( + builder: GenerateVideoRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let resolution = plan_resolution(builder.resolution, strategy)?; + let duration = plan_duration(builder.duration_seconds, strategy)?; + let prompt = builder.prompt.clone().unwrap_or_default(); + let reference_image_urls = resolve_image_urls(builder.reference_images.clone())?; + let reference_video_urls = resolve_video_urls(builder.reference_videos.clone())?; + let reference_audio_urls = resolve_audio_urls(builder.reference_audio.clone())?; + let start_image_url = optional_url(builder.start_frame.clone())?; + let end_image_url = optional_url(builder.end_frame.clone())?; + + let has_references = !reference_image_urls.is_empty() + || !reference_video_urls.is_empty() + || !reference_audio_urls.is_empty(); + + // Dispatch: any reference asset → reference-to-video; start_frame → image; else → text. + let mode = if has_references { + if start_image_url.is_some() || end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: "MiniMax H3 reference-to-video cannot also accept a start_frame or end_frame".to_string(), + })); + } + check_reference_counts( + reference_image_urls.len(), + reference_video_urls.len(), + reference_audio_urls.len(), + )?; + FalMinimaxH3Mode::ReferenceToVideo(MinimaxH3ReferenceToVideoRequest { + prompt, + reference_image_urls: non_empty(reference_image_urls), + reference_video_urls: non_empty(reference_video_urls), + reference_audio_urls: non_empty(reference_audio_urls), + duration, + resolution, + aspect_ratio: aspect_ratio.map(to_reference_aspect_ratio), + }) + } else if let Some(image_url) = start_image_url { + // Image-to-video has no aspect_ratio input — the output follows the start + // frame — so any requested aspect ratio is silently dropped here. + FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest { + prompt, + image_url, + end_image_url, + duration, + resolution, + }) + } else { + if end_image_url.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "end_frame", + value: "MiniMax H3 requires a start_frame when end_frame is provided".to_string(), + })); + } + FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest { + prompt, + duration, + resolution, + aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio), + }) + }; + + Ok(FalMinimaxH3RequestState { mode }) +} + +// ── Input helpers ── + +/// fal's reference limits: at most 9 images, 3 videos, 3 audio clips, and 12 +/// files overall; audio cannot be the only reference input. +fn check_reference_counts( + image_count: usize, + video_count: usize, + audio_count: usize, +) -> Result<(), ArtcraftRouterError> { + if image_count > MAX_REFERENCE_IMAGES { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_IMAGES} reference images, got {image_count}"), + })); + } + if video_count > MAX_REFERENCE_VIDEOS { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_videos", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_VIDEOS} reference videos, got {video_count}"), + })); + } + if audio_count > MAX_REFERENCE_AUDIO { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_audio", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_AUDIO} reference audio clips, got {audio_count}"), + })); + } + let total = image_count + video_count + audio_count; + if total > MAX_REFERENCE_FILES { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_images", + value: format!("MiniMax H3 supports at most {MAX_REFERENCE_FILES} reference files overall, got {total}"), + })); + } + if audio_count > 0 && image_count == 0 && video_count == 0 { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "reference_audio", + value: "MiniMax H3 requires at least one reference image or video alongside reference audio".to_string(), + })); + } + Ok(()) +} + +fn resolve_image_urls( + reference_images: Option, +) -> Result, ArtcraftRouterError> { + match reference_images { + None => Ok(vec![]), + Some(ImageListRef::Urls(urls)) => Ok(urls), + Some(ImageListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn resolve_video_urls( + reference_videos: Option, +) -> Result, ArtcraftRouterError> { + match reference_videos { + None => Ok(vec![]), + Some(VideoListRef::Urls(urls)) => Ok(urls), + Some(VideoListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn resolve_audio_urls( + reference_audio: Option, +) -> Result, ArtcraftRouterError> { + match reference_audio { + None => Ok(vec![]), + Some(AudioListRef::Urls(urls)) => Ok(urls), + Some(AudioListRef::MediaFileTokens(_)) => { + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + } + } +} + +fn non_empty(urls: Vec) -> Option> { + if urls.is_empty() { None } else { Some(urls) } +} + +// ── Plan helpers ── + +/// `None` (including the `Auto*` ratios) leaves the field unset so fal's +/// per-mode default applies: 16:9 for text-to-video, adaptive for +/// reference-to-video. +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + use PlanAspectRatio as Ar; + match aspect_ratio { + None + | Some(RouterAspectRatio::Auto) + | Some(RouterAspectRatio::Auto2k) + | Some(RouterAspectRatio::Auto3k) + | Some(RouterAspectRatio::Auto4k) => Ok(None), + + Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)), + Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)), + Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)), + Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)), + Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)), + Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", other), + })) + } + _ => Ok(Some(Ar::SixteenByNine)), + }, + } +} + +/// MiniMax H3 offers exactly two output sizes: 768P and 2K. +fn plan_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + // 480p/720p round up to 768P, the smallest size H3 offers. + Some(RouterResolution::FourEightyP) + | Some(RouterResolution::SevenTwentyP) => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)), + Some(RouterResolution::TenEightyP) + | Some(RouterResolution::TwoK) => Ok(Some(MinimaxH3Resolution::TwoK)), + + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(MinimaxH3Resolution::TwoK)), + RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)), + }, + } +} + +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match duration_seconds { + None => Ok(None), + Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => { + Ok(Some(seconds as u8)) + } + Some(other) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", other), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8)) + } + }, + } +} + +// ── Leaf converters ── + +fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> MinimaxH3TextToVideoAspectRatio { + match a { + PlanAspectRatio::TwentyOneByNine => MinimaxH3TextToVideoAspectRatio::TwentyOneByNine, + PlanAspectRatio::SixteenByNine => MinimaxH3TextToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::FourByThree => MinimaxH3TextToVideoAspectRatio::FourByThree, + PlanAspectRatio::Square => MinimaxH3TextToVideoAspectRatio::Square, + PlanAspectRatio::ThreeByFour => MinimaxH3TextToVideoAspectRatio::ThreeByFour, + PlanAspectRatio::NineBySixteen => MinimaxH3TextToVideoAspectRatio::NineBySixteen, + } +} + +fn to_reference_aspect_ratio(a: PlanAspectRatio) -> MinimaxH3ReferenceToVideoAspectRatio { + match a { + PlanAspectRatio::TwentyOneByNine => MinimaxH3ReferenceToVideoAspectRatio::TwentyOneByNine, + PlanAspectRatio::SixteenByNine => MinimaxH3ReferenceToVideoAspectRatio::SixteenByNine, + PlanAspectRatio::FourByThree => MinimaxH3ReferenceToVideoAspectRatio::FourByThree, + PlanAspectRatio::Square => MinimaxH3ReferenceToVideoAspectRatio::Square, + PlanAspectRatio::ThreeByFour => MinimaxH3ReferenceToVideoAspectRatio::ThreeByFour, + PlanAspectRatio::NineBySixteen => MinimaxH3ReferenceToVideoAspectRatio::NineBySixteen, + } +} + +#[cfg(test)] +mod tests { + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_video_model::RouterVideoModel; + + use super::*; + + const START_URL: &str = "https://example.com/start.png"; + const END_URL: &str = "https://example.com/end.png"; + + mod dispatch_tests { + use super::*; + + #[test] + fn no_inputs_picks_t2v() { + let state = build_fal_minimax_h3_state(base_builder()).expect("build"); + assert!(matches!(state.mode, FalMinimaxH3Mode::TextToVideo(_))); + } + + #[test] + fn start_frame_picks_i2v() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + let state = build_fal_minimax_h3_state(b).expect("build"); + assert!(matches!(state.mode, FalMinimaxH3Mode::ImageToVideo(_))); + } + + #[test] + fn start_and_end_frame_picks_i2v_with_end_image_url() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::ImageToVideo(req) = state.mode else { + panic!("expected ImageToVideo"); + }; + assert_eq!(req.image_url, START_URL); + assert_eq!(req.end_image_url.as_deref(), Some(END_URL)); + } + + #[test] + fn end_frame_without_start_frame_errors() { + let mut b = base_builder(); + b.end_frame = Some(ImageRef::Url(END_URL.to_string())); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn reference_images_pick_reference_to_video() { + let state = build_fal_minimax_h3_state(builder_with_reference_images(3)).expect("build"); + let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else { + panic!("expected ReferenceToVideo"); + }; + assert_eq!(req.reference_image_urls.map(|urls| urls.len()), Some(3)); + assert_eq!(req.reference_video_urls, None); + assert_eq!(req.reference_audio_urls, None); + } + + #[test] + fn reference_videos_pick_reference_to_video() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()])); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else { + panic!("expected ReferenceToVideo"); + }; + assert_eq!(req.reference_video_urls.map(|urls| urls.len()), Some(1)); + } + + #[test] + fn ten_reference_images_error() { + assert!(build_fal_minimax_h3_state(builder_with_reference_images(10)).is_err()); + } + + #[test] + fn four_reference_videos_error() { + let mut b = base_builder(); + b.reference_videos = Some(VideoListRef::Urls( + (0..4).map(|i| format!("https://example.com/v-{i}.mp4")).collect(), + )); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn thirteen_reference_files_error() { + // 9 images + 3 videos + 1 audio = 13 > 12 total. + let mut b = builder_with_reference_images(9); + b.reference_videos = Some(VideoListRef::Urls( + (0..3).map(|i| format!("https://example.com/v-{i}.mp4")).collect(), + )); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn audio_only_reference_errors() { + let mut b = base_builder(); + b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()])); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn reference_images_with_start_frame_error() { + let mut b = builder_with_reference_images(2); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + assert!(build_fal_minimax_h3_state(b).is_err()); + } + } + + mod plan_tests { + use super::*; + + #[test] + fn duration_20_errors_under_error_out() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn duration_20_clamps_to_15_under_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(20); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.duration, Some(15)); + } + + #[test] + fn duration_3_clamps_to_5_under_pay_less() { + let mut b = base_builder(); + b.duration_seconds = Some(3); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade; + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.duration, Some(5)); + } + + #[test] + fn resolution_480p_and_720p_map_to_768p() { + for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] { + let mut b = base_builder(); + b.resolution = Some(res); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.resolution, Some(MinimaxH3Resolution::SevenSixtyEightP), "res={res:?}"); + } + } + + #[test] + fn resolution_1080p_and_2k_map_to_2k() { + for res in [RouterResolution::TenEightyP, RouterResolution::TwoK] { + let mut b = base_builder(); + b.resolution = Some(res); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.resolution, Some(MinimaxH3Resolution::TwoK), "res={res:?}"); + } + } + + #[test] + fn resolution_4k_errors_under_error_out() { + let mut b = base_builder(); + b.resolution = Some(RouterResolution::FourK); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + assert!(build_fal_minimax_h3_state(b).is_err()); + } + + #[test] + fn twenty_one_by_nine_is_supported() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.aspect_ratio, Some(MinimaxH3TextToVideoAspectRatio::TwentyOneByNine)); + } + + #[test] + fn auto_aspect_ratio_is_left_unset() { + let mut b = base_builder(); + b.aspect_ratio = Some(RouterAspectRatio::Auto); + let state = build_fal_minimax_h3_state(b).expect("build"); + let FalMinimaxH3Mode::TextToVideo(req) = state.mode else { + panic!("expected TextToVideo"); + }; + assert_eq!(req.aspect_ratio, None); + } + + #[test] + fn i2v_aspect_ratio_is_silently_dropped() { + let mut b = base_builder(); + b.start_frame = Some(ImageRef::Url(START_URL.to_string())); + b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); + b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut; + // Image-to-video has no aspect_ratio field; the request still builds. + let state = build_fal_minimax_h3_state(b).expect("build"); + assert!(matches!(state.mode, FalMinimaxH3Mode::ImageToVideo(_))); + } + } + + fn base_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + ..Default::default() + } + } + + fn builder_with_reference_images(count: usize) -> GenerateVideoRequestBuilder { + let urls = (0..count) + .map(|i| format!("https://example.com/ref-{}.png", i)) + .collect(); + let mut b = base_builder(); + b.reference_images = Some(ImageListRef::Urls(urls)); + b + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/cost.rs new file mode 100644 index 0000000000..01c5238638 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/cost.rs @@ -0,0 +1,129 @@ +use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; + +use crate::generate::generate_video::providers::fal::minimax_h3::request::{ + FalMinimaxH3Mode, FalMinimaxH3RequestState, +}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + +#[derive(Clone, Debug)] +pub struct FalMinimaxH3CostState { + pub cost_in_usd_cents: u64, +} + +impl FalMinimaxH3CostState { + pub fn from_request(request: &FalMinimaxH3RequestState) -> Self { + // Cost math is owned by fal_client's per-endpoint + // `FalRequestCostCalculator` implementations. The router state just + // forwards the result so router cost ≡ fal_client cost by construction. + let cost_in_usd_cents = match &request.mode { + FalMinimaxH3Mode::TextToVideo(req) => req.calculate_cost_in_cents(), + FalMinimaxH3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(), + FalMinimaxH3Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(), + }; + Self { cost_in_usd_cents } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + VideoGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.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::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::router_provider::RouterProvider; + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3_state; + + use super::*; + + // Pricing (from fal_client's minimax_h3 cost modules): + // 768P: $0.16/sec → 5s = 80¢ + // 2K: $0.26/sec → 5s = 130¢, 10s = 260¢ + // Reference-to-video additionally bills 8¢ per reference image beyond the + // first 5. fal defaults when unset: duration = 5s, resolution = 2K. + + #[test] + fn t2v_default_settings_is_130() { + assert_eq!(cost_cents(base_builder(None, None)), 130); + } + + #[test] + fn t2v_5s_768p_is_80() { + // RouterResolution::SevenTwentyP maps to H3's 768P. + assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 80); + } + + #[test] + fn t2v_10s_2k_is_260() { + assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 260); + } + + #[test] + fn i2v_5s_768p_is_80() { + let mut b = base_builder(Some(5), Some(RouterResolution::FourEightyP)); + b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string())); + assert_eq!(cost_cents(b), 80); + } + + #[test] + fn reference_five_images_5s_2k_is_130() { + assert_eq!(cost_cents(builder_with_references(5, Some(RouterResolution::TenEightyP))), 130); + } + + #[test] + fn reference_nine_images_5s_2k_is_162() { + // 130¢ video + 4 extra images × 8¢ = 162¢. + assert_eq!(cost_cents(builder_with_references(9, Some(RouterResolution::TenEightyP))), 162); + } + + #[test] + fn estimate_cost_forwards_cents() { + let estimate = FalMinimaxH3CostState { cost_in_usd_cents: 130 }.estimate_cost(); + assert_eq!(estimate.cost_in_usd_cents, Some(130)); + assert_eq!(estimate.cost_in_credits, Some(130)); + assert!(!estimate.is_free); + } + + fn base_builder( + duration_seconds: Option, + resolution: Option, + ) -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::MinimaxH3, + provider: RouterProvider::Fal, + prompt: Some("test".to_string()), + duration_seconds, + resolution, + ..Default::default() + } + } + + fn builder_with_references( + image_count: usize, + resolution: Option, + ) -> GenerateVideoRequestBuilder { + let urls = (0..image_count) + .map(|i| format!("https://example.com/ref-{}.png", i)) + .collect(); + let mut b = base_builder(Some(5), resolution); + b.reference_images = Some(ImageListRef::Urls(urls)); + b + } + + fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 { + let state = build_fal_minimax_h3_state(builder).expect("build"); + FalMinimaxH3CostState::from_request(&state).cost_in_usd_cents + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/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/providers/fal/minimax_h3/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/request.rs new file mode 100644 index 0000000000..b466624d48 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/minimax_h3/request.rs @@ -0,0 +1,140 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest; +use fal_client::requests::api::video::reference::minimax_h3::api::MinimaxH3ReferenceToVideoRequest; +use fal_client::requests::api::video::text::minimax_h3::api::MinimaxH3TextToVideoRequest; +use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video::generate_video_response::{ + FalVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub enum FalMinimaxH3Mode { + TextToVideo(MinimaxH3TextToVideoRequest), + ImageToVideo(MinimaxH3ImageToVideoRequest), + ReferenceToVideo(MinimaxH3ReferenceToVideoRequest), +} + +#[derive(Clone, Debug)] +pub struct FalMinimaxH3RequestState { + pub mode: FalMinimaxH3Mode, +} + +impl FalMinimaxH3RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + match &self.mode { + FalMinimaxH3Mode::TextToVideo(request) => send_request(request, client).await, + FalMinimaxH3Mode::ImageToVideo(request) => send_request(request, client).await, + FalMinimaxH3Mode::ReferenceToVideo(request) => send_request(request, client).await, + } + } +} + +// ── Helpers ── + +async fn send_request(request: &T, client: &RouterFalClient) -> Result +where + T: FalEndpoint + Clone + Debug + Send + Sync + 'static, +{ + let outbound: Arc = Arc::new(request.clone()); + let payload = send_fal_request(request, client).await?; + Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload { + request_id: payload.request_id, + gateway_request_id: payload.gateway_request_id, + maybe_status_url: payload.status_url, + maybe_response_url: payload.response_url, + maybe_outbound_request: Some(outbound), + })) +} + +struct FalResponseIds { + request_id: Option, + gateway_request_id: Option, + status_url: Option, + response_url: Option, +} + +async fn send_fal_request( + request: &T, + client: &RouterFalClient, +) -> Result { + if let Some(webhook_url) = &client.webhook_url { + let response = request.send_webhook_request(&client.api_key, webhook_url).await?; + Ok(FalResponseIds { + request_id: response.request_id, + gateway_request_id: response.gateway_request_id, + status_url: None, + response_url: None, + }) + } else { + let response = request.send_queue_request(&client.api_key).await?; + Ok(FalResponseIds { + request_id: Some(response.request_id), + gateway_request_id: None, + status_url: Some(response.status_url), + response_url: Some(response.response_url), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::video::text::minimax_h3::api::{ + MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_video_webhook() { + let state = FalMinimaxH3RequestState { + mode: FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest { + prompt: "a calm lake at sunrise".to_string(), + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::SixteenByNine), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_image_to_video_webhook() { + let state = FalMinimaxH3RequestState { + mode: FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest { + prompt: "the lake comes alive".to_string(), + image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(), + end_image_url: None, + duration: Some(5), + resolution: Some(MinimaxH3Resolution::SevenSixtyEightP), + }), + }; + let response = state.send(&client_with_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } + + // ── Helpers ── + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new_with_webhook( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs index 49f1ec503a..3cf222133c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/fal/mod.rs @@ -5,6 +5,7 @@ pub mod kling_2_5_turbo_pro; pub mod kling_2_6_pro; pub mod kling_3p0_pro; pub mod kling_3p0_standard; +pub mod minimax_h3; pub mod seedance_1p0_lite; pub mod seedance_1p5_pro; pub mod sora_2; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs index 49d8eeac61..3fcea77bf0 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/video_generation_request.rs @@ -23,6 +23,8 @@ use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::cost::A use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState; use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::cost::ArtcraftKling3p0StandardCostState; use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState; +use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState; +use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState; use crate::generate::generate_video::providers::artcraft::preview_model::cost::ArtcraftPreviewModelCostState; use crate::generate::generate_video::providers::artcraft::preview_model::request::ArtcraftPreviewModelRequestState; use crate::generate::generate_video::providers::artcraft::preview_model_fast::cost::ArtcraftPreviewModelFastCostState; @@ -89,6 +91,8 @@ use crate::generate::generate_video::providers::fal::kling_3p0_pro::cost::FalKli use crate::generate::generate_video::providers::fal::kling_3p0_pro::request::FalKling3p0ProRequestState; use crate::generate::generate_video::providers::fal::kling_3p0_standard::cost::FalKling3p0StandardCostState; use crate::generate::generate_video::providers::fal::kling_3p0_standard::request::FalKling3p0StandardRequestState; +use crate::generate::generate_video::providers::fal::minimax_h3::cost::FalMinimaxH3CostState; +use crate::generate::generate_video::providers::fal::minimax_h3::request::FalMinimaxH3RequestState; use crate::generate::generate_video::providers::fal::seedance_1p0_lite::cost::FalSeedance10LiteCostState; use crate::generate::generate_video::providers::fal::seedance_1p0_lite::request::FalSeedance10LiteRequestState; use crate::generate::generate_video::providers::fal::sora_2::cost::FalSora2CostState; @@ -144,6 +148,7 @@ pub enum VideoGenerationRequest { ArtcraftKling2p6Pro(ArtcraftKling2p6ProRequestState), ArtcraftKling3p0Pro(ArtcraftKling3p0ProRequestState), ArtcraftKling3p0Standard(ArtcraftKling3p0StandardRequestState), + ArtcraftMinimaxH3(ArtcraftMinimaxH3RequestState), ArtcraftPreviewModel(ArtcraftPreviewModelRequestState), ArtcraftPreviewModelFast(ArtcraftPreviewModelFastRequestState), ArtcraftSeedance10Lite(ArtcraftSeedance10LiteRequestState), @@ -177,6 +182,7 @@ pub enum VideoGenerationRequest { FalKling2p6Pro(FalKling2p6ProRequestState), FalKling3p0Pro(FalKling3p0ProRequestState), FalKling3p0Standard(FalKling3p0StandardRequestState), + FalMinimaxH3(FalMinimaxH3RequestState), FalSeedance10Lite(FalSeedance10LiteRequestState), FalSeedance1p5Pro(FalSeedance1p5ProRequestState), FalSora2(FalSora2RequestState), @@ -214,6 +220,7 @@ impl VideoGenerationRequest { Self::ArtcraftKling2p6Pro(_) => RouterProvider::Artcraft, Self::ArtcraftKling3p0Pro(_) => RouterProvider::Artcraft, Self::ArtcraftKling3p0Standard(_) => RouterProvider::Artcraft, + Self::ArtcraftMinimaxH3(_) => RouterProvider::Artcraft, Self::ArtcraftPreviewModel(_) => RouterProvider::Artcraft, Self::ArtcraftPreviewModelFast(_) => RouterProvider::Artcraft, Self::ArtcraftSeedance10Lite(_) => RouterProvider::Artcraft, @@ -247,6 +254,7 @@ impl VideoGenerationRequest { Self::FalKling2p6Pro(_) => RouterProvider::Fal, Self::FalKling3p0Pro(_) => RouterProvider::Fal, Self::FalKling3p0Standard(_) => RouterProvider::Fal, + Self::FalMinimaxH3(_) => RouterProvider::Fal, Self::FalSeedance10Lite(_) => RouterProvider::Fal, Self::FalSeedance1p5Pro(_) => RouterProvider::Fal, Self::FalSora2(_) => RouterProvider::Fal, @@ -284,6 +292,7 @@ impl VideoGenerationRequest { VideoGenerationRequest::ArtcraftKling2p6Pro(request) => Ok(ArtcraftKling2p6ProCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftKling3p0Pro(request) => Ok(ArtcraftKling3p0ProCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftKling3p0Standard(request) => Ok(ArtcraftKling3p0StandardCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftMinimaxH3(request) => Ok(ArtcraftMinimaxH3CostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftPreviewModel(request) => Ok(ArtcraftPreviewModelCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftPreviewModelFast(request) => Ok(ArtcraftPreviewModelFastCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftSeedance10Lite(request) => Ok(ArtcraftSeedance10LiteCostState::from_request(request).estimate_cost()), @@ -317,6 +326,7 @@ impl VideoGenerationRequest { VideoGenerationRequest::FalKling2p6Pro(request) => Ok(FalKling2p6ProCostState::from_request(request).estimate_cost()), VideoGenerationRequest::FalKling3p0Pro(request) => Ok(FalKling3p0ProCostState::from_request(request).estimate_cost()), VideoGenerationRequest::FalKling3p0Standard(request) => Ok(FalKling3p0StandardCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::FalMinimaxH3(request) => Ok(FalMinimaxH3CostState::from_request(request).estimate_cost()), VideoGenerationRequest::FalSeedance10Lite(request) => Ok(FalSeedance10LiteCostState::from_request(request).estimate_cost()), VideoGenerationRequest::FalSeedance1p5Pro(request) => Ok(FalSeedance1p5ProCostState::from_request(request).estimate_cost()), VideoGenerationRequest::FalSora2(request) => Ok(FalSora2CostState::from_request(request).estimate_cost()), @@ -385,6 +395,10 @@ impl VideoGenerationRequest { let client_ref = client.get_artcraft_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::ArtcraftMinimaxH3(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, VideoGenerationRequest::ArtcraftPreviewModel(request) => { let client_ref = client.get_artcraft_client_ref()?; request.send(client_ref).await @@ -517,6 +531,10 @@ impl VideoGenerationRequest { let client_ref = client.get_fal_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::FalMinimaxH3(request) => { + let client_ref = client.get_fal_client_ref()?; + request.send(client_ref).await + }, VideoGenerationRequest::FalSeedance10Lite(request) => { let client_ref = client.get_fal_client_ref()?; request.send(client_ref).await 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 109c2fd3e5..3b4009773a 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 @@ -61,6 +61,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option Some(CommonModelType::Seedance2p0), RouterVideoModel::Seedance2p0Fast => Some(CommonModelType::Seedance2p0Fast), RouterVideoModel::HappyHorse1p0 => Some(CommonModelType::HappyHorse1p0), + RouterVideoModel::MinimaxH3 => Some(CommonModelType::MinimaxH3), RouterVideoModel::Sora2 => Some(CommonModelType::Sora2), RouterVideoModel::Sora2Pro => Some(CommonModelType::Sora2Pro), RouterVideoModel::Veo2 => Some(CommonModelType::Veo2), 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 3f3b17fea1..7905cfdb54 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 @@ -180,6 +180,8 @@ pub enum InferenceModelType { Kling3p0Pro, #[serde(rename = "happy_horse_1p0")] HappyHorse1p0, + #[serde(rename = "minimax_h3")] + MinimaxH3, #[serde(rename = "seedance_1p0_lite")] Seedance10Lite, #[serde(rename = "seedance_1p0_pro")] @@ -366,6 +368,7 @@ impl InferenceModelType { Self::Kling3p0Standard => "kling_3p0_standard", Self::Kling3p0Pro => "kling_3p0_pro", Self::HappyHorse1p0 => "happy_horse_1p0", + Self::MinimaxH3 => "minimax_h3", Self::Seedance10Lite => "seedance_1p0_lite", Self::Seedance10Pro => "seedance_1p0_pro", Self::Seedance1p5Pro => "seedance_1p5_pro", @@ -497,6 +500,7 @@ impl InferenceModelType { "kling_3p0_standard" => Ok(Self::Kling3p0Standard), "kling_3p0_pro" => Ok(Self::Kling3p0Pro), "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "minimax_h3" => Ok(Self::MinimaxH3), "seedance_1p0_lite" => Ok(Self::Seedance10Lite), "seedance_1p0_pro" => Ok(Self::Seedance10Pro), "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), @@ -632,6 +636,7 @@ impl InferenceModelType { Self::Kling3p0Standard, Self::Kling3p0Pro, Self::HappyHorse1p0, + Self::MinimaxH3, Self::Seedance10Lite, Self::Seedance10Pro, Self::Seedance1p5Pro, @@ -750,6 +755,7 @@ impl InferenceModelType { CommonModelType::Kling3p0Standard => Self::Kling3p0Standard, CommonModelType::Kling3p0Pro => Self::Kling3p0Pro, CommonModelType::HappyHorse1p0 => Self::HappyHorse1p0, + CommonModelType::MinimaxH3 => Self::MinimaxH3, CommonModelType::Seedance10Lite => Self::Seedance10Lite, CommonModelType::Seedance10Pro => Self::Seedance10Pro, CommonModelType::Seedance1p5Pro => Self::Seedance1p5Pro, @@ -890,6 +896,7 @@ mod tests { assert_serialization(InferenceModelType::Kling3p0Standard, "kling_3p0_standard"); assert_serialization(InferenceModelType::Kling3p0Pro, "kling_3p0_pro"); assert_serialization(InferenceModelType::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(InferenceModelType::MinimaxH3, "minimax_h3"); assert_serialization(InferenceModelType::Seedance10Lite, "seedance_1p0_lite"); assert_serialization(InferenceModelType::Seedance10Pro, "seedance_1p0_pro"); assert_serialization(InferenceModelType::Seedance1p5Pro, "seedance_1p5_pro"); @@ -1017,6 +1024,7 @@ mod tests { assert_eq!(InferenceModelType::Kling3p0Standard.to_str(), "kling_3p0_standard"); assert_eq!(InferenceModelType::Kling3p0Pro.to_str(), "kling_3p0_pro"); assert_eq!(InferenceModelType::HappyHorse1p0.to_str(), "happy_horse_1p0"); + assert_eq!(InferenceModelType::MinimaxH3.to_str(), "minimax_h3"); assert_eq!(InferenceModelType::Seedance10Lite.to_str(), "seedance_1p0_lite"); assert_eq!(InferenceModelType::Seedance10Pro.to_str(), "seedance_1p0_pro"); assert_eq!(InferenceModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro"); @@ -1146,6 +1154,7 @@ mod tests { assert_eq!(InferenceModelType::from_str("kling_3p0_standard").unwrap(), InferenceModelType::Kling3p0Standard); assert_eq!(InferenceModelType::from_str("kling_3p0_pro").unwrap(), InferenceModelType::Kling3p0Pro); assert_eq!(InferenceModelType::from_str("happy_horse_1p0").unwrap(), InferenceModelType::HappyHorse1p0); + assert_eq!(InferenceModelType::from_str("minimax_h3").unwrap(), InferenceModelType::MinimaxH3); assert_eq!(InferenceModelType::from_str("seedance_1p0_lite").unwrap(), InferenceModelType::Seedance10Lite); assert_eq!(InferenceModelType::from_str("seedance_1p0_pro").unwrap(), InferenceModelType::Seedance10Pro); assert_eq!(InferenceModelType::from_str("seedance_1p5_pro").unwrap(), InferenceModelType::Seedance1p5Pro); 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 3daf1f3d03..42a3d53801 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 @@ -151,6 +151,8 @@ pub enum CommonModelType { Kling3p0Pro, #[serde(rename = "happy_horse_1p0")] HappyHorse1p0, + #[serde(rename = "minimax_h3")] + MinimaxH3, #[serde(rename = "seedance_1p0_lite")] Seedance10Lite, #[serde(rename = "seedance_1p0_pro")] @@ -321,6 +323,7 @@ impl CommonModelType { Self::Kling3p0Standard => "kling_3p0_standard", Self::Kling3p0Pro => "kling_3p0_pro", Self::HappyHorse1p0 => "happy_horse_1p0", + Self::MinimaxH3 => "minimax_h3", Self::Seedance10Lite => "seedance_1p0_lite", Self::Seedance10Pro => "seedance_1p0_pro", Self::Seedance1p5Pro => "seedance_1p5_pro", @@ -435,6 +438,7 @@ impl CommonModelType { "kling_3p0_standard" => Ok(Self::Kling3p0Standard), "kling_3p0_pro" => Ok(Self::Kling3p0Pro), "happy_horse_1p0" => Ok(Self::HappyHorse1p0), + "minimax_h3" => Ok(Self::MinimaxH3), "seedance_1p0_lite" => Ok(Self::Seedance10Lite), "seedance_1p0_pro" => Ok(Self::Seedance10Pro), "seedance_1p5_pro" => Ok(Self::Seedance1p5Pro), @@ -553,6 +557,7 @@ impl CommonModelType { Self::Kling3p0Standard, Self::Kling3p0Pro, Self::HappyHorse1p0, + Self::MinimaxH3, Self::Seedance10Lite, Self::Seedance10Pro, Self::Seedance1p5Pro, @@ -668,6 +673,7 @@ impl CommonModelType { Self::Kling3p0Standard => CommonModelClass::Video, Self::Kling3p0Pro => CommonModelClass::Video, Self::HappyHorse1p0 => CommonModelClass::Video, + Self::MinimaxH3 => CommonModelClass::Video, Self::Seedance10Lite => CommonModelClass::Video, Self::Seedance10Pro => CommonModelClass::Video, Self::Seedance1p5Pro => CommonModelClass::Video, @@ -792,6 +798,7 @@ mod tests { assert_serialization(CommonModelType::Kling3p0Standard, "kling_3p0_standard"); assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro"); assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(CommonModelType::MinimaxH3, "minimax_h3"); assert_serialization(CommonModelType::Seedance10Lite, "seedance_1p0_lite"); assert_serialization(CommonModelType::Seedance10Pro, "seedance_1p0_pro"); assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro"); @@ -901,6 +908,7 @@ mod tests { assert_eq!(CommonModelType::Kling3p0Standard.to_str(), "kling_3p0_standard"); assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro"); assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0"); + assert_eq!(CommonModelType::MinimaxH3.to_str(), "minimax_h3"); assert_eq!(CommonModelType::Seedance10Lite.to_str(), "seedance_1p0_lite"); assert_eq!(CommonModelType::Seedance10Pro.to_str(), "seedance_1p0_pro"); assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro"); @@ -1012,6 +1020,7 @@ mod tests { assert_eq!(CommonModelType::from_str("kling_3p0_standard").unwrap(), CommonModelType::Kling3p0Standard); assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro); assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0); + assert_eq!(CommonModelType::from_str("minimax_h3").unwrap(), CommonModelType::MinimaxH3); assert_eq!(CommonModelType::from_str("seedance_1p0_lite").unwrap(), CommonModelType::Seedance10Lite); assert_eq!(CommonModelType::from_str("seedance_1p0_pro").unwrap(), CommonModelType::Seedance10Pro); assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro); @@ -1071,7 +1080,7 @@ mod tests { #[test] fn all_variants() { let mut variants = CommonModelType::all_variants(); - assert_eq!(variants.len(), 99); + assert_eq!(variants.len(), 100); // Image models assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell)); @@ -1123,6 +1132,7 @@ mod tests { assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Standard)); assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro)); assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0)); + assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3)); assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Lite)); assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Pro)); assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro)); 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 416c965c7b..6bb5197139 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 @@ -41,6 +41,9 @@ pub enum CommonVideoModel { #[serde(rename = "happy_horse_1p0")] HappyHorse1p0, + #[serde(rename = "minimax_h3")] + MinimaxH3, + #[serde(rename = "seedance_1p0_lite")] Seedance10Lite, @@ -139,6 +142,7 @@ impl CommonVideoModel { Self::Kling3p0Standard => CommonModelType::Kling3p0Standard, Self::Kling3p0Pro => CommonModelType::Kling3p0Pro, Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0, + Self::MinimaxH3 => CommonModelType::MinimaxH3, Self::Seedance10Lite => CommonModelType::Seedance10Lite, Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro, Self::Seedance2p0 => CommonModelType::Seedance2p0, @@ -188,6 +192,7 @@ mod tests { assert_serialization(CommonVideoModel::Kling3p0Standard, "kling_3p0_standard"); assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro"); assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0"); + assert_serialization(CommonVideoModel::MinimaxH3, "minimax_h3"); assert_serialization(CommonVideoModel::Seedance10Lite, "seedance_1p0_lite"); assert_serialization(CommonVideoModel::Seedance1p5Pro, "seedance_1p5_pro"); assert_serialization(CommonVideoModel::Seedance2p0, "seedance_2p0"); @@ -230,6 +235,7 @@ mod tests { ("kling_3p0_standard", CommonVideoModel::Kling3p0Standard), ("kling_3p0_pro", CommonVideoModel::Kling3p0Pro), ("happy_horse_1p0", CommonVideoModel::HappyHorse1p0), + ("minimax_h3", CommonVideoModel::MinimaxH3), ("seedance_1p0_lite", CommonVideoModel::Seedance10Lite), ("seedance_1p5_pro", CommonVideoModel::Seedance1p5Pro), ("seedance_2p0", CommonVideoModel::Seedance2p0), @@ -279,6 +285,7 @@ mod tests { CommonVideoModel::Kling3p0Standard, CommonVideoModel::Kling3p0Pro, CommonVideoModel::HappyHorse1p0, + CommonVideoModel::MinimaxH3, CommonVideoModel::Seedance10Lite, CommonVideoModel::Seedance1p5Pro, CommonVideoModel::Seedance2p0, @@ -327,6 +334,7 @@ mod tests { (CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard), (CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro), (CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0), + (CommonVideoModel::MinimaxH3, CommonModelType::MinimaxH3), (CommonVideoModel::Seedance10Lite, CommonModelType::Seedance10Lite), (CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro), (CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0), diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/minimax_video_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/minimax_video_models.rs new file mode 100644 index 0000000000..a357767d3c --- /dev/null +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/minimax_video_models.rs @@ -0,0 +1,50 @@ +use artcraft_api_defs::omni_gen::models::omni_gen_video_models::OmniGenVideoModelDetails; +use enums::common::generation::common_aspect_ratio::CommonAspectRatio; +use enums::common::generation::common_resolution::CommonResolution; +use enums::common::generation::common_video_model::CommonVideoModel; +use enums::common::generation::model_creator::ModelCreator; + +/// MiniMax (Hailuo) video models. +pub fn minimax_video_models() -> Vec { + let mut models = Vec::new(); + + models.push(OmniGenVideoModelDetails { + model: CommonVideoModel::MinimaxH3, + model_creator: Some(ModelCreator::Hailuo), + full_name: Some("MiniMax H3".to_string()), + text_prompt_supported: Some(true), + text_prompt_max_length: Some(7000), + starting_keyframe_supported: Some(true), + ending_keyframe_supported: Some(true), + image_references_supported: Some(true), + image_references_max: Some(9), + video_references_supported: Some(true), + video_references_max: Some(3), + video_references_max_total_duration_seconds: Some(15), + audio_references_supported: Some(true), + audio_references_max: Some(3), + audio_references_max_total_duration_seconds: Some(15), + aspect_ratio_options: Some(vec![ + CommonAspectRatio::WideTwentyOneByNine, + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::WideFourByThree, + CommonAspectRatio::Square, + CommonAspectRatio::TallThreeByFour, + CommonAspectRatio::TallNineBySixteen, + ]), + aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine), + // The model renders 768P or 2K; 720p and below land on 768P, 1080p and + // above land on 2K. + resolution_options: Some(vec![ + CommonResolution::SevenTwentyP, + CommonResolution::TwoK, + ]), + resolution_default: Some(CommonResolution::TwoK), + duration_seconds_min: Some(5), + duration_seconds_max: Some(15), + duration_seconds_default: Some(5), + ..Default::default() + }); + + models +} diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs index ac64f0869e..7ffbb5db56 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/by_type/mod.rs @@ -1,6 +1,7 @@ pub mod grok_video_models; pub mod happy_horse_video_models; pub mod kling_video_models; +pub mod minimax_video_models; pub mod seedance_1x_video_models; pub mod seedance_2p0_video_models; pub mod seedance_2p5_video_models; diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs index 1b0a0d06b6..e60ca97e78 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/video_models/video_models.rs @@ -6,6 +6,7 @@ use once_cell::sync::Lazy; use super::by_type::grok_video_models::grok_video_models; use super::by_type::happy_horse_video_models::happy_horse_video_models; use super::by_type::kling_video_models::{kling_disabled_video_models, kling_video_models}; +use super::by_type::minimax_video_models::minimax_video_models; use super::by_type::seedance_1x_video_models::{seedance_1p0_video_models, seedance_1p5_video_models}; use super::by_type::seedance_2p0_video_models::seedance_2p0_video_models; use super::by_type::seedance_2p5_video_models::seedance_2p5_video_models; @@ -30,6 +31,7 @@ fn build_omni_gen_video_models() -> Vec { models.extend(happy_horse_video_models()); models.extend(kling_video_models()); models.extend(kling_disabled_video_models()); + models.extend(minimax_video_models()); models.extend(seedance_1p0_video_models()); models.extend(seedance_1p5_video_models()); models.extend(seedance_2p0_video_models()); @@ -99,6 +101,10 @@ fn build_omni_gen_video_model_providers() -> Vec Result RouterVideoModel::Seedance2p0BytePlusUltraMini, CommonVideoModel::Seedance2p5Preview => RouterVideoModel::Seedance2p5Preview, CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0, + CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3, CommonVideoModel::Sora2 => RouterVideoModel::Sora2, CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro, CommonVideoModel::Veo2 => RouterVideoModel::Veo2, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs index 40ebbbc174..5ba3977a69 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/cost/video/omni_gen_video_cost_handler.rs @@ -53,6 +53,7 @@ mod tests { use actix_http::StatusCode; use actix_web::test::TestRequest; use actix_web::ResponseError; + use enums::common::generation::common_resolution::CommonResolution; use enums::common::generation::common_video_model::CommonVideoModel; use super::*; @@ -74,11 +75,29 @@ mod tests { assert!(response.cost_in_credits.unwrap() > 0); } + /// MiniMax H3 defaults (5s, 2K) quote at 150 credits; the 768P tier + /// (720p and below) quotes at 92 for 5s. + #[tokio::test] + async fn minimax_h3_quotes_by_resolution_tier() { + let default_quote = post_cost_request(base_request(CommonVideoModel::MinimaxH3)) + .await + .expect("minimax h3 default cost estimate should succeed"); + assert_eq!(default_quote.cost_in_credits, Some(150)); + + let mut low_res = base_request(CommonVideoModel::MinimaxH3); + low_res.resolution = Some(CommonResolution::SevenTwentyP); + let low_res_quote = post_cost_request(low_res) + .await + .expect("minimax h3 768P-tier cost estimate should succeed"); + assert_eq!(low_res_quote.cost_in_credits, Some(92)); + } + #[tokio::test] async fn bare_model_gets_a_quote_for_representative_models() { for model in [ CommonVideoModel::GrokImagineVideo, CommonVideoModel::Kling3p0Pro, + CommonVideoModel::MinimaxH3, CommonVideoModel::Seedance2p0, CommonVideoModel::Sora2, CommonVideoModel::Veo3p1,