diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs index 45e0d75b1d..8106b25a24 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/omni_gen/cost_and_generate_requests/omni_gen_video_cost_and_generate_request.rs @@ -9,7 +9,7 @@ use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; /// Shared request body for both the video cost estimate and video generation endpoints. -#[derive(Serialize, Deserialize, ToSchema, Debug)] +#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)] pub struct OmniGenVideoCostAndGenerateRequest { /// REQUIRED (even if marked optional) /// Idempotency token to prevent duplicate requests. 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 2b3300efd2..fb7b73a70c 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 @@ -48,6 +48,7 @@ use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_see use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast; use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan; use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0; +use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast; use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; @@ -142,6 +143,7 @@ impl GenerateVideoRequestBuilder { pub fn use_new_builder(&self) -> bool { match (self.provider, self.model) { (Provider::Artcraft, CommonVideoModel::Seedance2p0) => true, + (Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => true, (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true, (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true, _ => false, @@ -152,6 +154,7 @@ impl GenerateVideoRequestBuilder { pub fn build2(self) -> Result { match (self.provider, self.model) { (Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self), + (Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self), (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self), (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self), _ => self.unsupported_provider_and_model(), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/CLAUDE.md b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/CLAUDE.md index 3c4d539e91..e2bfa867d9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/CLAUDE.md +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/CLAUDE.md @@ -71,10 +71,15 @@ generate_video_v2/ ├── video_generation_request.rs ← VideoGenerationRequest enum └── providers/ ├── artcraft/ ← Artcraft provider (uses media file tokens directly) - │ └── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly) + │ ├── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly) + │ │ ├── mod.rs + │ │ ├── build.rs ← builder → Request conversion + plan helpers + │ │ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client) + │ │ └── request.rs ← request state + send() via Artcraft multi-function API + │ └── seedance_2p0_fast/ ← Seedance 2.0 Fast model (skips draft, 480p/720p only) │ ├── mod.rs - │ ├── build.rs ← builder → Request conversion + plan helpers - │ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client) + │ ├── build.rs ← builder → Request conversion + plan helpers (no 1080p) + │ ├── cost.rs ← standalone cost estimation with Fast-specific pricing │ └── request.rs ← request state + send() via Artcraft omni-gen API └── kinovi/ ← Kinovi/Seedance2Pro provider ├── mod.rs diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs index a9daa8a352..9e18c20503 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs @@ -1 +1,3 @@ +pub(crate) mod resolve; pub mod seedance_2p0; +pub mod seedance_2p0_fast; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/resolve.rs new file mode 100644 index 0000000000..849ea41e2e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/resolve.rs @@ -0,0 +1,71 @@ +//! Shared resolve helpers for the Artcraft provider. +//! +//! Artcraft only accepts media file tokens — raw URLs are rejected. + +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; + +pub(super) fn resolve_image_ref( + image_ref: Option, +) -> Result, ArtcraftRouterError> { + match image_ref { + None => Ok(None), + Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)), + Some(ImageRef::Url(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_image_list_ref( + image_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match image_list_ref { + None => Ok(None), + Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(ImageListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_video_list_ref( + video_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match video_list_ref { + None => Ok(None), + Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(VideoListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_audio_list_ref( + audio_list_ref: Option, +) -> Result>, ArtcraftRouterError> { + match audio_list_ref { + None => Ok(None), + Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), + Some(AudioListRef::Urls(_)) => { + Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) + } + } +} + +pub(super) fn resolve_character_list_ref( + character_list_ref: Option, +) -> Option> { + match character_list_ref { + None => None, + Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens), + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/build.rs index f58e0f4283..89c0321871 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/build.rs @@ -1,19 +1,18 @@ -use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ - Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, - Seedance2p0OutputResolution, -}; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +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 crate::api::audio_list_ref::AudioListRef; -use crate::api::character_list_ref::CharacterListRef; use crate::api::common_aspect_ratio::CommonAspectRatio; use crate::api::common_resolution::CommonResolution; -use crate::api::image_list_ref::ImageListRef; -use crate::api::image_ref::ImageRef; -use crate::api::video_list_ref::VideoListRef; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::errors::client_error::ClientError; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video_v2::providers::artcraft::resolve::{ + resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref, + resolve_image_ref, resolve_video_list_ref, +}; use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; @@ -22,129 +21,66 @@ pub fn build_artcraft_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> let strategy = builder.request_mismatch_mitigation_strategy; let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; - let output_resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; let prompt = builder.prompt.take(); - let start_frame_media_token = resolve_image_ref(builder.start_frame.take())?; - let end_frame_media_token = resolve_image_ref(builder.end_frame.take())?; - let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?; - let reference_video_media_tokens = resolve_video_list_ref(builder.reference_videos.take())?; - let reference_audio_media_tokens = resolve_audio_list_ref(builder.reference_audio.take())?; - let reference_character_tokens = resolve_character_list_ref(builder.reference_character_tokens.take()); + let start_frame = resolve_image_ref(builder.start_frame.take())?; + let end_frame = resolve_image_ref(builder.end_frame.take())?; + let reference_images = resolve_image_list_ref(builder.reference_images.take())?; + let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?; + let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?; + let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take()); let idempotency_token = builder.get_or_generate_idempotency_token(); - let request = Seedance2p0MultiFunctionVideoGenRequest { - uuid_idempotency_token: idempotency_token, + let request = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModelEnum::Seedance2p0), + idempotency_token: Some(idempotency_token), prompt, - start_frame_media_token, - end_frame_media_token, - reference_image_media_tokens, - reference_video_media_tokens, - reference_audio_media_tokens, - reference_character_tokens, + start_frame_image_media_token: start_frame, + end_frame_image_media_token: end_frame, + reference_image_media_tokens: reference_images, + reference_video_media_tokens: reference_videos, + reference_audio_media_tokens: reference_audio, + reference_character_tokens: reference_characters, + resolution, aspect_ratio, - output_resolution, - duration_seconds, - batch_count: Some(batch_count), + duration_seconds: duration_seconds.map(|d| d as u16), + video_batch_count: Some(batch_count), + negative_prompt: None, + generate_audio: None, + quality: None, }; let state = ArtcraftSeedance2p0RequestState { request }; Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state))) } -// ── Resolve helpers ── +// -- Plan helpers -- -fn resolve_image_ref( - image_ref: Option, -) -> Result, ArtcraftRouterError> { - match image_ref { - None => Ok(None), - Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)), - Some(ImageRef::Url(_)) => { - Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) - } - } -} - -fn resolve_image_list_ref( - image_list_ref: Option, -) -> Result>, ArtcraftRouterError> { - match image_list_ref { - None => Ok(None), - Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), - Some(ImageListRef::Urls(_)) => { - Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) - } - } -} - -fn resolve_video_list_ref( - video_list_ref: Option, -) -> Result>, ArtcraftRouterError> { - match video_list_ref { - None => Ok(None), - Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), - Some(VideoListRef::Urls(_)) => { - Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) - } - } -} - -fn resolve_audio_list_ref( - audio_list_ref: Option, -) -> Result>, ArtcraftRouterError> { - match audio_list_ref { - None => Ok(None), - Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), - Some(AudioListRef::Urls(_)) => { - Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) - } - } -} - -fn resolve_character_list_ref( - character_list_ref: Option, -) -> Option> { - match character_list_ref { - None => None, - Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens), - } -} - -// ── Plan helpers ── - -// Supported aspect ratios and their AR values (width / height): -// Portrait9x16 = 0.5625, Portrait3x4 = 0.75, Square1x1 = 1.0, Standard4x3 = 1.33, Landscape16x9 = 1.78 -// -// All supported ratios cost the same, so PayMoreUpgrade and PayLessDowngrade both -// select the nearest match rather than rounding in a specific direction. fn plan_aspect_ratio( aspect_ratio: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result, ArtcraftRouterError> { +) -> Result, ArtcraftRouterError> { match aspect_ratio { - // No preference or auto — let the model decide None | Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => Ok(None), - // Direct mappings Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => { - Ok(Some(Seedance2p0AspectRatio::Landscape16x9)) + Ok(Some(CommonAspectRatioEnum::WideSixteenByNine)) } Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => { - Ok(Some(Seedance2p0AspectRatio::Portrait9x16)) + Ok(Some(CommonAspectRatioEnum::TallNineBySixteen)) } Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => { - Ok(Some(Seedance2p0AspectRatio::Square1x1)) + Ok(Some(CommonAspectRatioEnum::Square)) } - Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)), - Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)), + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)), - // Mismatches — apply strategy Some(unsupported) => match strategy { RequestMismatchMitigationStrategy::ErrorOut => { Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { @@ -160,16 +96,15 @@ fn plan_aspect_ratio( } } -/// Pick the nearest supported aspect ratio by AR value (width / height). -fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio { +fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum { match aspect_ratio { - CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3, // 1.25, nearest 1.33 - CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3, // 1.50, nearest 1.33 - CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9, // 2.33, nearest 1.78 - CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4, // 0.80, nearest 0.75 - CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4, // 0.67, nearest 0.75 - CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16, // 0.43, nearest 0.56 - _ => Seedance2p0AspectRatio::Square1x1, + CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree, + CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree, + CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine, + CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour, + CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour, + CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen, + _ => CommonAspectRatioEnum::Square, } } @@ -177,16 +112,14 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRat fn plan_output_resolution( resolution: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result, ArtcraftRouterError> { +) -> Result, ArtcraftRouterError> { match resolution { None => Ok(None), - // Direct mappings - Some(CommonResolution::FourEightyP) => Ok(Some(Seedance2p0OutputResolution::FourEightyP)), - Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance2p0OutputResolution::SevenTwentyP)), - Some(CommonResolution::TenEightyP) => Ok(Some(Seedance2p0OutputResolution::TenEightyP)), + Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)), + Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)), + Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)), - // Mismatches Some(unsupported) => match strategy { RequestMismatchMitigationStrategy::ErrorOut => { Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { @@ -196,31 +129,29 @@ fn plan_output_resolution( } RequestMismatchMitigationStrategy::PayMoreUpgrade => { Ok(Some(match unsupported { - CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP, - _ => Seedance2p0OutputResolution::TenEightyP, + CommonResolution::HalfK => CommonResolutionEnum::FourEightyP, + _ => CommonResolutionEnum::TenEightyP, })) } RequestMismatchMitigationStrategy::PayLessDowngrade => { Ok(Some(match unsupported { - CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP, - _ => Seedance2p0OutputResolution::TenEightyP, + CommonResolution::HalfK => CommonResolutionEnum::FourEightyP, + _ => CommonResolutionEnum::TenEightyP, })) } }, } } -// Seedance2p0 supports batch counts of 1, 2, and 4 only. +// Batch counts: 1, 2, 4. fn plan_batch_count( video_batch_count: Option, strategy: RequestMismatchMitigationStrategy, -) -> Result { +) -> Result { let count = video_batch_count.unwrap_or(1); match count { 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), - 1 => Ok(Seedance2p0BatchCount::One), - 2 => Ok(Seedance2p0BatchCount::Two), - 4 => Ok(Seedance2p0BatchCount::Four), + 1 | 2 | 4 => Ok(count), _ => match strategy { RequestMismatchMitigationStrategy::ErrorOut => { Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { @@ -228,17 +159,15 @@ fn plan_batch_count( value: format!("{}", count), })) } - RequestMismatchMitigationStrategy::PayMoreUpgrade => { - Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four }) - } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4), RequestMismatchMitigationStrategy::PayLessDowngrade => { - Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four }) + Ok(if count < 4 { 2 } else { 4 }) } }, } } -// Seedance2p0 supports duration of 4–15 seconds. +// Duration: 4-15 seconds. fn plan_duration( duration_seconds: Option, strategy: RequestMismatchMitigationStrategy, @@ -262,350 +191,150 @@ fn plan_duration( #[cfg(test)] mod tests { - use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ - Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0OutputResolution, - }; + use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; use tokens::tokens::characters::CharacterToken; use tokens::tokens::media_files::MediaFileToken; use crate::api::character_list_ref::CharacterListRef; - use crate::api::common_aspect_ratio::CommonAspectRatio; use crate::api::common_resolution::CommonResolution; use crate::api::image_list_ref::ImageListRef; use crate::api::image_ref::ImageRef; use crate::api::provider::Provider; - use crate::api::video_list_ref::VideoListRef; - use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; - use crate::errors::artcraft_router_error::ArtcraftRouterError; - use crate::errors::client_error::ClientError; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; - use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; use super::*; - // ── Materialized field conversions ── + // ── Field conversions ── - mod materialized_field_conversions { + mod field_conversions { use super::*; #[test] - fn prompt_is_passed_through() { - let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder())); - assert_eq!(state.request.prompt.as_deref(), Some("a cat dancing")); + fn model_is_seedance_2p0() { + let req = unwrap_request(make_builder(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0))); } #[test] - fn prompt_defaults_to_none() { - let builder = GenerateVideoRequestBuilder { prompt: None, ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(state.request.prompt.is_none()); + fn prompt_passed_through() { + let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); } #[test] - fn duration_seconds_converted() { - let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert_eq!(state.request.duration_seconds, Some(10)); - } - - #[test] - fn duration_defaults_to_none() { - let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(state.request.duration_seconds.is_none()); + fn duration_passed_through() { + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); })); + assert_eq!(req.request.duration_seconds, Some(10)); } #[test] fn duration_clamped_to_max() { - let builder = GenerateVideoRequestBuilder { - duration_seconds: Some(99), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert_eq!(state.request.duration_seconds, Some(15)); + let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); })); + assert_eq!(req.request.duration_seconds, Some(15)); } #[test] - fn batch_count_one() { - let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::One))); - } - - #[test] - fn batch_count_two() { - let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Two))); - } - - #[test] - fn batch_count_four() { - let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four))); + fn batch_count_passed_through() { + let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); } } - // ── Aspect ratio conversions ── + // ── Resolution ── - mod aspect_ratio_conversions { + mod resolution_tests { use super::*; #[test] - fn aspect_ratio_wide() { - let builder = GenerateVideoRequestBuilder { - aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9))); + fn res_480p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); } #[test] - fn aspect_ratio_tall() { - let builder = GenerateVideoRequestBuilder { - aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16))); + fn res_720p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); } #[test] - fn aspect_ratio_square() { - let builder = GenerateVideoRequestBuilder { - aspect_ratio: Some(CommonAspectRatio::Square), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1))); + fn res_1080p() { + let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP)); } #[test] - fn aspect_ratio_defaults_to_none() { - let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(state.request.aspect_ratio.is_none()); + fn none_stays_none() { + let req = unwrap_request(make_builder(|_| {})); + assert!(req.request.resolution.is_none()); } } - // ── Resolution conversions ── + // ── Media tokens ── - mod resolution_conversions { + mod media_token_tests { use super::*; #[test] - fn resolution_480p() { - let builder = GenerateVideoRequestBuilder { - resolution: Some(CommonResolution::FourEightyP), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::FourEightyP))); + fn start_frame_token_passed_through() { + let token = MediaFileToken::new("mf_start".to_string()); + let req = unwrap_request(make_builder(|b| { + b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); + })); + assert_eq!(req.request.start_frame_image_media_token, Some(token)); } #[test] - fn resolution_720p() { - let builder = GenerateVideoRequestBuilder { - resolution: Some(CommonResolution::SevenTwentyP), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::SevenTwentyP))); + fn url_start_frame_rejected() { + let result = build_artcraft_seedance_2p0(GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com".to_string())), + ..base_builder() + }); + assert!(result.is_err()); } #[test] - fn resolution_1080p() { - let builder = GenerateVideoRequestBuilder { - resolution: Some(CommonResolution::TenEightyP), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP))); + fn reference_image_tokens_passed_through() { + let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_image_media_tokens, Some(tokens)); } #[test] - fn resolution_none() { - let builder = GenerateVideoRequestBuilder { resolution: None, ..artcraft_builder() }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(state.request.output_resolution.is_none()); + fn character_tokens_passed_through() { + let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())]; + let req = unwrap_request(make_builder(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); } - - #[test] - fn unsupported_resolution_error_out() { - let builder = GenerateVideoRequestBuilder { - resolution: Some(CommonResolution::FourK), - request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, - ..artcraft_builder() - }; - assert!(build_artcraft_seedance_2p0(builder).is_err()); - } - - #[test] - fn unsupported_resolution_rounds_up() { - let builder = GenerateVideoRequestBuilder { - resolution: Some(CommonResolution::FourK), - request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP))); - } - } - - // ── Media token resolution ── - - mod media_token_resolution { - use super::*; - - #[test] - fn start_frame_media_token() { - let builder = GenerateVideoRequestBuilder { - start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start123".to_string()))), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert_eq!(state.request.start_frame_media_token.as_ref().unwrap().as_str(), "mf_start123"); - } - - #[test] - fn end_frame_media_token() { - let builder = GenerateVideoRequestBuilder { - end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end456".to_string()))), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - assert_eq!(state.request.end_frame_media_token.as_ref().unwrap().as_str(), "mf_end456"); - } - - #[test] - fn reference_image_media_tokens() { - let builder = GenerateVideoRequestBuilder { - reference_images: Some(ImageListRef::MediaFileTokens(vec![ - MediaFileToken::new("mf_ref1".to_string()), - MediaFileToken::new("mf_ref2".to_string()), - ])), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - let tokens = state.request.reference_image_media_tokens.unwrap(); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].as_str(), "mf_ref1"); - assert_eq!(tokens[1].as_str(), "mf_ref2"); - } - - #[test] - fn reference_video_media_tokens() { - let builder = GenerateVideoRequestBuilder { - reference_videos: Some(VideoListRef::MediaFileTokens(vec![ - MediaFileToken::new("mf_vid1".to_string()), - ])), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - let tokens = state.request.reference_video_media_tokens.unwrap(); - assert_eq!(tokens.len(), 1); - assert_eq!(tokens[0].as_str(), "mf_vid1"); - } - - #[test] - fn character_tokens() { - let builder = GenerateVideoRequestBuilder { - reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ - CharacterToken::new("char_abc".to_string()), - CharacterToken::new("char_def".to_string()), - ])), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - let tokens = state.request.reference_character_tokens.unwrap(); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0].as_str(), "char_abc"); - assert_eq!(tokens[1].as_str(), "char_def"); - } - - #[test] - fn url_image_ref_returns_error() { - let builder = GenerateVideoRequestBuilder { - start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())), - ..artcraft_builder() - }; - assert!(matches!( - build_artcraft_seedance_2p0(builder), - Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) - )); - } - - #[test] - fn empty_refs_are_none() { - let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder())); - assert!(state.request.start_frame_media_token.is_none()); - assert!(state.request.end_frame_media_token.is_none()); - assert!(state.request.reference_image_media_tokens.is_none()); - assert!(state.request.reference_video_media_tokens.is_none()); - assert!(state.request.reference_audio_media_tokens.is_none()); - assert!(state.request.reference_character_tokens.is_none()); - } - } - - // ── Returns Request, not Draft ── - - #[test] - fn build_returns_request_variant() { - let result = build_artcraft_seedance_2p0(artcraft_builder()).expect("build should succeed"); - assert!(matches!(result, VideoGenerationDraftOrRequest::Request(_))); - } - - // ── Full combination ── - - #[test] - fn full_request_all_fields() { - let builder = GenerateVideoRequestBuilder { - prompt: Some("full test".to_string()), - aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen), - resolution: Some(CommonResolution::TenEightyP), - duration_seconds: Some(10), - video_batch_count: Some(4), - start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))), - end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))), - reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_img".to_string())])), - reference_videos: Some(VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_vid".to_string())])), - reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ - CharacterToken::new("char_xyz".to_string()), - ])), - ..artcraft_builder() - }; - let state = unwrap_request(build_artcraft_seedance_2p0(builder)); - - assert_eq!(state.request.prompt.as_deref(), Some("full test")); - assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16))); - assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP))); - assert_eq!(state.request.duration_seconds, Some(10)); - assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four))); - assert!(state.request.start_frame_media_token.is_some()); - assert!(state.request.end_frame_media_token.is_some()); - assert!(state.request.reference_image_media_tokens.is_some()); - assert!(state.request.reference_video_media_tokens.is_some()); - assert!(state.request.reference_character_tokens.is_some()); } // ── Helpers ── - fn artcraft_builder() -> GenerateVideoRequestBuilder { + fn base_builder() -> GenerateVideoRequestBuilder { GenerateVideoRequestBuilder { provider: Provider::Artcraft, - prompt: Some("a cat dancing".to_string()), duration_seconds: Some(5), video_batch_count: Some(1), ..Default::default() } } - fn unwrap_request(result: Result) -> ArtcraftSeedance2p0RequestState { - match result.expect("build should succeed") { + fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = base_builder(); + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0RequestState { + let result = build_artcraft_seedance_2p0(builder).expect("build should succeed"); + match result { VideoGenerationDraftOrRequest::Request( VideoGenerationRequest::ArtcraftSeedance2p0(state) ) => state, diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/cost.rs index 6a5fed8a02..bfd4115f90 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/cost.rs @@ -1,6 +1,4 @@ -use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ - Seedance2p0BatchCount, Seedance2p0OutputResolution, -}; +use enums::common::generation::common_resolution::CommonResolution; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; @@ -13,7 +11,7 @@ use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::reque // from the upstream credit rates and their credit-package prices, // then set ArtCraft credits = cents. -/// USD cents per second by resolution, derived from Upstream rates: +/// USD cents per second by resolution, derived from upstream rates: /// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s /// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s /// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s @@ -25,19 +23,18 @@ const CENTS_PER_SECOND_720P: f64 = 16.0; const CENTS_PER_SECOND_1080P: f64 = 46.632; pub struct ArtcraftSeedance2p0CostState { - pub resolution: Seedance2p0OutputResolution, - pub duration_seconds: u8, - pub batch_count: Seedance2p0BatchCount, + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, pub has_video_reference: bool, } impl ArtcraftSeedance2p0CostState { pub fn from_request(request: &ArtcraftSeedance2p0RequestState) -> Self { - let resolution = request.request.output_resolution - .unwrap_or(Seedance2p0OutputResolution::SevenTwentyP); + let resolution = request.request.resolution + .unwrap_or(CommonResolution::SevenTwentyP); let duration_seconds = request.request.duration_seconds.unwrap_or(5); - let batch_count = request.request.batch_count - .unwrap_or(Seedance2p0BatchCount::One); + 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()); @@ -47,18 +44,13 @@ impl ArtcraftSeedance2p0CostState { pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { let cents_per_second = match self.resolution { - Seedance2p0OutputResolution::FourEightyP => CENTS_PER_SECOND_480P, - Seedance2p0OutputResolution::SevenTwentyP => CENTS_PER_SECOND_720P, - Seedance2p0OutputResolution::TenEightyP => CENTS_PER_SECOND_1080P, + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P, + // Everything else (including 720p) prices at 720p. + _ => CENTS_PER_SECOND_720P, }; - let batch_multiplier: f64 = match self.batch_count { - Seedance2p0BatchCount::One => 1.0, - Seedance2p0BatchCount::Two => 2.0, - Seedance2p0BatchCount::Four => 4.0, - }; - - let usd_cents = (self.duration_seconds as f64 * cents_per_second * batch_multiplier).round() as u64; + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; // ArtCraft credits: 100 credits = $1.00, so credits = cents. VideoGenerationCostEstimate { @@ -74,208 +66,164 @@ impl ArtcraftSeedance2p0CostState { #[cfg(test)] mod tests { - use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ - Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution, - }; - use tokens::tokens::media_files::MediaFileToken; - use crate::api::common_resolution::CommonResolution; use crate::api::provider::Provider; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; - use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; - use super::*; - - // ── 720p pricing ── + // -- 720p pricing -- mod pricing_720p { use super::*; #[test] - fn cost_720p_batch_1() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One), 64); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 6, Seedance2p0BatchCount::One), 96); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 7, Seedance2p0BatchCount::One), 112); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One), 160); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One), 240); + fn batch_1() { + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 64); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 80); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 6, 1), 96); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 160); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 240); } #[test] - fn cost_720p_batch_2() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Two), 128); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Two), 160); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Two), 480); + fn batch_2() { + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 128); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 160); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 480); } #[test] - fn cost_720p_batch_4() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Four), 256); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Four), 320); - assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Four), 960); + fn batch_4() { + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 256); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 320); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 960); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1)); } } - // ── 480p pricing ── + // -- 480p pricing -- mod pricing_480p { use super::*; #[test] - fn cost_480p_batch_1() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 4, Seedance2p0BatchCount::One), 31); - assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39); - assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 10, Seedance2p0BatchCount::One), 78); - assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 15, Seedance2p0BatchCount::One), 117); + fn batch_1() { + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 31); + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 39); + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 78); + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 117); } #[test] - fn cost_480p_batch_2() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Two), 78); + fn batch_2() { + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 78); } #[test] - fn cost_480p_batch_4() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Four), 155); + fn batch_4() { + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 155); } } - // ── 1080p pricing ── + // -- 1080p pricing -- mod pricing_1080p { use super::*; #[test] - fn cost_1080p_batch_1() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 4, Seedance2p0BatchCount::One), 187); - assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233); - assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 10, Seedance2p0BatchCount::One), 466); - assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 15, Seedance2p0BatchCount::One), 699); + fn batch_1() { + assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 187); + assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 233); + assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 466); + assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 699); } #[test] - fn cost_1080p_batch_2() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two), 466); + fn batch_2() { + assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 466); } #[test] - fn cost_1080p_batch_4() { - assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four), 933); + fn batch_4() { + assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 933); } } - // ── Relative pricing ── + // -- Relative pricing -- mod relative_pricing_tests { use super::*; #[test] fn cost_480p_cheaper_than_720p_cheaper_than_1080p() { - let c480 = usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One); - let c720 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One); - let c1080 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One); - assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720); - assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080); + let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1); + let c1080 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1); + assert!(c480 < c720); + assert!(c720 < c1080); } #[test] fn cost_scales_with_duration() { - let c4 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One); - let c10 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One); - let c15 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One); + let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1); + let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1); + let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1); assert!(c4 < c10); assert!(c10 < c15); } #[test] fn cost_scales_with_batch() { - let b1 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One); - let b2 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two); - let b4 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four); + let b1 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1); + let b2 = cost_cents(Some(CommonResolution::TenEightyP), 5, 2); + let b4 = cost_cents(Some(CommonResolution::TenEightyP), 5, 4); assert!(b1 < b2); assert!(b2 < b4); } } - // ── Video reference does NOT affect cost (yet) ── + // -- Credits equal cents -- - #[test] - fn video_reference_does_not_affect_cost() { - let base = ArtcraftSeedance2p0CostState { - resolution: Seedance2p0OutputResolution::SevenTwentyP, - duration_seconds: 5, - batch_count: Seedance2p0BatchCount::One, - has_video_reference: false, - }; - let without = base.estimate_cost(); - let with = ArtcraftSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost(); - assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents); - assert_eq!(without.cost_in_credits, with.cost_in_credits); - } - - // ── from_request() tests ── - - mod from_request_tests { + mod credits_tests { use super::*; #[test] - fn from_request_720p() { - let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, false); - let cost = ArtcraftSeedance2p0CostState::from_request(&req); - assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP)); - assert_eq!(cost.duration_seconds, 5); - assert!(matches!(cost.batch_count, Seedance2p0BatchCount::One)); - assert!(!cost.has_video_reference); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80)); - } - - #[test] - fn from_request_none_defaults_to_720p() { - let req = make_request_state(None, 5, Seedance2p0BatchCount::One, false); - let cost = ArtcraftSeedance2p0CostState::from_request(&req); - assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP)); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80)); - } - - #[test] - fn from_request_480p() { - let req = make_request_state(Some(Seedance2p0OutputResolution::FourEightyP), 5, Seedance2p0BatchCount::One, false); - let cost = ArtcraftSeedance2p0CostState::from_request(&req); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39)); - } - - #[test] - fn from_request_1080p_batch_2() { - let req = make_request_state(Some(Seedance2p0OutputResolution::TenEightyP), 5, Seedance2p0BatchCount::Two, false); - let cost = ArtcraftSeedance2p0CostState::from_request(&req); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466)); - } - - #[test] - fn from_request_with_video_reference() { - let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, true); - let cost = ArtcraftSeedance2p0CostState::from_request(&req); - assert!(cost.has_video_reference); - assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80)); + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(CommonResolution::FourEightyP), + Some(CommonResolution::SevenTwentyP), + Some(CommonResolution::TenEightyP), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } } } - // ── Cross-check: Artcraft v2 matches Kinovi v2 via GenerateVideoRequestBuilder ── - - mod cross_check_with_kinovi_via_builder { - use crate::api::common_video_model::CommonVideoModel; + // -- Cross-check with Kinovi via builder -- + mod cross_check_with_kinovi { use super::*; - /// Combinatorial test: build the same request with Provider::Artcraft and - /// Provider::Seedance2Pro, then compare USD cents. They must match for all - /// resolution × duration × batch combinations. #[test] fn artcraft_matches_kinovi_all_combos() { let resolutions = [ Some(CommonResolution::FourEightyP), Some(CommonResolution::SevenTwentyP), - None, // defaults to 720p + None, Some(CommonResolution::TenEightyP), ]; let durations: [u16; 4] = [4, 5, 10, 15]; @@ -284,39 +232,24 @@ mod tests { for res in &resolutions { for dur in &durations { for batch in &batches { - let artcraft = GenerateVideoRequestBuilder { - model: CommonVideoModel::Seedance2p0, - provider: Provider::Artcraft, - resolution: *res, - duration_seconds: Some(*dur), - video_batch_count: Some(*batch), - ..Default::default() - }; + let artcraft_cost = build_cost(*res, *dur, *batch); let kinovi = GenerateVideoRequestBuilder { - model: CommonVideoModel::Seedance2p0, provider: Provider::Seedance2Pro, resolution: *res, duration_seconds: Some(*dur), video_batch_count: Some(*batch), ..Default::default() }; - - let artcraft_cost = artcraft.build2() - .expect("artcraft build2 should succeed") - .estimate_cost() - .expect("artcraft estimate_cost should succeed"); - let kinovi_cost = kinovi.build2() - .expect("kinovi build2 should succeed") + .expect("kinovi build2") .estimate_cost() - .expect("kinovi estimate_cost should succeed"); + .expect("kinovi estimate_cost"); assert_eq!( artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents, - "USD cents mismatch: res={:?} dur={}s batch={} — artcraft={:?}, kinovi={:?}", + "USD cents mismatch: res={:?} dur={}s batch={}", res, dur, batch, - artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents, ); } } @@ -324,208 +257,34 @@ mod tests { } } - // ── Cross-check: Artcraft v2 matches v1 estimate_video_cost_artcraft_seedance2p0 ── + // -- Helpers -- - mod cross_check_with_v1 { - use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio; - use tokens::tokens::media_files::MediaFileToken; - - use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0; - use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0; - - use super::*; - - /// Combinatorial test: compare v2 ArtcraftSeedance2p0CostState against - /// v1 estimate_video_cost_artcraft_seedance2p0 for all resolution × - /// duration × batch combinations. - #[test] - fn v2_matches_v1_all_combos() { - let resolutions = [ - (Some(CommonResolution::FourEightyP), Seedance2p0OutputResolution::FourEightyP), - (Some(CommonResolution::SevenTwentyP), Seedance2p0OutputResolution::SevenTwentyP), - (None, Seedance2p0OutputResolution::SevenTwentyP), // None defaults to 720p - (Some(CommonResolution::TenEightyP), Seedance2p0OutputResolution::TenEightyP), - ]; - let durations: [u8; 4] = [4, 5, 10, 15]; - let batches = [ - (Seedance2p0BatchCount::One, Seedance2p0BatchCount::One), - (Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Two), - (Seedance2p0BatchCount::Four, Seedance2p0BatchCount::Four), - ]; - - for (v1_res, v2_res) in &resolutions { - for dur in &durations { - for (v1_batch, v2_batch) in &batches { - // v1: construct a PlanArtcraftSeedance2p0 and call the v1 cost function - let v1_plan = PlanArtcraftSeedance2p0 { - prompt: None, - start_frame: None, - end_frame: None, - reference_images: None, - reference_videos: None, - reference_audio: None, - reference_characters: None, - aspect_ratio: Some(Seedance2p0AspectRatio::Square1x1), - resolution: *v1_res, - duration_seconds: Some(*dur), - batch_count: *v1_batch, - idempotency_token: "test".to_string(), - }; - let v1_cost = estimate_video_cost_artcraft_seedance2p0(&v1_plan); - - // v2: construct cost state directly - let v2_cost = ArtcraftSeedance2p0CostState { - resolution: *v2_res, - duration_seconds: *dur, - batch_count: *v2_batch, - has_video_reference: false, - }.estimate_cost(); - - assert_eq!( - v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents, - "USD cents mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}", - v1_res, dur, v1_batch, - v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents, - ); - - assert_eq!( - v2_cost.cost_in_credits, v1_cost.cost_in_credits, - "Credits mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}", - v1_res, dur, v1_batch, - v2_cost.cost_in_credits, v1_cost.cost_in_credits, - ); - } - } - } - } - } - - // ── Credits spot checks ── - - mod credits_tests { - use super::*; - - #[test] - fn credits_equal_usd_cents() { - // ArtCraft credits: 100 credits = $1.00, so credits always equal cents. - for res in [ - Seedance2p0OutputResolution::FourEightyP, - Seedance2p0OutputResolution::SevenTwentyP, - Seedance2p0OutputResolution::TenEightyP, - ] { - for dur in [4, 5, 10, 15] { - for batch in [Seedance2p0BatchCount::One, Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Four] { - assert_eq!( - credits(res, dur, batch), - usd_cents(res, dur, batch), - "credits should equal usd_cents for {:?} {}s {:?}", res, dur, batch, - ); - } - } - } - } - - #[test] - fn credits_720p() { - assert_eq!(credits(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80); - } - - #[test] - fn credits_480p() { - assert_eq!(credits(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39); - } - - #[test] - fn credits_1080p() { - assert_eq!(credits(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233); - } - } - - // ── Builder round-trip ── - - mod builder_round_trip { - use super::*; - - #[test] - fn build2_then_estimate_cost_720p() { - let builder = GenerateVideoRequestBuilder { - provider: Provider::Artcraft, - resolution: Some(CommonResolution::SevenTwentyP), - duration_seconds: Some(5), - video_batch_count: Some(1), - ..Default::default() - }; - let result = builder.build2().expect("build2 should succeed"); - let cost = result.estimate_cost().expect("estimate_cost should succeed"); - assert_eq!(cost.cost_in_usd_cents, Some(80)); - } - - #[test] - fn build2_then_estimate_cost_1080p_batch_4() { - let builder = GenerateVideoRequestBuilder { - provider: Provider::Artcraft, - resolution: Some(CommonResolution::TenEightyP), - duration_seconds: Some(5), - video_batch_count: Some(4), - ..Default::default() - }; - let result = builder.build2().expect("build2 should succeed"); - let cost = result.estimate_cost().expect("estimate_cost should succeed"); - assert_eq!(cost.cost_in_usd_cents, Some(933)); - } - } - - // ── Helpers ── - - fn usd_cents( - resolution: Seedance2p0OutputResolution, - duration_seconds: u8, - batch_count: Seedance2p0BatchCount, - ) -> u64 { - ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false } + /// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost. + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + provider: Provider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) .cost_in_usd_cents .unwrap() } - - fn credits( - resolution: Seedance2p0OutputResolution, - duration_seconds: u8, - batch_count: Seedance2p0BatchCount, - ) -> u64 { - ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false } - .estimate_cost() - .cost_in_credits - .unwrap() - } - - fn make_request_state( - resolution: Option, - duration_seconds: u8, - batch_count: Seedance2p0BatchCount, - with_video_ref: bool, - ) -> ArtcraftSeedance2p0RequestState { - let reference_video_media_tokens = if with_video_ref { - Some(vec![MediaFileToken::new("mf_testvid".to_string())]) - } else { - None - }; - - ArtcraftSeedance2p0RequestState { - request: Seedance2p0MultiFunctionVideoGenRequest { - uuid_idempotency_token: "test-idem".to_string(), - prompt: Some("test".to_string()), - start_frame_media_token: None, - end_frame_media_token: None, - reference_image_media_tokens: None, - reference_video_media_tokens, - reference_audio_media_tokens: None, - reference_character_tokens: None, - aspect_ratio: None, - output_resolution: resolution, - duration_seconds: Some(duration_seconds), - batch_count: Some(batch_count), - }, - } - } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/request.rs index 353ea596f0..c2a91c75a2 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/request.rs @@ -1,5 +1,5 @@ -use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest; -use artcraft_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen; +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate; use crate::client::router_artcraft_client::RouterArtcraftClient; use crate::errors::artcraft_router_error::ArtcraftRouterError; @@ -10,13 +10,13 @@ use crate::generate::generate_video::generate_video_response::{ #[derive(Clone, Debug)] pub struct ArtcraftSeedance2p0RequestState { - /// Final materialized request; ready to fire. - pub request: Seedance2p0MultiFunctionVideoGenRequest, + /// Final materialized request; ready to fire via the omni-gen video endpoint. + pub request: OmniGenVideoCostAndGenerateRequest, } impl ArtcraftSeedance2p0RequestState { pub async fn send(&self, client: &RouterArtcraftClient) -> Result { - let response = seedance_2p0_multi_function_video_gen( + let response = omni_gen_video_generate( &client.api_host, Some(&client.credentials), self.request.clone(), @@ -25,8 +25,8 @@ impl ArtcraftSeedance2p0RequestState { .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload { - inference_job_token: response.inference_job_token, - all_inference_job_tokens: response.all_inference_job_tokens, + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], })) } } @@ -42,7 +42,6 @@ mod tests { use crate::api::image_list_ref::ImageListRef; use crate::api::image_ref::ImageRef; use crate::api::provider::Provider; - use crate::api::video_list_ref::VideoListRef; use crate::client::router_artcraft_client::RouterArtcraftClient; use crate::client::router_client::RouterClient; use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; @@ -58,14 +57,13 @@ mod tests { WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN, }; - - // ── Aspect ratio tests ── + // -- Aspect ratio tests -- mod aspect_ratio_tests { use super::*; #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn landscape() { let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), @@ -77,7 +75,7 @@ mod tests { } #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn portrait() { let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("A cat sitting on a windowsill watching rain.".to_string()), @@ -89,7 +87,7 @@ mod tests { } #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn square() { let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("A hummingbird hovering near a flower.".to_string()), @@ -101,13 +99,13 @@ mod tests { } } - // ── Resolution tests ── + // -- Resolution tests -- mod resolution_tests { use super::*; #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn res_480p() { let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("A shiba inu playing in autumn leaves.".to_string()), @@ -119,7 +117,7 @@ mod tests { } #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn res_720p() { let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()), @@ -131,7 +129,7 @@ mod tests { } #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn res_1080p() { let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("A fox walking through a snowy forest.".to_string()), @@ -143,13 +141,14 @@ mod tests { } } - // ── Modality tests ── + // -- Modality tests -- mod modality_tests { use super::*; + use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN; #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn text_to_video() { let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()), @@ -161,10 +160,8 @@ mod tests { } #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn keyframe_start_and_end_frame() { - // Artcraft uses media file tokens directly — no URL resolution needed. - // Tokens from test_data::web::image_urls comments. let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("The dog walks from the lake to the forest.".to_string()), start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), @@ -177,15 +174,13 @@ mod tests { } #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost + #[ignore] async fn image_references() { - // Artcraft uses media file tokens directly. - // Tokens from test_data::web::image_urls comments. let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()), reference_images: Some(ImageListRef::MediaFileTokens(vec![ MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()), - MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()), + MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()), MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()), ])), aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), @@ -196,25 +191,8 @@ mod tests { } #[tokio::test] - #[ignore] // manually run — fires a real API request and incurs cost - async fn video_references() { - // TODO: Needs a real video media file token to test properly. - let response = run_pipeline(GenerateVideoRequestBuilder { - prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()), - reference_videos: Some(VideoListRef::MediaFileTokens(vec![ - MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()), // Using an image token as placeholder - ])), - aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), - ..artcraft_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 + #[ignore] async fn character_references() { - // Character tokens are passed through directly to the Artcraft API. let response = run_pipeline(GenerateVideoRequestBuilder { prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()), reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ @@ -227,11 +205,9 @@ mod tests { assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); assert_eq!(1, 2, "Inspect output above"); } - - // TODO: Add audio reference test once a media file token for a test audio file is available. } - // ── Helpers ── + // -- Helpers -- fn artcraft_builder() -> GenerateVideoRequestBuilder { GenerateVideoRequestBuilder { diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/build.rs new file mode 100644 index 0000000000..d9ef0ad7df --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/build.rs @@ -0,0 +1,426 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +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 crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::common_resolution::CommonResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video_v2::providers::artcraft::resolve::{ + resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref, + resolve_image_ref, resolve_video_list_ref, +}; +use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState; +use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; +use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; + +pub fn build_artcraft_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let prompt = builder.prompt.take(); + + let start_frame = resolve_image_ref(builder.start_frame.take())?; + let end_frame = resolve_image_ref(builder.end_frame.take())?; + let reference_images = resolve_image_list_ref(builder.reference_images.take())?; + let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?; + let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?; + let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take()); + let idempotency_token = builder.get_or_generate_idempotency_token(); + + let request = OmniGenVideoCostAndGenerateRequest { + model: Some(CommonVideoModelEnum::Seedance2p0Fast), + idempotency_token: Some(idempotency_token), + prompt, + start_frame_image_media_token: start_frame, + end_frame_image_media_token: end_frame, + reference_image_media_tokens: reference_images, + reference_video_media_tokens: reference_videos, + reference_audio_media_tokens: reference_audio, + reference_character_tokens: reference_characters, + resolution, + aspect_ratio, + duration_seconds: duration_seconds.map(|d| d as u16), + video_batch_count: Some(batch_count), + negative_prompt: None, + generate_audio: None, + quality: None, + }; + + let state = ArtcraftSeedance2p0FastRequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state))) +} + +// -- Plan helpers -- + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match aspect_ratio { + None + | Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => Ok(None), + + // Direct mappings + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => { + Ok(Some(CommonAspectRatioEnum::WideSixteenByNine)) + } + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => { + Ok(Some(CommonAspectRatioEnum::TallNineBySixteen)) + } + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => { + Ok(Some(CommonAspectRatioEnum::Square)) + } + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)), + + // Mismatches + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(nearest_aspect_ratio(unsupported))) + } + }, + } +} + +fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum { + match aspect_ratio { + CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree, + CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree, + CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine, + CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour, + CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour, + CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen, + _ => CommonAspectRatioEnum::Square, + } +} + +// Seedance 2.0 Fast supports output resolutions: 480p and 720p only. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)), + Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)), + + // 1080p not supported for Fast + Some(CommonResolution::TenEightyP) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", CommonResolution::TenEightyP), + })) + } + _ => Ok(Some(CommonResolutionEnum::SevenTwentyP)), + }, + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(match unsupported { + CommonResolution::HalfK => CommonResolutionEnum::FourEightyP, + _ => CommonResolutionEnum::SevenTwentyP, + })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + CommonResolution::HalfK => CommonResolutionEnum::FourEightyP, + _ => CommonResolutionEnum::SevenTwentyP, + })) + } + }, + } +} + +// Batch counts: 1, 2, 4. +fn plan_batch_count( + video_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = video_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 | 2 | 4 => Ok(count), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4), + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(if count < 4 { 2 } else { 4 }) + } + }, + } +} + +// Duration: 4-15 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + const MIN: u16 = 4; + const MAX: u16 = 15; + match duration_seconds { + None => Ok(None), + Some(d) if d >= MIN && d <= MAX => Ok(Some(d as u8)), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(Some(d.clamp(MIN, MAX) as u8)), + }, + } +} + +#[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 tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::provider::Provider; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── Field conversions ── + + mod field_conversions { + use super::*; + + #[test] + fn prompt_is_passed_through() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); })); + assert_eq!(req.request.prompt, Some("test".to_string())); + } + + #[test] + fn prompt_none_stays_none() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(req.request.prompt.is_none()); + } + + #[test] + fn model_is_seedance_2p0_fast() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Fast))); + } + + #[test] + fn duration_passed_through() { + let req = unwrap_request(artcraft_fast_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(artcraft_fast_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(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); })); + assert_eq!(req.request.video_batch_count, Some(4)); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn wide() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine); + })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine)); + } + + #[test] + fn tall() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.aspect_ratio = Some(CommonAspectRatio::TallNineBySixteen); + })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen)); + } + + #[test] + fn square() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.aspect_ratio = Some(CommonAspectRatio::Square); + })); + assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square)); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(req.request.aspect_ratio.is_none()); + } + } + + // ── Resolution ── + + mod resolution_tests { + use super::*; + + #[test] + fn res_480p() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.resolution = Some(CommonResolution::FourEightyP); + })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP)); + } + + #[test] + fn res_720p() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.resolution = Some(CommonResolution::SevenTwentyP); + })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_downgrades_to_720p() { + let req = unwrap_request(artcraft_fast_builder_with(|b| { + b.resolution = Some(CommonResolution::TenEightyP); + })); + assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP)); + } + + #[test] + fn res_1080p_error_out() { + let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::Artcraft, + resolution: Some(CommonResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..Default::default() + }); + assert!(result.is_err()); + } + + #[test] + fn none_stays_none() { + let req = unwrap_request(artcraft_fast_builder_with(|_| {})); + assert!(req.request.resolution.is_none()); + } + } + + // ── Media tokens ── + + 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(artcraft_fast_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_fast(GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::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(artcraft_fast_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(artcraft_fast_builder_with(|b| { + b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); + })); + assert_eq!(req.request.reference_character_tokens, Some(tokens)); + } + } + + // ── Helpers ── + + fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder { + let mut builder = GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + f(&mut builder); + builder + } + + fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0FastRequestState { + let result = build_artcraft_seedance_2p0_fast(builder).expect("build should succeed"); + match result { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0Fast(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0Fast request"), + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/cost.rs new file mode 100644 index 0000000000..697e5a0da4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/cost.rs @@ -0,0 +1,260 @@ +use enums::common::generation::common_resolution::CommonResolution; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState; + +// -- Pricing constants -- +// +// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents. +// +// The per-second USD cost varies by resolution. We derive cents from the +// upstream credit rates and their credit-package prices, then set +// ArtCraft credits = cents. + +/// USD cents per second by resolution, derived from upstream Fast rates: +/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s +/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s +/// +/// We keep these as f64 because per-second rates are fractional; rounding +/// happens once at the end after multiplying by duration * batch. +const CENTS_PER_SECOND_480P: f64 = 5.181; +const CENTS_PER_SECOND_720P: f64 = 12.727; + +pub struct ArtcraftSeedance2p0FastCostState { + pub resolution: CommonResolution, + pub duration_seconds: u16, + pub batch_count: u16, + pub has_video_reference: bool, +} + +impl ArtcraftSeedance2p0FastCostState { + pub fn from_request(request: &ArtcraftSeedance2p0FastRequestState) -> 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 { + let cents_per_second = match self.resolution { + CommonResolution::FourEightyP => CENTS_PER_SECOND_480P, + // Everything else (including 720p and unsupported resolutions) prices at 720p. + _ => CENTS_PER_SECOND_720P, + }; + + let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64; + + // 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, + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::provider::Provider; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // -- 720p pricing -- + + mod pricing_720p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 51); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 64); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 127); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 191); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 102); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 127); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 382); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 204); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 255); + assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 764); + } + + #[test] + fn none_defaults_to_720p() { + assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1)); + } + } + + // -- 480p pricing -- + + mod pricing_480p { + use super::*; + + #[test] + fn batch_1() { + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 21); + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 26); + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 52); + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 78); + } + + #[test] + fn batch_2() { + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 52); + } + + #[test] + fn batch_4() { + assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 104); + } + } + + // -- Relative pricing -- + + mod relative_pricing_tests { + use super::*; + + #[test] + fn cost_480p_cheaper_than_720p() { + let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1); + let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1); + assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1); + let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1); + let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1); + let b2 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2); + let b4 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // -- Credits equal cents -- + + mod credits_tests { + use super::*; + + #[test] + fn credits_equal_usd_cents_all_combos() { + let resolutions = [ + Some(CommonResolution::FourEightyP), + Some(CommonResolution::SevenTwentyP), + None, + ]; + for res in resolutions { + for dur in [4, 5, 10, 15] { + for batch in [1, 2, 4] { + let cost = build_cost(res, dur, batch); + assert_eq!( + cost.cost_in_credits, cost.cost_in_usd_cents, + "credits should equal cents for res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // -- Cross-check with Kinovi Fast via builder -- + + mod cross_check_with_kinovi { + use super::*; + + #[test] + fn artcraft_fast_matches_kinovi_fast_all_combos() { + let resolutions = [ + Some(CommonResolution::FourEightyP), + Some(CommonResolution::SevenTwentyP), + None, + ]; + let durations: [u16; 4] = [4, 5, 10, 15]; + let batches: [u16; 3] = [1, 2, 4]; + + for res in &resolutions { + for dur in &durations { + for batch in &batches { + let artcraft_cost = build_cost(*res, *dur, *batch); + + let kinovi = GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::Seedance2Pro, + resolution: *res, + duration_seconds: Some(*dur), + video_batch_count: Some(*batch), + ..Default::default() + }; + let kinovi_cost = kinovi.build2() + .expect("kinovi build2") + .estimate_cost() + .expect("kinovi estimate_cost"); + + assert_eq!( + artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents, + "USD cents mismatch: res={:?} dur={}s batch={}", + res, dur, batch, + ); + } + } + } + } + } + + // -- Helpers -- + + fn build_cost( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate { + let builder = GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::Artcraft, + resolution, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + builder.build2() + .expect("build2 should succeed") + .estimate_cost() + .expect("estimate_cost should succeed") + } + + fn cost_cents( + resolution: Option, + duration_seconds: u16, + video_batch_count: u16, + ) -> u64 { + build_cost(resolution, duration_seconds, video_batch_count) + .cost_in_usd_cents + .unwrap() + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/request.rs new file mode 100644 index 0000000000..0a8948e761 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0_fast/request.rs @@ -0,0 +1,242 @@ +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate; + +use crate::client::router_artcraft_client::RouterArtcraftClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{ + ArtcraftVideoResponsePayload, GenerateVideoResponse, +}; + +#[derive(Clone, Debug)] +pub struct ArtcraftSeedance2p0FastRequestState { + /// Final materialized request; ready to fire via the omni-gen video endpoint. + pub request: OmniGenVideoCostAndGenerateRequest, +} + +impl ArtcraftSeedance2p0FastRequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + let response = omni_gen_video_generate( + &client.api_host, + Some(&client.credentials), + self.request.clone(), + ) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?; + + Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload { + inference_job_token: response.inference_job_token.clone(), + all_inference_job_tokens: vec![response.inference_job_token], + })) + } +} + +#[cfg(test)] +mod tests { + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::provider::Provider; + use crate::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_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + + use test_data::web::character_tokens::{JIM, KNIGHT}; + use test_data::web::image_media_tokens::{ + FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN, + JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN, + WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN, + }; + + // -- Aspect ratio tests -- + + mod aspect_ratio_tests { + use super::*; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn landscape() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..artcraft_fast_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 portrait() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A cat sitting on a windowsill watching rain.".to_string()), + aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen), + ..artcraft_fast_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 square() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A hummingbird hovering near a flower.".to_string()), + aspect_ratio: Some(CommonAspectRatio::Square), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Resolution tests -- + + mod resolution_tests { + use super::*; + + #[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(CommonResolution::FourEightyP), + ..artcraft_fast_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_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()), + resolution: Some(CommonResolution::SevenTwentyP), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Modality tests -- + + mod modality_tests { + use super::*; + use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN; + + #[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 sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..artcraft_fast_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 keyframe_start_and_end_frame() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog walks from the lake to the forest.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))), + end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..artcraft_fast_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 image_references() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()), + reference_images: Some(ImageListRef::MediaFileTokens(vec![ + MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()), + MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()), + MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()), + ])), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..artcraft_fast_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 character_references() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()), + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new(JIM.token.to_string()), + CharacterToken::new(KNIGHT.token.to_string()), + ])), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..artcraft_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // -- Helpers -- + + fn artcraft_fast_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::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)"), + }; + + 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_v2/video_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs index 8a5141b85e..e5f7828302 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs @@ -4,6 +4,8 @@ use crate::generate::generate_video::generate_video_response::GenerateVideoRespo use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::cost::ArtcraftSeedance2p0CostState; use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; +use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState; +use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState; @@ -12,6 +14,7 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re #[derive(Clone, Debug)] pub enum VideoGenerationRequest { ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState), + ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState), KinoviSeedance2p0(KinoviSeedance2p0RequestState), KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState), } @@ -22,6 +25,7 @@ impl VideoGenerationRequest { pub fn estimate_cost(&self) -> Result { match self { VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()), + VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()), VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()), VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()), } @@ -35,6 +39,10 @@ impl VideoGenerationRequest { let client_ref = client.get_artcraft_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => { + let client_ref = client.get_artcraft_client_ref()?; + request.send(client_ref).await + }, VideoGenerationRequest::KinoviSeedance2p0(request) => { let client_ref = client.get_seedance2pro_client_ref()?; request.send(client_ref).await diff --git a/crates/testing/test_data/src/web/image_media_tokens.rs b/crates/testing/test_data/src/web/image_media_tokens.rs index 05bbc100c5..5334117228 100644 --- a/crates/testing/test_data/src/web/image_media_tokens.rs +++ b/crates/testing/test_data/src/web/image_media_tokens.rs @@ -3,3 +3,4 @@ pub const JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN: &str = "m_m1bz02z1kkzanxy6rb4vk1kvq9de9g"; pub const FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN: &str = "m_f5yy0fdmkwrf2hxaq4yw4sd95tvvrf"; pub const WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN: &str = "m_xmzdeqhjpxs5418kvepcrj5a3vkfb1"; +pub const TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN: &str = "m_5y81rcnpnrbey55da6cc2s9z4d2f45"; \ No newline at end of file