first implementation

This commit is contained in:
Brandon Thomas committed 2026-04-21 19:04:37 -04:00
1 parent a560541b62
commit f7601e6a35
10 files changed
+1217 -4

No files matched your search

@@ -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,
@@ -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<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
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(),
@@ -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`:
@@ -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<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
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<ImageRef>,
) -> Result<Option<tokens::tokens::media_files::MediaFileToken>, 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<ImageListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, 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<VideoListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, 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<AudioListRef>,
) -> Result<Option<Vec<tokens::tokens::media_files::MediaFileToken>>, 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<CharacterListRef>,
) -> Option<Vec<tokens::tokens::characters::CharacterToken>> {
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<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0AspectRatio>, 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<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance2p0OutputResolution>, 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<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
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<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, 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<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> ArtcraftSeedance2p0RequestState {
match result.expect("build should succeed") {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0 request"),
}
}
}
@@ -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<Seedance2p0OutputResolution>,
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),
},
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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<GenerateVideoResponse, ArtcraftRouterError> {
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
}
}
@@ -1 +1,2 @@
pub mod artcraft;
pub mod kinovi;
@@ -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<VideoGenerationCostEstimate, ArtcraftRouterError> {
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<GenerateVideoResponse, ArtcraftRouterError> {
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