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 70e21a6258..6dffa0a7ec 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 @@ -32,6 +32,9 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast; use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu; use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::build::build_artcraft_seedance_2p0_bpu_fast; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::build::build_artcraft_seedance_2p0_mini; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::build::build_artcraft_seedance_2p0_bp_mini; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::build::build_artcraft_seedance_2p0_bpu_mini; use crate::generate::generate_video::providers::artcraft::sora_2::build::build_artcraft_sora_2; use crate::generate::generate_video::providers::artcraft::sora_2_pro::build::build_artcraft_sora_2_pro; use crate::generate::generate_video::providers::artcraft::veo_2::build::build_artcraft_veo_2; @@ -176,6 +179,9 @@ impl GenerateVideoRequestBuilder { (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self), (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self), (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraFast) => build_artcraft_seedance_2p0_bpu_fast(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Mini) => build_artcraft_seedance_2p0_mini(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self), + (RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self), (RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self), (RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self), (RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self), 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 13aaf8b336..b6db1c4411 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 @@ -26,6 +26,9 @@ pub mod seedance_2p0_bp; pub mod seedance_2p0_bp_fast; pub mod seedance_2p0_bpu; pub mod seedance_2p0_bpu_fast; +pub mod seedance_2p0_mini; +pub mod seedance_2p0_bp_mini; +pub mod seedance_2p0_bpu_mini; pub mod sora_2; pub mod sora_2_pro; pub mod veo_2; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/build.rs new file mode 100644 index 0000000000..59ac7fb3d3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/build.rs @@ -0,0 +1,225 @@ +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::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade + // to 720p), and all six aspect ratios including 21:9. + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlusMini, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_bitrate::RouterBitrate; + 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::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusMini))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn batch_count_clamped_to_platform_max() { + // The ArtCraft platform caps batch at 4 and downgrades higher requests. + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn ultra_wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(builder_with(|_| {})); + 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(builder_with(|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() { + let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + 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(builder_with(|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(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusMiniRequestState { + let result = build_artcraft_seedance_2p0_bp_mini(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlusMini request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/cost.rs new file mode 100644 index 0000000000..ffad17da03 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/cost.rs @@ -0,0 +1,176 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState; + +pub struct ArtcraftSeedance2p0BytePlusMiniCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusMiniCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusMiniRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see + // seedance_common::seedance_2p0_byteplus_mini_usd_cents. + let usd_cents = seedance_2p0_byteplus_mini_usd_cents( + self.resolution, + self.duration_seconds, + self.batch_count, + self.has_video_reference, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + 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, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // Every combination, driven through the full build pipeline. These prices are + // the supplier's per-combo cost plus a 5% margin, rounded up to whole cents. + + mod pricing_480p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false)); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + for has_ref in [false, true] { + let cost = build_cost(res, dur, batch, has_ref); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + } + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> VideoGenerationCostEstimate { + let reference_videos = with_video_reference.then(|| { + VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())]) + }); + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cents( + resolution: RouterResolution, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> u64 { + build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference) + .cost_in_usd_cents + .unwrap() + } + + fn cents_no_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count, false) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/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/seedance_2p0_bp_mini/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/request.rs new file mode 100644 index 0000000000..74bc9b59d1 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bp_mini/request.rs @@ -0,0 +1,86 @@ +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 ArtcraftSeedance2p0BytePlusMiniRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusMiniRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[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::router_provider::RouterProvider; + 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; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + fn mini_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + 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)"), + }; + request.send_request(&client).await.expect("send_request should succeed") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/build.rs new file mode 100644 index 0000000000..aa4bac1a45 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/build.rs @@ -0,0 +1,225 @@ +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::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade + // to 720p), and all six aspect ratios including 21:9. + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0BytePlusUltraMini, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_bitrate::RouterBitrate; + 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::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusUltraMini))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn batch_count_clamped_to_platform_max() { + // The ArtCraft platform caps batch at 4 and downgrades higher requests. + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn ultra_wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(builder_with(|_| {})); + 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(builder_with(|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() { + let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + 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(builder_with(|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(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusUltraMiniRequestState { + let result = build_artcraft_seedance_2p0_bpu_mini(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)) => state, + _ => panic!("expected ArtcraftSeedance2p0BytePlusUltraMini request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/cost.rs new file mode 100644 index 0000000000..88b0d8cbb3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/cost.rs @@ -0,0 +1,176 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState; + +pub struct ArtcraftSeedance2p0BytePlusUltraMiniCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0BytePlusUltraMiniCostState { + pub fn from_request(request: &ArtcraftSeedance2p0BytePlusUltraMiniRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see + // seedance_common::seedance_2p0_byteplus_mini_usd_cents. + let usd_cents = seedance_2p0_byteplus_mini_usd_cents( + self.resolution, + self.duration_seconds, + self.batch_count, + self.has_video_reference, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + 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, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // Every combination, driven through the full build pipeline. These prices are + // the supplier's per-combo cost plus a 5% margin, rounded up to whole cents. + + mod pricing_480p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false)); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + for has_ref in [false, true] { + let cost = build_cost(res, dur, batch, has_ref); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + } + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> VideoGenerationCostEstimate { + let reference_videos = with_video_reference.then(|| { + VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())]) + }); + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cents( + resolution: RouterResolution, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> u64 { + build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference) + .cost_in_usd_cents + .unwrap() + } + + fn cents_no_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count, false) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/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/seedance_2p0_bpu_mini/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/request.rs new file mode 100644 index 0000000000..c794885c52 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_bpu_mini/request.rs @@ -0,0 +1,86 @@ +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 ArtcraftSeedance2p0BytePlusUltraMiniRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0BytePlusUltraMiniRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[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::router_provider::RouterProvider; + 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; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + fn mini_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0BytePlusUltraMini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + 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)"), + }; + request.send_request(&client).await.expect("send_request should succeed") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/build.rs new file mode 100644 index 0000000000..b484487935 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/build.rs @@ -0,0 +1,225 @@ +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::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState; +use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result { + // Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade + // to 720p), and all six aspect ratios including 21:9. + let request = build_artcraft_omni_video_request( + builder, + CommonVideoModelEnum::Seedance2p0Mini, + SupportedResolutions::Fast, + UltraWideSupport::Supported, + )?; + let state = ArtcraftSeedance2p0MiniRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state))) +} + +#[cfg(test)] +mod tests { + use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum; + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::router_aspect_ratio::RouterAspectRatio; + use crate::api::router_bitrate::RouterBitrate; + 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::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video::video_generation_request::VideoGenerationRequest; + + use super::*; + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn model_is_correct() { + let req = unwrap_request(builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Mini))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); + } + + #[test] + fn duration_clamped_to_max() { + let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); + } + + #[test] + fn batch_count_passed_through() { + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn batch_count_clamped_to_platform_max() { + // The ArtCraft platform caps batch at 4 and downgrades higher requests. + let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + + #[test] + fn bitrate_passed_through() { + let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); })); + assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High)); + } + } + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn ultra_wide() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + } + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + resolution: Some(RouterResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(builder_with(|_| {})); + 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(builder_with(|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() { + let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..Default::default() + }); + 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(builder_with(|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(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0MiniRequestState { + let result = build_artcraft_seedance_2p0_mini(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)) => state, + _ => panic!("expected ArtcraftSeedance2p0Mini request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/cost.rs new file mode 100644 index 0000000000..9c36520ba2 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/cost.rs @@ -0,0 +1,176 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_mini_usd_cents; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState; + +pub struct ArtcraftSeedance2p0MiniCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0MiniCostState { + pub fn from_request(request: &ArtcraftSeedance2p0MiniRequestState) -> Self { + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); + let duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.video_batch_count.unwrap_or(1); + let has_video_reference = request.request.reference_video_media_tokens + .as_ref() + .is_some_and(|tokens| !tokens.is_empty()); + + Self { resolution, duration_seconds, batch_count, has_video_reference } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + // Seedance 2.0 Mini is priced uniformly across its variants; see + // seedance_common::seedance_2p0_mini_usd_cents. + let usd_cents = seedance_2p0_mini_usd_cents( + self.resolution, + self.duration_seconds, + self.batch_count, + self.has_video_reference, + ); + + // ArtCraft credits: 100 credits = $1.00, so credits = cents. + 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, + } + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::router_resolution::RouterResolution; + use crate::api::router_video_model::RouterVideoModel; + use crate::api::router_provider::RouterProvider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; + + // Every combination, driven through the full build pipeline. These prices are + // the supplier's per-combo cost plus a 5% margin, rounded up to whole cents. + + mod pricing_480p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 13); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 49); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17); + assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21); + assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42); + assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 62); + } + } + + mod pricing_720p { + use super::*; + + #[test] + fn without_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 87); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 130); + } + + #[test] + fn with_video_reference() { + assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42); + assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 52); + assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 104); + assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 156); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false)); + } + } + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(RouterResolution::FourEightyP), + Some(RouterResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4u16, 5, 10, 15] { + for batch in [1u16, 2, 4] { + for has_ref in [false, true] { + let cost = build_cost(res, dur, batch, has_ref); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + } + } + } + } + } + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> VideoGenerationCostEstimate { + let reference_videos = with_video_reference.then(|| { + VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())]) + }); + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + reference_videos, + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cents( + resolution: RouterResolution, + duration_seconds: u16, + video_batch_count: u16, + with_video_reference: bool, + ) -> u64 { + build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference) + .cost_in_usd_cents + .unwrap() + } + + fn cents_no_ref( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count, false) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/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/seedance_2p0_mini/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/request.rs new file mode 100644 index 0000000000..1efad69ea3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_2p0_mini/request.rs @@ -0,0 +1,86 @@ +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 ArtcraftSeedance2p0MiniRequestState { + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0MiniRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + send_artcraft_omni_video_request(&self.request, client).await + } +} + +#[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::router_provider::RouterProvider; + 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; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(RouterResolution::FourEightyP), + ..mini_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + fn mini_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: RouterVideoModel::Seedance2p0Mini, + provider: RouterProvider::Artcraft, + duration_seconds: Some(4), + 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)"), + }; + request.send_request(&client).await.expect("send_request should succeed") + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs index 396ae2ded0..9559745e74 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/seedance_common.rs @@ -2,6 +2,8 @@ //! //! ArtCraft credits equal USD cents (100 credits = $1.00). +use enums::common::generation::common_resolution::CommonResolution; + // 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held // in hundredths of a USD cent per second so the math is exact integer arithmetic // (no floating point), then rounded up to whole cents. @@ -35,6 +37,107 @@ pub fn seedance_2p0_four_k_usd_cents( total_centi_cents.div_ceil(100) } +// ── Seedance 2.0 Mini pricing ── +// +// Mini offers only 480p and 720p. The rates below are the per-second USER price +// in USD cents (margin already included); a reference video adds a per-second +// surcharge. Rates are fractional, so the total is rounded UP to a whole cent +// once at the end (after multiplying by duration × batch). +// +// The regular Mini and the BytePlus / BytePlus Ultra Minis carry DIFFERENT +// margins, so they have separate rate sets and helpers below. + +/// Regular Mini — 480p price, USD cents per second. +const MINI_CENTS_PER_SECOND_480P: f64 = 3.24074074; +/// Regular Mini — 480p reference-video surcharge, USD cents per second. +const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.86419753; +/// Regular Mini — 720p price, USD cents per second. +const MINI_CENTS_PER_SECOND_720P: f64 = 8.64197531; +/// Regular Mini — 720p reference-video surcharge, USD cents per second. +const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.72839506; + +/// BytePlus / BytePlus Ultra Mini — 480p price, USD cents per second. +const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.27160494; +/// BytePlus / BytePlus Ultra Mini — 480p reference-video surcharge, USD cents per second. +const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.87242798; +/// BytePlus / BytePlus Ultra Mini — 720p price, USD cents per second. +const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 8.72427984; +/// BytePlus / BytePlus Ultra Mini — 720p reference-video surcharge, USD cents per second. +const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.74485597; + +/// ArtCraft's user-facing price (USD cents) for the regular Seedance 2.0 Mini. +/// +/// Only 480p and 720p are offered; any other resolution prices at 720p. A +/// reference video adds the per-second surcharge. The fractional total is +/// rounded UP to a whole cent. +pub fn seedance_2p0_mini_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + batch_count: u16, + has_video_reference: bool, +) -> u64 { + mini_usd_cents( + resolution, + duration_seconds, + batch_count, + has_video_reference, + MINI_CENTS_PER_SECOND_480P, + MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P, + MINI_CENTS_PER_SECOND_720P, + MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P, + ) +} + +/// ArtCraft's user-facing price (USD cents) for the Seedance 2.0 BytePlus Mini +/// and BytePlus Ultra Mini (which share the same rates — a higher margin than +/// the regular Mini). +/// +/// Only 480p and 720p are offered; any other resolution prices at 720p. A +/// reference video adds the per-second surcharge. The fractional total is +/// rounded UP to a whole cent. +pub fn seedance_2p0_byteplus_mini_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + batch_count: u16, + has_video_reference: bool, +) -> u64 { + mini_usd_cents( + resolution, + duration_seconds, + batch_count, + has_video_reference, + BYTEPLUS_MINI_CENTS_PER_SECOND_480P, + BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P, + BYTEPLUS_MINI_CENTS_PER_SECOND_720P, + BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P, + ) +} + +#[allow(clippy::too_many_arguments)] +fn mini_usd_cents( + resolution: CommonResolution, + duration_seconds: u16, + batch_count: u16, + has_video_reference: bool, + cents_per_second_480p: f64, + video_reference_surcharge_per_second_480p: f64, + cents_per_second_720p: f64, + video_reference_surcharge_per_second_720p: f64, +) -> u64 { + let (base_per_second, video_reference_surcharge_per_second) = match resolution { + CommonResolution::FourEightyP => (cents_per_second_480p, video_reference_surcharge_per_second_480p), + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => (cents_per_second_720p, video_reference_surcharge_per_second_720p), + }; + + let mut cents_per_second = base_per_second; + if has_video_reference { + cents_per_second += video_reference_surcharge_per_second; + } + + (cents_per_second * duration_seconds as f64 * batch_count as f64).ceil() as u64 +} + #[cfg(test)] mod tests { use super::*; @@ -72,4 +175,154 @@ mod tests { assert_eq!(seedance_2p0_four_k_usd_cents(5, 4, false), 1732); assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, true), 1038); } + + // ── Seedance 2.0 Mini ── + // + // Every combination (480p/720p × with/without video reference × 4/5/10/15s) + // at batch 1. These are the supplier's per-combo cost plus a 5% margin, + // rounded up to whole cents. + + mod mini { + use super::*; + + fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 { + seedance_2p0_mini_usd_cents(res, dur, 1, has_ref) + } + + #[test] + fn four_eighty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 13); + assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17); + assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33); + assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 49); + } + + #[test] + fn four_eighty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17); + assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21); + assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42); + assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 62); + } + + #[test] + fn seven_twenty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 87); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 130); + } + + #[test] + fn seven_twenty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 52); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 104); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 156); + } + + #[test] + fn video_reference_always_costs_more() { + for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] { + for dur in [4u16, 5, 10, 15] { + assert!( + cents(res, dur, true) > cents(res, dur, false), + "video ref should cost more at {res:?} {dur}s", + ); + } + } + } + + #[test] + fn unsupported_resolution_prices_at_720p() { + assert_eq!( + seedance_2p0_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false), + seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false), + ); + } + + #[test] + fn batch_count_scales_the_total() { + // Batch is baked in before the single round-up, so batched totals can be + // a cent under N× the single price. + assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 87); + assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 173); + assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33); + } + } + + // ── Seedance 2.0 BytePlus / BytePlus Ultra Mini ── + // + // Same combinations as the regular Mini, but at a higher margin (6% vs 5%), + // so the prices are equal-or-higher than the regular Mini. + + mod byteplus_mini { + use super::*; + + fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 { + seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref) + } + + #[test] + fn four_eighty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14); + assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17); + assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33); + assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 50); + } + + #[test] + fn four_eighty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17); + assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21); + assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42); + assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 63); + } + + #[test] + fn seven_twenty_p_without_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 88); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 131); + } + + #[test] + fn seven_twenty_p_with_video_reference() { + assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42); + assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 53); + assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 105); + assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 158); + } + + #[test] + fn never_cheaper_than_regular_mini() { + for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] { + for dur in [4u16, 5, 10, 15] { + for has_ref in [false, true] { + assert!( + seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref) + >= seedance_2p0_mini_usd_cents(res, dur, 1, has_ref), + "byteplus mini (6%) should be >= regular mini (5%) at {res:?} {dur}s ref={has_ref}", + ); + } + } + } + } + + #[test] + fn unsupported_resolution_prices_at_720p() { + assert_eq!( + seedance_2p0_byteplus_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false), + seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false), + ); + } + + #[test] + fn batch_count_scales_the_total() { + assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 88); + assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 175); + assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33); + } + } } 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 fe0f18df67..43fbd214e3 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 @@ -47,6 +47,12 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::cost use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState; use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::cost::ArtcraftSeedance2p0BytePlusUltraFastCostState; use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cost::ArtcraftSeedance2p0MiniCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::cost::ArtcraftSeedance2p0BytePlusUltraMiniCostState; +use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState; use crate::generate::generate_video::providers::artcraft::sora_2::cost::ArtcraftSora2CostState; use crate::generate::generate_video::providers::artcraft::sora_2::request::ArtcraftSora2RequestState; use crate::generate::generate_video::providers::artcraft::sora_2_pro::cost::ArtcraftSora2ProCostState; @@ -132,6 +138,9 @@ pub enum VideoGenerationRequest { ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState), ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState), ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState), + ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState), + ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState), + ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState), ArtcraftSora2(ArtcraftSora2RequestState), ArtcraftSora2Pro(ArtcraftSora2ProRequestState), ArtcraftVeo2(ArtcraftVeo2RequestState), @@ -190,6 +199,9 @@ impl VideoGenerationRequest { Self::ArtcraftSeedance2p0BytePlusFast(_) => RouterProvider::Artcraft, Self::ArtcraftSeedance2p0BytePlusUltra(_) => RouterProvider::Artcraft, Self::ArtcraftSeedance2p0BytePlusUltraFast(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft, + Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft, Self::ArtcraftSora2(_) => RouterProvider::Artcraft, Self::ArtcraftSora2Pro(_) => RouterProvider::Artcraft, Self::ArtcraftVeo2(_) => RouterProvider::Artcraft, @@ -248,6 +260,9 @@ impl VideoGenerationRequest { VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => Ok(ArtcraftSeedance2p0BytePlusUltraCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => Ok(ArtcraftSeedance2p0BytePlusUltraFastCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftSora2Pro(request) => Ok(ArtcraftSora2ProCostState::from_request(request).estimate_cost()), VideoGenerationRequest::ArtcraftVeo2(request) => Ok(ArtcraftVeo2CostState::from_request(request).estimate_cost()), @@ -373,6 +388,18 @@ impl VideoGenerationRequest { let client_ref = client.get_artcraft_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, + VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, VideoGenerationRequest::ArtcraftSora2(request) => { let client_ref = client.get_artcraft_client_ref()?; request.send(client_ref).await