From 0f669af37cd6e1c1254dd038fd52814facbb4976 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 21 Apr 2026 18:12:15 -0400 Subject: [PATCH] seedance 2.0 fast kinovi impl --- .../generate_video_request_builder.rs | 3 + .../src/generate/generate_video_v2/CLAUDE.md | 110 ++++ .../generate_video_v2/providers/kinovi/mod.rs | 2 + .../providers/kinovi/resolve.rs | 126 ++++ .../providers/kinovi/seedance_2p0/draft.rs | 142 +---- .../kinovi/seedance_2p0_fast/build.rs | 590 ++++++++++++++++++ .../kinovi/seedance_2p0_fast/cost.rs | 405 ++++++++++++ .../kinovi/seedance_2p0_fast/draft.rs | 98 +++ .../providers/kinovi/seedance_2p0_fast/mod.rs | 4 + .../kinovi/seedance_2p0_fast/request.rs | 257 ++++++++ .../video_generation_draft.rs | 8 + .../video_generation_request.rs | 8 + 12 files changed, 1619 insertions(+), 134 deletions(-) create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/CLAUDE.md create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/resolve.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/draft.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs 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 caf645d9aa..7b29d8b595 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/generate_video_request_builder.rs @@ -48,6 +48,7 @@ use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_see use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0_fast::plan_generate_video_seedance2pro_seedance2p0_fast; use crate::generate::generate_video::video_generation_plan::VideoGenerationPlan; use crate::generate::generate_video_v2::providers::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; /// Plan to either (1) generate a video or (2) determine how much it costs to generate that video. @@ -140,6 +141,7 @@ impl GenerateVideoRequestBuilder { pub fn use_new_builder(&self) -> bool { match (self.provider, self.model) { (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true, + (Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true, _ => false, } } @@ -148,6 +150,7 @@ impl GenerateVideoRequestBuilder { pub fn build2(self) -> Result { match (self.provider, self.model) { (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 new file mode 100644 index 0000000000..610d46da58 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/CLAUDE.md @@ -0,0 +1,110 @@ +# generate_video_v2 + +This module implements the v2 video generation pipeline. It replaces the v1 system +(in `generate_video/`) with a two-phase approach that separates request planning from +execution, allowing cost estimation and media uploads to happen at the right time. + +## Lifecycle + +``` +GenerateVideoRequestBuilder + │ + ▼ build2() +VideoGenerationDraftOrRequest + │ + ├─ Draft(VideoGenerationDraftRequest) + │ │ + │ ▼ estimate_cost() ← cost estimate from draft state + │ │ + │ ▼ finalize(context) ← uploads media, resolves tokens + │ │ + │ ▼ + │ VideoGenerationRequest + │ │ + │ ▼ estimate_cost() ← cost estimate from finalized request + │ │ + │ ▼ send_request() ← fires the API call + │ │ + │ ▼ + │ GenerateVideoResponse + │ + └─ Request(VideoGenerationRequest) ← skips draft phase (not used yet) +``` + +### Key types + +- **`GenerateVideoRequestBuilder`** — generic builder with provider/model/prompt/resolution/etc. + Located in `generate_video/generate_video_request_builder.rs`. The `use_new_builder()` method + 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`. + +- **`VideoGenerationDraftRequest`** — enum with one variant per provider+model. Holds + materialized settings (resolution, duration, batch count) plus unresolved media references. + Can estimate cost without network calls. + +- **`VideoGenerationDraftContext`** — context needed during `finalize()`: the provider client, + media file token-to-URL map, and character token-to-ID map. + +- **`VideoGenerationRequest`** — enum with one variant per provider+model. Holds a fully + materialized request ready to send. All media URLs are resolved and uploaded. + +### Cost estimation + +Cost can be estimated at two stages: +1. **From draft** — before `finalize()`. Uses the planned resolution/duration/batch count. + Good for showing the user a price before committing. +2. **From request** — after `finalize()`. Uses the final materialized request. Should match + the draft estimate for the same parameters. + +## Module layout + +``` +generate_video_v2/ +├── mod.rs +├── video_generation_draft.rs ← VideoGenerationDraftRequest enum +├── video_generation_draft_context.rs ← context for finalize() +├── video_generation_draft_or_request.rs +├── video_generation_request.rs ← VideoGenerationRequest enum +└── providers/ + └── kinovi/ ← Kinovi/Seedance2Pro provider + ├── mod.rs + ├── resolve.rs ← shared: media token resolution, upload helpers + ├── upload.rs ← shared: download + re-upload to Seedance2Pro CDN + ├── seedance_2p0/ ← Seedance 2.0 Pro model + │ ├── mod.rs + │ ├── build.rs ← builder → draft conversion + plan helpers + │ ├── cost.rs ← cost estimation state + │ ├── draft.rs ← draft state + finalize (to_request) + │ └── request.rs ← request state + send() + └── seedance_2p0_fast/ ← Seedance 2.0 Fast model + ├── mod.rs + ├── build.rs + ├── cost.rs + ├── draft.rs + └── request.rs +``` + +## Adding a new provider or model + +1. **Create the module** under `providers/{provider}/{model}/` with these files: + - `mod.rs` — declares sub-modules + - `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()`. + - `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` + - `video_generation_request.rs` — `VideoGenerationRequest` enum + `estimate_cost` + `send_request` + +4. **Wire into the builder** in `generate_video_request_builder.rs`: + - Add the `(Provider, Model)` pair to `use_new_builder()` returning `true` + - Add the dispatch arm in `build2()` + +5. **Tests**: each `build.rs` and `cost.rs` should have comprehensive tests. `request.rs` + should have `#[ignore]` live API tests for manual verification. diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/mod.rs index 671c1f9e75..2e7d1daa7a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/mod.rs @@ -1,2 +1,4 @@ pub mod seedance_2p0; +pub mod seedance_2p0_fast; +pub(crate) mod resolve; pub(crate) mod upload; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/resolve.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/resolve.rs new file mode 100644 index 0000000000..84aea6eb1f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/resolve.rs @@ -0,0 +1,126 @@ +use std::collections::HashMap; + +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use tokens::tokens::media_files::MediaFileToken; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video_v2::providers::kinovi::upload::upload_to_seedance2pro; +use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext; + +/// Either resolved URLs or unresolved media file tokens. +pub(crate) enum UrlsOrTokens { + Urls(Vec), + Tokens(Vec), +} + +pub(crate) fn image_list_ref_into_urls_or_tokens(list: ImageListRef) -> UrlsOrTokens { + match list { + ImageListRef::Urls(urls) => UrlsOrTokens::Urls(urls), + ImageListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), + } +} + +pub(crate) fn video_list_ref_into_urls_or_tokens(list: VideoListRef) -> UrlsOrTokens { + match list { + VideoListRef::Urls(urls) => UrlsOrTokens::Urls(urls), + VideoListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), + } +} + +pub(crate) fn audio_list_ref_into_urls_or_tokens(list: AudioListRef) -> UrlsOrTokens { + match list { + AudioListRef::Urls(urls) => UrlsOrTokens::Urls(urls), + AudioListRef::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), + } +} + +/// Resolve a single ImageRef and upload to Seedance2Pro CDN. +pub(crate) async fn resolve_and_upload_single( + session: &Seedance2ProSession, + image_ref: Option, + maybe_map: Option<&HashMap>, +) -> Result, ArtcraftRouterError> { + let source_url = match image_ref { + None => return Ok(None), + Some(ImageRef::Url(url)) => url, + Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?, + }; + Ok(Some(upload_to_seedance2pro(session, &source_url).await?)) +} + +/// Resolve a list of refs to URLs and upload each to Seedance2Pro CDN. +/// Order is preserved. +pub(crate) async fn resolve_and_upload_list( + session: &Seedance2ProSession, + urls_or_tokens: Option, + maybe_map: Option<&HashMap>, +) -> Result>, ArtcraftRouterError> { + let source_urls = match urls_or_tokens { + None => return Ok(None), + Some(UrlsOrTokens::Urls(urls)) if urls.is_empty() => return Ok(None), + Some(UrlsOrTokens::Urls(urls)) => urls, + Some(UrlsOrTokens::Tokens(tokens)) if tokens.is_empty() => return Ok(None), + Some(UrlsOrTokens::Tokens(tokens)) => resolve_tokens(maybe_map, &tokens)?, + }; + + let mut uploaded = Vec::with_capacity(source_urls.len()); + for url in &source_urls { + uploaded.push(upload_to_seedance2pro(session, url).await?); + } + Ok(Some(uploaded)) +} + +/// Map character tokens to their Kinovi character IDs, preserving order. +pub(crate) fn resolve_character_tokens( + character_list_ref: Option<&CharacterListRef>, + draft_context: &VideoGenerationDraftContext<'_>, +) -> Result>, ArtcraftRouterError> { + let list = match character_list_ref { + None => return Ok(None), + Some(r) => r, + }; + + let tokens = match list { + CharacterListRef::CharacterTokens(tokens) if tokens.is_empty() => return Ok(None), + CharacterListRef::CharacterTokens(tokens) => tokens, + }; + + let map = draft_context.get_character_token_to_kinovi_map()?; + + let ids: Result, _> = tokens.iter() + .map(|token| { + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::CharacterTokenNotFoundInMap { + token: token.clone(), + }) + }) + }) + .collect(); + + ids.map(Some) +} + +fn resolve_token( + maybe_map: Option<&HashMap>, + token: &MediaFileToken, +) -> Result { + let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; + map.get(token).cloned().ok_or_else(|| { + ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { + token: token.clone(), + }) + }) +} + +fn resolve_tokens( + maybe_map: Option<&HashMap>, + tokens: &[MediaFileToken], +) -> Result, ArtcraftRouterError> { + tokens.iter().map(|t| resolve_token(maybe_map, t)).collect() +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/draft.rs index d584629384..c1e43bdd78 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/draft.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0/draft.rs @@ -1,8 +1,4 @@ -use std::collections::HashMap; - -use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution}; -use tokens::tokens::media_files::MediaFileToken; use crate::api::audio_list_ref::AudioListRef; use crate::api::character_list_ref::CharacterListRef; @@ -10,9 +6,12 @@ use crate::api::image_list_ref::ImageListRef; use crate::api::image_ref::ImageRef; use crate::api::video_list_ref::VideoListRef; use crate::errors::artcraft_router_error::ArtcraftRouterError; -use crate::errors::client_error::ClientError; +use crate::generate::generate_video_v2::providers::kinovi::resolve::{ + audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, + resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens, + video_list_ref_into_urls_or_tokens, +}; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState; -use crate::generate::generate_video_v2::providers::kinovi::upload::upload_to_seedance2pro; use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext; #[derive(Debug, Clone)] @@ -61,15 +60,15 @@ impl KinoviSeedance2p0DraftState { end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?; reference_image_urls = resolve_and_upload_list( - session, remaining.reference_images.map(ImageListRef::into_urls_or_tokens), map, + session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, ).await?; reference_video_urls = resolve_and_upload_list( - session, remaining.reference_videos.map(VideoListRef::into_urls_or_tokens), map, + session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, ).await?; reference_audio_urls = resolve_and_upload_list( - session, remaining.reference_audio.map(AudioListRef::into_urls_or_tokens), map, + session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, ).await?; character_ids = resolve_character_tokens( @@ -97,128 +96,3 @@ impl KinoviSeedance2p0DraftState { Ok(KinoviSeedance2p0RequestState { request }) } } - -/// Either resolved URLs or unresolved media file tokens. -enum UrlsOrTokens { - Urls(Vec), - Tokens(Vec), -} - -// Allow each list ref type to decompose into this common representation. -impl ImageListRef { - fn into_urls_or_tokens(self) -> UrlsOrTokens { - match self { - Self::Urls(urls) => UrlsOrTokens::Urls(urls), - Self::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), - } - } -} - -impl VideoListRef { - fn into_urls_or_tokens(self) -> UrlsOrTokens { - match self { - Self::Urls(urls) => UrlsOrTokens::Urls(urls), - Self::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), - } - } -} - -impl AudioListRef { - fn into_urls_or_tokens(self) -> UrlsOrTokens { - match self { - Self::Urls(urls) => UrlsOrTokens::Urls(urls), - Self::MediaFileTokens(tokens) => UrlsOrTokens::Tokens(tokens), - } - } -} - -// --- Resolve + upload helpers --- - -/// Resolve a single ImageRef and upload to Seedance2Pro CDN. -async fn resolve_and_upload_single( - session: &Seedance2ProSession, - image_ref: Option, - maybe_map: Option<&HashMap>, -) -> Result, ArtcraftRouterError> { - let source_url = match image_ref { - None => return Ok(None), - Some(ImageRef::Url(url)) => url, - Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?, - }; - Ok(Some(upload_to_seedance2pro(session, &source_url).await?)) -} - -/// Resolve a list of refs to URLs and upload each to Seedance2Pro CDN. -/// Order is preserved. -async fn resolve_and_upload_list( - session: &Seedance2ProSession, - urls_or_tokens: Option, - maybe_map: Option<&HashMap>, -) -> Result>, ArtcraftRouterError> { - let source_urls = match urls_or_tokens { - None => return Ok(None), - Some(UrlsOrTokens::Urls(urls)) if urls.is_empty() => return Ok(None), - Some(UrlsOrTokens::Urls(urls)) => urls, - Some(UrlsOrTokens::Tokens(tokens)) if tokens.is_empty() => return Ok(None), - Some(UrlsOrTokens::Tokens(tokens)) => resolve_tokens(maybe_map, &tokens)?, - }; - - let mut uploaded = Vec::with_capacity(source_urls.len()); - for url in &source_urls { - uploaded.push(upload_to_seedance2pro(session, url).await?); - } - Ok(Some(uploaded)) -} - -// --- Token resolution --- - -fn resolve_token( - maybe_map: Option<&HashMap>, - token: &MediaFileToken, -) -> Result { - let map = maybe_map.ok_or(ArtcraftRouterError::Client(ClientError::MediaFileToUrlMapNotProvided))?; - map.get(token).cloned().ok_or_else(|| { - ArtcraftRouterError::Client(ClientError::MediaFileTokenNotFoundInMap { - token: token.clone(), - }) - }) -} - -fn resolve_tokens( - maybe_map: Option<&HashMap>, - tokens: &[MediaFileToken], -) -> Result, ArtcraftRouterError> { - tokens.iter().map(|t| resolve_token(maybe_map, t)).collect() -} - -// --- Character token resolution --- - -/// Map character tokens to their Kinovi character IDs, preserving order. -fn resolve_character_tokens( - character_list_ref: Option<&CharacterListRef>, - draft_context: &VideoGenerationDraftContext<'_>, -) -> Result>, ArtcraftRouterError> { - let list = match character_list_ref { - None => return Ok(None), - Some(r) => r, - }; - - let tokens = match list { - CharacterListRef::CharacterTokens(tokens) if tokens.is_empty() => return Ok(None), - CharacterListRef::CharacterTokens(tokens) => tokens, - }; - - let map = draft_context.get_character_token_to_kinovi_map()?; - - let ids: Result, _> = tokens.iter() - .map(|token| { - map.get(token).cloned().ok_or_else(|| { - ArtcraftRouterError::Client(ClientError::CharacterTokenNotFoundInMap { - token: token.clone(), - }) - }) - }) - .collect(); - - ids.map(Some) -} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/build.rs new file mode 100644 index 0000000000..4f2aa069de --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/build.rs @@ -0,0 +1,590 @@ +use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution}; + +use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::common_resolution::CommonResolution; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::client_error::ClientError; +use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; +use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::{KinoviSeedance2p0FastDraftState, KinoviSeedance2p0FastRemainingItems}; +use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest; +use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + +pub fn build_kinovi_seedance_2p0_fast(builder: GenerateVideoRequestBuilder) -> Result { + let draft = do_build_kinovi_seedance_2p0_fast(builder)?; + Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft))) +} + +fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?; + let resolution = plan_output_resolution(builder.resolution.take(), strategy)?; + let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?; + let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?; + let prompt = builder.prompt.take().unwrap_or_default(); + + let unhandled_request_state = KinoviSeedance2p0FastRemainingItems { + start_frame: builder.start_frame.take(), + end_frame: builder.end_frame.take(), + reference_images: builder.reference_images.take(), + reference_videos: builder.reference_videos.take(), + reference_audio: builder.reference_audio.take(), + reference_character_tokens: builder.reference_character_tokens.take(), + }; + + Ok(KinoviSeedance2p0FastDraftState { + aspect_ratio, + resolution, + batch_count, + duration_seconds, + prompt, + unhandled_request_state: Some(unhandled_request_state), + }) +} + +// ── 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 { + match aspect_ratio { + // No preference or auto — default to landscape + None + | Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9), + + // Direct mappings + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => { + Ok(KinoviAspectRatio::Landscape16x9) + } + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => { + Ok(KinoviAspectRatio::Portrait9x16) + } + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => { + Ok(KinoviAspectRatio::Square1x1) + } + Some(CommonAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3), + Some(CommonAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::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(nearest_aspect_ratio(unsupported)) + } + }, + } +} + +/// Pick the nearest supported aspect ratio by AR value (width / height). +fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviAspectRatio { + match aspect_ratio { + CommonAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33 + CommonAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33 + CommonAspectRatio::WideTwentyOneByNine => KinoviAspectRatio::Landscape16x9, // 2.33, nearest 1.78 + CommonAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75 + CommonAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75 + CommonAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56 + _ => KinoviAspectRatio::Square1x1, + } +} + +// Seedance 2.0 Fast supports output resolutions: 480p and 720p only. +// 1080p is NOT supported — downgrade to 720p or error based on strategy. +fn plan_output_resolution( + resolution: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result, ArtcraftRouterError> { + match resolution { + None => Ok(None), + + // Direct mappings + Some(CommonResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)), + Some(CommonResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)), + + // 1080p is not supported for Fast — handle via strategy + Some(CommonResolution::TenEightyP) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", CommonResolution::TenEightyP), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade + | RequestMismatchMitigationStrategy::PayLessDowngrade => { + // 1080p not available — downgrade to 720p (highest supported) + Ok(Some(KinoviOutputResolution::SevenTwentyP)) + } + }, + + // Other unsupported resolutions + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "resolution", + value: format!("{:?}", unsupported), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(Some(match unsupported { + CommonResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::SevenTwentyP, + })) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(Some(match unsupported { + CommonResolution::HalfK => KinoviOutputResolution::FourEightyP, + _ => KinoviOutputResolution::SevenTwentyP, + })) + } + }, + } +} + +// Seedance2p0Fast 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(KinoviBatchCount::One), + 2 => Ok(KinoviBatchCount::Two), + 4 => Ok(KinoviBatchCount::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "video_batch_count", + value: format!("{}", count), + })) + } + RequestMismatchMitigationStrategy::PayMoreUpgrade => { + Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four }) + } + RequestMismatchMitigationStrategy::PayLessDowngrade => { + Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four }) + } + }, + } +} + +// Seedance2p0Fast supports duration of 4–15 seconds. +fn plan_duration( + duration_seconds: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + const MIN: u16 = 4; + const MAX: u16 = 15; + const DEFAULT: u8 = 5; + match duration_seconds { + None => Ok(DEFAULT), + Some(d) if d >= MIN && d <= MAX => Ok(d as u8), + Some(d) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "duration_seconds", + value: format!("{}", d), + })) + } + _ => Ok(d.clamp(MIN, MAX) as u8), + }, + } +} + +#[cfg(test)] +mod tests { + use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviOutputResolution}; + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::audio_list_ref::AudioListRef; + use crate::api::character_list_ref::CharacterListRef; + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::provider::Provider; + use crate::api::video_list_ref::VideoListRef; + use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState; + use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest; + use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + + use super::*; + + fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::Seedance2Pro, + prompt: Some("a cat dancing".to_string()), + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn unwrap_draft(result: Result) -> KinoviSeedance2p0FastDraftState { + match result.expect("build should succeed") { + VideoGenerationDraftOrRequest::Draft( + VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0Fast draft"), + } + } + + // ── Materialized field conversions ── + + #[test] + fn prompt_is_passed_through() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(seedance2pro_fast_builder())); + assert_eq!(draft.prompt, "a cat dancing"); + } + + #[test] + fn prompt_defaults_to_empty() { + let builder = GenerateVideoRequestBuilder { prompt: None, ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.prompt, ""); + } + + #[test] + fn duration_seconds_converted() { + let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.duration_seconds, 10); + } + + #[test] + fn duration_defaults_to_5() { + let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.duration_seconds, 5); + } + + #[test] + fn duration_clamped_to_max() { + let builder = GenerateVideoRequestBuilder { + duration_seconds: Some(99), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert_eq!(draft.duration_seconds, 15); + } + + #[test] + fn batch_count_one() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::One)); + } + + #[test] + fn batch_count_two() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Two)); + } + + #[test] + fn batch_count_four() { + let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.batch_count, KinoviBatchCount::Four)); + } + + // ── Aspect ratio conversions ── + + #[test] + fn aspect_ratio_wide() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + + #[test] + fn aspect_ratio_tall() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16)); + } + + #[test] + fn aspect_ratio_square() { + let builder = GenerateVideoRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::Square), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1)); + } + + #[test] + fn aspect_ratio_defaults_to_landscape() { + let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9)); + } + + // ── Resolution conversions ── + + #[test] + fn resolution_480p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(CommonResolution::FourEightyP), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP))); + } + + #[test] + fn resolution_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(CommonResolution::SevenTwentyP), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_downgrades_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(CommonResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade, + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_upgrade_also_downgrades_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(CommonResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + #[test] + fn resolution_1080p_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(CommonResolution::TenEightyP), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..seedance2pro_fast_builder() + }; + assert!(build_kinovi_seedance_2p0_fast(builder).is_err()); + } + + #[test] + fn resolution_none() { + let builder = GenerateVideoRequestBuilder { resolution: None, ..seedance2pro_fast_builder() }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(draft.resolution.is_none()); + } + + #[test] + fn unsupported_resolution_error_out() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(CommonResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + ..seedance2pro_fast_builder() + }; + assert!(build_kinovi_seedance_2p0_fast(builder).is_err()); + } + + #[test] + fn unsupported_resolution_rounds_to_720p() { + let builder = GenerateVideoRequestBuilder { + resolution: Some(CommonResolution::FourK), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + } + + // ── unhandled_request_state: media refs are placed there ── + + #[test] + fn unhandled_state_is_present() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(seedance2pro_fast_builder())); + assert!(draft.unhandled_request_state.is_some()); + } + + #[test] + fn start_frame_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::Url(url)) if url == "https://example.com/start.jpg")); + } + + #[test] + fn end_frame_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.end_frame, Some(ImageRef::Url(url)) if url == "https://example.com/end.jpg")); + } + + #[test] + fn start_frame_media_token_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123")); + } + + #[test] + fn reference_images_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_images: Some(ImageListRef::Urls(vec![ + "https://example.com/ref1.jpg".to_string(), + "https://example.com/ref2.jpg".to_string(), + ])), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + match remaining.reference_images { + Some(ImageListRef::Urls(urls)) => { + assert_eq!(urls.len(), 2); + assert_eq!(urls[0], "https://example.com/ref1.jpg"); + assert_eq!(urls[1], "https://example.com/ref2.jpg"); + } + _ => panic!("expected Urls variant"), + } + } + + #[test] + fn reference_videos_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_videos: Some(VideoListRef::Urls(vec![ + "https://example.com/vid.mp4".to_string(), + ])), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.reference_videos, Some(VideoListRef::Urls(urls)) if urls.len() == 1)); + } + + #[test] + fn reference_audio_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_audio: Some(AudioListRef::Urls(vec![ + "https://example.com/audio.mp3".to_string(), + ])), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(matches!(remaining.reference_audio, Some(AudioListRef::Urls(urls)) if urls.len() == 1)); + } + + #[test] + fn character_tokens_placed_in_unhandled() { + let builder = GenerateVideoRequestBuilder { + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_abc".to_string()), + CharacterToken::new("char_def".to_string()), + ])), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + let remaining = draft.unhandled_request_state.unwrap(); + match remaining.reference_character_tokens { + Some(CharacterListRef::CharacterTokens(tokens)) => { + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[0].as_str(), "char_abc"); + assert_eq!(tokens[1].as_str(), "char_def"); + } + _ => panic!("expected CharacterTokens variant"), + } + } + + #[test] + fn empty_refs_are_none_in_unhandled() { + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(seedance2pro_fast_builder())); + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_none()); + assert!(remaining.end_frame.is_none()); + assert!(remaining.reference_images.is_none()); + assert!(remaining.reference_videos.is_none()); + assert!(remaining.reference_audio.is_none()); + assert!(remaining.reference_character_tokens.is_none()); + } + + // ── 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::SevenTwentyP), + duration_seconds: Some(10), + video_batch_count: Some(4), + start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())), + end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())), + reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])), + reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])), + reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])), + reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![ + CharacterToken::new("char_xyz".to_string()), + ])), + ..seedance2pro_fast_builder() + }; + let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder)); + + assert_eq!(draft.prompt, "full test"); + assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16)); + assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP))); + assert_eq!(draft.duration_seconds, 10); + assert!(matches!(draft.batch_count, KinoviBatchCount::Four)); + + let remaining = draft.unhandled_request_state.unwrap(); + assert!(remaining.start_frame.is_some()); + assert!(remaining.end_frame.is_some()); + assert!(remaining.reference_images.is_some()); + assert!(remaining.reference_videos.is_some()); + assert!(remaining.reference_audio.is_some()); + assert!(remaining.reference_character_tokens.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/cost.rs new file mode 100644 index 0000000000..24a73e537e --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/cost.rs @@ -0,0 +1,405 @@ +use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution}; +use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; +use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState; +use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState; + +pub struct KinoviSeedance2p0FastCostState { + pub resolution: KinoviOutputResolution, + pub duration_seconds: u8, + pub batch_count: KinoviBatchCount, + + /// Forward-looking: Kinovi charges for this. We need to take it into + /// account in the future. + pub has_video_reference: bool, +} + +impl KinoviSeedance2p0FastCostState { + pub fn from_request(request: &KinoviSeedance2p0FastRequestState) -> Self { + Self { + resolution: request.request.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP), + duration_seconds: request.request.duration_seconds, + batch_count: request.request.batch_count, + has_video_reference: request.request.reference_video_urls + .as_ref() + .is_some_and(|urls| !urls.is_empty()), + } + } + + pub fn from_draft(draft: &KinoviSeedance2p0FastDraftState) -> Self { + let has_video_reference = draft.unhandled_request_state + .as_ref() + .and_then(|rem| rem.reference_videos.as_ref()) + .is_some(); + + Self { + resolution: draft.resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP), + duration_seconds: draft.duration_seconds, + batch_count: draft.batch_count, + has_video_reference, + } + } + + pub fn estimate_cost(&self) -> VideoGenerationCostEstimate { + let request = KinoviGenerateVideoRequest { + // Pricing factors + model_type: KinoviModelType::Seedance2Fast, + output_resolution: Some(self.resolution), + duration_seconds: self.duration_seconds, + batch_count: self.batch_count, + + // NB: has_video_reference is tracked but does not affect price yet. + // When Kinovi starts charging for video references, add cost here. + + // No impact on price + prompt: String::new(), + aspect_ratio: KinoviAspectRatio::Portrait9x16, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls: None, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + }; + + let cost_in_credits = request.estimate_credits(); + let cost_in_usd_cents = request.estimate_cost_in_usd_cents(); + + VideoGenerationCostEstimate { + cost_in_credits: Some(cost_in_credits as u64), + cost_in_usd_cents: Some(cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::provider::Provider; + use crate::api::video_list_ref::VideoListRef; + use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder; + + // ── Direct estimate_cost() tests ── + // These must match the reference implementation values exactly. + + // -- 720p (legacy pricing: 220 credits/$1, 28 credits/sec) -- + + mod pricing_720p { + use super::*; + + #[test] + fn cost_720p_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 51); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 64); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 127); + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 191); + } + + #[test] + fn cost_720p_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 127); + } + + #[test] + fn cost_720p_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 255); + } + } + + // -- 480p (new pricing: 193 credits/$1, 10 credits/sec) -- + + mod pricing_480p { + use super::*; + + #[test] + fn cost_480p_batch_1() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 21); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 26); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 52); + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 78); + } + + #[test] + fn cost_480p_batch_2() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 52); + } + + #[test] + fn cost_480p_batch_4() { + assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 104); + } + } + + // -- Relative pricing -- + + #[test] + fn cost_480p_cheaper_than_720p() { + let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One); + let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One); + assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720); + } + + #[test] + fn cost_scales_with_duration() { + let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One); + let c10 = usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One); + let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One); + assert!(c4 < c10); + assert!(c10 < c15); + } + + #[test] + fn cost_scales_with_batch() { + let b1 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One); + let b2 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two); + let b4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four); + assert!(b1 < b2); + assert!(b2 < b4); + } + + // -- Video reference does NOT affect cost (yet) -- + + #[test] + fn video_reference_does_not_affect_cost() { + let base = KinoviSeedance2p0FastCostState { + resolution: KinoviOutputResolution::SevenTwentyP, + duration_seconds: 5, + batch_count: KinoviBatchCount::One, + has_video_reference: false, + }; + let without = base.estimate_cost(); + let with = KinoviSeedance2p0FastCostState { 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(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP)); + assert_eq!(cost.duration_seconds, 5); + assert!(matches!(cost.batch_count, KinoviBatchCount::One)); + assert!(!cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64)); + } + + #[test] + fn from_request_none_defaults_to_720p() { + let req = make_request_state(None, 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64)); + } + + #[test] + fn from_request_480p() { + let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26)); + } + + #[test] + fn from_request_with_video_reference() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(cost.has_video_reference); + // Video refs don't affect cost yet + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64)); + } + + #[test] + fn from_request_without_video_reference() { + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let cost = KinoviSeedance2p0FastCostState::from_request(&req); + assert!(!cost.has_video_reference); + } + } + + // ── from_draft() tests ── + + mod from_draft_tests { + use super::*; + + #[test] + fn from_draft_720p_default() { + let draft = make_draft(5, 1, None, false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert!(matches!(cost.resolution, KinoviOutputResolution::SevenTwentyP)); + assert_eq!(cost.duration_seconds, 5); + assert!(matches!(cost.batch_count, KinoviBatchCount::One)); + assert!(!cost.has_video_reference); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64)); + } + + #[test] + fn from_draft_480p() { + let draft = make_draft(5, 1, Some(CommonResolution::FourEightyP), false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert!(matches!(cost.resolution, KinoviOutputResolution::FourEightyP)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(26)); + } + + #[test] + fn from_draft_with_video_reference() { + let draft = make_draft(5, 1, None, true); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert!(cost.has_video_reference); + // Video refs don't affect cost yet + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(64)); + } + + #[test] + fn from_draft_without_video_reference() { + let draft = make_draft(5, 1, None, false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert!(!cost.has_video_reference); + } + + #[test] + fn from_draft_duration_15_batch_2() { + let draft = make_draft(15, 2, Some(CommonResolution::SevenTwentyP), false); + let cost = KinoviSeedance2p0FastCostState::from_draft(&draft); + assert_eq!(cost.duration_seconds, 15); + assert!(matches!(cost.batch_count, KinoviBatchCount::Two)); + assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(382)); + } + } + + // ── Cross-check: from_draft matches from_request ── + + mod cross_check_tests { + use super::*; + + #[test] + fn draft_and_request_produce_same_cost() { + // 720p, 5s, batch 1 + let draft = make_draft(5, 1, Some(CommonResolution::SevenTwentyP), false); + let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false); + let draft_cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviSeedance2p0FastCostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + + #[test] + fn draft_and_request_produce_same_cost_480p() { + let draft = make_draft(10, 2, Some(CommonResolution::FourEightyP), false); + let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, false); + let draft_cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost(); + let req_cost = KinoviSeedance2p0FastCostState::from_request(&req).estimate_cost(); + assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents); + assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits); + } + } + + // ── Credits spot checks ── + + #[test] + fn credits_720p() { + assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 140); + } + + #[test] + fn credits_480p() { + assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 50); + } + + // ── Helpers ── + + fn usd_cents( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + ) -> u64 { + KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false } + .estimate_cost() + .cost_in_usd_cents + .unwrap() + } + + fn credits( + resolution: KinoviOutputResolution, + duration_seconds: u8, + batch_count: KinoviBatchCount, + ) -> u64 { + KinoviSeedance2p0FastCostState { resolution, duration_seconds, batch_count, has_video_reference: false } + .estimate_cost() + .cost_in_credits + .unwrap() + } + + /// Build a draft via the builder to test from_draft(). + fn make_draft( + duration_seconds: u16, + video_batch_count: u16, + resolution: Option, + with_video_ref: bool, + ) -> KinoviSeedance2p0FastDraftState { + let reference_videos = if with_video_ref { + Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()])) + } else { + None + }; + + let builder = GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::Seedance2Pro, + resolution, + reference_videos, + duration_seconds: Some(duration_seconds), + video_batch_count: Some(video_batch_count), + ..Default::default() + }; + + match builder.build2().expect("build2 should succeed") { + crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest::Draft( + crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) + ) => draft, + _ => panic!("expected KinoviSeedance2p0Fast draft"), + } + } + + /// Build a request state for from_request() tests. + fn make_request_state( + resolution: Option, + duration_seconds: u8, + batch_count: KinoviBatchCount, + with_video_ref: bool, + ) -> KinoviSeedance2p0FastRequestState { + let reference_video_urls = if with_video_ref { + Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()]) + } else { + None + }; + + KinoviSeedance2p0FastRequestState { + request: KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Fast, + prompt: "test".to_string(), + aspect_ratio: KinoviAspectRatio::Landscape16x9, + output_resolution: resolution, + duration_seconds, + batch_count, + start_frame_url: None, + end_frame_url: None, + reference_image_urls: None, + reference_video_urls, + reference_audio_urls: None, + character_ids: None, + use_face_blur_hack: None, + }, + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/draft.rs new file mode 100644 index 0000000000..6f3890ae23 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/draft.rs @@ -0,0 +1,98 @@ +use seedance2pro_client::requests::generate_video::generate_video::{KinoviAspectRatio, KinoviBatchCount, KinoviGenerateVideoRequest, KinoviModelType, KinoviOutputResolution}; + +use crate::api::audio_list_ref::AudioListRef; +use crate::api::character_list_ref::CharacterListRef; +use crate::api::image_list_ref::ImageListRef; +use crate::api::image_ref::ImageRef; +use crate::api::video_list_ref::VideoListRef; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::generate::generate_video_v2::providers::kinovi::resolve::{ + audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, + resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens, + video_list_ref_into_urls_or_tokens, +}; +use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState; +use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0FastDraftState { + // Materialized / finalized types + + pub prompt: String, + pub aspect_ratio: KinoviAspectRatio, + pub resolution: Option, + pub duration_seconds: u8, + pub batch_count: KinoviBatchCount, + + // Pending types that need to be queried. + pub unhandled_request_state: Option, +} + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0FastRemainingItems { + pub start_frame: Option, + pub end_frame: Option, + pub reference_images: Option, + pub reference_videos: Option, + pub reference_audio: Option, + pub reference_character_tokens: Option, +} + +impl KinoviSeedance2p0FastDraftState { + pub async fn to_request( + &mut self, + draft_context: &VideoGenerationDraftContext<'_>, + ) -> Result { + let client = draft_context.get_seedance2pro_client_ref()?; + let session = &client.session; + + let mut start_frame_url = None; + let mut end_frame_url = None; + let mut reference_image_urls = None; + let mut reference_video_urls = None; + let mut reference_audio_urls = None; + let mut character_ids = None; + + if let Some(remaining) = self.unhandled_request_state.take() { + let map = draft_context.media_file_to_artcraft_url_map; + + start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?; + end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?; + + reference_image_urls = resolve_and_upload_list( + session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, + ).await?; + + reference_video_urls = resolve_and_upload_list( + session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, + ).await?; + + reference_audio_urls = resolve_and_upload_list( + session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, + ).await?; + + character_ids = resolve_character_tokens( + remaining.reference_character_tokens.as_ref(), + draft_context, + )?; + } + + let request = KinoviGenerateVideoRequest { + model_type: KinoviModelType::Seedance2Fast, + prompt: self.prompt.clone(), + aspect_ratio: self.aspect_ratio, + output_resolution: self.resolution, + duration_seconds: self.duration_seconds, + batch_count: self.batch_count, + start_frame_url, + end_frame_url, + reference_image_urls, + reference_video_urls, + reference_audio_urls, + character_ids, + use_face_blur_hack: None, + }; + + Ok(KinoviSeedance2p0FastRequestState { request }) + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/mod.rs new file mode 100644 index 0000000000..fcfc9ddce8 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/mod.rs @@ -0,0 +1,4 @@ +pub mod build; +pub mod draft; +pub mod request; +pub mod cost; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs new file mode 100644 index 0000000000..3ef63ef836 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/providers/kinovi/seedance_2p0_fast/request.rs @@ -0,0 +1,257 @@ +use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload}; +use seedance2pro_client::requests::generate_video::generate_video::{generate_video, GenerateVideoArgs, KinoviGenerateVideoRequest}; + +#[derive(Debug, Clone)] +pub struct KinoviSeedance2p0FastRequestState { + /// Final materialized request; ready to fire. + pub request: KinoviGenerateVideoRequest, +} + +impl KinoviSeedance2p0FastRequestState { + pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result { + let session = &client.session; + + let args = GenerateVideoArgs { + session, + host_override: None, + request: self.request.clone(), // TODO: Yuck. + }; + + let response = generate_video(args) + .await + .map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?; + + Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload { + order_id: response.order_id, + task_id: response.task_id, + })) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tokens::tokens::characters::CharacterToken; + use tokens::tokens::media_files::MediaFileToken; + + use crate::api::character_list_ref::CharacterListRef; + use crate::api::common_aspect_ratio::CommonAspectRatio; + use crate::api::common_resolution::CommonResolution; + use crate::api::common_video_model::CommonVideoModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::image_ref::ImageRef; + use crate::api::provider::Provider; + use crate::api::video_list_ref::VideoListRef; + use crate::client::router_client::RouterClient; + use crate::client::router_seedance2pro_client::RouterSeedance2ProClient; + 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_context::VideoGenerationDraftContext; + use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest; + use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; + + mod aspect_ratio_tests { + use super::*; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn landscape() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..seedance2pro_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn portrait() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A cat sitting on a windowsill watching rain.".to_string()), + aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen), + ..seedance2pro_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn square() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A hummingbird hovering near a flower.".to_string()), + aspect_ratio: Some(CommonAspectRatio::Square), + ..seedance2pro_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + mod resolution_tests { + use super::*; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_480p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A shiba inu playing in autumn leaves.".to_string()), + resolution: Some(CommonResolution::FourEightyP), + ..seedance2pro_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn res_720p() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()), + resolution: Some(CommonResolution::SevenTwentyP), + ..seedance2pro_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + mod modality_tests { + use super::*; + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn text_to_video() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..seedance2pro_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn keyframe_start_and_end_frame() { + let start_token = MediaFileToken::new("mf_start".to_string()); + let end_token = MediaFileToken::new("mf_end".to_string()); + + let mut media_map = HashMap::new(); + media_map.insert(start_token.clone(), test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL.to_string()); + media_map.insert(end_token.clone(), test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL.to_string()); + + let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder { + prompt: Some("The dog walks from the lake to the forest.".to_string()), + start_frame: Some(ImageRef::MediaFileToken(start_token)), + end_frame: Some(ImageRef::MediaFileToken(end_token)), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..seedance2pro_fast_builder() + }, media_map).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + + #[tokio::test] + #[ignore] // manually run — fires a real API request and incurs cost + async fn image_references() { + let response = run_pipeline(GenerateVideoRequestBuilder { + prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()), + reference_images: Some(ImageListRef::Urls(vec![ + test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL.to_string(), + test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL.to_string(), + test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL.to_string(), + ])), + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..seedance2pro_fast_builder() + }).await; + assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_))); + assert_eq!(1, 2, "Inspect output above"); + } + } + + // ── Helpers ── + + fn seedance2pro_fast_builder() -> GenerateVideoRequestBuilder { + GenerateVideoRequestBuilder { + model: CommonVideoModel::Seedance2p0Fast, + provider: Provider::Seedance2Pro, + duration_seconds: Some(4), + video_batch_count: Some(1), + ..Default::default() + } + } + + fn get_seedance2pro_client() -> RouterClient { + let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/seedance2pro_cookies.txt") + .expect("Failed to read seedance2pro cookies"); + let session = Seedance2ProSession::from_cookies_string(cookies.trim().to_string()); + RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)) + } + + /// Run the full pipeline: builder -> build2 -> finalize -> send. + async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse { + let client = get_seedance2pro_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let draft = match draft_or_request { + VideoGenerationDraftOrRequest::Draft(d) => d, + _ => panic!("expected Draft variant"), + }; + + let draft_context = VideoGenerationDraftContext { + client: Some(&client), + ..Default::default() + }; + + let request = draft.finalize(draft_context).await.expect("finalize should succeed"); + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Seedance2Pro(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } + + /// Run the full pipeline with a media file token map for resolving references. + async fn run_pipeline_with_media_map( + builder: GenerateVideoRequestBuilder, + media_map: HashMap, + ) -> GenerateVideoResponse { + let client = get_seedance2pro_client(); + + let draft_or_request = builder.build2().expect("build2 should succeed"); + let draft = match draft_or_request { + VideoGenerationDraftOrRequest::Draft(d) => d, + _ => panic!("expected Draft variant"), + }; + + let draft_context = VideoGenerationDraftContext { + client: Some(&client), + media_file_to_artcraft_url_map: Some(&media_map), + ..Default::default() + }; + + let request = draft.finalize(draft_context).await.expect("finalize should succeed"); + let response = request.send_request(&client).await.expect("send_request should succeed"); + + match &response { + GenerateVideoResponse::Seedance2Pro(p) => { + println!("task_id={}, order_id={}", p.task_id, p.order_id); + } + other => println!("response: {:?}", other), + } + + response + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs index 8e2e6545d7..71bb928d3f 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_draft.rs @@ -3,6 +3,8 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError; use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState; use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState; +use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState; +use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState; use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext; use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest; @@ -12,6 +14,7 @@ use crate::generate::generate_video_v2::video_generation_request::VideoGeneratio #[derive(Clone, Debug)] pub enum VideoGenerationDraftRequest { KinoviSeedance2p0(KinoviSeedance2p0DraftState), + KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState), } impl VideoGenerationDraftRequest { @@ -20,6 +23,7 @@ impl VideoGenerationDraftRequest { pub fn estimate_cost(&self) -> Result { match self { VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()), + VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()), } } @@ -31,6 +35,10 @@ impl VideoGenerationDraftRequest { let result = draft.to_request(&draft_context).await?; Ok(VideoGenerationRequest::KinoviSeedance2p0(result)) }, + VideoGenerationDraftRequest::KinoviSeedance2p0Fast(mut draft) => { + let result = draft.to_request(&draft_context).await?; + Ok(VideoGenerationRequest::KinoviSeedance2p0Fast(result)) + }, } } } 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 bec7bdc965..e3cf236d9f 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video_v2/video_generation_request.rs @@ -4,10 +4,13 @@ use crate::generate::generate_video::generate_video_response::GenerateVideoRespo use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate; use crate::generate::generate_video_v2::providers::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; +use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState; #[derive(Clone, Debug)] pub enum VideoGenerationRequest { KinoviSeedance2p0(KinoviSeedance2p0RequestState), + KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState), } impl VideoGenerationRequest { @@ -16,6 +19,7 @@ impl VideoGenerationRequest { pub fn estimate_cost(&self) -> Result { match self { 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 { let client_ref = client.get_seedance2pro_client_ref()?; request.send(client_ref).await }, + VideoGenerationRequest::KinoviSeedance2p0Fast(request) => { + let client_ref = client.get_seedance2pro_client_ref()?; + request.send(client_ref).await + }, } } }