mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 05:20:58 -05:00
Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb7e631834 | ||
|
|
2b5e509a29 | ||
|
|
28db87c1b2 | ||
|
|
292cf33f57 | ||
|
|
58b7cd0cd4 | ||
|
|
101b735c35 | ||
|
|
32291ce080 | ||
|
|
cd9c06d2ce | ||
|
|
a780779f7a | ||
|
|
076b7fe4a4 | ||
|
|
7eef728d22 | ||
|
|
d09c7320fd | ||
|
|
0ca91e29b7 | ||
|
|
8af4657393 | ||
|
|
f8ee94e538 | ||
|
|
01e0e27037 | ||
|
|
794f65b517 | ||
|
|
c53b407952 | ||
|
|
e0611e0147 | ||
|
|
8a03449128 | ||
|
|
e4af73c9ac | ||
|
|
efa9a54bee | ||
|
|
718b8261fd | ||
|
|
61c4936c29 | ||
|
|
c23e652499 | ||
|
|
a26ebce483 | ||
|
|
1d3da065a2 | ||
|
|
b4a3eb88bf | ||
|
|
48ab5f73ee | ||
|
|
76add858c9 | ||
|
|
e39e67195e | ||
|
|
ea49ddf60a | ||
|
|
e0c04efa8f | ||
|
|
dc140c0bc1 | ||
|
|
21d3617970 | ||
|
|
11a0ac82b6 | ||
|
|
4b0618e50b | ||
|
|
35aae54d49 | ||
|
|
088edd270f | ||
|
|
61311028e2 | ||
|
|
6d22d0d3c3 | ||
|
|
50e68b0721 | ||
|
|
1351a40b3d | ||
|
|
c1f4ae21e2 | ||
|
|
934ab64eac | ||
|
|
badad317b8 | ||
|
|
7f35eec2b4 | ||
|
|
4cb5a713a8 | ||
|
|
ae8fd30b4b | ||
|
|
d37a732d60 | ||
|
|
726bde7c11 | ||
|
|
1864eedb7a | ||
|
|
853f596efc | ||
|
|
5535d50ec3 | ||
|
|
34cd723417 | ||
|
|
681f94b245 | ||
|
|
cfa5b211ea | ||
|
|
8436d42d41 | ||
|
|
53acdd544f | ||
|
|
5ea6172ce8 | ||
|
|
9166148701 | ||
|
|
e6e739e874 | ||
|
|
4112652c4e | ||
|
|
969d7d7609 | ||
|
|
1f53b6a4cf | ||
|
|
3947eebe7e | ||
|
|
5eb2abf112 | ||
|
|
31a9704faf | ||
|
|
02676680b1 | ||
|
|
ba19e9ae40 | ||
|
|
00499252a1 | ||
|
|
c7b9de7cf9 | ||
|
|
c2ca096099 | ||
|
|
a04ee4f76c | ||
|
|
8bc552c450 | ||
|
|
128d3e43c5 |
+2
-1
@@ -15,11 +15,12 @@ body:
|
||||
type: json
|
||||
content: |
|
||||
{
|
||||
"model": "seedream_5_lite",
|
||||
"model": "gpt_image_2",
|
||||
"provider": "artcraft",
|
||||
"generation_mode": {"type": "text_to_image" },
|
||||
"aspect_ratio": "wide_sixteen_by_nine",
|
||||
"resolution": "two_k",
|
||||
"quality": "low",
|
||||
"image_batch_count": 1
|
||||
}
|
||||
order: 2000
|
||||
|
||||
+8
-2
@@ -3,13 +3,14 @@ use utoipa::ToSchema;
|
||||
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
|
||||
pub const ESTIMATE_IMAGE_COST_PATH: &str = "/v1/generate/cost_estimate/image";
|
||||
|
||||
/// Request body for the image cost estimate endpoint.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct EstimateImageCostRequest {
|
||||
/// The image model to estimate costs for.
|
||||
pub model: CommonImageModel,
|
||||
@@ -29,12 +30,15 @@ pub struct EstimateImageCostRequest {
|
||||
/// Optional resolution.
|
||||
pub resolution: Option<CommonResolution>,
|
||||
|
||||
// Optional quality.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// Number of images to generate in parallel.
|
||||
pub image_batch_count: Option<u16>,
|
||||
}
|
||||
|
||||
/// Describes the type of image generation being requested.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum GenerationMode {
|
||||
/// No input images — generate from prompt only.
|
||||
@@ -94,6 +98,7 @@ mod tests {
|
||||
generation_mode: GenerationMode::TextToImage,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
};
|
||||
let serialized = serde_json::to_string(&request).unwrap();
|
||||
@@ -108,6 +113,7 @@ mod tests {
|
||||
generation_mode: GenerationMode::ImageEdit { count: 2 },
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
};
|
||||
let serialized = serde_json::to_string(&request).unwrap();
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Shared request body for both the video cost estimate and video generation endpoints.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniGenVideoCostAndGenerateRequest {
|
||||
/// REQUIRED (even if marked optional)
|
||||
/// Idempotency token to prevent duplicate requests.
|
||||
|
||||
@@ -41,6 +41,9 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "seedance_2p0_fast")]
|
||||
Seedance2p0Fast,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
|
||||
+2
@@ -52,6 +52,8 @@ pub async fn execute_seedance2pro_seedance2p0(
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -49,5 +49,7 @@ pub async fn execute_seedance2pro_seedance2p0_fast(
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
+9
@@ -47,7 +47,10 @@ 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::happy_horse_1p0::build::build_artcraft_happy_horse_1p0;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
|
||||
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;
|
||||
@@ -141,7 +144,10 @@ impl GenerateVideoRequestBuilder {
|
||||
|
||||
pub fn use_new_builder(&self) -> bool {
|
||||
match (self.provider, self.model) {
|
||||
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => true,
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => true,
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => true,
|
||||
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => true,
|
||||
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true,
|
||||
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true,
|
||||
_ => false,
|
||||
@@ -151,7 +157,10 @@ impl GenerateVideoRequestBuilder {
|
||||
// New builder (transitional state)
|
||||
pub fn build2(self) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
match (self.provider, self.model) {
|
||||
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
|
||||
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
|
||||
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(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(),
|
||||
|
||||
+2
@@ -16,6 +16,8 @@ pub struct MuapiVideoResponsePayload {
|
||||
pub struct Seedance2proVideoResponsePayload {
|
||||
pub order_id: String,
|
||||
pub task_id: String,
|
||||
pub maybe_order_ids: Option<Vec<String>>,
|
||||
pub maybe_task_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
+8
-3
@@ -71,10 +71,15 @@ generate_video_v2/
|
||||
├── video_generation_request.rs ← VideoGenerationRequest enum
|
||||
└── providers/
|
||||
├── artcraft/ ← Artcraft provider (uses media file tokens directly)
|
||||
│ └── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
|
||||
│ ├── seedance_2p0/ ← Seedance 2.0 Pro model (skips draft, returns Request directly)
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── build.rs ← builder → Request conversion + plan helpers
|
||||
│ │ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
|
||||
│ │ └── request.rs ← request state + send() via Artcraft multi-function API
|
||||
│ └── seedance_2p0_fast/ ← Seedance 2.0 Fast model (skips draft, 480p/720p only)
|
||||
│ ├── mod.rs
|
||||
│ ├── build.rs ← builder → Request conversion + plan helpers
|
||||
│ ├── cost.rs ← standalone cost estimation (independent of seedance2pro_client)
|
||||
│ ├── build.rs ← builder → Request conversion + plan helpers (no 1080p)
|
||||
│ ├── cost.rs ← standalone cost estimation with Fast-specific pricing
|
||||
│ └── request.rs ← request state + send() via Artcraft omni-gen API
|
||||
└── kinovi/ ← Kinovi/Seedance2Pro provider
|
||||
├── mod.rs
|
||||
|
||||
@@ -2,4 +2,4 @@ pub mod providers;
|
||||
pub mod video_generation_request;
|
||||
pub mod video_generation_draft;
|
||||
pub mod video_generation_draft_or_request;
|
||||
mod video_generation_draft_context;
|
||||
pub mod video_generation_draft_context;
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
|
||||
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
|
||||
resolve_image_ref, resolve_video_list_ref,
|
||||
};
|
||||
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
|
||||
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_happy_horse_1p0(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 resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take();
|
||||
|
||||
let start_frame = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame = resolve_image_ref(builder.end_frame.take())?;
|
||||
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
|
||||
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
|
||||
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModelEnum::HappyHorse1p0),
|
||||
idempotency_token: Some(idempotency_token),
|
||||
prompt,
|
||||
start_frame_image_media_token: start_frame,
|
||||
end_frame_image_media_token: end_frame,
|
||||
reference_image_media_tokens: reference_images,
|
||||
reference_video_media_tokens: reference_videos,
|
||||
reference_audio_media_tokens: reference_audio,
|
||||
reference_character_tokens: reference_characters,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
};
|
||||
|
||||
let state = ArtcraftHappyHorse1p0RequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftHappyHorse1p0(state)))
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
|
||||
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
|
||||
}
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
|
||||
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
|
||||
}
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
|
||||
Ok(Some(CommonAspectRatioEnum::Square))
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(nearest_aspect_ratio(unsupported)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
|
||||
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
|
||||
_ => CommonAspectRatioEnum::Square,
|
||||
}
|
||||
}
|
||||
|
||||
// Happy Horse supports 720p and 1080p only (no 480p).
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(CommonResolutionEnum::TenEightyP))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(CommonResolutionEnum::SevenTwentyP))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u16, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 | 2 | 4 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { 2 } else { 4 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_is_happy_horse_1p0() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::HappyHorse1p0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_480p_upgrades() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
}
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_happy_horse_1p0(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: crate::api::common_video_model::CommonVideoModel::HappyHorse1p0,
|
||||
provider: Provider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftHappyHorse1p0RequestState {
|
||||
let result = build_artcraft_happy_horse_1p0(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(state)
|
||||
) => state,
|
||||
_ => panic!("expected ArtcraftHappyHorse1p0 request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Happy Horse 1.0 upstream credit pricing:
|
||||
//
|
||||
// | Resolution | Credits/sec |
|
||||
// |------------|-------------|
|
||||
// | 720p | 33 |
|
||||
// | 1080p | 66 |
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
//
|
||||
// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents.
|
||||
// We compute USD cents from upstream credits ÷ credits_per_dollar × 100.
|
||||
|
||||
const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33;
|
||||
const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66;
|
||||
const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0;
|
||||
|
||||
pub struct ArtcraftHappyHorse1p0CostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftHappyHorse1p0CostState {
|
||||
pub fn from_request(request: &ArtcraftHappyHorse1p0RequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
|
||||
Self { resolution, duration_seconds, batch_count }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let credits_per_second = match self.resolution {
|
||||
CommonResolution::TenEightyP => UPSTREAM_CREDITS_PER_SECOND_1080P,
|
||||
_ => UPSTREAM_CREDITS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let upstream_credits = credits_per_second as u64
|
||||
* self.duration_seconds as u64
|
||||
* self.batch_count as u64;
|
||||
|
||||
let usd_cents = (upstream_credits as f64 / UPSTREAM_CREDITS_PER_DOLLAR * 100.0).round() as u64;
|
||||
|
||||
// ArtCraft credits = USD cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
// ── 720p pricing (33 upstream credits/sec) ──
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 33 credits/sec; upstream_credits / 193 * 100
|
||||
// (NB: duration 3s is clamped to 4s by the builder)
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 68);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 85);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 171);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 171);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 342);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1080p pricing (66 upstream credits/sec) ──
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 66 credits/sec; upstream_credits / 193 * 100
|
||||
// (NB: duration 3s is clamped to 4s by the builder)
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 137);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 171);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 342);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 513);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 342);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 684);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_cheaper_than_1080p() {
|
||||
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
|
||||
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
|
||||
assert!(c720 < c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r1080p_approximately_double_720p() {
|
||||
// 1080p upstream credits are exactly 2× 720p, but USD cents may differ
|
||||
// by ±1 due to rounding (each is rounded independently).
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), dur, 1);
|
||||
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), dur, 1);
|
||||
let expected = c720 * 2;
|
||||
assert!(
|
||||
c1080 >= expected - 1 && c1080 <= expected + 1,
|
||||
"1080p ({}) should be ~2× 720p ({}) at {}s", c1080, c720, dur,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = cost_cents(None, 4, 1);
|
||||
let c10 = cost_cents(None, 10, 1);
|
||||
let c15 = cost_cents(None, 15, 1);
|
||||
assert!(c4 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_batch() {
|
||||
let b1 = cost_cents(None, 5, 1);
|
||||
let b2 = cost_cents(None, 5, 2);
|
||||
let b4 = cost_cents(None, 5, 4);
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credits equal cents ──
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
Some(CommonResolution::TenEightyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4, 5, 10, 15] {
|
||||
for batch in [1, 2, 4] {
|
||||
let cost = build_cost(res, dur, batch);
|
||||
assert_eq!(
|
||||
cost.cost_in_credits, cost.cost_in_usd_cents,
|
||||
"credits should equal cents for res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check with Kinovi ──
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None,
|
||||
Some(CommonResolution::TenEightyP),
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::HappyHorse1p0,
|
||||
provider: Provider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::HappyHorse1p0,
|
||||
provider: Provider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
ArtcraftVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftHappyHorse1p0RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftHappyHorse1p0RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = omni_gen_video_generate(
|
||||
&client.api_host,
|
||||
Some(&client.credentials),
|
||||
self.request.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
|
||||
inference_job_token: response.inference_job_token.clone(),
|
||||
all_inference_job_tokens: vec![response.inference_job_token],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_1080p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A fox walking through a snowy forest.".to_string()),
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn keyframe_start_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog watches the lake as the sun sets.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
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 {
|
||||
model: CommonVideoModel::HappyHorse1p0,
|
||||
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
|
||||
}
|
||||
}
|
||||
+3
@@ -1 +1,4 @@
|
||||
pub mod happy_horse_1p0;
|
||||
pub(crate) mod resolve;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
//! Shared resolve helpers for the Artcraft provider.
|
||||
//!
|
||||
//! Artcraft only accepts media file tokens — raw URLs are rejected.
|
||||
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
||||
pub(super) fn resolve_image_ref(
|
||||
image_ref: Option<ImageRef>,
|
||||
) -> Result<Option<MediaFileToken>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
|
||||
Some(ImageRef::Url(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_image_list_ref(
|
||||
image_list_ref: Option<ImageListRef>,
|
||||
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
|
||||
match image_list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(ImageListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_video_list_ref(
|
||||
video_list_ref: Option<VideoListRef>,
|
||||
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
|
||||
match video_list_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(VideoListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_audio_list_ref(
|
||||
audio_list_ref: Option<AudioListRef>,
|
||||
) -> Result<Option<Vec<MediaFileToken>>, ArtcraftRouterError> {
|
||||
match audio_list_ref {
|
||||
None => Ok(None),
|
||||
Some(AudioListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
|
||||
Some(AudioListRef::Urls(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_character_list_ref(
|
||||
character_list_ref: Option<CharacterListRef>,
|
||||
) -> Option<Vec<CharacterToken>> {
|
||||
match character_list_ref {
|
||||
None => None,
|
||||
Some(CharacterListRef::CharacterTokens(tokens)) => Some(tokens),
|
||||
}
|
||||
}
|
||||
+127
-398
@@ -1,19 +1,18 @@
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest,
|
||||
Seedance2p0OutputResolution,
|
||||
};
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
|
||||
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
|
||||
resolve_image_ref, resolve_video_list_ref,
|
||||
};
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
@@ -22,129 +21,66 @@ pub fn build_artcraft_seedance_2p0(mut builder: GenerateVideoRequestBuilder) ->
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let output_resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take();
|
||||
|
||||
let start_frame_media_token = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame_media_token = resolve_image_ref(builder.end_frame.take())?;
|
||||
let reference_image_media_tokens = resolve_image_list_ref(builder.reference_images.take())?;
|
||||
let reference_video_media_tokens = resolve_video_list_ref(builder.reference_videos.take())?;
|
||||
let reference_audio_media_tokens = resolve_audio_list_ref(builder.reference_audio.take())?;
|
||||
let reference_character_tokens = resolve_character_list_ref(builder.reference_character_tokens.take());
|
||||
let start_frame = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame = resolve_image_ref(builder.end_frame.take())?;
|
||||
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
|
||||
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
|
||||
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
|
||||
let request = Seedance2p0MultiFunctionVideoGenRequest {
|
||||
uuid_idempotency_token: idempotency_token,
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModelEnum::Seedance2p0),
|
||||
idempotency_token: Some(idempotency_token),
|
||||
prompt,
|
||||
start_frame_media_token,
|
||||
end_frame_media_token,
|
||||
reference_image_media_tokens,
|
||||
reference_video_media_tokens,
|
||||
reference_audio_media_tokens,
|
||||
reference_character_tokens,
|
||||
start_frame_image_media_token: start_frame,
|
||||
end_frame_image_media_token: end_frame,
|
||||
reference_image_media_tokens: reference_images,
|
||||
reference_video_media_tokens: reference_videos,
|
||||
reference_audio_media_tokens: reference_audio,
|
||||
reference_character_tokens: reference_characters,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
output_resolution,
|
||||
duration_seconds,
|
||||
batch_count: Some(batch_count),
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
};
|
||||
|
||||
let state = ArtcraftSeedance2p0RequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)))
|
||||
}
|
||||
|
||||
// ── Resolve helpers ──
|
||||
// -- Plan helpers --
|
||||
|
||||
fn resolve_image_ref(
|
||||
image_ref: Option<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> {
|
||||
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
// No preference or auto — let the model decide
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Landscape16x9))
|
||||
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
|
||||
}
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Portrait9x16))
|
||||
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
|
||||
}
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
|
||||
Ok(Some(Seedance2p0AspectRatio::Square1x1))
|
||||
Ok(Some(CommonAspectRatioEnum::Square))
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance2p0AspectRatio::Standard4x3)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance2p0AspectRatio::Portrait3x4)),
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -160,16 +96,15 @@ fn plan_aspect_ratio(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the nearest supported aspect ratio by AR value (width / height).
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRatio {
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => Seedance2p0AspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
CommonAspectRatio::WideThreeByTwo => Seedance2p0AspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
CommonAspectRatio::WideTwentyOneByNine => Seedance2p0AspectRatio::Landscape16x9, // 2.33, nearest 1.78
|
||||
CommonAspectRatio::TallFourByFive => Seedance2p0AspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
CommonAspectRatio::TallTwoByThree => Seedance2p0AspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
CommonAspectRatio::TallNineByTwentyOne => Seedance2p0AspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
_ => Seedance2p0AspectRatio::Square1x1,
|
||||
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
|
||||
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
|
||||
_ => CommonAspectRatioEnum::Square,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,16 +112,14 @@ fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> Seedance2p0AspectRat
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance2p0OutputResolution>, ArtcraftRouterError> {
|
||||
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(CommonResolution::FourEightyP) => Ok(Some(Seedance2p0OutputResolution::FourEightyP)),
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance2p0OutputResolution::SevenTwentyP)),
|
||||
Some(CommonResolution::TenEightyP) => Ok(Some(Seedance2p0OutputResolution::TenEightyP)),
|
||||
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
Some(CommonResolution::TenEightyP) => Ok(Some(CommonResolutionEnum::TenEightyP)),
|
||||
|
||||
// Mismatches
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -196,31 +129,29 @@ fn plan_output_resolution(
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
|
||||
_ => Seedance2p0OutputResolution::TenEightyP,
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::TenEightyP,
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => Seedance2p0OutputResolution::FourEightyP,
|
||||
_ => Seedance2p0OutputResolution::TenEightyP,
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::TenEightyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
|
||||
// Batch counts: 1, 2, 4.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Seedance2p0BatchCount, ArtcraftRouterError> {
|
||||
) -> Result<u16, 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),
|
||||
1 | 2 | 4 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -228,17 +159,15 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(if count < 4 { Seedance2p0BatchCount::Four } else { Seedance2p0BatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { Seedance2p0BatchCount::Two } else { Seedance2p0BatchCount::Four })
|
||||
Ok(if count < 4 { 2 } else { 4 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports duration of 4–15 seconds.
|
||||
// Duration: 4-15 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -262,350 +191,150 @@ fn plan_duration(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0OutputResolution,
|
||||
};
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
// ── Field conversions ──
|
||||
|
||||
mod materialized_field_conversions {
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
|
||||
assert_eq!(state.request.prompt.as_deref(), Some("a cat dancing"));
|
||||
fn model_is_seedance_2p0() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder { prompt: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.prompt.is_none());
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_seconds_converted() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.duration_seconds.is_none());
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
duration_seconds: Some(99),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.duration_seconds, Some(15));
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_one() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::One)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_two() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Two)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_four() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio conversions ──
|
||||
// ── Resolution ──
|
||||
|
||||
mod aspect_ratio_conversions {
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_wide() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Landscape16x9)));
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_tall() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_square() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Square1x1)));
|
||||
fn res_1080p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.aspect_ratio.is_none());
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution conversions ──
|
||||
// ── Media tokens ──
|
||||
|
||||
mod resolution_conversions {
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolution_480p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourEightyP),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::FourEightyP)));
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::SevenTwentyP)));
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_none() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: None, ..artcraft_builder() };
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(state.request.output_resolution.is_none());
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..artcraft_builder()
|
||||
};
|
||||
assert!(build_artcraft_seedance_2p0(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_rounds_up() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media token resolution ──
|
||||
|
||||
mod media_token_resolution {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_media_token() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start123".to_string()))),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.start_frame_media_token.as_ref().unwrap().as_str(), "mf_start123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frame_media_token() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end456".to_string()))),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
assert_eq!(state.request.end_frame_media_token.as_ref().unwrap().as_str(), "mf_end456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_media_tokens() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref1".to_string()),
|
||||
MediaFileToken::new("mf_ref2".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
let tokens = state.request.reference_image_media_tokens.unwrap();
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(tokens[0].as_str(), "mf_ref1");
|
||||
assert_eq!(tokens[1].as_str(), "mf_ref2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_media_tokens() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_vid1".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
let tokens = state.request.reference_video_media_tokens.unwrap();
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(tokens[0].as_str(), "mf_vid1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
CharacterToken::new("char_def".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
let tokens = state.request.reference_character_tokens.unwrap();
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(tokens[0].as_str(), "char_abc");
|
||||
assert_eq!(tokens[1].as_str(), "char_def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_image_ref_returns_error() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/image.jpg".to_string())),
|
||||
..artcraft_builder()
|
||||
};
|
||||
assert!(matches!(
|
||||
build_artcraft_seedance_2p0(builder),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_refs_are_none() {
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(artcraft_builder()));
|
||||
assert!(state.request.start_frame_media_token.is_none());
|
||||
assert!(state.request.end_frame_media_token.is_none());
|
||||
assert!(state.request.reference_image_media_tokens.is_none());
|
||||
assert!(state.request.reference_video_media_tokens.is_none());
|
||||
assert!(state.request.reference_audio_media_tokens.is_none());
|
||||
assert!(state.request.reference_character_tokens.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Returns Request, not Draft ──
|
||||
|
||||
#[test]
|
||||
fn build_returns_request_variant() {
|
||||
let result = build_artcraft_seedance_2p0(artcraft_builder()).expect("build should succeed");
|
||||
assert!(matches!(result, VideoGenerationDraftOrRequest::Request(_)));
|
||||
}
|
||||
|
||||
// ── Full combination ──
|
||||
|
||||
#[test]
|
||||
fn full_request_all_fields() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
prompt: Some("full test".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
duration_seconds: Some(10),
|
||||
video_batch_count: Some(4),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string()))),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_img".to_string())])),
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_vid".to_string())])),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_xyz".to_string()),
|
||||
])),
|
||||
..artcraft_builder()
|
||||
};
|
||||
let state = unwrap_request(build_artcraft_seedance_2p0(builder));
|
||||
|
||||
assert_eq!(state.request.prompt.as_deref(), Some("full test"));
|
||||
assert!(matches!(state.request.aspect_ratio, Some(Seedance2p0AspectRatio::Portrait9x16)));
|
||||
assert!(matches!(state.request.output_resolution, Some(Seedance2p0OutputResolution::TenEightyP)));
|
||||
assert_eq!(state.request.duration_seconds, Some(10));
|
||||
assert!(matches!(state.request.batch_count, Some(Seedance2p0BatchCount::Four)));
|
||||
assert!(state.request.start_frame_media_token.is_some());
|
||||
assert!(state.request.end_frame_media_token.is_some());
|
||||
assert!(state.request.reference_image_media_tokens.is_some());
|
||||
assert!(state.request.reference_video_media_tokens.is_some());
|
||||
assert!(state.request.reference_character_tokens.is_some());
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
prompt: Some("a cat dancing".to_string()),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_request(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> ArtcraftSeedance2p0RequestState {
|
||||
match result.expect("build should succeed") {
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0RequestState {
|
||||
let result = build_artcraft_seedance_2p0(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(state)
|
||||
) => state,
|
||||
|
||||
+118
-359
@@ -1,6 +1,4 @@
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0BatchCount, Seedance2p0OutputResolution,
|
||||
};
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
@@ -13,7 +11,7 @@ use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::reque
|
||||
// from the upstream credit rates and their credit-package prices,
|
||||
// then set ArtCraft credits = cents.
|
||||
|
||||
/// USD cents per second by resolution, derived from Upstream rates:
|
||||
/// USD cents per second by resolution, derived from upstream rates:
|
||||
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
|
||||
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
|
||||
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
|
||||
@@ -25,19 +23,18 @@ const CENTS_PER_SECOND_720P: f64 = 16.0;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 46.632;
|
||||
|
||||
pub struct ArtcraftSeedance2p0CostState {
|
||||
pub resolution: Seedance2p0OutputResolution,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Seedance2p0BatchCount,
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0CostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0RequestState) -> Self {
|
||||
let resolution = request.request.output_resolution
|
||||
.unwrap_or(Seedance2p0OutputResolution::SevenTwentyP);
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.batch_count
|
||||
.unwrap_or(Seedance2p0BatchCount::One);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
@@ -47,18 +44,13 @@ impl ArtcraftSeedance2p0CostState {
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cents_per_second = match self.resolution {
|
||||
Seedance2p0OutputResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
Seedance2p0OutputResolution::SevenTwentyP => CENTS_PER_SECOND_720P,
|
||||
Seedance2p0OutputResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
// Everything else (including 720p) prices at 720p.
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let batch_multiplier: f64 = match self.batch_count {
|
||||
Seedance2p0BatchCount::One => 1.0,
|
||||
Seedance2p0BatchCount::Two => 2.0,
|
||||
Seedance2p0BatchCount::Four => 4.0,
|
||||
};
|
||||
|
||||
let usd_cents = (self.duration_seconds as f64 * cents_per_second * batch_multiplier).round() as u64;
|
||||
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
@@ -74,208 +66,164 @@ impl ArtcraftSeedance2p0CostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0OutputResolution,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── 720p pricing ──
|
||||
// -- 720p pricing --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One), 64);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 6, Seedance2p0BatchCount::One), 96);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 7, Seedance2p0BatchCount::One), 112);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One), 160);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One), 240);
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 64);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 80);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 6, 1), 96);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 160);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Two), 128);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Two), 160);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Two), 480);
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 128);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 160);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::Four), 256);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::Four), 320);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::Four), 960);
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 256);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 320);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 960);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 480p pricing ──
|
||||
// -- 480p pricing --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 4, Seedance2p0BatchCount::One), 31);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 10, Seedance2p0BatchCount::One), 78);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 15, Seedance2p0BatchCount::One), 117);
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 31);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 39);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 78);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 117);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Two), 78);
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::Four), 155);
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 155);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1080p pricing ──
|
||||
// -- 1080p pricing --
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_1() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 4, Seedance2p0BatchCount::One), 187);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 10, Seedance2p0BatchCount::One), 466);
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 15, Seedance2p0BatchCount::One), 699);
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 4, 1), 187);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 1), 233);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10, 1), 466);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15, 1), 699);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_2() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two), 466);
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 2), 466);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_4() {
|
||||
assert_eq!(usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four), 933);
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5, 4), 933);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
// -- Relative pricing --
|
||||
|
||||
mod relative_pricing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
|
||||
let c480 = usd_cents(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One);
|
||||
let c720 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One);
|
||||
let c1080 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
|
||||
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
|
||||
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
|
||||
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1);
|
||||
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
|
||||
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
|
||||
assert!(c480 < c720);
|
||||
assert!(c720 < c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 4, Seedance2p0BatchCount::One);
|
||||
let c10 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 10, Seedance2p0BatchCount::One);
|
||||
let c15 = usd_cents(Seedance2p0OutputResolution::SevenTwentyP, 15, Seedance2p0BatchCount::One);
|
||||
let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1);
|
||||
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
|
||||
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1);
|
||||
assert!(c4 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_batch() {
|
||||
let b1 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One);
|
||||
let b2 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Two);
|
||||
let b4 = usd_cents(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::Four);
|
||||
let b1 = cost_cents(Some(CommonResolution::TenEightyP), 5, 1);
|
||||
let b2 = cost_cents(Some(CommonResolution::TenEightyP), 5, 2);
|
||||
let b4 = cost_cents(Some(CommonResolution::TenEightyP), 5, 4);
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Video reference does NOT affect cost (yet) ──
|
||||
// -- Credits equal cents --
|
||||
|
||||
#[test]
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
let base = ArtcraftSeedance2p0CostState {
|
||||
resolution: Seedance2p0OutputResolution::SevenTwentyP,
|
||||
duration_seconds: 5,
|
||||
batch_count: Seedance2p0BatchCount::One,
|
||||
has_video_reference: false,
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = ArtcraftSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
|
||||
assert_eq!(without.cost_in_credits, with.cost_in_credits);
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
|
||||
mod from_request_tests {
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_request_720p() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Seedance2p0BatchCount::One));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_none_defaults_to_720p() {
|
||||
let req = make_request_state(None, 5, Seedance2p0BatchCount::One, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert!(matches!(cost.resolution, Seedance2p0OutputResolution::SevenTwentyP));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::FourEightyP), 5, Seedance2p0BatchCount::One, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_1080p_batch_2() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::TenEightyP), 5, Seedance2p0BatchCount::Two, false);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_with_video_reference() {
|
||||
let req = make_request_state(Some(Seedance2p0OutputResolution::SevenTwentyP), 5, Seedance2p0BatchCount::One, true);
|
||||
let cost = ArtcraftSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(80));
|
||||
fn credits_equal_usd_cents_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
Some(CommonResolution::TenEightyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4, 5, 10, 15] {
|
||||
for batch in [1, 2, 4] {
|
||||
let cost = build_cost(res, dur, batch);
|
||||
assert_eq!(
|
||||
cost.cost_in_credits, cost.cost_in_usd_cents,
|
||||
"credits should equal cents for res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check: Artcraft v2 matches Kinovi v2 via GenerateVideoRequestBuilder ──
|
||||
|
||||
mod cross_check_with_kinovi_via_builder {
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
// -- Cross-check with Kinovi via builder --
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
/// Combinatorial test: build the same request with Provider::Artcraft and
|
||||
/// Provider::Seedance2Pro, then compare USD cents. They must match for all
|
||||
/// resolution × duration × batch combinations.
|
||||
#[test]
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None, // defaults to 720p
|
||||
None,
|
||||
Some(CommonResolution::TenEightyP),
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
@@ -284,39 +232,24 @@ mod tests {
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0,
|
||||
provider: Provider::Artcraft,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0,
|
||||
provider: Provider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let artcraft_cost = artcraft.build2()
|
||||
.expect("artcraft build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("artcraft estimate_cost should succeed");
|
||||
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2 should succeed")
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost should succeed");
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={} — artcraft={:?}, kinovi={:?}",
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -324,208 +257,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check: Artcraft v2 matches v1 estimate_video_cost_artcraft_seedance2p0 ──
|
||||
// -- Helpers --
|
||||
|
||||
mod cross_check_with_v1 {
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0AspectRatio;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::generate::generate_video::cost::artcraft::estimate_video_cost_artcraft_seedance2p0::estimate_video_cost_artcraft_seedance2p0;
|
||||
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Combinatorial test: compare v2 ArtcraftSeedance2p0CostState against
|
||||
/// v1 estimate_video_cost_artcraft_seedance2p0 for all resolution ×
|
||||
/// duration × batch combinations.
|
||||
#[test]
|
||||
fn v2_matches_v1_all_combos() {
|
||||
let resolutions = [
|
||||
(Some(CommonResolution::FourEightyP), Seedance2p0OutputResolution::FourEightyP),
|
||||
(Some(CommonResolution::SevenTwentyP), Seedance2p0OutputResolution::SevenTwentyP),
|
||||
(None, Seedance2p0OutputResolution::SevenTwentyP), // None defaults to 720p
|
||||
(Some(CommonResolution::TenEightyP), Seedance2p0OutputResolution::TenEightyP),
|
||||
];
|
||||
let durations: [u8; 4] = [4, 5, 10, 15];
|
||||
let batches = [
|
||||
(Seedance2p0BatchCount::One, Seedance2p0BatchCount::One),
|
||||
(Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Two),
|
||||
(Seedance2p0BatchCount::Four, Seedance2p0BatchCount::Four),
|
||||
];
|
||||
|
||||
for (v1_res, v2_res) in &resolutions {
|
||||
for dur in &durations {
|
||||
for (v1_batch, v2_batch) in &batches {
|
||||
// v1: construct a PlanArtcraftSeedance2p0 and call the v1 cost function
|
||||
let v1_plan = PlanArtcraftSeedance2p0 {
|
||||
prompt: None,
|
||||
start_frame: None,
|
||||
end_frame: None,
|
||||
reference_images: None,
|
||||
reference_videos: None,
|
||||
reference_audio: None,
|
||||
reference_characters: None,
|
||||
aspect_ratio: Some(Seedance2p0AspectRatio::Square1x1),
|
||||
resolution: *v1_res,
|
||||
duration_seconds: Some(*dur),
|
||||
batch_count: *v1_batch,
|
||||
idempotency_token: "test".to_string(),
|
||||
};
|
||||
let v1_cost = estimate_video_cost_artcraft_seedance2p0(&v1_plan);
|
||||
|
||||
// v2: construct cost state directly
|
||||
let v2_cost = ArtcraftSeedance2p0CostState {
|
||||
resolution: *v2_res,
|
||||
duration_seconds: *dur,
|
||||
batch_count: *v2_batch,
|
||||
has_video_reference: false,
|
||||
}.estimate_cost();
|
||||
|
||||
assert_eq!(
|
||||
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
|
||||
v1_res, dur, v1_batch,
|
||||
v2_cost.cost_in_usd_cents, v1_cost.cost_in_usd_cents,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
|
||||
"Credits mismatch: res={:?} dur={}s batch={:?} — v2={:?}, v1={:?}",
|
||||
v1_res, dur, v1_batch,
|
||||
v2_cost.cost_in_credits, v1_cost.cost_in_credits,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credits spot checks ──
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits always equal cents.
|
||||
for res in [
|
||||
Seedance2p0OutputResolution::FourEightyP,
|
||||
Seedance2p0OutputResolution::SevenTwentyP,
|
||||
Seedance2p0OutputResolution::TenEightyP,
|
||||
] {
|
||||
for dur in [4, 5, 10, 15] {
|
||||
for batch in [Seedance2p0BatchCount::One, Seedance2p0BatchCount::Two, Seedance2p0BatchCount::Four] {
|
||||
assert_eq!(
|
||||
credits(res, dur, batch),
|
||||
usd_cents(res, dur, batch),
|
||||
"credits should equal usd_cents for {:?} {}s {:?}", res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_720p() {
|
||||
assert_eq!(credits(Seedance2p0OutputResolution::SevenTwentyP, 5, Seedance2p0BatchCount::One), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_480p() {
|
||||
assert_eq!(credits(Seedance2p0OutputResolution::FourEightyP, 5, Seedance2p0BatchCount::One), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_1080p() {
|
||||
assert_eq!(credits(Seedance2p0OutputResolution::TenEightyP, 5, Seedance2p0BatchCount::One), 233);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Builder round-trip ──
|
||||
|
||||
mod builder_round_trip {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build2_then_estimate_cost_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let result = builder.build2().expect("build2 should succeed");
|
||||
let cost = result.estimate_cost().expect("estimate_cost should succeed");
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build2_then_estimate_cost_1080p_batch_4() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(4),
|
||||
..Default::default()
|
||||
};
|
||||
let result = builder.build2().expect("build2 should succeed");
|
||||
let cost = result.estimate_cost().expect("estimate_cost should succeed");
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(933));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn usd_cents(
|
||||
resolution: Seedance2p0OutputResolution,
|
||||
duration_seconds: u8,
|
||||
batch_count: Seedance2p0BatchCount,
|
||||
) -> u64 {
|
||||
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
|
||||
fn build_cost(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn credits(
|
||||
resolution: Seedance2p0OutputResolution,
|
||||
duration_seconds: u8,
|
||||
batch_count: Seedance2p0BatchCount,
|
||||
) -> u64 {
|
||||
ArtcraftSeedance2p0CostState { resolution, duration_seconds, batch_count, has_video_reference: false }
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_request_state(
|
||||
resolution: Option<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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-47
@@ -1,5 +1,5 @@
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0MultiFunctionVideoGenRequest;
|
||||
use artcraft_client::endpoints::generate::video::multi_function::seedance_2p0_multi_function_video_gen::seedance_2p0_multi_function_video_gen;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -10,13 +10,13 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0RequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: Seedance2p0MultiFunctionVideoGenRequest,
|
||||
/// Final materialized request; ready to fire via the omni-gen video endpoint.
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = seedance_2p0_multi_function_video_gen(
|
||||
let response = omni_gen_video_generate(
|
||||
&client.api_host,
|
||||
Some(&client.credentials),
|
||||
self.request.clone(),
|
||||
@@ -25,8 +25,8 @@ impl ArtcraftSeedance2p0RequestState {
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
|
||||
inference_job_token: response.inference_job_token,
|
||||
all_inference_job_tokens: response.all_inference_job_tokens,
|
||||
inference_job_token: response.inference_job_token.clone(),
|
||||
all_inference_job_tokens: vec![response.inference_job_token],
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,6 @@ mod tests {
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
@@ -58,14 +57,13 @@ mod tests {
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
|
||||
// ── Aspect ratio tests ──
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
@@ -77,7 +75,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
@@ -89,7 +87,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
@@ -101,13 +99,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution tests ──
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
@@ -119,7 +117,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
@@ -131,7 +129,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn res_1080p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A fox walking through a snowy forest.".to_string()),
|
||||
@@ -143,13 +141,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modality tests ──
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
|
||||
@@ -161,10 +160,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
// Artcraft uses media file tokens directly — no URL resolution needed.
|
||||
// Tokens from test_data::web::image_urls comments.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
@@ -177,15 +174,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn image_references() {
|
||||
// Artcraft uses media file tokens directly.
|
||||
// Tokens from test_data::web::image_urls comments.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
@@ -196,25 +191,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn video_references() {
|
||||
// TODO: Needs a real video media file token to test properly.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("Change @video1 to a nighttime scene with moonlight.".to_string()),
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()), // Using an image token as placeholder
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
#[ignore]
|
||||
async fn character_references() {
|
||||
// Character tokens are passed through directly to the Artcraft API.
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
@@ -227,11 +205,9 @@ mod tests {
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
// TODO: Add audio reference test once a media file token for a test audio file is available.
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
// -- Helpers --
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::resolve::{
|
||||
resolve_audio_list_ref, resolve_character_list_ref, resolve_image_list_ref,
|
||||
resolve_image_ref, resolve_video_list_ref,
|
||||
};
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take();
|
||||
|
||||
let start_frame = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame = resolve_image_ref(builder.end_frame.take())?;
|
||||
let reference_images = resolve_image_list_ref(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_video_list_ref(builder.reference_videos.take())?;
|
||||
let reference_audio = resolve_audio_list_ref(builder.reference_audio.take())?;
|
||||
let reference_characters = resolve_character_list_ref(builder.reference_character_tokens.take());
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(CommonVideoModelEnum::Seedance2p0Fast),
|
||||
idempotency_token: Some(idempotency_token),
|
||||
prompt,
|
||||
start_frame_image_media_token: start_frame,
|
||||
end_frame_image_media_token: end_frame,
|
||||
reference_image_media_tokens: reference_images,
|
||||
reference_video_media_tokens: reference_videos,
|
||||
reference_audio_media_tokens: reference_audio,
|
||||
reference_character_tokens: reference_characters,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
generate_audio: None,
|
||||
quality: None,
|
||||
};
|
||||
|
||||
let state = ArtcraftSeedance2p0FastRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)))
|
||||
}
|
||||
|
||||
// -- Plan helpers --
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<CommonAspectRatioEnum>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
|
||||
Ok(Some(CommonAspectRatioEnum::WideSixteenByNine))
|
||||
}
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
|
||||
Ok(Some(CommonAspectRatioEnum::TallNineBySixteen))
|
||||
}
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
|
||||
Ok(Some(CommonAspectRatioEnum::Square))
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(CommonAspectRatioEnum::WideFourByThree)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(CommonAspectRatioEnum::TallThreeByFour)),
|
||||
|
||||
// Mismatches
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(nearest_aspect_ratio(unsupported)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> CommonAspectRatioEnum {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideThreeByTwo => CommonAspectRatioEnum::WideFourByThree,
|
||||
CommonAspectRatio::WideTwentyOneByNine => CommonAspectRatioEnum::WideSixteenByNine,
|
||||
CommonAspectRatio::TallFourByFive => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallTwoByThree => CommonAspectRatioEnum::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineByTwentyOne => CommonAspectRatioEnum::TallNineBySixteen,
|
||||
_ => CommonAspectRatioEnum::Square,
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Fast supports output resolutions: 480p and 720p only.
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<CommonResolutionEnum>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
Some(CommonResolution::FourEightyP) => Ok(Some(CommonResolutionEnum::FourEightyP)),
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
|
||||
// 1080p not supported for Fast
|
||||
Some(CommonResolution::TenEightyP) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", CommonResolution::TenEightyP),
|
||||
}))
|
||||
}
|
||||
_ => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
},
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
CommonResolution::HalfK => CommonResolutionEnum::FourEightyP,
|
||||
_ => CommonResolutionEnum::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Batch counts: 1, 2, 4.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u16, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 | 2 | 4 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { 2 } else { 4 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Duration: 4-15 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<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 enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Field conversions ──
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_seedance_2p0_fast() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Fast)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio ──
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wide() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(CommonAspectRatio::TallNineBySixteen);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(CommonAspectRatio::Square);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.aspect_ratio.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(CommonResolution::FourEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(CommonResolution::SevenTwentyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(CommonResolution::TenEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_error_out() {
|
||||
let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media tokens ──
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_fast(GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
MediaFileToken::new("mf_a".to_string()),
|
||||
MediaFileToken::new("mf_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
CharacterToken::new("char_a".to_string()),
|
||||
CharacterToken::new("char_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0FastRequestState {
|
||||
let result = build_artcraft_seedance_2p0_fast(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)
|
||||
) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
|
||||
|
||||
// -- Pricing constants --
|
||||
//
|
||||
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
|
||||
//
|
||||
// The per-second USD cost varies by resolution. We derive cents from the
|
||||
// upstream credit rates and their credit-package prices, then set
|
||||
// ArtCraft credits = cents.
|
||||
|
||||
/// USD cents per second by resolution, derived from upstream Fast rates:
|
||||
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
|
||||
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
/// happens once at the end after multiplying by duration * batch.
|
||||
const CENTS_PER_SECOND_480P: f64 = 5.181;
|
||||
const CENTS_PER_SECOND_720P: f64 = 12.727;
|
||||
|
||||
pub struct ArtcraftSeedance2p0FastCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0FastCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0FastRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let usd_cents = (self.duration_seconds as f64 * cents_per_second * self.batch_count as f64).round() as u64;
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
// -- 720p pricing --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1), 51);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1), 64);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1), 127);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1), 191);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 2), 102);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2), 127);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 2), 382);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 4, 4), 204);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4), 255);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 15, 4), 764);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 5, 1), cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// -- 480p pricing --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 4, 1), 21);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 1), 26);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10, 1), 52);
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15, 1), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 2), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5, 4), 104);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Relative pricing --
|
||||
|
||||
mod relative_pricing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p() {
|
||||
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 5, 1);
|
||||
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
|
||||
assert!(c480 < c720, "480p ({}) should be cheaper than 720p ({})", c480, c720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = cost_cents(Some(CommonResolution::SevenTwentyP), 4, 1);
|
||||
let c10 = cost_cents(Some(CommonResolution::SevenTwentyP), 10, 1);
|
||||
let c15 = cost_cents(Some(CommonResolution::SevenTwentyP), 15, 1);
|
||||
assert!(c4 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_batch() {
|
||||
let b1 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 1);
|
||||
let b2 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 2);
|
||||
let b4 = cost_cents(Some(CommonResolution::SevenTwentyP), 5, 4);
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Credits equal cents --
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4, 5, 10, 15] {
|
||||
for batch in [1, 2, 4] {
|
||||
let cost = build_cost(res, dur, batch);
|
||||
assert_eq!(
|
||||
cost.cost_in_credits, cost.cost_in_usd_cents,
|
||||
"credits should equal cents for res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cross-check with Kinovi Fast via builder --
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_fast_matches_kinovi_fast_all_combos() {
|
||||
let resolutions = [
|
||||
Some(CommonResolution::FourEightyP),
|
||||
Some(CommonResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cost_cents(
|
||||
resolution: Option<CommonResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
ArtcraftVideoResponsePayload, GenerateVideoResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0FastRequestState {
|
||||
/// Final materialized request; ready to fire via the omni-gen video endpoint.
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0FastRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = omni_gen_video_generate(
|
||||
&client.api_host,
|
||||
Some(&client.credentials),
|
||||
self.request.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Artcraft(ArtcraftVideoResponsePayload {
|
||||
inference_job_token: response.inference_job_token.clone(),
|
||||
all_inference_job_tokens: vec![response.inference_job_token],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use test_data::web::character_tokens::{JIM, KNIGHT};
|
||||
use test_data::web::image_media_tokens::{
|
||||
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
|
||||
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(CommonResolution::FourEightyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn character_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new(JIM.token.to_string()),
|
||||
CharacterToken::new(KNIGHT.token.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::Seedance2p0Fast,
|
||||
provider: Provider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
|
||||
KinoviHappyHorse1p0AspectRatio, KinoviHappyHorse1p0BatchCount,
|
||||
KinoviHappyHorse1p0OutputResolution,
|
||||
};
|
||||
|
||||
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::happy_horse_1p0::draft::{
|
||||
KinoviHappyHorse1p0DraftState, KinoviHappyHorse1p0RemainingItems,
|
||||
};
|
||||
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_happy_horse_1p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let draft = do_build(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft)))
|
||||
}
|
||||
|
||||
fn do_build(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviHappyHorse1p0DraftState, ArtcraftRouterError> {
|
||||
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 = KinoviHappyHorse1p0RemainingItems {
|
||||
start_frame: builder.start_frame.take(),
|
||||
};
|
||||
|
||||
Ok(KinoviHappyHorse1p0DraftState {
|
||||
prompt,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<CommonAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviHappyHorse1p0AspectRatio>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None
|
||||
| Some(CommonAspectRatio::Auto)
|
||||
| Some(CommonAspectRatio::Auto2k)
|
||||
| Some(CommonAspectRatio::Auto4k) => Ok(None),
|
||||
|
||||
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => {
|
||||
Ok(Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9))
|
||||
}
|
||||
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => {
|
||||
Ok(Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16))
|
||||
}
|
||||
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
|
||||
Ok(Some(KinoviHappyHorse1p0AspectRatio::Square1x1))
|
||||
}
|
||||
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3)),
|
||||
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(KinoviHappyHorse1p0AspectRatio::Portrait3x4)),
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(nearest_aspect_ratio(unsupported)))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> KinoviHappyHorse1p0AspectRatio {
|
||||
match aspect_ratio {
|
||||
CommonAspectRatio::WideFiveByFour => KinoviHappyHorse1p0AspectRatio::Landscape4x3,
|
||||
CommonAspectRatio::WideThreeByTwo => KinoviHappyHorse1p0AspectRatio::Landscape4x3,
|
||||
CommonAspectRatio::WideTwentyOneByNine => KinoviHappyHorse1p0AspectRatio::Landscape16x9,
|
||||
CommonAspectRatio::TallFourByFive => KinoviHappyHorse1p0AspectRatio::Portrait3x4,
|
||||
CommonAspectRatio::TallTwoByThree => KinoviHappyHorse1p0AspectRatio::Portrait3x4,
|
||||
CommonAspectRatio::TallNineByTwentyOne => KinoviHappyHorse1p0AspectRatio::Portrait9x16,
|
||||
_ => KinoviHappyHorse1p0AspectRatio::Square1x1,
|
||||
}
|
||||
}
|
||||
|
||||
// Happy Horse supports 720p and 1080p only (no 480p).
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<CommonResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviHappyHorse1p0OutputResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
Some(CommonResolution::SevenTwentyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)),
|
||||
Some(CommonResolution::TenEightyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)),
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviHappyHorse1p0BatchCount>, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(None),
|
||||
2 => Ok(Some(KinoviHappyHorse1p0BatchCount::Two)),
|
||||
4 => Ok(Some(KinoviHappyHorse1p0BatchCount::Four)),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(if count >= 4 { KinoviHappyHorse1p0BatchCount::Four } else { KinoviHappyHorse1p0BatchCount::Two }))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(if count <= 2 { KinoviHappyHorse1p0BatchCount::Two } else { KinoviHappyHorse1p0BatchCount::Four }))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Happy Horse supports 4–15 seconds, defaults to 5.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u8, ArtcraftRouterError> {
|
||||
let seconds = duration_seconds.unwrap_or(5);
|
||||
Ok(seconds.clamp(4, 15) as u8)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder()));
|
||||
assert_eq!(draft.prompt, "a cat dancing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_defaults_to_empty() {
|
||||
let builder = GenerateVideoRequestBuilder { prompt: None, ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert_eq!(draft.prompt, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_seconds_converted() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert_eq!(draft.duration_seconds, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_defaults_to_5() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert_eq!(draft.duration_seconds, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert_eq!(draft.duration_seconds, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_none_for_one() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(1), ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(draft.batch_count.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_two() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(2), ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Two)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_four() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Four)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio conversions ──
|
||||
|
||||
mod aspect_ratio_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_wide() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_tall() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_square() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Square1x1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_none_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(draft.aspect_ratio.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_auto_defaults_to_none() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::Auto),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(draft.aspect_ratio.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_aspect_ratio_falls_back() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideFiveByFour),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_aspect_ratio_errors_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
aspect_ratio: Some(CommonAspectRatio::WideFiveByFour),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..happy_horse_builder()
|
||||
};
|
||||
assert!(build_kinovi_happy_horse_1p0(builder).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution conversions ──
|
||||
|
||||
mod resolution_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolution_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::SevenTwentyP),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_none() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: None, ..happy_horse_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(draft.resolution.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..happy_horse_builder()
|
||||
};
|
||||
assert!(build_kinovi_happy_horse_1p0(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_upgrades() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_downgrades() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(CommonResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── unhandled_request_state ──
|
||||
|
||||
mod unhandled_request_state {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unhandled_state_is_present() {
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder()));
|
||||
assert!(draft.unhandled_request_state.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_placed_in_unhandled() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(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 start_frame_media_token_placed_in_unhandled() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(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 empty_refs_are_none_in_unhandled() {
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(happy_horse_builder()));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.start_frame.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::TenEightyP),
|
||||
duration_seconds: Some(10),
|
||||
video_batch_count: Some(4),
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
|
||||
assert_eq!(draft.prompt, "full test");
|
||||
assert!(matches!(draft.aspect_ratio, Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16)));
|
||||
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)));
|
||||
assert_eq!(draft.duration_seconds, 10);
|
||||
assert!(matches!(draft.batch_count, Some(KinoviHappyHorse1p0BatchCount::Four)));
|
||||
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.start_frame.is_some());
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn happy_horse_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
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<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviHappyHorse1p0DraftState {
|
||||
match result.expect("build should succeed") {
|
||||
VideoGenerationDraftOrRequest::Draft(
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft)
|
||||
) => draft,
|
||||
_ => panic!("expected KinoviHappyHorse1p0 draft"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
|
||||
GenerateHappyHorse1p0Request, KinoviHappyHorse1p0BatchCount,
|
||||
KinoviHappyHorse1p0OutputResolution,
|
||||
};
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::draft::KinoviHappyHorse1p0DraftState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
|
||||
|
||||
pub struct KinoviHappyHorse1p0CostState {
|
||||
pub resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviHappyHorse1p0BatchCount>,
|
||||
}
|
||||
|
||||
impl KinoviHappyHorse1p0CostState {
|
||||
pub fn from_request(request: &KinoviHappyHorse1p0RequestState) -> Self {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_draft(draft: &KinoviHappyHorse1p0DraftState) -> Self {
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: draft.batch_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let pricing_request = GenerateHappyHorse1p0Request {
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// No impact on price
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
start_frame_url: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_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::provider::Provider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
// ── 720p pricing (33 credits/sec) ──
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_720p_every_duration() {
|
||||
assert_eq!(credits(None, 3), 99);
|
||||
assert_eq!(credits(None, 4), 132);
|
||||
assert_eq!(credits(None, 5), 165);
|
||||
assert_eq!(credits(None, 6), 198);
|
||||
assert_eq!(credits(None, 7), 231);
|
||||
assert_eq!(credits(None, 8), 264);
|
||||
assert_eq!(credits(None, 9), 297);
|
||||
assert_eq!(credits(None, 10), 330);
|
||||
assert_eq!(credits(None, 11), 363);
|
||||
assert_eq!(credits(None, 12), 396);
|
||||
assert_eq!(credits(None, 13), 429);
|
||||
assert_eq!(credits(None, 14), 462);
|
||||
assert_eq!(credits(None, 15), 495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_same_as_default() {
|
||||
let default = credits(None, 5);
|
||||
let explicit = credits(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), 5);
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p() {
|
||||
// 165 credits / 193 * 100 = 85.49 → 85¢
|
||||
assert_eq!(usd_cents(None, 5, None), 85);
|
||||
// 495 credits / 193 * 100 = 256.48 → 256¢
|
||||
assert_eq!(usd_cents(None, 15, None), 256);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1080p pricing (66 credits/sec) ──
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_1080p_every_duration() {
|
||||
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
|
||||
assert_eq!(credits(r, 3), 198);
|
||||
assert_eq!(credits(r, 4), 264);
|
||||
assert_eq!(credits(r, 5), 330);
|
||||
assert_eq!(credits(r, 6), 396);
|
||||
assert_eq!(credits(r, 7), 462);
|
||||
assert_eq!(credits(r, 8), 528);
|
||||
assert_eq!(credits(r, 9), 594);
|
||||
assert_eq!(credits(r, 10), 660);
|
||||
assert_eq!(credits(r, 11), 726);
|
||||
assert_eq!(credits(r, 12), 792);
|
||||
assert_eq!(credits(r, 13), 858);
|
||||
assert_eq!(credits(r, 14), 924);
|
||||
assert_eq!(credits(r, 15), 990);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p() {
|
||||
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
|
||||
// 330 credits / 193 * 100 = 170.98 → 171¢
|
||||
assert_eq!(usd_cents(r, 5, None), 171);
|
||||
// 990 credits / 193 * 100 = 512.95 → 513¢
|
||||
assert_eq!(usd_cents(r, 15, None), 513);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch multiplier ──
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_2_doubles_credits() {
|
||||
let base = credits(None, 5);
|
||||
let batch2 = credits_with_batch(None, 5, Some(KinoviHappyHorse1p0BatchCount::Two));
|
||||
assert_eq!(batch2, base * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_quadruples_credits() {
|
||||
let base = credits(None, 5);
|
||||
let batch4 = credits_with_batch(None, 5, Some(KinoviHappyHorse1p0BatchCount::Four));
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_applies_to_1080p() {
|
||||
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
|
||||
let base = credits(r, 5);
|
||||
let batch4 = credits_with_batch(r, 5, Some(KinoviHappyHorse1p0BatchCount::Four));
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn r1080p_is_double_720p() {
|
||||
for dur in 3..=15u8 {
|
||||
let c720 = credits(None, dur);
|
||||
let c1080 = credits(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), dur);
|
||||
assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c3 = credits(None, 3);
|
||||
let c10 = credits(None, 10);
|
||||
let c15 = credits(None, 15);
|
||||
assert!(c3 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_free() {
|
||||
let est = KinoviHappyHorse1p0CostState {
|
||||
resolution: None, duration_seconds: 5, batch_count: None,
|
||||
}.estimate_cost();
|
||||
assert!(!est.is_free);
|
||||
}
|
||||
}
|
||||
|
||||
// ── from_draft() ──
|
||||
|
||||
mod from_draft_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_draft_720p_default() {
|
||||
let draft = make_draft(5, 1, None);
|
||||
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(165));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_1080p_batch_4() {
|
||||
let draft = make_draft(5, 4, Some(CommonResolution::TenEightyP));
|
||||
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft);
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330 * 4));
|
||||
}
|
||||
}
|
||||
|
||||
// ── from_request() ──
|
||||
|
||||
mod from_request_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_request_default() {
|
||||
let req = make_request_state(None, 5, None);
|
||||
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(165));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_1080p() {
|
||||
let req = make_request_state(
|
||||
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), 5, None,
|
||||
);
|
||||
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check: draft matches request ──
|
||||
|
||||
mod cross_check {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn draft_and_request_produce_same_cost() {
|
||||
let draft = make_draft(5, 1, Some(CommonResolution::SevenTwentyP));
|
||||
let req = make_request_state(
|
||||
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), 5, None,
|
||||
);
|
||||
let draft_cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
|
||||
let req_cost = KinoviHappyHorse1p0CostState::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_1080p_batch_2() {
|
||||
let draft = make_draft(10, 2, Some(CommonResolution::TenEightyP));
|
||||
let req = make_request_state(
|
||||
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), 10,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two),
|
||||
);
|
||||
let draft_cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
|
||||
let req_cost = KinoviHappyHorse1p0CostState::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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn credits(
|
||||
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
) -> u64 {
|
||||
credits_with_batch(resolution, duration_seconds, None)
|
||||
}
|
||||
|
||||
fn credits_with_batch(
|
||||
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
|
||||
) -> u64 {
|
||||
KinoviHappyHorse1p0CostState { resolution, duration_seconds, batch_count }
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn usd_cents(
|
||||
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
|
||||
) -> u64 {
|
||||
KinoviHappyHorse1p0CostState { resolution, duration_seconds, batch_count }
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_draft(
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
resolution: Option<CommonResolution>,
|
||||
) -> KinoviHappyHorse1p0DraftState {
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::HappyHorse1p0,
|
||||
provider: Provider::Seedance2Pro,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match builder.build2().expect("build2 should succeed") {
|
||||
VideoGenerationDraftOrRequest::Draft(
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft)
|
||||
) => draft,
|
||||
_ => panic!("expected KinoviHappyHorse1p0 draft"),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_request_state(
|
||||
resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
|
||||
) -> KinoviHappyHorse1p0RequestState {
|
||||
KinoviHappyHorse1p0RequestState {
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: "test".to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
start_frame_url: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
|
||||
GenerateHappyHorse1p0Request, KinoviHappyHorse1p0AspectRatio,
|
||||
KinoviHappyHorse1p0BatchCount, KinoviHappyHorse1p0OutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::resolve::resolve_and_upload_single;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
|
||||
use crate::generate::generate_video_v2::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviHappyHorse1p0DraftState {
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: Option<KinoviHappyHorse1p0AspectRatio>,
|
||||
pub resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviHappyHorse1p0BatchCount>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviHappyHorse1p0RemainingItems>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviHappyHorse1p0RemainingItems {
|
||||
pub start_frame: Option<ImageRef>,
|
||||
}
|
||||
|
||||
impl KinoviHappyHorse1p0DraftState {
|
||||
pub async fn to_request(
|
||||
&mut self,
|
||||
draft_context: &VideoGenerationDraftContext<'_>,
|
||||
) -> Result<KinoviHappyHorse1p0RequestState, ArtcraftRouterError> {
|
||||
let client = draft_context.get_seedance2pro_client_ref()?;
|
||||
let session = &client.session;
|
||||
|
||||
let mut start_frame_url = 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?;
|
||||
}
|
||||
|
||||
let request = GenerateHappyHorse1p0Request {
|
||||
prompt: self.prompt.clone(),
|
||||
aspect_ratio: self.aspect_ratio,
|
||||
output_resolution: self.resolution,
|
||||
batch_count: self.batch_count,
|
||||
duration_seconds: self.duration_seconds,
|
||||
start_frame_url,
|
||||
};
|
||||
|
||||
Ok(KinoviHappyHorse1p0RequestState { request })
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
|
||||
generate_happy_horse_1p0, GenerateHappyHorse1p0Args, GenerateHappyHorse1p0Request,
|
||||
};
|
||||
|
||||
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};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviHappyHorse1p0RequestState {
|
||||
pub request: GenerateHappyHorse1p0Request,
|
||||
}
|
||||
|
||||
impl KinoviHappyHorse1p0RequestState {
|
||||
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
|
||||
let args = GenerateHappyHorse1p0Args {
|
||||
session,
|
||||
host_override: None,
|
||||
request: self.request.clone(),
|
||||
};
|
||||
|
||||
let response = generate_happy_horse_1p0(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::api::common_resolution::CommonResolution;
|
||||
use crate::api::common_video_model::CommonVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::provider::Provider;
|
||||
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;
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
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),
|
||||
..happy_horse_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
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),
|
||||
..happy_horse_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
..happy_horse_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
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),
|
||||
..happy_horse_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_1080p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A fox walking through a snowy forest.".to_string()),
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
..happy_horse_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..happy_horse_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn keyframe_start_frame() {
|
||||
let start_token = MediaFileToken::new("mf_start".to_string());
|
||||
|
||||
let mut media_map = HashMap::new();
|
||||
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
|
||||
|
||||
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog watches the sunset over the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(start_token)),
|
||||
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
..happy_horse_builder()
|
||||
}, media_map).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn keyframe_1080p_square() {
|
||||
let start_token = MediaFileToken::new("mf_start".to_string());
|
||||
|
||||
let mut media_map = HashMap::new();
|
||||
media_map.insert(start_token.clone(), JUNO_AT_LAKE_IMAGE_URL.to_string());
|
||||
|
||||
let response = run_pipeline_with_media_map(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A dragon soaring over the mountains.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(start_token)),
|
||||
aspect_ratio: Some(CommonAspectRatio::Square),
|
||||
resolution: Some(CommonResolution::TenEightyP),
|
||||
duration_seconds: Some(15),
|
||||
..happy_horse_builder()
|
||||
}, media_map).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Seedance2Pro(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn happy_horse_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: CommonVideoModel::HappyHorse1p0,
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async fn run_pipeline_with_media_map(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
media_map: HashMap<MediaFileToken, String>,
|
||||
) -> 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
|
||||
}
|
||||
}
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
pub mod happy_horse_1p0;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
pub(crate) mod resolve;
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ impl KinoviSeedance2p0RequestState {
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ impl KinoviSeedance2p0FastRequestState {
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -1,6 +1,8 @@
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::api::provider::Provider;
|
||||
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::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::draft::KinoviHappyHorse1p0DraftState;
|
||||
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;
|
||||
@@ -13,15 +15,25 @@ use crate::generate::generate_video_v2::video_generation_request::VideoGeneratio
|
||||
*/
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoGenerationDraftRequest {
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
|
||||
}
|
||||
|
||||
impl VideoGenerationDraftRequest {
|
||||
|
||||
pub fn get_provider(&self) -> Provider {
|
||||
match self {
|
||||
Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a cost estimate to fulfill the request.
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
|
||||
}
|
||||
@@ -31,6 +43,10 @@ impl VideoGenerationDraftRequest {
|
||||
/// This may involve uploading media to the provider.
|
||||
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p0(result))
|
||||
|
||||
+11
@@ -3,6 +3,7 @@ use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -37,3 +38,13 @@ impl <'a> VideoGenerationDraftContext<'a> {
|
||||
.ok_or_else(|| ArtcraftRouterError::Client(ClientError::CharacterTokenToKinoviCharacterIdNotProvided))
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for VideoGenerationDraftContext<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("VideoGenerationDraftContext")
|
||||
.field("client", &self.client.is_some())
|
||||
.field("media_file_to_artcraft_url_map", &self.media_file_to_artcraft_url_map)
|
||||
.field("character_token_to_kinovi_id_map", &self.character_token_to_kinovi_id_map)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -1,8 +1,10 @@
|
||||
use crate::api::provider::Provider;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video_v2::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VideoGenerationDraftOrRequest {
|
||||
Draft(VideoGenerationDraftRequest),
|
||||
Request(VideoGenerationRequest),
|
||||
@@ -10,6 +12,13 @@ pub enum VideoGenerationDraftOrRequest {
|
||||
|
||||
impl VideoGenerationDraftOrRequest {
|
||||
|
||||
pub fn get_provider(&self) -> Provider {
|
||||
match self {
|
||||
Self::Draft(draft) => draft.get_provider(),
|
||||
Self::Request(request) => request.get_provider(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftOrRequest::Draft(draft) => draft.estimate_cost(),
|
||||
|
||||
+36
@@ -1,9 +1,16 @@
|
||||
use crate::api::provider::Provider;
|
||||
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::happy_horse_1p0::cost::ArtcraftHappyHorse1p0CostState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::cost::ArtcraftSeedance2p0CostState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState;
|
||||
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
|
||||
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
|
||||
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;
|
||||
@@ -11,17 +18,34 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoGenerationRequest {
|
||||
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
|
||||
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
|
||||
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
}
|
||||
|
||||
impl VideoGenerationRequest {
|
||||
|
||||
pub fn get_provider(&self) -> Provider {
|
||||
match self {
|
||||
Self::ArtcraftHappyHorse1p0(_) => Provider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0(_) => Provider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Fast(_) => Provider::Artcraft,
|
||||
Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a cost estimate to fulfill the request.
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::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()),
|
||||
}
|
||||
@@ -31,10 +55,22 @@ impl VideoGenerationRequest {
|
||||
/// If successful, returns the job IDs.
|
||||
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
|
||||
let client_ref = client.get_seedance2pro_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviSeedance2p0(request) => {
|
||||
let client_ref = client.get_seedance2pro_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod video;
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs
|
||||
, WorkflowRunTaskRequest,
|
||||
};
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateHappyHorse1p0Args<'a> {
|
||||
pub request: GenerateHappyHorse1p0Request,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateHappyHorse1p0Request {
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: Option<KinoviHappyHorse1p0AspectRatio>,
|
||||
pub output_resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
pub batch_count: Option<KinoviHappyHorse1p0BatchCount>,
|
||||
pub duration_seconds: u8,
|
||||
pub start_frame_url: Option<String>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviHappyHorse1p0AspectRatio {
|
||||
Portrait9x16,
|
||||
Portrait3x4,
|
||||
Square1x1,
|
||||
Landscape4x3,
|
||||
Landscape16x9,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviHappyHorse1p0OutputResolution {
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviHappyHorse1p0BatchCount {
|
||||
One,
|
||||
Two,
|
||||
Four,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Happy Horse 1.0 credit pricing:
|
||||
//
|
||||
// | Resolution | Credits/sec |
|
||||
// |------------|-------------|
|
||||
// | 720p | 33 |
|
||||
// | 1080p | 66 |
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
|
||||
impl GenerateHappyHorse1p0Request {
|
||||
/// Estimate the credit cost for this generation request.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
let credits_per_second: u32 = match self.output_resolution {
|
||||
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => 66,
|
||||
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => 33,
|
||||
};
|
||||
|
||||
let per_video = u32::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u32 = match self.batch_count {
|
||||
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
|
||||
};
|
||||
per_video * batch_multiplier
|
||||
}
|
||||
|
||||
/// Credits per dollar for billing conversion.
|
||||
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
|
||||
fn credits_per_dollar() -> f64 {
|
||||
193.0
|
||||
}
|
||||
|
||||
/// Estimate the USD cost in cents for this generation request.
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let cost = credits / Self::credits_per_dollar() * 100.0;
|
||||
cost.round() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateHappyHorse1p0Response {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_happy_horse_1p0(
|
||||
args: GenerateHappyHorse1p0Args<'_>,
|
||||
) -> Result<GenerateHappyHorse1p0Response, Seedance2ProError> {
|
||||
let req = args.request;
|
||||
|
||||
let raw_request = WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::HappyHorse1p0,
|
||||
prompt: req.prompt,
|
||||
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
|
||||
output_resolution: req.output_resolution.map(map_output_resolution),
|
||||
batch_count: map_batch_count(req.batch_count),
|
||||
duration_seconds: req.duration_seconds,
|
||||
start_frame_url: req.start_frame_url,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: Some(false),
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
request: raw_request,
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateHappyHorse1p0Response {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
task_ids: raw_response.task_ids,
|
||||
order_ids: raw_response.order_ids,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
fn map_aspect_ratio(ar: Option<KinoviHappyHorse1p0AspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviHappyHorse1p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviHappyHorse1p0AspectRatio::Landscape4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviHappyHorse1p0AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
|
||||
None => KinoviAspectRatioRaw::Landscape16x9,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_resolution(res: KinoviHappyHorse1p0OutputResolution) -> KinoviOutputResolutionRaw {
|
||||
match res {
|
||||
KinoviHappyHorse1p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
KinoviHappyHorse1p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_batch_count(bc: Option<KinoviHappyHorse1p0BatchCount>) -> KinoviBatchCountRaw {
|
||||
match bc {
|
||||
Some(KinoviHappyHorse1p0BatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request(
|
||||
duration_seconds: u8,
|
||||
output_resolution: Option<KinoviHappyHorse1p0OutputResolution>,
|
||||
batch_count: Option<KinoviHappyHorse1p0BatchCount>,
|
||||
) -> GenerateHappyHorse1p0Request {
|
||||
GenerateHappyHorse1p0Request {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
output_resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
start_frame_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn r720(dur: u8) -> GenerateHappyHorse1p0Request {
|
||||
make_request(dur, None, None)
|
||||
}
|
||||
|
||||
fn r1080(dur: u8) -> GenerateHappyHorse1p0Request {
|
||||
make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None)
|
||||
}
|
||||
|
||||
// ── 720p credits (33 credits/sec) ──
|
||||
|
||||
mod credits_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r720(3).estimate_credits(), 99);
|
||||
assert_eq!(r720(4).estimate_credits(), 132);
|
||||
assert_eq!(r720(5).estimate_credits(), 165);
|
||||
assert_eq!(r720(6).estimate_credits(), 198);
|
||||
assert_eq!(r720(7).estimate_credits(), 231);
|
||||
assert_eq!(r720(8).estimate_credits(), 264);
|
||||
assert_eq!(r720(9).estimate_credits(), 297);
|
||||
assert_eq!(r720(10).estimate_credits(), 330);
|
||||
assert_eq!(r720(11).estimate_credits(), 363);
|
||||
assert_eq!(r720(12).estimate_credits(), 396);
|
||||
assert_eq!(r720(13).estimate_credits(), 429);
|
||||
assert_eq!(r720(14).estimate_credits(), 462);
|
||||
assert_eq!(r720(15).estimate_credits(), 495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_same_as_default() {
|
||||
let default = r720(5).estimate_credits();
|
||||
let explicit = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), None).estimate_credits();
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1080p credits (66 credits/sec) ──
|
||||
|
||||
mod credits_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r1080(3).estimate_credits(), 198);
|
||||
assert_eq!(r1080(4).estimate_credits(), 264);
|
||||
assert_eq!(r1080(5).estimate_credits(), 330);
|
||||
assert_eq!(r1080(6).estimate_credits(), 396);
|
||||
assert_eq!(r1080(7).estimate_credits(), 462);
|
||||
assert_eq!(r1080(8).estimate_credits(), 528);
|
||||
assert_eq!(r1080(9).estimate_credits(), 594);
|
||||
assert_eq!(r1080(10).estimate_credits(), 660);
|
||||
assert_eq!(r1080(11).estimate_credits(), 726);
|
||||
assert_eq!(r1080(12).estimate_credits(), 792);
|
||||
assert_eq!(r1080(13).estimate_credits(), 858);
|
||||
assert_eq!(r1080(14).estimate_credits(), 924);
|
||||
assert_eq!(r1080(15).estimate_credits(), 990);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch multiplier ──
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).estimate_credits();
|
||||
assert_eq!(base, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_doubles() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_credits();
|
||||
assert_eq!(batch2, base * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_quadruples() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch4 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Four)).estimate_credits();
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_1080p() {
|
||||
let base = r1080(5).estimate_credits();
|
||||
let batch2 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_credits();
|
||||
let batch4 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Four)).estimate_credits();
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn r1080p_is_exactly_double_720p() {
|
||||
for dur in 3..=15u8 {
|
||||
let c720 = make_request(dur, None, None).estimate_credits();
|
||||
let c1080 = make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None).estimate_credits();
|
||||
assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c3 = r720(3).estimate_credits();
|
||||
let c10 = r720(10).estimate_credits();
|
||||
let c15 = r720(15).estimate_credits();
|
||||
assert!(c3 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents ──
|
||||
|
||||
mod usd_cents_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_per_dollar_is_193() {
|
||||
assert_eq!(GenerateHappyHorse1p0Request::credits_per_dollar(), 193.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_5s() {
|
||||
// 165 credits / 193 * 100 = 85.49 → 85¢
|
||||
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_5s() {
|
||||
// 330 credits / 193 * 100 = 170.98 → 171¢
|
||||
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 171);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_15s() {
|
||||
// 495 credits / 193 * 100 = 256.48 → 256¢
|
||||
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_15s() {
|
||||
// 990 credits / 193 * 100 = 512.95 → 513¢
|
||||
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 513);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_usd_cents() {
|
||||
let base = r720(5).estimate_cost_in_usd_cents();
|
||||
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_cost_in_usd_cents();
|
||||
// Batch 2 should be approximately 2× base (rounding may differ slightly)
|
||||
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
|
||||
"batch 2 ({}) should be ~2× base ({})", batch2, base);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = r720(5).estimate_credits();
|
||||
|
||||
let ratios = [
|
||||
KinoviHappyHorse1p0AspectRatio::Landscape16x9,
|
||||
KinoviHappyHorse1p0AspectRatio::Portrait9x16,
|
||||
KinoviHappyHorse1p0AspectRatio::Square1x1,
|
||||
KinoviHappyHorse1p0AspectRatio::Landscape4x3,
|
||||
KinoviHappyHorse1p0AspectRatio::Portrait3x4,
|
||||
];
|
||||
|
||||
for ar in &ratios {
|
||||
let req = GenerateHappyHorse1p0Request {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: Some(*ar),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod text_to_video {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_default() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 4,
|
||||
start_frame_url: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_720p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: "A golden retriever running through a field of sunflowers".to_string(),
|
||||
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9),
|
||||
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_1080p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: "A dragon soaring over a medieval castle at sunset".to_string(),
|
||||
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9),
|
||||
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 4,
|
||||
start_frame_url: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratios {
|
||||
use super::*;
|
||||
|
||||
async fn test_aspect_ratio(ar: KinoviHappyHorse1p0AspectRatio, label: &str) -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: format!("A cat sitting in a sunbeam ({})", label),
|
||||
aspect_ratio: Some(ar),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 4,
|
||||
start_frame_url: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("{} — task_id={}, order_id={}", label, result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_landscape_16x9() -> AnyhowResult<()> {
|
||||
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Landscape16x9, "16:9").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_portrait_9x16() -> AnyhowResult<()> {
|
||||
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Portrait9x16, "9:16").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_square_1x1() -> AnyhowResult<()> {
|
||||
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Square1x1, "1:1").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_standard_4x3() -> AnyhowResult<()> {
|
||||
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Landscape4x3, "4:3").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_portrait_3x4() -> AnyhowResult<()> {
|
||||
test_aspect_ratio(KinoviHappyHorse1p0AspectRatio::Portrait3x4, "3:4").await
|
||||
}
|
||||
}
|
||||
|
||||
mod keyframe {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_720p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session).await?;
|
||||
println!("Uploaded start frame: {}", start_frame_url);
|
||||
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: "The corgi dog watches the lake as the sun sets.".to_string(),
|
||||
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Portrait9x16),
|
||||
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 8,
|
||||
start_frame_url: Some(start_frame_url),
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 720p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_1080p_square() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session).await?;
|
||||
println!("Uploaded start frame: {}", start_frame_url);
|
||||
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: "A dragon and a raptor fighting on the beach.".to_string(),
|
||||
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Square1x1),
|
||||
output_resolution: Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 15,
|
||||
start_frame_url: Some(start_frame_url),
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 1080p square — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_landscape_default_resolution() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session).await?;
|
||||
println!("Uploaded start frame: {}", start_frame_url);
|
||||
|
||||
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateHappyHorse1p0Request {
|
||||
prompt: "The dog runs along the shore, kicking up sand.".to_string(),
|
||||
aspect_ratio: Some(KinoviHappyHorse1p0AspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: Some(start_frame_url),
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe landscape default res — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_test_image(session: &Seedance2ProSession) -> AnyhowResult<String> {
|
||||
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
|
||||
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
|
||||
).await?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: "jpg".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: image_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
|
||||
WorkflowRunTaskRequest,
|
||||
};
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSeedance2p0Args<'a> {
|
||||
pub request: GenerateSeedance2p0Request,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSeedance2p0Request {
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: Option<KinoviSeedance2p0AspectRatio>,
|
||||
pub output_resolution: Option<KinoviSeedance2p0OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p0BatchCount>,
|
||||
pub start_frame_url: Option<String>,
|
||||
pub end_frame_url: Option<String>,
|
||||
pub reference_image_urls: Option<Vec<String>>,
|
||||
pub reference_video_urls: Option<Vec<String>>,
|
||||
pub reference_audio_urls: Option<Vec<String>>,
|
||||
pub character_ids: Option<Vec<String>>,
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0AspectRatio {
|
||||
Landscape16x9,
|
||||
Portrait9x16,
|
||||
Square1x1,
|
||||
Standard4x3,
|
||||
Portrait3x4,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0OutputResolution {
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0BatchCount {
|
||||
One,
|
||||
Two,
|
||||
Four,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.0 Pro credit pricing:
|
||||
//
|
||||
// | Resolution | Credits/sec |
|
||||
// |------------|-------------|
|
||||
// | 480p | 15 |
|
||||
// | 720p | 40 |
|
||||
// | 1080p | 90 |
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
|
||||
impl GenerateSeedance2p0Request {
|
||||
/// Estimate the credit cost for this generation request.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
let credits_per_second: u32 = match self.output_resolution {
|
||||
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
|
||||
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
|
||||
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 90,
|
||||
};
|
||||
|
||||
let per_video = u32::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u32 = match self.batch_count {
|
||||
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
|
||||
Some(KinoviSeedance2p0BatchCount::Two) => 2,
|
||||
Some(KinoviSeedance2p0BatchCount::Four) => 4,
|
||||
};
|
||||
per_video * batch_multiplier
|
||||
}
|
||||
|
||||
/// Credits per dollar for billing conversion.
|
||||
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
|
||||
fn credits_per_dollar() -> f64 {
|
||||
193.0
|
||||
}
|
||||
|
||||
/// Estimate the USD cost in cents for this generation request.
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let cost = credits / Self::credits_per_dollar() * 100.0;
|
||||
cost.round() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSeedance2p0Response {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_seedance_2p0(
|
||||
args: GenerateSeedance2p0Args<'_>,
|
||||
) -> Result<GenerateSeedance2p0Response, Seedance2ProError> {
|
||||
let req = args.request;
|
||||
|
||||
let raw_request = WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2Pro,
|
||||
prompt: req.prompt,
|
||||
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
|
||||
output_resolution: req.output_resolution.map(map_output_resolution),
|
||||
batch_count: map_batch_count(req.batch_count),
|
||||
duration_seconds: req.duration_seconds,
|
||||
start_frame_url: req.start_frame_url,
|
||||
end_frame_url: req.end_frame_url,
|
||||
reference_image_urls: req.reference_image_urls,
|
||||
reference_video_urls: req.reference_video_urls,
|
||||
reference_audio_urls: req.reference_audio_urls,
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
request: raw_request,
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSeedance2p0Response {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
task_ids: raw_response.task_ids,
|
||||
order_ids: raw_response.order_ids,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0AspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p0AspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p0AspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p0AspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p0AspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviSeedance2p0AspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
|
||||
None => KinoviAspectRatioRaw::Landscape16x9,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_resolution(res: KinoviSeedance2p0OutputResolution) -> KinoviOutputResolutionRaw {
|
||||
match res {
|
||||
KinoviSeedance2p0OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
|
||||
KinoviSeedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
KinoviSeedance2p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountRaw {
|
||||
match bc {
|
||||
Some(KinoviSeedance2p0BatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn build_request(
|
||||
duration_seconds: u8,
|
||||
output_resolution: Option<KinoviSeedance2p0OutputResolution>,
|
||||
batch_count: Option<KinoviSeedance2p0BatchCount>,
|
||||
) -> GenerateSeedance2p0Request {
|
||||
GenerateSeedance2p0Request {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
output_resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn r480(dur: u8) -> GenerateSeedance2p0Request {
|
||||
build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None)
|
||||
}
|
||||
|
||||
fn r720(dur: u8) -> GenerateSeedance2p0Request {
|
||||
build_request(dur, None, None)
|
||||
}
|
||||
|
||||
fn r1080(dur: u8) -> GenerateSeedance2p0Request {
|
||||
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
|
||||
}
|
||||
|
||||
// ── 480p credits (15 credits/sec) ──
|
||||
|
||||
mod credits_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r480(3).estimate_credits(), 45);
|
||||
assert_eq!(r480(4).estimate_credits(), 60);
|
||||
assert_eq!(r480(5).estimate_credits(), 75);
|
||||
assert_eq!(r480(6).estimate_credits(), 90);
|
||||
assert_eq!(r480(7).estimate_credits(), 105);
|
||||
assert_eq!(r480(8).estimate_credits(), 120);
|
||||
assert_eq!(r480(9).estimate_credits(), 135);
|
||||
assert_eq!(r480(10).estimate_credits(), 150);
|
||||
assert_eq!(r480(11).estimate_credits(), 165);
|
||||
assert_eq!(r480(12).estimate_credits(), 180);
|
||||
assert_eq!(r480(13).estimate_credits(), 195);
|
||||
assert_eq!(r480(14).estimate_credits(), 210);
|
||||
assert_eq!(r480(15).estimate_credits(), 225);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 720p credits (40 credits/sec) ──
|
||||
|
||||
mod credits_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r720(3).estimate_credits(), 120);
|
||||
assert_eq!(r720(4).estimate_credits(), 160);
|
||||
assert_eq!(r720(5).estimate_credits(), 200);
|
||||
assert_eq!(r720(6).estimate_credits(), 240);
|
||||
assert_eq!(r720(7).estimate_credits(), 280);
|
||||
assert_eq!(r720(8).estimate_credits(), 320);
|
||||
assert_eq!(r720(9).estimate_credits(), 360);
|
||||
assert_eq!(r720(10).estimate_credits(), 400);
|
||||
assert_eq!(r720(11).estimate_credits(), 440);
|
||||
assert_eq!(r720(12).estimate_credits(), 480);
|
||||
assert_eq!(r720(13).estimate_credits(), 520);
|
||||
assert_eq!(r720(14).estimate_credits(), 560);
|
||||
assert_eq!(r720(15).estimate_credits(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_same_as_default() {
|
||||
let default = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).estimate_credits();
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1080p credits (90 credits/sec) ──
|
||||
|
||||
mod credits_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r1080(3).estimate_credits(), 270);
|
||||
assert_eq!(r1080(4).estimate_credits(), 360);
|
||||
assert_eq!(r1080(5).estimate_credits(), 450);
|
||||
assert_eq!(r1080(6).estimate_credits(), 540);
|
||||
assert_eq!(r1080(7).estimate_credits(), 630);
|
||||
assert_eq!(r1080(8).estimate_credits(), 720);
|
||||
assert_eq!(r1080(9).estimate_credits(), 810);
|
||||
assert_eq!(r1080(10).estimate_credits(), 900);
|
||||
assert_eq!(r1080(11).estimate_credits(), 990);
|
||||
assert_eq!(r1080(12).estimate_credits(), 1080);
|
||||
assert_eq!(r1080(13).estimate_credits(), 1170);
|
||||
assert_eq!(r1080(14).estimate_credits(), 1260);
|
||||
assert_eq!(r1080(15).estimate_credits(), 1350);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch multiplier ──
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).estimate_credits();
|
||||
assert_eq!(base, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_doubles() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
|
||||
assert_eq!(batch2, base * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_quadruples() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_1080p() {
|
||||
let base = r1080(5).estimate_credits();
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_480p() {
|
||||
let base = r480(5).estimate_credits();
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c3 = r720(3).estimate_credits();
|
||||
let c10 = r720(10).estimate_credits();
|
||||
let c15 = r720(15).estimate_credits();
|
||||
assert!(c3 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_ordering() {
|
||||
for dur in 3..=15u8 {
|
||||
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).estimate_credits();
|
||||
let c720 = build_request(dur, None, None).estimate_credits();
|
||||
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).estimate_credits();
|
||||
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
|
||||
assert!(c720 < c1080, "720p should be cheaper than 1080p at {}s", dur);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents ──
|
||||
|
||||
mod usd_cents_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_per_dollar_is_193() {
|
||||
assert_eq!(GenerateSeedance2p0Request::credits_per_dollar(), 193.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_5s() {
|
||||
// 200 credits / 193 * 100 = 103.63 → 104¢
|
||||
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 104);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_480p_5s() {
|
||||
// 75 credits / 193 * 100 = 38.86 → 39¢
|
||||
assert_eq!(r480(5).estimate_cost_in_usd_cents(), 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_5s() {
|
||||
// 450 credits / 193 * 100 = 233.16 → 233¢
|
||||
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 233);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_15s() {
|
||||
// 600 credits / 193 * 100 = 310.88 → 311¢
|
||||
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 311);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_15s() {
|
||||
// 1350 credits / 193 * 100 = 699.48 → 699¢
|
||||
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 699);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_usd_cents() {
|
||||
let base = r720(5).estimate_cost_in_usd_cents();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).estimate_cost_in_usd_cents();
|
||||
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
|
||||
"batch 2 ({}) should be ~2× base ({})", batch2, base);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = r720(5).estimate_credits();
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p0AspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p0AspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p0AspectRatio::Square1x1,
|
||||
KinoviSeedance2p0AspectRatio::Standard4x3,
|
||||
KinoviSeedance2p0AspectRatio::Portrait3x4,
|
||||
];
|
||||
|
||||
for ar in &ratios {
|
||||
let req = GenerateSeedance2p0Request {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: Some(*ar),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
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,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
|
||||
const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te";
|
||||
const MOCHI_ID: &str = "char_1775177718294_g2pitx";
|
||||
|
||||
mod text_to_video {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_default() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
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,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_1080p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "A dragon soaring over a medieval castle at sunset".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: Some(KinoviSeedance2p0OutputResolution::TenEightyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
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,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod keyframe {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_start_frame() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "The corgi dog watches the lake as the sun sets.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: Some(start_frame_url),
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_start_and_end_frame() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
|
||||
let end_frame_url = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "The dog walks from the lake toward the camera.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: Some(start_frame_url),
|
||||
end_frame_url: Some(end_frame_url),
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod image_reference {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_image_references() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let img1 = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
|
||||
let img2 = upload_test_image(&session, test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "The dog in @1 runs through the scenery in @2.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: Some(vec![img1, img2]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod video_reference {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_reference() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "Change @video1 to a nighttime scene with moonlight.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
|
||||
]),
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod character_reference {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_single_character() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "@Steampunk Clown is juggling flaming torches in a circus tent.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_two_characters() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_test_image(session: &Seedance2ProSession, image_url: &str) -> AnyhowResult<String> {
|
||||
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
|
||||
image_url,
|
||||
).await?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: "jpg".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: image_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
}
|
||||
+678
@@ -0,0 +1,678 @@
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
|
||||
WorkflowRunTaskRequest,
|
||||
};
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSeedance2p0FastArgs<'a> {
|
||||
pub request: GenerateSeedance2p0FastRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSeedance2p0FastRequest {
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: Option<KinoviSeedance2p0FastAspectRatio>,
|
||||
pub output_resolution: Option<KinoviSeedance2p0FastOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p0FastBatchCount>,
|
||||
pub start_frame_url: Option<String>,
|
||||
pub end_frame_url: Option<String>,
|
||||
pub reference_image_urls: Option<Vec<String>>,
|
||||
pub reference_video_urls: Option<Vec<String>>,
|
||||
pub reference_audio_urls: Option<Vec<String>>,
|
||||
pub character_ids: Option<Vec<String>>,
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0FastAspectRatio {
|
||||
Landscape16x9,
|
||||
Portrait9x16,
|
||||
Square1x1,
|
||||
Standard4x3,
|
||||
Portrait3x4,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0FastOutputResolution {
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0FastBatchCount {
|
||||
One,
|
||||
Two,
|
||||
Four,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.0 Fast credit pricing:
|
||||
//
|
||||
// | Resolution | Credits/sec |
|
||||
// |------------|-------------|
|
||||
// | 480p | 14 |
|
||||
// | 720p | 28 |
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
|
||||
impl GenerateSeedance2p0FastRequest {
|
||||
/// Estimate the credit cost for this generation request.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
let credits_per_second: u32 = match self.output_resolution {
|
||||
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => 14,
|
||||
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => 28,
|
||||
};
|
||||
|
||||
let per_video = u32::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u32 = match self.batch_count {
|
||||
None | Some(KinoviSeedance2p0FastBatchCount::One) => 1,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Two) => 2,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Four) => 4,
|
||||
};
|
||||
per_video * batch_multiplier
|
||||
}
|
||||
|
||||
/// Credits per dollar for billing conversion.
|
||||
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
|
||||
fn credits_per_dollar() -> f64 {
|
||||
193.0
|
||||
}
|
||||
|
||||
/// Estimate the USD cost in cents for this generation request.
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let cost = credits / Self::credits_per_dollar() * 100.0;
|
||||
cost.round() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSeedance2p0FastResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_seedance_2p0_fast(
|
||||
args: GenerateSeedance2p0FastArgs<'_>,
|
||||
) -> Result<GenerateSeedance2p0FastResponse, Seedance2ProError> {
|
||||
let req = args.request;
|
||||
|
||||
let raw_request = WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2Fast,
|
||||
prompt: req.prompt,
|
||||
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
|
||||
output_resolution: req.output_resolution.map(map_output_resolution),
|
||||
batch_count: map_batch_count(req.batch_count),
|
||||
duration_seconds: req.duration_seconds,
|
||||
start_frame_url: req.start_frame_url,
|
||||
end_frame_url: req.end_frame_url,
|
||||
reference_image_urls: req.reference_image_urls,
|
||||
reference_video_urls: req.reference_video_urls,
|
||||
reference_audio_urls: req.reference_audio_urls,
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
request: raw_request,
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSeedance2p0FastResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
task_ids: raw_response.task_ids,
|
||||
order_ids: raw_response.order_ids,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0FastAspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviSeedance2p0FastAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
|
||||
None => KinoviAspectRatioRaw::Landscape16x9,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_resolution(res: KinoviSeedance2p0FastOutputResolution) -> KinoviOutputResolutionRaw {
|
||||
match res {
|
||||
KinoviSeedance2p0FastOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
|
||||
KinoviSeedance2p0FastOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCountRaw {
|
||||
match bc {
|
||||
Some(KinoviSeedance2p0FastBatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn build_request(
|
||||
duration_seconds: u8,
|
||||
output_resolution: Option<KinoviSeedance2p0FastOutputResolution>,
|
||||
batch_count: Option<KinoviSeedance2p0FastBatchCount>,
|
||||
) -> GenerateSeedance2p0FastRequest {
|
||||
GenerateSeedance2p0FastRequest {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
output_resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn r480(dur: u8) -> GenerateSeedance2p0FastRequest {
|
||||
build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None)
|
||||
}
|
||||
|
||||
fn r720(dur: u8) -> GenerateSeedance2p0FastRequest {
|
||||
build_request(dur, None, None)
|
||||
}
|
||||
|
||||
// ── 480p credits (14 credits/sec) ──
|
||||
|
||||
mod credits_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r480(3).estimate_credits(), 42);
|
||||
assert_eq!(r480(4).estimate_credits(), 56);
|
||||
assert_eq!(r480(5).estimate_credits(), 70);
|
||||
assert_eq!(r480(6).estimate_credits(), 84);
|
||||
assert_eq!(r480(7).estimate_credits(), 98);
|
||||
assert_eq!(r480(8).estimate_credits(), 112);
|
||||
assert_eq!(r480(9).estimate_credits(), 126);
|
||||
assert_eq!(r480(10).estimate_credits(), 140);
|
||||
assert_eq!(r480(11).estimate_credits(), 154);
|
||||
assert_eq!(r480(12).estimate_credits(), 168);
|
||||
assert_eq!(r480(13).estimate_credits(), 182);
|
||||
assert_eq!(r480(14).estimate_credits(), 196);
|
||||
assert_eq!(r480(15).estimate_credits(), 210);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 720p credits (28 credits/sec) ──
|
||||
|
||||
mod credits_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r720(3).estimate_credits(), 84);
|
||||
assert_eq!(r720(4).estimate_credits(), 112);
|
||||
assert_eq!(r720(5).estimate_credits(), 140);
|
||||
assert_eq!(r720(6).estimate_credits(), 168);
|
||||
assert_eq!(r720(7).estimate_credits(), 196);
|
||||
assert_eq!(r720(8).estimate_credits(), 224);
|
||||
assert_eq!(r720(9).estimate_credits(), 252);
|
||||
assert_eq!(r720(10).estimate_credits(), 280);
|
||||
assert_eq!(r720(11).estimate_credits(), 308);
|
||||
assert_eq!(r720(12).estimate_credits(), 336);
|
||||
assert_eq!(r720(13).estimate_credits(), 364);
|
||||
assert_eq!(r720(14).estimate_credits(), 392);
|
||||
assert_eq!(r720(15).estimate_credits(), 420);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_same_as_default() {
|
||||
let default = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).estimate_credits();
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch multiplier ──
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).estimate_credits();
|
||||
assert_eq!(base, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_doubles() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_credits();
|
||||
assert_eq!(batch2, base * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_quadruples() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).estimate_credits();
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_480p() {
|
||||
let base = r480(5).estimate_credits();
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_credits();
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).estimate_credits();
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c3 = r720(3).estimate_credits();
|
||||
let c10 = r720(10).estimate_credits();
|
||||
let c15 = r720(15).estimate_credits();
|
||||
assert!(c3 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_ordering() {
|
||||
for dur in 3..=15u8 {
|
||||
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).estimate_credits();
|
||||
let c720 = build_request(dur, None, None).estimate_credits();
|
||||
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents ──
|
||||
|
||||
mod usd_cents_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_per_dollar_is_193() {
|
||||
assert_eq!(GenerateSeedance2p0FastRequest::credits_per_dollar(), 193.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_5s() {
|
||||
// 140 credits / 193 * 100 = 72.54 → 73¢
|
||||
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 73);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_480p_5s() {
|
||||
// 70 credits / 193 * 100 = 36.27 → 36¢
|
||||
assert_eq!(r480(5).estimate_cost_in_usd_cents(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_15s() {
|
||||
// 420 credits / 193 * 100 = 217.62 → 218¢
|
||||
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 218);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_480p_15s() {
|
||||
// 210 credits / 193 * 100 = 108.81 → 109¢
|
||||
assert_eq!(r480(15).estimate_cost_in_usd_cents(), 109);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_usd_cents() {
|
||||
let base = r720(5).estimate_cost_in_usd_cents();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_cost_in_usd_cents();
|
||||
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
|
||||
"batch 2 ({}) should be ~2× base ({})", batch2, base);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio doesn't affect cost ──
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = r720(5).estimate_credits();
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p0FastAspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p0FastAspectRatio::Square1x1,
|
||||
KinoviSeedance2p0FastAspectRatio::Standard4x3,
|
||||
KinoviSeedance2p0FastAspectRatio::Portrait3x4,
|
||||
];
|
||||
|
||||
for ar in &ratios {
|
||||
let req = GenerateSeedance2p0FastRequest {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: Some(*ar),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
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,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
|
||||
const STEAMPUNK_CLOWN_ID: &str = "char_1775176566518_sik0te";
|
||||
const MOCHI_ID: &str = "char_1775177718294_g2pitx";
|
||||
|
||||
mod text_to_video {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_default() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
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,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "A golden retriever running through a field of sunflowers".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
|
||||
output_resolution: Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
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,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod keyframe {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_keyframe_start_frame() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let start_frame_url = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "A corgi dog runs along the lake shore, splashing water.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: Some(start_frame_url),
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod image_reference {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_image_references() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let img1 = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?;
|
||||
let img2 = upload_test_image(&session, test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "The dog in @1 runs through the scenery in @2. Golden hour.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: Some(vec![img1, img2]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod video_reference {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_reference() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "Change @video1 to night time.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
|
||||
]),
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod character_reference {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_single_character() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "@Mochi the shiba inu is eating a cheese pizza on the table.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Portrait9x16),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_two_characters() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0FastRequest {
|
||||
prompt: "@Steampunk Clown and @Mochi are playing fetch in a sunny park.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0FastAspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_test_image(session: &Seedance2ProSession, image_url: &str) -> AnyhowResult<String> {
|
||||
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
|
||||
image_url,
|
||||
).await?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: "jpg".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: image_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod generate_happy_horse_1p0;
|
||||
pub mod generate_seedance_2p0;
|
||||
pub mod generate_seedance_2p0_fast;
|
||||
@@ -4,5 +4,6 @@ pub (crate) mod test_utils;
|
||||
// User API
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod generate;
|
||||
pub mod requests;
|
||||
pub mod utils;
|
||||
|
||||
+9
-10
@@ -13,6 +13,13 @@ use wreq_util::Emulation;
|
||||
|
||||
// --- Request args ---
|
||||
|
||||
/// Wrapper that bundles a [`KinoviGenerateVideoRequest`] with session and host info.
|
||||
pub struct GenerateVideoArgs<'a> {
|
||||
pub request: KinoviGenerateVideoRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
/// Video generation parameters (no session/host info).
|
||||
#[derive(Clone)]
|
||||
pub struct KinoviGenerateVideoRequest {
|
||||
@@ -64,13 +71,6 @@ pub struct KinoviGenerateVideoRequest {
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
}
|
||||
|
||||
/// Wrapper that bundles a [`KinoviGenerateVideoRequest`] with session and host info.
|
||||
pub struct GenerateVideoArgs<'a> {
|
||||
pub request: KinoviGenerateVideoRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for KinoviGenerateVideoRequest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GenerateVideoRequest")
|
||||
@@ -281,8 +281,9 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
|
||||
let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty());
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio;
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
|
||||
|
||||
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
|
||||
|
||||
@@ -352,8 +353,6 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
|
||||
|
||||
info!("Seedance2pro request : {:?}", request_body);
|
||||
|
||||
println!("\n\nSeedance2pro request : \n\n {:?}\n\n", request_body);
|
||||
|
||||
let cookie = args.session.cookies.as_str();
|
||||
|
||||
let client = Client::builder()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod generate_character;
|
||||
pub mod generate_video;
|
||||
pub mod workflow_run_task;
|
||||
pub mod get_user_auth_details;
|
||||
pub mod kinovi_host;
|
||||
pub mod poll_characters;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod workflow_run_task;
|
||||
mod request_types;
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct BatchRequest {
|
||||
#[serde(rename = "0")]
|
||||
pub zero: BatchRequestInner,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct BatchRequestInner {
|
||||
pub json: BatchRequestJson,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct BatchRequestJson {
|
||||
#[serde(rename = "businessType")]
|
||||
pub business_type: &'static str,
|
||||
#[serde(rename = "apiParams")]
|
||||
pub api_params: ApiParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct ApiParams {
|
||||
pub prompt: String,
|
||||
pub resolution: String,
|
||||
#[serde(rename = "contentMode")]
|
||||
pub content_mode: &'static str,
|
||||
pub model: &'static str,
|
||||
pub duration: String,
|
||||
pub mode: &'static str,
|
||||
#[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")]
|
||||
pub output_resolution: Option<&'static str>,
|
||||
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
|
||||
pub face_blur_mode: Option<&'static str>,
|
||||
#[serde(rename = "characterIds", skip_serializing_if = "Option::is_none")]
|
||||
pub character_ids: Option<Vec<String>>,
|
||||
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
|
||||
pub uploaded_urls: Option<Vec<String>>,
|
||||
#[serde(rename = "audioUrls", skip_serializing_if = "Option::is_none")]
|
||||
pub audio_urls: Option<Vec<String>>,
|
||||
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
|
||||
pub batch_count: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub (super) struct BatchResponseItem {
|
||||
pub result: BatchResponseResult,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub (super) struct BatchResponseResult {
|
||||
pub data: BatchResponseData,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub (super) struct BatchResponseData {
|
||||
pub json: TaskResponseJson,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub (super) struct TaskResponseJson {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
pub violation_warning: bool,
|
||||
}
|
||||
+1599
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,3 @@
|
||||
pub mod aspect_ratio;
|
||||
pub mod resolution;
|
||||
pub mod resolution;
|
||||
pub mod quality;
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use artcraft_router::api::common_quality::CommonQuality as RouterCommonQuality;
|
||||
|
||||
/// Just cargo culting this. might not be necessary for standalone types anymore
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonQuality {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl CommonQuality {
|
||||
pub fn to_artcraft_router_type(&self) -> RouterCommonQuality {
|
||||
match self {
|
||||
CommonQuality::High => RouterCommonQuality::High,
|
||||
CommonQuality::Medium => RouterCommonQuality::Medium,
|
||||
CommonQuality::Low => RouterCommonQuality::Low,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod common_quality;
|
||||
+1
-1
@@ -17,7 +17,7 @@ pub async fn estimate_image_cost_command(
|
||||
request: EstimateImageCostRequest,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrError<EstimateImageCostResponse, EstimateImageCostError> {
|
||||
debug!("estimate_image_cost_command called");
|
||||
debug!("estimate_image_cost_command called: {:?}", request);
|
||||
|
||||
let result = estimate_image_cost(
|
||||
&app_env_configs.storyteller_host,
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ use artcraft_api_defs::generate::cost_estimate::estimate_video_cost::{
|
||||
EstimateVideoCostResponse,
|
||||
};
|
||||
use artcraft_client::endpoints::generate::cost_estimate::video::estimate_video_cost::estimate_video_cost;
|
||||
use log::debug;
|
||||
use log::{debug, info};
|
||||
use tauri::State;
|
||||
|
||||
impl SerializeMarker for EstimateVideoCostResponse {}
|
||||
@@ -18,7 +18,7 @@ pub async fn estimate_video_cost_command(
|
||||
request: EstimateVideoCostRequest,
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrError<EstimateVideoCostResponse, EstimateVideoCostError> {
|
||||
debug!("estimate_video_cost_command called");
|
||||
debug!("estimate_video_cost_command called: {:?}", request);
|
||||
|
||||
let result = estimate_video_cost(
|
||||
&app_env_configs.storyteller_host,
|
||||
|
||||
@@ -235,6 +235,7 @@ impl From<ArtcraftRouterError> for GenerateError {
|
||||
ArtcraftRouterError::Provider(ProviderError::Muapi(_)) => Self::ArtcraftRouterNotYetSupportedProvider("muapi"),
|
||||
ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(_)) => Self::ArtcraftRouterNotYetSupportedProvider("seedance2pro"),
|
||||
ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)),
|
||||
ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(message) => Self::ArtcraftRouterNotYetSupportedProvider("unsupported model for new router API"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -1,4 +1,5 @@
|
||||
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::core::api_adapters::quality::common_quality::CommonQuality;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::core::commands::enqueue::image_edit::artcraft::handle_image_edit_artcraft::handle_image_edit_artcraft;
|
||||
@@ -15,6 +16,8 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::provider_priority::ProviderPriorityStore;
|
||||
@@ -24,6 +27,9 @@ use crate::core::utils::simple_http_download::simple_http_download;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
|
||||
@@ -36,13 +42,8 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
|
||||
use std::time::Duration;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
|
||||
/// This is used in the Tauri command bridge.
|
||||
/// Don't change the serializations without coordinating with the frontend.
|
||||
@@ -134,6 +135,10 @@ pub struct EnqueueEditImageCommand {
|
||||
/// Image resolution
|
||||
pub image_resolution: Option<EditImageResolution>,
|
||||
|
||||
/// Quality.
|
||||
/// Tends to be used by OpenAI models.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Only for image angle edit models.
|
||||
pub horizontal_angle: Option<f64>,
|
||||
|
||||
+10
-5
@@ -1,3 +1,4 @@
|
||||
use crate::core::api_adapters::quality::common_quality::CommonQuality;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::core::commands::enqueue::image_edit::image_edit_models::image_edit_model_to_model_type;
|
||||
@@ -14,6 +15,8 @@ use crate::core::events::generation_events::generation_enqueue_failure_event::Ge
|
||||
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
|
||||
use crate::core::events::sendable_event_trait::SendableEvent;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
|
||||
use crate::core::state::provider_priority::ProviderPriorityStore;
|
||||
@@ -23,6 +26,9 @@ use crate::core::utils::simple_http_download::simple_http_download;
|
||||
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
|
||||
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
|
||||
use errors::AnyhowError;
|
||||
@@ -34,13 +40,8 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
|
||||
use std::time::Duration;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
|
||||
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
|
||||
|
||||
#[derive(Deserialize, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -95,6 +96,10 @@ pub struct EnqueueInpaintImageCommand {
|
||||
|
||||
/// Number of images to generate.
|
||||
pub image_count: Option<u32>,
|
||||
|
||||
/// Quality.
|
||||
/// Tends to be used by OpenAI models.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// If true, force the dimensions of the source image and mask image to match.
|
||||
pub require_matching_dimensions: Option<bool>,
|
||||
|
||||
@@ -73,6 +73,7 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::NanoBananaPro) => Some(TaskModelType::NanoBananaPro),
|
||||
Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1),
|
||||
Some(GenerationModel::GptImage1p5) => Some(TaskModelType::GptImage1p5),
|
||||
Some(GenerationModel::GptImage2) => Some(TaskModelType::GptImage2),
|
||||
Some(GenerationModel::Seedream4) => Some(TaskModelType::Seedream4),
|
||||
Some(GenerationModel::Seedream4p5) => Some(TaskModelType::Seedream4p5),
|
||||
Some(GenerationModel::Seedream5Lite) => Some(TaskModelType::Seedream5Lite),
|
||||
|
||||
+1
@@ -37,6 +37,7 @@ pub async fn handle_text_to_image_artcraft(
|
||||
TextToImageModel::FluxPro11Ultra => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::FluxPro11Ultra, GenerationModel::FluxPro11Ultra).await,
|
||||
TextToImageModel::GptImage1 => handle_artcraft_gpt_image_1_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
|
||||
TextToImageModel::GptImage1p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await,
|
||||
TextToImageModel::GptImage2 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage2, GenerationModel::GptImage2).await,
|
||||
TextToImageModel::Gemini25Flash | TextToImageModel::NanoBanana => {
|
||||
handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanoBanana, GenerationModel::NanoBanana).await
|
||||
},
|
||||
|
||||
+7
-1
@@ -11,6 +11,7 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterCommonAspectRatio;
|
||||
use artcraft_router::api::common_image_model::CommonImageModel;
|
||||
use artcraft_router::api::common_resolution::CommonResolution as RouterCommonResolution;
|
||||
use artcraft_router::api::common_quality::CommonQuality as RouterCommonQuality;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
@@ -42,6 +43,7 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
|
||||
|
||||
let aspect_ratio = get_aspect_ratio_t2i(request);
|
||||
let resolution = get_resolution_t2i(request);
|
||||
let quality = get_quality_t2i(request);
|
||||
|
||||
let router_request = GenerateImageRequest {
|
||||
model,
|
||||
@@ -50,7 +52,7 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
|
||||
image_inputs,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
quality: None, // TODO: Add quality
|
||||
quality,
|
||||
image_batch_count: request.number_images.map(|n| n as u16),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
@@ -117,6 +119,10 @@ fn get_resolution_t2i(request: &EnqueueTextToImageRequest) -> Option<RouterCommo
|
||||
None
|
||||
}
|
||||
|
||||
fn get_quality_t2i(request: &EnqueueTextToImageRequest) -> Option<RouterCommonQuality> {
|
||||
request.quality.map(|quality| quality.to_artcraft_router_type())
|
||||
}
|
||||
|
||||
fn convert_desktop_aspect_ratio(ar: CommonAspectRatio2) -> RouterCommonAspectRatio {
|
||||
match ar {
|
||||
CommonAspectRatio2::Auto => RouterCommonAspectRatio::Auto,
|
||||
|
||||
+7
@@ -1,4 +1,5 @@
|
||||
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::core::api_adapters::quality::common_quality::CommonQuality;
|
||||
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
|
||||
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
|
||||
@@ -59,6 +60,8 @@ pub enum TextToImageModel {
|
||||
GptImage1,
|
||||
#[serde(rename = "gpt_image_1p5")]
|
||||
GptImage1p5,
|
||||
#[serde(rename = "gpt_image_2")]
|
||||
GptImage2,
|
||||
#[serde(rename = "gemini_25_flash")]
|
||||
Gemini25Flash,
|
||||
#[serde(rename = "nano_banana")]
|
||||
@@ -99,6 +102,10 @@ pub struct EnqueueTextToImageRequest {
|
||||
/// Not all models support each of these resolutions, but we can choose or interpolate sensibly.
|
||||
pub common_resolution: Option<CommonResolution>,
|
||||
|
||||
/// Quality.
|
||||
/// Tends to be used by OpenAI models.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
/// Aspect ratio.
|
||||
#[deprecated(note="use common_aspect_ratio")]
|
||||
pub aspect_ratio: Option<TextToImageSize>,
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ pub fn text_to_image_model_to_model_type(model: TextToImageModel) -> CommonModel
|
||||
TextToImageModel::Recraft3 => CommonModelType::Recraft3,
|
||||
TextToImageModel::GptImage1 => CommonModelType::GptImage1,
|
||||
TextToImageModel::GptImage1p5 => CommonModelType::GptImage1p5,
|
||||
TextToImageModel::GptImage2 => CommonModelType::GptImage2,
|
||||
TextToImageModel::Gemini25Flash => CommonModelType::NanoBanana,
|
||||
TextToImageModel::NanoBanana => CommonModelType::NanoBanana,
|
||||
TextToImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
|
||||
|
||||
@@ -33,6 +33,8 @@ pub enum GenerationModel {
|
||||
GptImage1,
|
||||
#[serde(rename = "gpt_image_1p5")]
|
||||
GptImage1p5,
|
||||
#[serde(rename = "gpt_image_2")]
|
||||
GptImage2,
|
||||
#[serde(rename = "grok_image")]
|
||||
GrokImage,
|
||||
#[serde(rename = "gemini_25_flash")]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
|
||||
/// This should match the CI builds.
|
||||
pub const ARTCRAFT_VERSION: &str = "0.28.0";
|
||||
pub const ARTCRAFT_VERSION: &str = "0.30.0";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.28.0",
|
||||
"version": "0.30.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.28.0",
|
||||
"version": "0.30.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
+2
-1
@@ -111,7 +111,8 @@ pub async fn list_media_files_by_batch_token(args: ListMediaFileByBatchArgs<'_>)
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let number_of_pages = (row_count_result / args.page_size as i64) as usize;
|
||||
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|record| {
|
||||
MediaFileListItem {
|
||||
|
||||
+2
-1
@@ -128,7 +128,8 @@ pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> An
|
||||
let query = query.build_query_as::<MediaFileListItemInternal>();
|
||||
let results = query.fetch_all(args.mysql_pool).await?;
|
||||
|
||||
let number_of_pages = (row_count_result / args.page_size as i64) as usize;
|
||||
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
|
||||
|
||||
let results = results.into_iter()
|
||||
.map(|record| {
|
||||
MediaFileListItem {
|
||||
|
||||
@@ -36,6 +36,10 @@ pub enum StaffAuditAction {
|
||||
/// Staff sent a manual alert via the pager system.
|
||||
#[serde(rename = "send_alert")]
|
||||
SendAlert,
|
||||
|
||||
/// Staff edited a user's feature flags.
|
||||
#[serde(rename = "edit_user_feature_flags")]
|
||||
EditUserFeatureFlags,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(StaffAuditAction);
|
||||
@@ -51,6 +55,7 @@ impl StaffAuditAction {
|
||||
Self::UnbanUser => "unban_user",
|
||||
Self::AddWalletBankedBalance => "add_wallet_banked_balance",
|
||||
Self::SendAlert => "send_alert",
|
||||
Self::EditUserFeatureFlags => "edit_user_feature_flags",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +67,7 @@ impl StaffAuditAction {
|
||||
"unban_user" => Ok(Self::UnbanUser),
|
||||
"add_wallet_banked_balance" => Ok(Self::AddWalletBankedBalance),
|
||||
"send_alert" => Ok(Self::SendAlert),
|
||||
"edit_user_feature_flags" => Ok(Self::EditUserFeatureFlags),
|
||||
_ => Err(format!("invalid StaffAuditAction value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
@@ -74,6 +80,7 @@ impl StaffAuditAction {
|
||||
Self::UnbanUser,
|
||||
Self::AddWalletBankedBalance,
|
||||
Self::SendAlert,
|
||||
Self::EditUserFeatureFlags,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -94,6 +101,7 @@ mod tests {
|
||||
assert_serialization(StaffAuditAction::UnbanUser, "unban_user");
|
||||
assert_serialization(StaffAuditAction::AddWalletBankedBalance, "add_wallet_banked_balance");
|
||||
assert_serialization(StaffAuditAction::SendAlert, "send_alert");
|
||||
assert_serialization(StaffAuditAction::EditUserFeatureFlags, "edit_user_feature_flags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -104,6 +112,7 @@ mod tests {
|
||||
assert_eq!(StaffAuditAction::UnbanUser.to_str(), "unban_user");
|
||||
assert_eq!(StaffAuditAction::AddWalletBankedBalance.to_str(), "add_wallet_banked_balance");
|
||||
assert_eq!(StaffAuditAction::SendAlert.to_str(), "send_alert");
|
||||
assert_eq!(StaffAuditAction::EditUserFeatureFlags.to_str(), "edit_user_feature_flags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -114,12 +123,13 @@ mod tests {
|
||||
assert_eq!(StaffAuditAction::from_str("unban_user").unwrap(), StaffAuditAction::UnbanUser);
|
||||
assert_eq!(StaffAuditAction::from_str("add_wallet_banked_balance").unwrap(), StaffAuditAction::AddWalletBankedBalance);
|
||||
assert_eq!(StaffAuditAction::from_str("send_alert").unwrap(), StaffAuditAction::SendAlert);
|
||||
assert_eq!(StaffAuditAction::from_str("edit_user_feature_flags").unwrap(), StaffAuditAction::EditUserFeatureFlags);
|
||||
assert!(StaffAuditAction::from_str("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
const EXPECTED_COUNT: usize = 6;
|
||||
const EXPECTED_COUNT: usize = 7;
|
||||
assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ pub enum UserFeatureFlag {
|
||||
/// Access to the faster Seedance 2.0 whitelist
|
||||
#[serde(rename = "sd_wl")]
|
||||
SeedanceWhitelist,
|
||||
|
||||
/// Access to Happy Horse
|
||||
#[serde(rename = "hh")]
|
||||
HappyHorse,
|
||||
|
||||
/// Access to Happy Horse (with a rate limit)
|
||||
#[serde(rename = "hh_rl")]
|
||||
HappyHorseRateLimit,
|
||||
}
|
||||
|
||||
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
|
||||
@@ -48,6 +56,8 @@ impl UserFeatureFlag {
|
||||
Self::Upload3d => "upload_3d",
|
||||
Self::VideoStyleTransfer => "video_style_transfer",
|
||||
Self::SeedanceWhitelist => "sd_wl",
|
||||
Self::HappyHorse => "hh",
|
||||
Self::HappyHorseRateLimit => "hh_rl",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +68,36 @@ impl UserFeatureFlag {
|
||||
"upload_3d" => Ok(Self::Upload3d),
|
||||
"video_style_transfer" => Ok(Self::VideoStyleTransfer),
|
||||
"sd_wl" => Ok(Self::SeedanceWhitelist),
|
||||
"hh" => Ok(Self::HappyHorse),
|
||||
"hh_rl" => Ok(Self::HappyHorseRateLimit),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ExploreMedia => "Explore Media",
|
||||
Self::Studio => "Storyteller Studio",
|
||||
Self::Upload3d => "Upload 3D",
|
||||
Self::VideoStyleTransfer => "Video Style Transfer",
|
||||
Self::SeedanceWhitelist => "DO NOT USE: Seedance Whitelist (legacy)",
|
||||
Self::HappyHorse => "Happy Horse (no Rate Limit)",
|
||||
Self::HappyHorseRateLimit => "Happy Horse (with Rate Limit)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ExploreMedia => "Grants a user the ability to list media on FakeYou (admin permission)",
|
||||
Self::Studio => "Access to Storyteller Studio product",
|
||||
Self::Upload3d => "Whether users are allowed to upload 3D models",
|
||||
Self::VideoStyleTransfer => "Access to video style transfer on Storyteller",
|
||||
Self::SeedanceWhitelist => "DO NOT USE THIS. IT WILL BREAK ACCOUNTS.",
|
||||
Self::HappyHorse => "Access to Happy Horse (without a rate limit - dangerous)",
|
||||
Self::HappyHorseRateLimit => "Access to Happy Horse (with a rate limit)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
// NB: BTreeSet is sorted
|
||||
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
|
||||
@@ -71,6 +107,8 @@ impl UserFeatureFlag {
|
||||
Self::Upload3d,
|
||||
Self::VideoStyleTransfer,
|
||||
Self::SeedanceWhitelist,
|
||||
Self::HappyHorse,
|
||||
Self::HappyHorseRateLimit,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -90,6 +128,8 @@ mod tests {
|
||||
assert_serialization(UserFeatureFlag::Upload3d, "upload_3d");
|
||||
assert_serialization(UserFeatureFlag::VideoStyleTransfer, "video_style_transfer");
|
||||
assert_serialization(UserFeatureFlag::SeedanceWhitelist, "sd_wl");
|
||||
assert_serialization(UserFeatureFlag::HappyHorse, "hh");
|
||||
assert_serialization(UserFeatureFlag::HappyHorseRateLimit, "hh_rl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -99,6 +139,8 @@ mod tests {
|
||||
assert_eq!(UserFeatureFlag::Upload3d.to_str(), "upload_3d");
|
||||
assert_eq!(UserFeatureFlag::VideoStyleTransfer.to_str(), "video_style_transfer");
|
||||
assert_eq!(UserFeatureFlag::SeedanceWhitelist.to_str(), "sd_wl");
|
||||
assert_eq!(UserFeatureFlag::HappyHorse.to_str(), "hh");
|
||||
assert_eq!(UserFeatureFlag::HappyHorseRateLimit.to_str(), "hh_rl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -108,18 +150,22 @@ mod tests {
|
||||
assert_eq!(UserFeatureFlag::from_str("upload_3d").unwrap(), UserFeatureFlag::Upload3d);
|
||||
assert_eq!(UserFeatureFlag::from_str("video_style_transfer").unwrap(), UserFeatureFlag::VideoStyleTransfer);
|
||||
assert_eq!(UserFeatureFlag::from_str("sd_wl").unwrap(), UserFeatureFlag::SeedanceWhitelist);
|
||||
assert_eq!(UserFeatureFlag::from_str("hh").unwrap(), UserFeatureFlag::HappyHorse);
|
||||
assert_eq!(UserFeatureFlag::from_str("hh_rl").unwrap(), UserFeatureFlag::HappyHorseRateLimit);
|
||||
assert!(UserFeatureFlag::from_str("foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = UserFeatureFlag::all_variants();
|
||||
assert_eq!(variants.len(), 5);
|
||||
assert_eq!(variants.len(), 7);
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::VideoStyleTransfer));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::SeedanceWhitelist));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorse));
|
||||
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::HappyHorseRateLimit));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ pub enum CommonModelType {
|
||||
Kling3p0Standard,
|
||||
#[serde(rename = "kling_3p0_pro")]
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "seedance_1p0_pro")]
|
||||
@@ -209,6 +211,7 @@ impl CommonModelType {
|
||||
Self::Kling2p6Pro => "kling_2p6_pro",
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Seedance10Lite => "seedance_1p0_lite",
|
||||
Self::Seedance10Pro => "seedance_1p0_pro",
|
||||
Self::Seedance1p5Pro => "seedance_1p5_pro",
|
||||
@@ -277,6 +280,7 @@ impl CommonModelType {
|
||||
"kling_2p6_pro" => Ok(Self::Kling2p6Pro),
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"seedance_1p0_lite" => Ok(Self::Seedance10Lite),
|
||||
"seedance_1p0_pro" => Ok(Self::Seedance10Pro),
|
||||
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
|
||||
@@ -349,6 +353,7 @@ impl CommonModelType {
|
||||
Self::Kling2p6Pro,
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Seedance10Lite,
|
||||
Self::Seedance10Pro,
|
||||
Self::Seedance1p5Pro,
|
||||
@@ -418,6 +423,7 @@ impl CommonModelType {
|
||||
Self::Kling2p6Pro => CommonModelClass::Video,
|
||||
Self::Kling3p0Standard => CommonModelClass::Video,
|
||||
Self::Kling3p0Pro => CommonModelClass::Video,
|
||||
Self::HappyHorse1p0 => CommonModelClass::Video,
|
||||
Self::Seedance10Lite => CommonModelClass::Video,
|
||||
Self::Seedance10Pro => CommonModelClass::Video,
|
||||
Self::Seedance1p5Pro => CommonModelClass::Video,
|
||||
@@ -496,6 +502,7 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Kling2p6Pro, "kling_2p6_pro");
|
||||
assert_serialization(CommonModelType::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonModelType::Seedance10Lite, "seedance_1p0_lite");
|
||||
assert_serialization(CommonModelType::Seedance10Pro, "seedance_1p0_pro");
|
||||
assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro");
|
||||
@@ -561,6 +568,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Kling2p6Pro.to_str(), "kling_2p6_pro");
|
||||
assert_eq!(CommonModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
|
||||
assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(CommonModelType::Seedance10Lite.to_str(), "seedance_1p0_lite");
|
||||
assert_eq!(CommonModelType::Seedance10Pro.to_str(), "seedance_1p0_pro");
|
||||
assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
|
||||
@@ -626,6 +634,7 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("kling_2p6_pro").unwrap(), CommonModelType::Kling2p6Pro);
|
||||
assert_eq!(CommonModelType::from_str("kling_3p0_standard").unwrap(), CommonModelType::Kling3p0Standard);
|
||||
assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro);
|
||||
assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0);
|
||||
assert_eq!(CommonModelType::from_str("seedance_1p0_lite").unwrap(), CommonModelType::Seedance10Lite);
|
||||
assert_eq!(CommonModelType::from_str("seedance_1p0_pro").unwrap(), CommonModelType::Seedance10Pro);
|
||||
assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro);
|
||||
@@ -651,7 +660,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 56);
|
||||
assert_eq!(variants.len(), 57);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -693,6 +702,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling2p6Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Standard));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Lite));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance10Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro));
|
||||
|
||||
@@ -2,7 +2,7 @@ use utoipa::ToSchema;
|
||||
|
||||
/// Video models available for generation.
|
||||
/// Mirrors artcraft_router::api::common_video_model::CommonVideoModel.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonVideoModel {
|
||||
#[serde(rename = "grok_video")]
|
||||
@@ -29,6 +29,9 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "kling_3p0_pro")]
|
||||
Kling3p0Pro,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "seedance_1p0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
@@ -75,6 +78,7 @@ impl CommonVideoModel {
|
||||
Self::Kling2p6Pro => CommonModelType::Kling2p6Pro,
|
||||
Self::Kling3p0Standard => CommonModelType::Kling3p0Standard,
|
||||
Self::Kling3p0Pro => CommonModelType::Kling3p0Pro,
|
||||
Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0,
|
||||
Self::Seedance10Lite => CommonModelType::Seedance10Lite,
|
||||
Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro,
|
||||
Self::Seedance2p0 => CommonModelType::Seedance2p0,
|
||||
@@ -94,6 +98,94 @@ impl CommonVideoModel {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::generation::common_model_type::CommonModelType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(CommonVideoModel::GrokVideo, "grok_video");
|
||||
assert_serialization(CommonVideoModel::Kling16Pro, "kling_1p6_pro");
|
||||
assert_serialization(CommonVideoModel::Kling21Pro, "kling_2p1_pro");
|
||||
assert_serialization(CommonVideoModel::Kling21Master, "kling_2p1_master");
|
||||
assert_serialization(CommonVideoModel::Kling2p5TurboPro, "kling_2p5_turbo_pro");
|
||||
assert_serialization(CommonVideoModel::Kling2p6Pro, "kling_2p6_pro");
|
||||
assert_serialization(CommonVideoModel::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonVideoModel::Seedance10Lite, "seedance_1p0_lite");
|
||||
assert_serialization(CommonVideoModel::Seedance1p5Pro, "seedance_1p5_pro");
|
||||
assert_serialization(CommonVideoModel::Seedance2p0, "seedance_2p0");
|
||||
assert_serialization(CommonVideoModel::Seedance2p0Fast, "seedance_2p0_fast");
|
||||
assert_serialization(CommonVideoModel::Sora2, "sora_2");
|
||||
assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro");
|
||||
assert_serialization(CommonVideoModel::Veo2, "veo_2");
|
||||
assert_serialization(CommonVideoModel::Veo3, "veo_3");
|
||||
assert_serialization(CommonVideoModel::Veo3Fast, "veo_3_fast");
|
||||
assert_serialization(CommonVideoModel::Veo3p1, "veo_3p1");
|
||||
assert_serialization(CommonVideoModel::Veo3p1Fast, "veo_3p1_fast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialization() {
|
||||
let cases = [
|
||||
("grok_video", CommonVideoModel::GrokVideo),
|
||||
("kling_1p6_pro", CommonVideoModel::Kling16Pro),
|
||||
("kling_2p1_pro", CommonVideoModel::Kling21Pro),
|
||||
("kling_2p1_master", CommonVideoModel::Kling21Master),
|
||||
("kling_2p5_turbo_pro", CommonVideoModel::Kling2p5TurboPro),
|
||||
("kling_2p6_pro", CommonVideoModel::Kling2p6Pro),
|
||||
("kling_3p0_standard", CommonVideoModel::Kling3p0Standard),
|
||||
("kling_3p0_pro", CommonVideoModel::Kling3p0Pro),
|
||||
("happy_horse_1p0", CommonVideoModel::HappyHorse1p0),
|
||||
("seedance_1p0_lite", CommonVideoModel::Seedance10Lite),
|
||||
("seedance_1p5_pro", CommonVideoModel::Seedance1p5Pro),
|
||||
("seedance_2p0", CommonVideoModel::Seedance2p0),
|
||||
("seedance_2p0_fast", CommonVideoModel::Seedance2p0Fast),
|
||||
("sora_2", CommonVideoModel::Sora2),
|
||||
("sora_2_pro", CommonVideoModel::Sora2Pro),
|
||||
("veo_2", CommonVideoModel::Veo2),
|
||||
("veo_3", CommonVideoModel::Veo3),
|
||||
("veo_3_fast", CommonVideoModel::Veo3Fast),
|
||||
("veo_3p1", CommonVideoModel::Veo3p1),
|
||||
("veo_3p1_fast", CommonVideoModel::Veo3p1Fast),
|
||||
];
|
||||
for (json_str, expected) in cases {
|
||||
let json = format!("\"{}\"", json_str);
|
||||
let deserialized: CommonVideoModel = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("Failed to deserialize {:?}: {}", json_str, e));
|
||||
assert_eq!(deserialized, expected, "Failed for {:?}", json_str);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip() {
|
||||
let all = [
|
||||
CommonVideoModel::GrokVideo,
|
||||
CommonVideoModel::Kling16Pro,
|
||||
CommonVideoModel::Kling21Pro,
|
||||
CommonVideoModel::Kling21Master,
|
||||
CommonVideoModel::Kling2p5TurboPro,
|
||||
CommonVideoModel::Kling2p6Pro,
|
||||
CommonVideoModel::Kling3p0Standard,
|
||||
CommonVideoModel::Kling3p0Pro,
|
||||
CommonVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::Seedance10Lite,
|
||||
CommonVideoModel::Seedance1p5Pro,
|
||||
CommonVideoModel::Seedance2p0,
|
||||
CommonVideoModel::Seedance2p0Fast,
|
||||
CommonVideoModel::Sora2,
|
||||
CommonVideoModel::Sora2Pro,
|
||||
CommonVideoModel::Veo2,
|
||||
CommonVideoModel::Veo3,
|
||||
CommonVideoModel::Veo3Fast,
|
||||
CommonVideoModel::Veo3p1,
|
||||
CommonVideoModel::Veo3p1Fast,
|
||||
];
|
||||
for variant in all {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: CommonVideoModel = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized, "Round-trip failed for {:?}", variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_video_models_convert_to_common_model_type() {
|
||||
@@ -106,6 +198,7 @@ mod tests {
|
||||
(CommonVideoModel::Kling2p6Pro, CommonModelType::Kling2p6Pro),
|
||||
(CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard),
|
||||
(CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro),
|
||||
(CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0),
|
||||
(CommonVideoModel::Seedance10Lite, CommonModelType::Seedance10Lite),
|
||||
(CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro),
|
||||
(CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0),
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum TaskModelType {
|
||||
GptImage1,
|
||||
#[serde(rename = "gpt_image_1p5")]
|
||||
GptImage1p5,
|
||||
#[serde(rename = "gpt_image_2")]
|
||||
GptImage2,
|
||||
#[serde(rename = "seedream_4")]
|
||||
Seedream4,
|
||||
#[serde(rename = "seedream_4p5")]
|
||||
@@ -134,6 +136,7 @@ impl TaskModelType {
|
||||
Self::NanoBananaPro => "nano_banana_pro",
|
||||
Self::GptImage1 => "gpt_image_1",
|
||||
Self::GptImage1p5 => "gpt_image_1p5",
|
||||
Self::GptImage2 => "gpt_image_2",
|
||||
Self::Seedream4 => "seedream_4",
|
||||
Self::Seedream4p5 => "seedream_4p5",
|
||||
Self::Seedream5Lite => "seedream_5_lite",
|
||||
@@ -187,6 +190,7 @@ impl TaskModelType {
|
||||
"nano_banana_pro" => Ok(Self::NanoBananaPro),
|
||||
"gpt_image_1" => Ok(Self::GptImage1),
|
||||
"gpt_image_1p5" => Ok(Self::GptImage1p5),
|
||||
"gpt_image_2" => Ok(Self::GptImage2),
|
||||
"seedream_4" => Ok(Self::Seedream4),
|
||||
"seedream_4p5" => Ok(Self::Seedream4p5),
|
||||
"seedream_5_lite" => Ok(Self::Seedream5Lite),
|
||||
@@ -243,6 +247,7 @@ impl TaskModelType {
|
||||
Self::NanoBananaPro,
|
||||
Self::GptImage1,
|
||||
Self::GptImage1p5,
|
||||
Self::GptImage2,
|
||||
Self::Seedream4,
|
||||
Self::Seedream4p5,
|
||||
Self::Seedream5Lite,
|
||||
@@ -306,6 +311,7 @@ mod tests {
|
||||
assert_serialization(TaskModelType::NanoBananaPro, "nano_banana_pro");
|
||||
assert_serialization(TaskModelType::GptImage1, "gpt_image_1");
|
||||
assert_serialization(TaskModelType::GptImage1p5, "gpt_image_1p5");
|
||||
assert_serialization(TaskModelType::GptImage2, "gpt_image_2");
|
||||
assert_serialization(TaskModelType::Seedream4, "seedream_4");
|
||||
assert_serialization(TaskModelType::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(TaskModelType::Seedream5Lite, "seedream_5_lite");
|
||||
@@ -358,6 +364,7 @@ mod tests {
|
||||
assert_eq!(TaskModelType::NanoBananaPro.to_str(), "nano_banana_pro");
|
||||
assert_eq!(TaskModelType::GptImage1.to_str(), "gpt_image_1");
|
||||
assert_eq!(TaskModelType::GptImage1p5.to_str(), "gpt_image_1p5");
|
||||
assert_eq!(TaskModelType::GptImage2.to_str(), "gpt_image_2");
|
||||
assert_eq!(TaskModelType::Seedream4.to_str(), "seedream_4");
|
||||
assert_eq!(TaskModelType::Seedream4p5.to_str(), "seedream_4p5");
|
||||
assert_eq!(TaskModelType::Seedream5Lite.to_str(), "seedream_5_lite");
|
||||
@@ -410,6 +417,7 @@ mod tests {
|
||||
assert_eq!(TaskModelType::from_str("nano_banana_pro").unwrap(), TaskModelType::NanoBananaPro);
|
||||
assert_eq!(TaskModelType::from_str("gpt_image_1").unwrap(), TaskModelType::GptImage1);
|
||||
assert_eq!(TaskModelType::from_str("gpt_image_1p5").unwrap(), TaskModelType::GptImage1p5);
|
||||
assert_eq!(TaskModelType::from_str("gpt_image_2").unwrap(), TaskModelType::GptImage2);
|
||||
assert_eq!(TaskModelType::from_str("seedream_4").unwrap(), TaskModelType::Seedream4);
|
||||
assert_eq!(TaskModelType::from_str("seedream_4p5").unwrap(), TaskModelType::Seedream4p5);
|
||||
assert_eq!(TaskModelType::from_str("seedream_5_lite").unwrap(), TaskModelType::Seedream5Lite);
|
||||
@@ -460,7 +468,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskModelType::all_variants();
|
||||
assert_eq!(variants.len(), 45);
|
||||
assert_eq!(variants.len(), 46);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
|
||||
@@ -475,6 +483,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::NanoBananaPro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage1p5));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GptImage2));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4p5));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream5Lite));
|
||||
|
||||
@@ -37,6 +37,14 @@ pub struct JobDependencies {
|
||||
/// ancient orders that will never match a pending job.
|
||||
pub maybe_max_job_age: Option<Duration>,
|
||||
|
||||
/// Maximum number of retries when a poll_orders request fails before
|
||||
/// alerting the pager and aborting the iteration.
|
||||
pub poll_max_retries: u32,
|
||||
|
||||
/// Maximum delay in milliseconds between poll retries. The delay increases
|
||||
/// with each attempt up to this cap.
|
||||
pub poll_retry_max_delay_millis: u64,
|
||||
|
||||
/// Page an alert when available Kinovi credits fall below this threshold.
|
||||
pub credits_alert_threshold: u64,
|
||||
|
||||
|
||||
+47
-17
@@ -6,7 +6,7 @@ use log::{error, info, warn};
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::{list_pending_seedance2pro_video_jobs, PendingSeedance2ProJob};
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, TaskStatus};
|
||||
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, PollOrdersResponse, TaskStatus};
|
||||
|
||||
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::jobs::video_polling_job::process_orders_batch::process_orders_batch;
|
||||
@@ -131,22 +131,7 @@ async fn website_polling_loop(
|
||||
total_pages_seen, cursor, total_orders_seen
|
||||
);
|
||||
|
||||
let response = match poll_orders(PollOrdersArgs {
|
||||
session: &deps.seedance2pro_session,
|
||||
cursor,
|
||||
host_override: None,
|
||||
}).await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Error polling Kinovi orders: {:?}", err);
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Kinovi API polling failed",
|
||||
anyhow::anyhow!("poll_orders failed: {:?}", err),
|
||||
None,
|
||||
);
|
||||
}
|
||||
};
|
||||
let response = poll_orders_with_retry(deps, cursor).await?;
|
||||
|
||||
let page_orders_count = response.orders.len() as u32;
|
||||
|
||||
@@ -232,3 +217,48 @@ async fn website_polling_loop(
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll orders from Kinovi with retries. On transient failures, waits with
|
||||
/// increasing delay (attempt × 2s, capped at `poll_retry_max_delay_millis`).
|
||||
/// After exhausting retries, alerts the pager and returns an error.
|
||||
async fn poll_orders_with_retry(
|
||||
deps: &JobDependencies,
|
||||
cursor: Option<u64>,
|
||||
) -> anyhow::Result<PollOrdersResponse> {
|
||||
let max_retries = deps.poll_max_retries;
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
match poll_orders(PollOrdersArgs {
|
||||
session: &deps.seedance2pro_session,
|
||||
cursor,
|
||||
host_override: None,
|
||||
}).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Error polling Kinovi orders (attempt {}/{}): {:?}",
|
||||
attempt, max_retries, err
|
||||
);
|
||||
|
||||
if attempt >= max_retries {
|
||||
return alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Kinovi API polling failed after retries",
|
||||
anyhow::anyhow!("poll_orders failed after {} attempts: {:?}", attempt, err),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let delay_millis = (attempt as u64 * 2_000).min(deps.poll_retry_max_delay_millis);
|
||||
tokio::time::sleep(Duration::from_millis(delay_millis)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alert_pager_and_return_err(
|
||||
&deps.pager,
|
||||
"Kinovi API polling failed after max retries",
|
||||
anyhow::anyhow!("poll_orders failed after {} attempts", max_retries),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,18 @@ async fn main() -> AnyhowResult<()> {
|
||||
info!("Max job age threshold: {} hours", duration.num_hours());
|
||||
}
|
||||
|
||||
let poll_max_retries: u32 = easyenv::get_env_num(
|
||||
"POLL_MAX_RETRIES",
|
||||
3,
|
||||
)?;
|
||||
|
||||
let poll_retry_max_delay_millis: u64 = easyenv::get_env_num(
|
||||
"POLL_RETRY_MAX_DELAY_MILLIS",
|
||||
10_000,
|
||||
)?;
|
||||
|
||||
info!("Poll max retries: {}, max retry delay: {}ms", poll_max_retries, poll_retry_max_delay_millis);
|
||||
|
||||
let credits_alert_threshold: u64 = easyenv::get_env_num(
|
||||
"CREDITS_ALERT_THRESHOLD",
|
||||
10_000,
|
||||
@@ -164,6 +176,8 @@ async fn main() -> AnyhowResult<()> {
|
||||
poll_interval_millis,
|
||||
maybe_pages_per_batch,
|
||||
maybe_max_job_age,
|
||||
poll_max_retries,
|
||||
poll_retry_max_delay_millis,
|
||||
credits_alert_threshold,
|
||||
application_shutdown: application_shutdown.clone(),
|
||||
shutdown_notify: shutdown_notify.clone(),
|
||||
|
||||
@@ -378,6 +378,10 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
|
||||
model: CommonImageModel::GptImage1p5,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::GptImage2,
|
||||
overrides: None,
|
||||
},
|
||||
OmniGenImageProviderModelDetails {
|
||||
model: CommonImageModel::Seedream4,
|
||||
overrides: None,
|
||||
|
||||
@@ -19,6 +19,33 @@ pub const OMNI_GEN_VIDEO_MODELS_AND_PROVIDERS: Lazy<OmniGenVideoModelsResponse>
|
||||
fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::HappyHorse1p0,
|
||||
model_creator: Some(ModelCreator::Alibaba),
|
||||
full_name: Some("Happy Horse 1.0".to_string()),
|
||||
text_prompt_supported: Some(true),
|
||||
starting_keyframe_supported: Some(true),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: Some(vec![
|
||||
CommonResolution::SevenTwentyP,
|
||||
CommonResolution::TenEightyP,
|
||||
]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
batch_size_options: Some(vec![1, 2, 4]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(3),
|
||||
duration_seconds_max: Some(15),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(OmniGenVideoModelDetails {
|
||||
model: CommonVideoModel::Seedance1p5Pro,
|
||||
model_creator: Some(ModelCreator::Bytedance),
|
||||
|
||||
@@ -85,7 +85,9 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asse
|
||||
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
|
||||
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
|
||||
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
|
||||
@@ -397,7 +399,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::write_engine_asset_media_file_handler,
|
||||
crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::write_scene_file_media_file_handler,
|
||||
crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::enqueue_gptsovits_model_download_handler,
|
||||
crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler,
|
||||
crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::moderator_edit_user_feature_flags_handler,
|
||||
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::moderator_list_all_available_user_feature_flags_handler,
|
||||
crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::moderator_list_user_feature_flags_handler,
|
||||
crate::http_server::endpoints::prompts::create_prompt_handler::create_prompt_handler,
|
||||
crate::http_server::endpoints::prompts::get_prompt_handler::get_prompt_handler,
|
||||
crate::http_server::endpoints::service::status_alert_handler::status_alert_handler,
|
||||
|
||||
+13
-2
@@ -9,6 +9,7 @@ use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{
|
||||
};
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as RouterAspectRatio;
|
||||
use artcraft_router::api::common_image_model::CommonImageModel as RouterImageModel;
|
||||
use artcraft_router::api::common_quality::CommonQuality as RouterQuality;
|
||||
use artcraft_router::api::common_resolution::CommonResolution as RouterResolution;
|
||||
use artcraft_router::api::provider::Provider as RouterProvider;
|
||||
use artcraft_router::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy;
|
||||
@@ -16,6 +17,7 @@ use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismat
|
||||
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
|
||||
@@ -37,6 +39,7 @@ pub async fn estimate_image_cost_handler(
|
||||
let router_model = map_image_model(request.model)?;
|
||||
let router_aspect_ratio = request.aspect_ratio.map(map_aspect_ratio);
|
||||
let router_resolution = request.resolution.map(map_resolution);
|
||||
let router_quality = request.quality.map(map_quality);
|
||||
|
||||
let router_request = GenerateImageRequest {
|
||||
model: router_model,
|
||||
@@ -45,7 +48,7 @@ pub async fn estimate_image_cost_handler(
|
||||
image_inputs: None, // TODO: Only some models charge for this - we'll need to add later.
|
||||
resolution: router_resolution,
|
||||
aspect_ratio: router_aspect_ratio,
|
||||
quality: None,
|
||||
quality: router_quality,
|
||||
image_batch_count: request.image_batch_count,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::GenerateAnyway),
|
||||
@@ -132,13 +135,13 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
|
||||
CommonImageModel::FluxPro11Ultra => RouterImageModel::FluxPro11Ultra,
|
||||
CommonImageModel::GptImage1 => RouterImageModel::GptImage1,
|
||||
CommonImageModel::GptImage1p5 => RouterImageModel::GptImage1p5,
|
||||
CommonImageModel::GptImage2 => RouterImageModel::GptImage2,
|
||||
CommonImageModel::NanoBanana => RouterImageModel::NanoBanana,
|
||||
CommonImageModel::NanoBanana2 => RouterImageModel::NanoBanana2,
|
||||
CommonImageModel::NanoBananaPro => RouterImageModel::NanoBananaPro,
|
||||
CommonImageModel::Seedream4 => RouterImageModel::Seedream4,
|
||||
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
|
||||
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
|
||||
CommonImageModel::GptImage2 => RouterImageModel::GptImage2,
|
||||
};
|
||||
Ok(router_model)
|
||||
}
|
||||
@@ -178,3 +181,11 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
|
||||
CommonResolution::TenEightyP => RouterResolution::TenEightyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_quality(res: CommonQuality) -> RouterQuality {
|
||||
match res {
|
||||
CommonQuality::Low => RouterQuality::Low,
|
||||
CommonQuality::Medium => RouterQuality::Medium,
|
||||
CommonQuality::High => RouterQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
|
||||
CommonVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
|
||||
CommonVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
|
||||
CommonVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
|
||||
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::Sora2 => RouterVideoModel::Sora2,
|
||||
CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
|
||||
CommonVideoModel::Veo2 => RouterVideoModel::Veo2,
|
||||
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
pub mod edit_user_feature_flags_handler;
|
||||
pub mod moderator_edit_user_feature_flags_handler;
|
||||
pub mod moderator_list_all_available_user_feature_flags_handler;
|
||||
pub mod moderator_list_user_feature_flags_handler;
|
||||
|
||||
+64
-45
@@ -1,6 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::iter::FromIterator;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
@@ -9,20 +10,27 @@ use actix_web::web::{Data, Json, Path};
|
||||
use actix_web::{HttpRequest, HttpResponse};
|
||||
use log::warn;
|
||||
use redis::{Client, Commands};
|
||||
use sqlx::Acquire;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction;
|
||||
use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType;
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{insert_staff_audit_log, InsertStaffAuditLogArgs};
|
||||
use mysql_queries::queries::users::user::get::get_user_token_by_username::get_user_token_by_username;
|
||||
use mysql_queries::queries::users::user::update::set_user_feature_flags::{set_user_feature_flags, SetUserFeatureFlagArgs};
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{
|
||||
require_moderator, UseDatabase,
|
||||
};
|
||||
use crate::state::server_state::ServerState;
|
||||
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
|
||||
|
||||
/// For the URL PathInfo
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -94,7 +102,7 @@ impl fmt::Display for EditUserFeatureFlagsError {
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/user_feature_flags/{username_or_token}",
|
||||
path = "/v1/moderation/user_feature_flags/user/{username_or_token}",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = SimpleGenericJsonSuccess),
|
||||
(status = 401, description = "Unauthorized", body = EditUserFeatureFlagsError),
|
||||
@@ -106,66 +114,47 @@ impl fmt::Display for EditUserFeatureFlagsError {
|
||||
("request" = EditUserFeatureFlagsRequest, description = "Payload for Request"),
|
||||
)
|
||||
)]
|
||||
pub async fn edit_user_feature_flags_handler(
|
||||
pub async fn moderator_edit_user_feature_flags_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<EditUserFeatureFlagPathInfo>,
|
||||
request: Json<EditUserFeatureFlagsRequest>,
|
||||
server_state: Data<Arc<ServerState>>,
|
||||
//redis_ttl_cache: Data<RedisTtlCache>,
|
||||
redis_pool: Data<r2d2::Pool<Client>>,
|
||||
) -> Result<HttpResponse, EditUserFeatureFlagsError> {
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
return Err(EditUserFeatureFlagsError::Unauthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if !user_session.can_ban_users {
|
||||
warn!("user is not allowed to add bans: {:?}", user_session.user_token.as_str());
|
||||
return Err(EditUserFeatureFlagsError::Unauthorized);
|
||||
}
|
||||
let user_session = require_moderator(
|
||||
&http_request,
|
||||
&server_state,
|
||||
UseDatabase::GrabNewConnection,
|
||||
).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
EditUserFeatureFlagsError::Unauthorized
|
||||
})?;
|
||||
|
||||
let username_or_token = path.username_or_token.trim();
|
||||
|
||||
let user_token;
|
||||
|
||||
if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
|
||||
user_token = UserToken::new_from_str(username_or_token);
|
||||
let user_token = if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
|
||||
UserToken::new_from_str(username_or_token)
|
||||
} else {
|
||||
user_token = get_user_token_by_username(&username_or_token, &server_state.mysql_pool)
|
||||
get_user_token_by_username(username_or_token, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Could not get user token by username: {:?}", e);
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
}
|
||||
.ok_or(EditUserFeatureFlagsError::ServerError)?
|
||||
};
|
||||
|
||||
let user_profile = get_user_profile_by_token(&user_token, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Could not get user session by token: {:?}", e);
|
||||
warn!("Could not get user profile by token: {:?}", e);
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
.ok_or(EditUserFeatureFlagsError::ServerError)?;
|
||||
|
||||
let mut user_feature_flags =
|
||||
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
|
||||
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
|
||||
|
||||
match &request.action {
|
||||
EditUserFeatureFlagsOption::AddFlags { flags } => {
|
||||
@@ -189,6 +178,7 @@ pub async fn edit_user_feature_flags_handler(
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// Update the user's feature flags.
|
||||
set_user_feature_flags(SetUserFeatureFlagArgs {
|
||||
subject_user_token: &user_profile.user_token,
|
||||
maybe_feature_flags: user_feature_flags.maybe_serialize_string().as_deref(),
|
||||
@@ -201,16 +191,45 @@ pub async fn edit_user_feature_flags_handler(
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
|
||||
//if let Ok(mut redis) = redis_ttl_cache.get_connection() {
|
||||
// // TODO(bt,2024-04-20): This should be coordinated with other code.
|
||||
// let cache_key = format!("cache:userProfile:{}", user_profile.username);
|
||||
// let _r = redis.delete_from_cache(&cache_key);
|
||||
//}
|
||||
// Insert staff audit log.
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Could not acquire MySQL connection for audit log: {:?}", e);
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
|
||||
let mut transaction = mysql_connection.begin()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Could not start transaction for audit log: {:?}", e);
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
|
||||
let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs {
|
||||
audit_action: StaffAuditAction::EditUserFeatureFlags,
|
||||
maybe_entity_type: Some(StaffAuditEntityType::User),
|
||||
maybe_entity_token: Some(user_profile.user_token.as_str()),
|
||||
staff_user_token: &user_session.user_token,
|
||||
actor_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Failed to insert staff audit log: {:?}", err);
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
|
||||
transaction.commit()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Could not commit audit log transaction: {:?}", e);
|
||||
EditUserFeatureFlagsError::ServerError
|
||||
})?;
|
||||
|
||||
// Invalidate Redis cache for the user profile.
|
||||
if let Ok(mut redis) = redis_pool.get() {
|
||||
// TODO(bt,2024-04-20): This should be coordinated with other code.
|
||||
let cache_key = format!("cache:userProfile:{}", user_profile.username);
|
||||
let _r : Result<Option<String>, _> = redis.del(&cache_key);
|
||||
let _r: Result<Option<String>, _> = redis.del(&cache_key);
|
||||
}
|
||||
|
||||
Ok(simple_json_success())
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{
|
||||
require_moderator, UseDatabase,
|
||||
};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorListUserFeatureFlagsResponse {
|
||||
pub success: bool,
|
||||
pub feature_flags: Vec<FeatureFlagDescriptor>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FeatureFlagDescriptor {
|
||||
/// Key the flag is identified by
|
||||
pub key: String,
|
||||
|
||||
/// Full name of the flag
|
||||
pub full_name: String,
|
||||
|
||||
/// Description of the flag
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
// ── Handler ──
|
||||
|
||||
/// List all available user feature flags.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/user_feature_flags/list",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ModeratorListUserFeatureFlagsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_list_all_available_user_feature_flags_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, AdvancedCommonWebError> {
|
||||
let _user_session = require_moderator(
|
||||
&http_request,
|
||||
&server_state,
|
||||
UseDatabase::GrabNewConnection,
|
||||
).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let feature_flags: Vec<FeatureFlagDescriptor> = UserFeatureFlag::all_variants()
|
||||
.into_iter()
|
||||
.map(|flag| FeatureFlagDescriptor {
|
||||
key: flag.to_str().to_string(),
|
||||
full_name: flag.name().to_string(),
|
||||
description: flag.description().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ModeratorListUserFeatureFlagsResponse {
|
||||
success: true,
|
||||
feature_flags,
|
||||
}))
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use mysql_queries::queries::users::user::get::get_user_token_by_username::get_user_token_by_username;
|
||||
use mysql_queries::queries::users::user_profiles::get_user_profile_by_token::get_user_profile_by_token;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{
|
||||
require_moderator, UseDatabase,
|
||||
};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListUserFeatureFlagsPathInfo {
|
||||
username_or_token: String,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorListUserFeatureFlagsResponse {
|
||||
pub success: bool,
|
||||
pub user_token: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub feature_flags: Vec<String>,
|
||||
}
|
||||
|
||||
// ── Handler ──
|
||||
|
||||
/// List the feature flags enabled for a specific user.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/user_feature_flags/user/{username_or_token}",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ModeratorListUserFeatureFlagsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 400, description = "Bad input"),
|
||||
),
|
||||
params(
|
||||
("username_or_token" = String, Path, description = "Username or user token"),
|
||||
)
|
||||
)]
|
||||
pub async fn moderator_list_user_feature_flags_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListUserFeatureFlagsPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, AdvancedCommonWebError> {
|
||||
let _user_session = require_moderator(
|
||||
&http_request,
|
||||
&server_state,
|
||||
UseDatabase::GrabNewConnection,
|
||||
).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
AdvancedCommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let username_or_token = path.username_or_token.trim();
|
||||
|
||||
let user_token = if username_or_token.starts_with(UserToken::token_prefix()) || username_or_token.starts_with("U:") {
|
||||
UserToken::new_from_str(username_or_token)
|
||||
} else {
|
||||
get_user_token_by_username(username_or_token, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Could not get user token by username: {:?}", err);
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AdvancedCommonWebError::NotFound
|
||||
})?
|
||||
};
|
||||
|
||||
let user_profile = get_user_profile_by_token(&user_token, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Could not get user profile by token: {:?}", err);
|
||||
AdvancedCommonWebError::from_anyhow_error(err)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AdvancedCommonWebError::NotFound
|
||||
})?;
|
||||
|
||||
let user_feature_flags =
|
||||
UserSessionFeatureFlags::new(user_profile.maybe_feature_flags.as_deref());
|
||||
|
||||
let flags: Vec<String> = user_feature_flags.clone_flags()
|
||||
.iter()
|
||||
.map(|flag| flag.to_str().to_string())
|
||||
.collect();
|
||||
|
||||
Ok(Json(ModeratorListUserFeatureFlagsResponse {
|
||||
success: true,
|
||||
user_token: user_profile.user_token.as_str().to_string(),
|
||||
username: user_profile.username,
|
||||
display_name: user_profile.display_name,
|
||||
feature_flags: flags,
|
||||
}))
|
||||
}
|
||||
+32
-19
@@ -6,10 +6,11 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost
|
||||
use artcraft_api_defs::omni_gen::cost_response::omni_gen_video_cost_response::OmniGenVideoCostResponse;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use log::warn;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Estimate the cost of a video generation.
|
||||
@@ -29,27 +30,39 @@ pub async fn omni_gen_video_cost_handler(
|
||||
request: Json<OmniGenVideoCostAndGenerateRequest>,
|
||||
_server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenVideoCostResponse>, AdvancedCommonWebError> {
|
||||
let mut generate_request = hydrate_to_router_request(&request)?;
|
||||
generate_request.provider = Provider::Artcraft;
|
||||
let mut builder = hydrate_to_router_request(&request)?;
|
||||
builder.provider = Provider::Artcraft;
|
||||
|
||||
let plan = match generate_request.build() {
|
||||
Ok(plan) => plan,
|
||||
Err(ArtcraftRouterError::UnsupportedModel(_)) => {
|
||||
generate_request.provider = Provider::Fal;
|
||||
generate_request.build()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build Fal cost plan: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to build cost plan: {}", e);
|
||||
return Err(AdvancedCommonWebError::from_error(e));
|
||||
}
|
||||
let estimate = if builder.use_new_builder() {
|
||||
builder.build2()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build2 cost estimate: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
.estimate_cost()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to estimate cost (v2): {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
} else {
|
||||
let plan = match builder.build() {
|
||||
Ok(plan) => plan,
|
||||
Err(ArtcraftRouterError::UnsupportedModel(_)) => {
|
||||
builder.provider = Provider::Fal;
|
||||
builder.build()
|
||||
.map_err(|e| {
|
||||
warn!("Failed to build Fal cost plan: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to build cost plan: {}", e);
|
||||
return Err(AdvancedCommonWebError::from_error(e));
|
||||
}
|
||||
};
|
||||
plan.estimate_costs()
|
||||
};
|
||||
|
||||
let estimate = plan.estimate_costs();
|
||||
|
||||
Ok(Json(OmniGenVideoCostResponse {
|
||||
success: true,
|
||||
cost_in_credits: estimate.cost_in_credits,
|
||||
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
//! Distillation step for the omni-gen video endpoints.
|
||||
//!
|
||||
//! Takes a raw [`OmniGenVideoCostAndGenerateRequest`] plus a (pre-computed)
|
||||
//! `MediaFileToken -> Url` map and produces a fully self-contained
|
||||
//! [`DistilledVideoRequest`] holding:
|
||||
//! - the (private) router request, kept for inspection in tests / debugging
|
||||
//! - the [`VideoGenerationCostEstimate`] (Artcraft provider, what we bill on)
|
||||
//! - the [`VideoGenerationPlan`] (execution provider, what we actually execute)
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::warn;
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::image_list_ref::ImageListRef;
|
||||
use artcraft_router::api::image_ref::ImageRef;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::api::video_list_ref::VideoListRef;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use artcraft_router::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use artcraft_router::generate::generate_video::video_generation_plan::VideoGenerationPlan;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
|
||||
|
||||
/// Self-contained, owned representation of a fully-distilled omni-gen video
|
||||
/// request: the router request, the bill-on cost estimate, and the executable
|
||||
/// plan, all in one place.
|
||||
pub struct DistilledVideoRequest {
|
||||
request: GenerateVideoRequestBuilder,
|
||||
|
||||
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
|
||||
pub cost: VideoGenerationCostEstimate,
|
||||
|
||||
/// Execution plan as computed by the execution provider — what we hand to the router.
|
||||
pub plan: VideoGenerationPlan,
|
||||
|
||||
/// The provider used for execution (Fal, Seedance2Pro, etc.).
|
||||
pub execution_provider: Provider,
|
||||
}
|
||||
|
||||
impl DistilledVideoRequest {
|
||||
/// Borrow the execution plan.
|
||||
pub fn plan(&self) -> &VideoGenerationPlan {
|
||||
&self.plan
|
||||
}
|
||||
|
||||
/// Borrow the underlying router request. Useful for tests / debugging.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn request(&self) -> &GenerateVideoRequestBuilder {
|
||||
&self.request
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`DistilledVideoRequest`] from a raw API request and a pre-computed
|
||||
/// `MediaFileToken -> Url` hydration map.
|
||||
pub fn distill_video_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
execution_provider: Provider,
|
||||
) -> Result<DistilledVideoRequest, AdvancedCommonWebError> {
|
||||
// 1. Convert the raw API request into a router request.
|
||||
let initial = hydrate_to_router_request(request)?;
|
||||
|
||||
// 2. Cost estimate (Artcraft provider).
|
||||
// `initial` already has media fields in token form (from hydrate_to_router_request),
|
||||
// which is exactly what the Artcraft cost builder needs. Just swap the provider.
|
||||
let cost: VideoGenerationCostEstimate = {
|
||||
let cost_request = GenerateVideoRequestBuilder {
|
||||
provider: Provider::Artcraft,
|
||||
..initial.clone()
|
||||
};
|
||||
let cost_plan = cost_request.build().map_err(|e| {
|
||||
warn!("Failed to build cost plan during video distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
cost_plan.estimate_costs()
|
||||
};
|
||||
|
||||
// 3. Resolve media tokens to URLs for the execution request.
|
||||
let start_frame_url = resolve_single_media_token(
|
||||
request.start_frame_image_media_token.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let end_frame_url = resolve_single_media_token(
|
||||
request.end_frame_image_media_token.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let reference_image_urls = resolve_media_token_list(
|
||||
request.reference_image_media_tokens.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let reference_video_urls = resolve_media_token_list(
|
||||
request.reference_video_media_tokens.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
let reference_audio_urls = resolve_media_token_list(
|
||||
request.reference_audio_media_tokens.as_ref(),
|
||||
media_file_hydration_map,
|
||||
)?;
|
||||
|
||||
// 4. Build the execution request with resolved URLs.
|
||||
let exec_request = GenerateVideoRequestBuilder {
|
||||
model: initial.model,
|
||||
provider: execution_provider,
|
||||
prompt: initial.prompt,
|
||||
negative_prompt: initial.negative_prompt,
|
||||
start_frame: start_frame_url.map(ImageRef::Url),
|
||||
end_frame: end_frame_url.map(ImageRef::Url),
|
||||
reference_images: reference_image_urls.map(ImageListRef::Urls),
|
||||
reference_videos: reference_video_urls.map(VideoListRef::Urls),
|
||||
reference_audio: reference_audio_urls.map(AudioListRef::Urls),
|
||||
reference_character_tokens: None,
|
||||
resolution: initial.resolution,
|
||||
aspect_ratio: initial.aspect_ratio,
|
||||
duration_seconds: initial.duration_seconds,
|
||||
video_batch_count: initial.video_batch_count,
|
||||
generate_audio: initial.generate_audio,
|
||||
request_mismatch_mitigation_strategy: initial.request_mismatch_mitigation_strategy,
|
||||
idempotency_token: initial.idempotency_token,
|
||||
};
|
||||
|
||||
// 5. Build the execution plan.
|
||||
let plan = exec_request.build().map_err(|e| {
|
||||
warn!("Failed to build video generation plan during distillation: {}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
Ok(DistilledVideoRequest {
|
||||
request: exec_request,
|
||||
cost,
|
||||
plan,
|
||||
execution_provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a single optional media token to its URL string.
|
||||
fn resolve_single_media_token(
|
||||
token: Option<&MediaFileToken>,
|
||||
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<Option<String>, AdvancedCommonWebError> {
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let map = hydration_map.ok_or_else(|| {
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"media token supplied but no hydration map was provided".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
match map.get(token) {
|
||||
Some(url) => Ok(Some(url.to_string())),
|
||||
None => Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
|
||||
"Media token not found in hydration map: {:?}",
|
||||
token
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a list of media tokens to their URL strings.
|
||||
fn resolve_media_token_list(
|
||||
tokens: Option<&Vec<MediaFileToken>>,
|
||||
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
|
||||
let tokens = match tokens {
|
||||
Some(tokens) if !tokens.is_empty() => tokens,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let map = hydration_map.ok_or_else(|| {
|
||||
AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"media tokens supplied but no hydration map was provided".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut urls: Vec<String> = Vec::with_capacity(tokens.len());
|
||||
for token in tokens {
|
||||
match map.get(token) {
|
||||
Some(url) => urls.push(url.to_string()),
|
||||
None => {
|
||||
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
|
||||
"Media token not found in hydration map: {:?}",
|
||||
token
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(urls))
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
//! Fal provider execution path.
|
||||
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_fal_client::RouterFalClient;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
use super::super::distill_video_request::DistilledVideoRequest;
|
||||
use super::execute_generation::GenerationResult;
|
||||
|
||||
/// Execute generation via Fal (the existing path).
|
||||
pub(super) async fn execute_generation_fal(
|
||||
distilled: &DistilledVideoRequest,
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
server_state: &ServerState,
|
||||
) -> Result<GenerationResult, AdvancedCommonWebError> {
|
||||
let fal_client = RouterFalClient::new(
|
||||
server_state.fal.api_key.clone(),
|
||||
server_state.fal.webhook_url.clone(),
|
||||
);
|
||||
let router_client = RouterClient::Fal(fal_client);
|
||||
|
||||
let generation_response = distilled.plan().generate_video(&router_client)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Video generation failed (Fal): {:?}", e);
|
||||
AdvancedCommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let external_job_id = match &generation_response {
|
||||
GenerateVideoResponse::Artcraft(p) => p.inference_job_token.as_str().to_string(),
|
||||
GenerateVideoResponse::Muapi(p) => p.request_id.as_str().to_string(),
|
||||
GenerateVideoResponse::Seedance2Pro(p) => p.order_id.clone(),
|
||||
GenerateVideoResponse::Fal(p) => p.request_id.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
let generation_mode = if request.start_frame_image_media_token.is_some() {
|
||||
CommonGenerationMode::Keyframe
|
||||
} else {
|
||||
CommonGenerationMode::Text
|
||||
};
|
||||
|
||||
Ok(GenerationResult {
|
||||
external_job_id,
|
||||
is_seedance2pro: false,
|
||||
maybe_seedance_order_ids: None,
|
||||
generation_mode,
|
||||
})
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
//! Main entrypoint for video generation execution.
|
||||
//!
|
||||
//! Routes to the appropriate provider (Fal or Seedance2Pro/Kinovi)
|
||||
//! based on the distilled request's `execution_provider`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
use super::super::distill_video_request::DistilledVideoRequest;
|
||||
use super::execute_fal::execute_generation_fal;
|
||||
use super::execute_kinovi::execute_generation_kinovi;
|
||||
|
||||
/// Result of a successful generation, regardless of provider.
|
||||
pub struct GenerationResult {
|
||||
/// The external job ID used to track the generation (first order_id for Seedance).
|
||||
pub external_job_id: String,
|
||||
|
||||
/// Whether this is a Seedance2Pro generation (changes DB insertion path).
|
||||
pub is_seedance2pro: bool,
|
||||
|
||||
/// For Seedance2Pro batch jobs, the list of all order IDs.
|
||||
pub maybe_seedance_order_ids: Option<Vec<String>>,
|
||||
|
||||
/// The generation mode (Text, Keyframe, Reference).
|
||||
pub generation_mode: CommonGenerationMode,
|
||||
}
|
||||
|
||||
/// Execute video generation via the appropriate provider.
|
||||
pub async fn execute_generation(
|
||||
distilled: &DistilledVideoRequest,
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
server_state: &ServerState,
|
||||
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
|
||||
kinovi_character_ids: Option<Vec<String>>,
|
||||
maybe_wallet_ledger_entry_token: Option<&WalletLedgerEntryToken>,
|
||||
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
) -> Result<GenerationResult, AdvancedCommonWebError> {
|
||||
match distilled.execution_provider {
|
||||
Provider::Seedance2Pro => {
|
||||
execute_generation_kinovi(
|
||||
distilled,
|
||||
request,
|
||||
server_state,
|
||||
media_file_hydration_map,
|
||||
kinovi_character_ids,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
mysql_connection,
|
||||
).await
|
||||
}
|
||||
_ => {
|
||||
execute_generation_fal(
|
||||
distilled,
|
||||
request,
|
||||
server_state,
|
||||
).await
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
mod execute_fal;
|
||||
mod execute_kinovi;
|
||||
pub mod execute_generation;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
//! Shared wallet billing logic for both video generation pipelines.
|
||||
|
||||
use log::info;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
pub struct BillWalletResult {
|
||||
pub apriori_job_token: InferenceJobToken,
|
||||
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
}
|
||||
|
||||
/// Generate an apriori job token and bill the user's wallet for the given cost.
|
||||
///
|
||||
/// If cost is 0, no wallet deduction is made but the apriori token is still generated.
|
||||
pub async fn bill_wallet(
|
||||
user_token: &UserToken,
|
||||
cost: u64,
|
||||
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
) -> Result<BillWalletResult, AdvancedCommonWebError> {
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
info!("Charging wallet: {} credits", cost);
|
||||
|
||||
let maybe_wallet_ledger_entry_token = if cost > 0 {
|
||||
let deduction_result = attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
mysql_connection,
|
||||
).await?;
|
||||
Some(deduction_result.ledger_entry_token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(BillWalletResult {
|
||||
apriori_job_token,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
})
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
//! Build a RouterClient for the given provider from server state.
|
||||
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_fal_client::RouterFalClient;
|
||||
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub fn build_router_client(
|
||||
provider: Provider,
|
||||
server_state: &ServerState,
|
||||
) -> Result<RouterClient, AdvancedCommonWebError> {
|
||||
match provider {
|
||||
Provider::Seedance2Pro => {
|
||||
let session = Seedance2ProSession::from_cookies_string(
|
||||
server_state.seedance2pro.cookies.clone()
|
||||
);
|
||||
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
|
||||
}
|
||||
Provider::Fal => {
|
||||
let fal_client = RouterFalClient::new(
|
||||
server_state.fal.api_key.clone(),
|
||||
server_state.fal.webhook_url.clone(),
|
||||
);
|
||||
Ok(RouterClient::Fal(fal_client))
|
||||
}
|
||||
other => {
|
||||
Err(AdvancedCommonWebError::server_error_with_message(
|
||||
&format!("Unsupported provider for video generation: {:?}", other),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-44
@@ -1,6 +1,7 @@
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_router::api::audio_list_ref::AudioListRef;
|
||||
use artcraft_router::api::character_list_ref::CharacterListRef;
|
||||
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioRouter;
|
||||
use artcraft_router::api::common_resolution::CommonResolution as CommonResolutionRouter;
|
||||
use artcraft_router::api::common_video_model::CommonVideoModel as CommonVideoModelRouter;
|
||||
@@ -14,6 +15,54 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
pub fn hydrate_to_router_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<GenerateVideoRequestBuilder, AdvancedCommonWebError> {
|
||||
let api_model = request.model
|
||||
.as_ref()
|
||||
.ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"model is required".to_string(),
|
||||
))?;
|
||||
|
||||
let model = convert_model(api_model)?;
|
||||
|
||||
let aspect_ratio = request.aspect_ratio
|
||||
.as_ref()
|
||||
.map(convert_aspect_ratio)
|
||||
.transpose()?;
|
||||
|
||||
let resolution = request.resolution
|
||||
.as_ref()
|
||||
.map(convert_resolution)
|
||||
.transpose()?;
|
||||
|
||||
Ok(GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: Provider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: request.negative_prompt.clone(),
|
||||
start_frame: request.start_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
end_frame: request.end_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
reference_images: request.reference_image_media_tokens.clone()
|
||||
.map(ImageListRef::MediaFileTokens),
|
||||
reference_videos: request.reference_video_media_tokens.clone()
|
||||
.map(VideoListRef::MediaFileTokens),
|
||||
reference_audio: request.reference_audio_media_tokens.clone()
|
||||
.map(AudioListRef::MediaFileTokens),
|
||||
reference_character_tokens: request.reference_character_tokens.clone()
|
||||
.map(CharacterListRef::CharacterTokens),
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_model(
|
||||
model: &CommonVideoModelEnum,
|
||||
) -> Result<CommonVideoModelRouter, AdvancedCommonWebError> {
|
||||
@@ -46,47 +95,3 @@ fn convert_resolution(
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hydrate_to_router_request(
|
||||
request: &OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<GenerateVideoRequestBuilder, AdvancedCommonWebError> {
|
||||
let api_model = request.model.as_ref()
|
||||
.ok_or_else(|| AdvancedCommonWebError::BadInputWithSimpleMessage(
|
||||
"model is required".to_string(),
|
||||
))?;
|
||||
|
||||
let model = convert_model(api_model)?;
|
||||
|
||||
let aspect_ratio = request.aspect_ratio.as_ref()
|
||||
.map(convert_aspect_ratio)
|
||||
.transpose()?;
|
||||
|
||||
let resolution = request.resolution.as_ref()
|
||||
.map(convert_resolution)
|
||||
.transpose()?;
|
||||
|
||||
Ok(GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: Provider::Artcraft,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: request.negative_prompt.clone(),
|
||||
start_frame: request.start_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
end_frame: request.end_frame_image_media_token.clone()
|
||||
.map(ImageRef::MediaFileToken),
|
||||
reference_images: request.reference_image_media_tokens.clone()
|
||||
.map(ImageListRef::MediaFileTokens),
|
||||
reference_videos: request.reference_video_media_tokens.clone()
|
||||
.map(VideoListRef::MediaFileTokens),
|
||||
reference_audio: request.reference_audio_media_tokens.clone()
|
||||
.map(AudioListRef::MediaFileTokens),
|
||||
reference_character_tokens: None,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: request.idempotency_token.clone(),
|
||||
})
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
pub mod bill_wallet;
|
||||
pub mod build_router_client;
|
||||
pub(crate) mod hydrate_router_request;
|
||||
pub mod pipeline_result;
|
||||
pub mod resolve_kinovi_character_ids;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::BillWalletResult;
|
||||
|
||||
/// Both pipelines produce this, then the shared suffix handles DB writes.
|
||||
pub struct PipelineResult {
|
||||
pub billing: BillWalletResult,
|
||||
pub response: GenerateVideoResponse,
|
||||
}
|
||||
+11
-7
@@ -1,5 +1,7 @@
|
||||
//! Resolve character tokens to Kinovi character IDs for prompting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::warn;
|
||||
use sqlx::MySql;
|
||||
|
||||
@@ -8,14 +10,14 @@ use tokens::tokens::characters::CharacterToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
/// Resolve character tokens to Kinovi character IDs for prompting.
|
||||
/// Resolve character tokens to a map of CharacterToken → Kinovi character ID.
|
||||
///
|
||||
/// Looks up the characters, filters to active ones with kinovi IDs, and warns about
|
||||
/// any that are missing or inactive (but doesn't fail the request).
|
||||
pub async fn resolve_kinovi_character_ids(
|
||||
maybe_tokens: Option<&[CharacterToken]>,
|
||||
connection: &mut sqlx::pool::PoolConnection<MySql>,
|
||||
) -> Result<Option<Vec<String>>, AdvancedCommonWebError> {
|
||||
) -> Result<Option<HashMap<CharacterToken, String>>, AdvancedCommonWebError> {
|
||||
let tokens = match maybe_tokens {
|
||||
None => return Ok(None),
|
||||
Some(tokens) if tokens.is_empty() => return Ok(None),
|
||||
@@ -38,10 +40,12 @@ pub async fn resolve_kinovi_character_ids(
|
||||
}
|
||||
}
|
||||
|
||||
let ids: Vec<String> = characters.iter()
|
||||
.filter(|c| c.is_active)
|
||||
.filter_map(|c| c.kinovi_character_id.clone())
|
||||
.collect();
|
||||
let map: HashMap<CharacterToken, String> = characters.iter()
|
||||
.filter(|c| c.is_active)
|
||||
.filter_map(|c| {
|
||||
c.kinovi_character_id.as_ref().map(|kid| (c.token.clone(), kid.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if ids.is_empty() { Ok(None) } else { Ok(Some(ids)) }
|
||||
if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
use log::warn;
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertFalJobArgs<'a, 'tx> {
|
||||
pub external_job_id: &'a str,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
|
||||
let InsertFalJobArgs {
|
||||
external_job_id,
|
||||
shared,
|
||||
} = args;
|
||||
|
||||
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
|
||||
apriori_job_token: shared.apriori_job_token,
|
||||
uuid_idempotency_token: shared.idempotency_token,
|
||||
maybe_external_third_party_id: external_job_id,
|
||||
fal_category: FalCategory::VideoGeneration,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => Ok(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting fal inference job: {:?}", err);
|
||||
Err(AdvancedCommonWebError::from_error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
use log::{error, warn};
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
|
||||
};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
use super::shared_job_args::SharedJobArgs;
|
||||
|
||||
pub struct InsertSeedance2proJobsArgs<'a, 'tx> {
|
||||
pub primary_order_id: &'a str,
|
||||
pub maybe_additional_order_ids: Option<&'a [String]>,
|
||||
pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>,
|
||||
pub shared: SharedJobArgs<'a, 'tx>,
|
||||
}
|
||||
|
||||
pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
|
||||
let InsertSeedance2proJobsArgs {
|
||||
primary_order_id,
|
||||
maybe_additional_order_ids,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
mut shared,
|
||||
} = args;
|
||||
|
||||
// Build a deduplicated list with primary_order_id first.
|
||||
let mut all_order_ids = vec![primary_order_id.to_string()];
|
||||
if let Some(additional) = maybe_additional_order_ids {
|
||||
for id in additional {
|
||||
if id != primary_order_id {
|
||||
all_order_ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(all_order_ids.len());
|
||||
|
||||
for (i, order_id) in all_order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
|
||||
let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &job_token,
|
||||
uuid_idempotency_token: &idempotency_str,
|
||||
maybe_external_third_party_id: order_id,
|
||||
maybe_prompt_token: shared.maybe_prompt_token,
|
||||
maybe_wallet_ledger_entry_token,
|
||||
maybe_creator_user_token: Some(shared.user_token),
|
||||
maybe_avt_token: shared.maybe_avt_token,
|
||||
creator_ip_address: shared.ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut **shared.transaction,
|
||||
maybe_inference_args: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => all_job_tokens.push(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
|
||||
if i == 0 { return Err(AdvancedCommonWebError::from_error(err)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
|
||||
})
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod insert_fal_job;
|
||||
pub mod insert_seedance2pro_jobs;
|
||||
pub mod shared_job_args;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct SharedJobArgs<'a, 'tx> {
|
||||
pub apriori_job_token: &'a InferenceJobToken,
|
||||
pub idempotency_token: &'a str,
|
||||
pub user_token: &'a UserToken,
|
||||
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
|
||||
pub maybe_prompt_token: Option<&'a PromptToken>,
|
||||
pub ip_address: &'a str,
|
||||
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
|
||||
}
|
||||
+4
-7
@@ -1,8 +1,5 @@
|
||||
pub mod omni_gen_video_generate_handler;
|
||||
pub mod distill_video_request;
|
||||
pub(crate) mod distill_helper;
|
||||
mod execute;
|
||||
mod request_helper;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod helpers;
|
||||
pub mod pipeline_v1;
|
||||
pub mod pipeline_v2;
|
||||
pub mod insert_db_job;
|
||||
|
||||
+135
-169
@@ -9,37 +9,32 @@ use url::Url;
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
|
||||
use artcraft_router::api::provider::Provider;
|
||||
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
|
||||
use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::visibility::Visibility;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
|
||||
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token::{
|
||||
insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token,
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
|
||||
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
|
||||
};
|
||||
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::distill_video_request::distill_video_request;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::execute::execute_generation::execute_generation;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::request_helper::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::run_pipeline_v1::{run_pipeline_v1, RunPipelineV1Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
|
||||
@@ -63,7 +58,7 @@ pub async fn omni_gen_video_generate_handler(
|
||||
request: Json<OmniGenVideoCostAndGenerateRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<OmniGenVideoGenerateResponse>, AdvancedCommonWebError> {
|
||||
|
||||
|
||||
info!("request: {:?}", request);
|
||||
|
||||
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
|
||||
@@ -85,11 +80,18 @@ pub async fn omni_gen_video_generate_handler(
|
||||
AdvancedCommonWebError::from(e)
|
||||
})?;
|
||||
|
||||
let user_token = match maybe_user_session.as_ref() {
|
||||
Some(session) => &session.user_token,
|
||||
let session = match maybe_user_session.as_ref() {
|
||||
Some(session) => session,
|
||||
None => return Err(AdvancedCommonWebError::NotAuthorized),
|
||||
};
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
let user_feature_flags =
|
||||
UserSessionFeatureFlags::new(session.maybe_feature_flags.as_deref());
|
||||
|
||||
// ==================== MODEL ACCESS CHECK ==================== //
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
@@ -112,8 +114,6 @@ pub async fn omni_gen_video_generate_handler(
|
||||
})?;
|
||||
|
||||
// ==================== RESOLVE MEDIA TOKENS ==================== //
|
||||
// Look up media file tokens BEFORE distilling. distill_video_request takes
|
||||
// a pre-computed `MediaFileToken -> Url` map and does no I/O of its own.
|
||||
|
||||
let media_file_hydration_map: Option<HashMap<MediaFileToken, Url>> = {
|
||||
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
@@ -157,73 +157,67 @@ pub async fn omni_gen_video_generate_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== RESOLVE CHARACTER TOKENS ==================== //
|
||||
let media_file_to_url_map: Option<HashMap<MediaFileToken, String>> =
|
||||
media_file_hydration_map.as_ref().map(|map| {
|
||||
map.iter().map(|(k, v)| (k.clone(), v.to_string())).collect()
|
||||
});
|
||||
|
||||
let kinovi_character_ids = resolve_kinovi_character_ids(
|
||||
request.reference_character_tokens.as_deref(),
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
// ==================== RESOLVE CHARACTERS ==================== //
|
||||
|
||||
// ==================== DETERMINE PROVIDER ==================== //
|
||||
|
||||
let execution_provider = match request.model {
|
||||
Some(CommonVideoModel::Seedance2p0) => Provider::Seedance2Pro,
|
||||
Some(CommonVideoModel::Seedance2p0Fast) => Provider::Seedance2Pro,
|
||||
_ => Provider::Fal,
|
||||
};
|
||||
|
||||
// ==================== DISTILL ==================== //
|
||||
|
||||
let distilled = distill_video_request(&request, media_file_hydration_map.as_ref(), execution_provider)?;
|
||||
|
||||
info!("distilled plan: {:?}", distilled.plan);
|
||||
|
||||
// ==================== BILLING ==================== //
|
||||
|
||||
let cost = distilled.cost.cost_in_credits.unwrap_or(0);
|
||||
|
||||
info!("Charging wallet: {} credits", cost);
|
||||
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
let maybe_deduction_result = if cost > 0 {
|
||||
Some(attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
let kinovi_character_id_map: Option<HashMap<CharacterToken, String>> =
|
||||
resolve_kinovi_character_ids(
|
||||
request.reference_character_tokens.as_deref(),
|
||||
&mut mysql_connection,
|
||||
).await?)
|
||||
} else {
|
||||
None
|
||||
).await?;
|
||||
|
||||
// ==================== HYDRATE ROUTER REQUEST ==================== //
|
||||
|
||||
let router_builder = hydrate_to_router_request(&request)?;
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
let use_v2 = match request.model {
|
||||
Some(CommonVideoModel::HappyHorse1p0) => true,
|
||||
Some(CommonVideoModel::Seedance2p0) => true,
|
||||
Some(CommonVideoModel::Seedance2p0Fast) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// ==================== EXECUTE GENERATION ==================== //
|
||||
let pipeline_result = if use_v2 {
|
||||
info!("Using pipeline v2");
|
||||
run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map: &media_file_to_url_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
}).await?
|
||||
} else {
|
||||
info!("Using pipeline v1");
|
||||
run_pipeline_v1(RunPipelineV1Args {
|
||||
request: &request,
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
media_url_map: &media_file_hydration_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
}).await?
|
||||
};
|
||||
|
||||
let gen_result = execute_generation(
|
||||
&distilled,
|
||||
&request,
|
||||
&server_state,
|
||||
media_file_hydration_map.as_ref(),
|
||||
kinovi_character_ids,
|
||||
maybe_deduction_result.as_ref().map(|d| &d.ledger_entry_token),
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
// ==================== DB TRANSACTION ==================== //
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let mut transaction = mysql_connection
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
AdvancedCommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// -- Prompt --
|
||||
|
||||
let prompt_result = insert_prompt(InsertPromptArgs {
|
||||
let prompt_token = match insert_prompt(InsertPromptArgs {
|
||||
maybe_apriori_prompt_token: None,
|
||||
prompt_type: PromptType::ArtcraftApp,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
@@ -232,18 +226,16 @@ pub async fn omni_gen_video_generate_handler(
|
||||
maybe_positive_prompt: request.prompt.as_deref(),
|
||||
maybe_negative_prompt: request.negative_prompt.as_deref(),
|
||||
maybe_other_args: None,
|
||||
maybe_generation_mode: Some(gen_result.generation_mode),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_resolution: None,
|
||||
maybe_generation_mode: Some(determine_generation_mode(&request)),
|
||||
maybe_aspect_ratio: request.aspect_ratio,
|
||||
maybe_resolution: request.resolution,
|
||||
maybe_batch_count: request.video_batch_count.map(|c| c as u8),
|
||||
maybe_generate_audio: request.generate_audio,
|
||||
maybe_duration_seconds: request.duration_seconds.map(|d| d as u32),
|
||||
creator_ip_address: &ip_address,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}).await;
|
||||
|
||||
let prompt_token = match prompt_result {
|
||||
}).await {
|
||||
Ok(token) => Some(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting prompt: {:?}", err);
|
||||
@@ -262,14 +254,12 @@ pub async fn omni_gen_video_generate_handler(
|
||||
context_semantic_type: PromptContextSemanticType::VidStartFrame,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(media_token) = &request.end_frame_image_media_token {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::VidEndFrame,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref_tokens) = &request.reference_image_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
@@ -278,7 +268,6 @@ pub async fn omni_gen_video_generate_handler(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref_tokens) = &request.reference_video_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
@@ -301,92 +290,49 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
// -- Inference job --
|
||||
|
||||
let maybe_ledger_entry_token = maybe_deduction_result.as_ref()
|
||||
.map(|d| &d.ledger_entry_token);
|
||||
|
||||
let job_token = if gen_result.is_seedance2pro {
|
||||
// Seedance2Pro path: insert one job per order_id (batch support).
|
||||
let fallback_ids = vec![gen_result.external_job_id.clone()];
|
||||
let order_ids = gen_result.maybe_seedance_order_ids
|
||||
.as_deref()
|
||||
.unwrap_or(&fallback_ids);
|
||||
|
||||
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(order_ids.len());
|
||||
|
||||
for (i, order_id) in order_ids.iter().enumerate() {
|
||||
let job_token = if i == 0 {
|
||||
apriori_job_token.clone()
|
||||
} else {
|
||||
InferenceJobToken::generate()
|
||||
};
|
||||
|
||||
let idempotency_str = if i == 0 {
|
||||
idempotency_token.clone()
|
||||
} else {
|
||||
format!("{}-batch-{}", idempotency_token, i)
|
||||
};
|
||||
|
||||
let db_result = insert_generic_inference_job_for_seedance2pro_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &job_token,
|
||||
uuid_idempotency_token: &idempotency_str,
|
||||
maybe_external_third_party_id: order_id,
|
||||
maybe_inference_args: None,
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_wallet_ledger_entry_token: maybe_ledger_entry_token,
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
let job_token = match &pipeline_result.response {
|
||||
GenerateVideoResponse::Seedance2Pro(payload) => {
|
||||
info!("Inserting seedance2pro job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
|
||||
primary_order_id: &payload.order_id,
|
||||
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
|
||||
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut *transaction,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => all_job_tokens.push(token),
|
||||
Err(err) => {
|
||||
warn!("Error inserting seedance2pro inference job (order_id={}): {:?}", order_id, err);
|
||||
if i == 0 {
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?
|
||||
}
|
||||
|
||||
all_job_tokens.first().cloned().ok_or_else(|| {
|
||||
error!("No inference job token was created");
|
||||
AdvancedCommonWebError::server_error_with_message("No inference job token was created")
|
||||
})?
|
||||
} else {
|
||||
// Fal / other providers path.
|
||||
// TODO: Pass maybe_wallet_ledger_entry_token to fal jobs once the fal insert supports it.
|
||||
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
|
||||
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
|
||||
apriori_job_token: &apriori_job_token,
|
||||
uuid_idempotency_token: &idempotency_token,
|
||||
maybe_external_third_party_id: &gen_result.external_job_id,
|
||||
fal_category: FalCategory::VideoGeneration,
|
||||
maybe_inference_args: None,
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
maybe_creator_user_token: Some(user_token),
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
creator_ip_address: &ip_address,
|
||||
creator_set_visibility: Visibility::Public,
|
||||
mysql_executor: &mut *transaction,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
phantom: Default::default(),
|
||||
}
|
||||
).await;
|
||||
|
||||
match db_result {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
warn!("Error inserting inference job: {:?}", err);
|
||||
return Err(AdvancedCommonWebError::from_error(err));
|
||||
}
|
||||
GenerateVideoResponse::Fal(payload) => {
|
||||
let external_id = payload.request_id.as_deref().ok_or_else(|| {
|
||||
error!("Fal generation response missing request_id");
|
||||
AdvancedCommonWebError::server_error_with_message("Fal generation response missing request_id")
|
||||
})?;
|
||||
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
|
||||
insert_fal_job(InsertFalJobArgs {
|
||||
external_job_id: external_id,
|
||||
shared: SharedJobArgs {
|
||||
apriori_job_token: &pipeline_result.billing.apriori_job_token,
|
||||
idempotency_token: &idempotency_token,
|
||||
user_token,
|
||||
maybe_avt_token: maybe_avt_token.as_ref(),
|
||||
maybe_prompt_token: prompt_token.as_ref(),
|
||||
ip_address: &ip_address,
|
||||
transaction: &mut transaction,
|
||||
},
|
||||
}).await?
|
||||
}
|
||||
GenerateVideoResponse::Artcraft(payload) => {
|
||||
payload.inference_job_token.clone()
|
||||
}
|
||||
other => {
|
||||
error!("Unexpected generation response variant: {:?}", other);
|
||||
return Err(AdvancedCommonWebError::server_error_with_message("Unexpected generation response"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -400,3 +346,23 @@ pub async fn omni_gen_video_generate_handler(
|
||||
inference_job_token: job_token,
|
||||
}))
|
||||
}
|
||||
|
||||
fn determine_generation_mode(request: &OmniGenVideoCostAndGenerateRequest) -> CommonGenerationMode {
|
||||
let has_keyframe = request.start_frame_image_media_token.is_some()
|
||||
|| request.end_frame_image_media_token.is_some();
|
||||
|
||||
if has_keyframe {
|
||||
return CommonGenerationMode::Keyframe;
|
||||
}
|
||||
|
||||
let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_video_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_audio_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|
||||
|| request.reference_character_tokens.as_ref().is_some_and(|t| !t.is_empty());
|
||||
|
||||
if has_reference {
|
||||
return CommonGenerationMode::Reference;
|
||||
}
|
||||
|
||||
CommonGenerationMode::Text
|
||||
}
|
||||
|
||||
-1
@@ -1,3 +1,2 @@
|
||||
pub(crate) mod hydrate_to_router_request;
|
||||
pub(crate) mod request_to_costs;
|
||||
pub(crate) mod request_to_plan;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user