diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs index dd9d5d8acf..f9279c3c63 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/generate/video/multi_function/seedance_2p0_multi_function_video_gen.rs @@ -7,7 +7,7 @@ use utoipa::ToSchema; pub const SEEDANCE_2P0_MULTI_FUNCTION_VIDEO_GEN_PATH: &str = "/v1/generate/video/multi_function/seedance_2p0"; /// Seedance 2.0 multi-function video generation (text-to-video, image-to-video, and reference-based). -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct Seedance2p0MultiFunctionVideoGenRequest { /// Idempotency token to prevent duplicate requests. pub uuid_idempotency_token: String, 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 7b29d8b595..2b3300efd2 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 @@ -47,6 +47,7 @@ use crate::generate::generate_video::plan::muapi::plan_generate_video_muapi_seed use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::plan_generate_video_seedance2pro_seedance2p0; 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::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; @@ -140,6 +141,7 @@ impl GenerateVideoRequestBuilder { pub fn use_new_builder(&self) -> bool { match (self.provider, self.model) { + (Provider::Artcraft, CommonVideoModel::Seedance2p0) => true, (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true, (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true, _ => false, @@ -149,6 +151,7 @@ impl GenerateVideoRequestBuilder { // New builder (transitional state) pub fn build2(self) -> Result { match (self.provider, self.model) { + (Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(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 610d46da58..3c4d539e91 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 @@ -28,7 +28,7 @@ VideoGenerationDraftOrRequest │ ▼ │ GenerateVideoResponse │ - └─ Request(VideoGenerationRequest) ← skips draft phase (not used yet) + └─ Request(VideoGenerationRequest) ← skips draft phase (used by Artcraft provider) ``` ### Key types @@ -38,7 +38,9 @@ VideoGenerationDraftOrRequest gates which provider+model combos use the v2 path. `build2()` dispatches to the correct provider's build function. -- **`VideoGenerationDraftOrRequest`** — the output of `build2()`. Currently always `Draft`. +- **`VideoGenerationDraftOrRequest`** — the output of `build2()`. Either `Draft` (for providers + that need media upload/resolution, like Kinovi) or `Request` (for providers that work with + media file tokens directly, like Artcraft). - **`VideoGenerationDraftRequest`** — enum with one variant per provider+model. Holds materialized settings (resolution, duration, batch count) plus unresolved media references. @@ -68,6 +70,12 @@ generate_video_v2/ ├── video_generation_draft_or_request.rs ├── 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) + │ ├── 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 omni-gen API └── kinovi/ ← Kinovi/Seedance2Pro provider ├── mod.rs ├── resolve.rs ← shared: media token resolution, upload helpers @@ -93,13 +101,17 @@ generate_video_v2/ - `build.rs` — `build_{provider}_{model}(builder) -> VideoGenerationDraftOrRequest`. Contains `plan_*` helpers for aspect ratio, resolution, batch count, duration. - `draft.rs` — draft state struct with `to_request()` async method for finalization. - - `cost.rs` — cost state with `from_draft()`, `from_request()`, and `estimate_cost()`. + **Not needed** if the provider uses media tokens directly (no upload/resolution needed); + in that case `build.rs` returns `VideoGenerationDraftOrRequest::Request(...)` directly. + - `cost.rs` — cost state with `from_request()` and `estimate_cost()`. Add `from_draft()` + if the provider uses the draft phase. - `request.rs` — request state with `send()` async method. 2. **Register the module** in the parent provider's `mod.rs`. 3. **Add enum variants** in: - `video_generation_draft.rs` — `VideoGenerationDraftRequest` enum + `estimate_cost` + `finalize` + (skip if provider has no draft phase) - `video_generation_request.rs` — `VideoGenerationRequest` enum + `estimate_cost` + `send_request` 4. **Wire into the builder** in `generate_video_request_builder.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 new file mode 100644 index 0000000000..a9daa8a352 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/mod.rs @@ -0,0 +1 @@ +pub mod seedance_2p0; 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 new file mode 100644 index 0000000000..f58e0f4283 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/build.rs @@ -0,0 +1,615 @@ +use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ + Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, + Seedance2p0OutputResolution, +}; + +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::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; + +pub fn build_artcraft_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Result { + 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 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 idempotency_token = builder.get_or_generate_idempotency_token(); + + let request = Seedance2p0MultiFunctionVideoGenRequest { + uuid_idempotency_token: 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, + aspect_ratio, + output_resolution, + duration_seconds, + batch_count: Some(batch_count), + }; + + let state = ArtcraftSeedance2p0RequestState { request }; + Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state))) +} + +// ── Resolve 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> { + 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)) + } + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => { + Ok(Some(Seedance2p0AspectRatio::Portrait9x16)) + } + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => { + Ok(Some(Seedance2p0AspectRatio::Square1x1)) + } + Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)), + + // Mismatches — apply strategy + 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))) + } + }, + } +} + +/// Pick the nearest supported aspect ratio by AR value (width / height). +fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio { + 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, + } +} + +// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> 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)), + + // Mismatches + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(match unsupported { + CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP, + _ => Seedance2p0OutputResolution::TenEightyP, + })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP, + _ => Seedance2p0OutputResolution::TenEightyP, + })) + } + }, + } +} + +// Seedance2p0 supports batch counts of 1, 2, and 4 only. +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 => Ok(Seedance2p0BatchCount::One), + 2 => Ok(Seedance2p0BatchCount::Two), + 4 => Ok(Seedance2p0BatchCount::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four }) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four }) + } + }, + } +} + +// Seedance2p0 supports duration of 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 artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ + Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0OutputResolution, + }; + 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 ── + + mod materialized_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")); + } + + #[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()); + } + + #[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()); + } + + #[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)); + } + + #[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))); + } + } + + // ── Aspect ratio conversions ── + + mod aspect_ratio_conversions { + 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))); + } + + #[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))); + } + + #[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))); + } + + #[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()); + } + } + + // ── Resolution conversions ── + + mod resolution_conversions { + 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))); + } + + #[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))); + } + + #[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))); + } + + #[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()); + } + + #[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 { + 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") { + VideoGenerationDraftOrRequest::Request( + VideoGenerationRequest::ArtcraftSeedance2p0(state) + ) => state, + _ => panic!("expected ArtcraftSeedance2p0 request"), + } + } +} 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 new file mode 100644 index 0000000000..dd4e6e994e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/cost.rs @@ -0,0 +1,456 @@ +use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ + Seedance2p0BatchCount, Seedance2p0OutputResolution, +}; + +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState; + +// ── Pricing constants ── + +/// Credits consumed per second of video by resolution tier. +const CREDITS_PER_SECOND_480P: u64 = 15; +const CREDITS_PER_SECOND_720P: u64 = 40; +const CREDITS_PER_SECOND_1080P: u64 = 90; + +/// Credits per dollar — determines the USD conversion rate. +/// 720p uses legacy pricing (25,000 credits for $99.99 ~ 250/dollar). +/// 480p and 1080p use new pricing (22,000 credits for $114 ~ 193/dollar). +const CREDITS_PER_DOLLAR_720P: f64 = 250.0; +const CREDITS_PER_DOLLAR_480P: f64 = 193.0; +const CREDITS_PER_DOLLAR_1080P: f64 = 193.0; + +pub struct ArtcraftSeedance2p0CostState { + pub resolution: Seedance2p0OutputResolution, + pub duration_seconds: u8, + pub batch_count: Seedance2p0BatchCount, + 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 duration_seconds = request.request.duration_seconds.unwrap_or(5); + let batch_count = request.request.batch_count + .unwrap_or(Seedance2p0BatchCount::One); + 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 credits_per_second = match self.resolution { + Seedance2p0OutputResolution::FourEightyP => CREDITS_PER_SECOND_480P, + Seedance2p0OutputResolution::SevenTwentyP => CREDITS_PER_SECOND_720P, + Seedance2p0OutputResolution::TenEightyP => CREDITS_PER_SECOND_1080P, + }; + + let batch_multiplier: u64 = match self.batch_count { + Seedance2p0BatchCount::One => 1, + Seedance2p0BatchCount::Two => 2, + Seedance2p0BatchCount::Four => 4, + }; + + let credits = (self.duration_seconds as u64) * credits_per_second * batch_multiplier; + + let credits_per_dollar = match self.resolution { + Seedance2p0OutputResolution::FourEightyP => CREDITS_PER_DOLLAR_480P, + Seedance2p0OutputResolution::SevenTwentyP => CREDITS_PER_DOLLAR_720P, + Seedance2p0OutputResolution::TenEightyP => CREDITS_PER_DOLLAR_1080P, + }; + + let usd_cents = (credits as f64 / credits_per_dollar * 100.0).round() as u64; + + VideoGenerationCostEstimate { + cost_in_credits: Some(credits), + 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 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 crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; + + use super::*; + + // ── 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); + } + + #[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); + } + + #[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); + } + } + + // ── 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); + } + + #[test] + fn cost_480p_batch_2() { + assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Two), 78); + } + + #[test] + fn cost_480p_batch_4() { + assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Four), 155); + } + } + + // ── 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); + } + + #[test] + fn cost_1080p_batch_2() { + assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two), 466); + } + + #[test] + fn cost_1080p_batch_4() { + assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four), 933); + } + } + + // ── 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); + } + + #[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); + 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); + assert!(b1 < b2); + assert!(b2 < b4); + } + } + + // ── Video reference does NOT affect cost (yet) ── + + #[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 { + 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)); + } + } + + // ── Cross-check: Artcraft v2 matches Kinovi v2 pricing ── + + mod cross_check_with_kinovi { + use seedance2pro_client::requests::generate_video::generate_video::{ + KinoviBatchCount, KinoviOutputResolution, + }; + + use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState; + + use super::*; + + #[test] + fn matches_kinovi_720p_5s_b1() { + assert_prices_match(Seedance2p0OutputResolution::SevenTwentyP, KinoviOutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One, KinoviBatchCount::One); + } + + #[test] + fn matches_kinovi_720p_15s_b2() { + assert_prices_match(Seedance2p0OutputResolution::SevenTwentyP, KinoviOutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Two, KinoviBatchCount::Two); + } + + #[test] + fn matches_kinovi_480p_5s_b1() { + assert_prices_match(Seedance2p0OutputResolution::FourEightyP, KinoviOutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One, KinoviBatchCount::One); + } + + #[test] + fn matches_kinovi_480p_10s_b4() { + assert_prices_match(Seedance2p0OutputResolution::FourEightyP, KinoviOutputResolution::FourEightyP, 10, Seedance2p0BatchCount::Four, KinoviBatchCount::Four); + } + + #[test] + fn matches_kinovi_1080p_5s_b1() { + assert_prices_match(Seedance2p0OutputResolution::TenEightyP, KinoviOutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One, KinoviBatchCount::One); + } + + #[test] + fn matches_kinovi_1080p_10s_b2() { + assert_prices_match(Seedance2p0OutputResolution::TenEightyP, KinoviOutputResolution::TenEightyP, 10, Seedance2p0BatchCount::Two, KinoviBatchCount::Two); + } + + #[test] + fn matches_kinovi_720p_4s_b4() { + assert_prices_match(Seedance2p0OutputResolution::SevenTwentyP, KinoviOutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Four, KinoviBatchCount::Four); + } + + #[test] + fn matches_kinovi_1080p_15s_b4() { + assert_prices_match(Seedance2p0OutputResolution::TenEightyP, KinoviOutputResolution::TenEightyP, 15, Seedance2p0BatchCount::Four, KinoviBatchCount::Four); + } + + fn assert_prices_match( + artcraft_res: Seedance2p0OutputResolution, + kinovi_res: KinoviOutputResolution, + duration_seconds: u8, + artcraft_batch: Seedance2p0BatchCount, + kinovi_batch: KinoviBatchCount, + ) { + let artcraft_cost = ArtcraftSeedance2p0CostState { + resolution: artcraft_res, + duration_seconds, + batch_count: artcraft_batch, + has_video_reference: false, + }.estimate_cost(); + + let kinovi_cost = KinoviSeedance2p0CostState { + resolution: kinovi_res, + duration_seconds, + batch_count: kinovi_batch, + has_video_reference: false, + }.estimate_cost(); + + assert_eq!( + artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents, + "USD cents mismatch for {:?} {}s {:?}: artcraft={:?}, kinovi={:?}", + artcraft_res, duration_seconds, artcraft_batch, + artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents, + ); + } + } + + // ── Credits spot checks ── + + mod credits_tests { + use super::*; + + #[test] + fn credits_720p() { + assert_eq!(credits(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 200); + } + + #[test] + fn credits_480p() { + assert_eq!(credits(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 75); + } + + #[test] + fn credits_1080p() { + assert_eq!(credits(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 450); + } + } + + // ── 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 } + .estimate_cost() + .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/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/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/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/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/request.rs new file mode 100644 index 0000000000..6022d04928 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/artcraft/seedance_2p0/request.rs @@ -0,0 +1,114 @@ +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 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 ArtcraftSeedance2p0RequestState { + /// Final materialized request; ready to fire. + pub request: Seedance2p0MultiFunctionVideoGenRequest, +} + +impl ArtcraftSeedance2p0RequestState { + pub async fn send(&self, client: &RouterArtcraftClient) -> Result { + let response = seedance_2p0_multi_function_video_gen( + &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, + all_inference_job_tokens: response.all_inference_job_tokens, + })) + } +} + +#[cfg(test)] +mod tests { + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + 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; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video_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_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 text_to_video_1080p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A fox walking through a snowy forest.".to_string()), + resolution: Some(CommonResolution::TenEightyP), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..artcraft_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Artcraft(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + // ── Helpers ── + + fn artcraft_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + 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/providers/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/mod.rs index 7444072b71..6fe4c6b3b5 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/mod.rs @@ -1 +1,2 @@ +pub mod artcraft; pub mod kinovi; \ No newline at end of file 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 e3cf236d9f..8a5141b85e 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 @@ -2,6 +2,8 @@ use crate::client::router_client::RouterClient; use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::generate::generate_video::generate_video_response::GenerateVideoResponse; 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::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; @@ -9,6 +11,7 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re #[derive(Clone, Debug)] pub enum VideoGenerationRequest { + ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState), KinoviSeedance2p0(KinoviSeedance2p0RequestState), KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState), } @@ -18,6 +21,7 @@ impl VideoGenerationRequest { /// Return a cost estimate to fulfill the request. pub fn estimate_cost(&self) -> Result { match self { + VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::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()), } @@ -27,6 +31,10 @@ impl VideoGenerationRequest { /// If successful, returns the job IDs. pub async fn send_request(&self, client: &RouterClient) -> Result { match self { + VideoGenerationRequest::ArtcraftSeedance2p0(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