Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfa8264642 | ||
|
|
fcb567ec3c | ||
|
|
734b757ef9 | ||
|
|
daf401c635 | ||
|
|
c0207c61ba | ||
|
|
0a7b7ae2ac | ||
|
|
92bc1e24c4 | ||
|
|
6dd7a88a12 | ||
|
|
d5a2b36525 | ||
|
|
e3c553ff2c | ||
|
|
094d33e405 | ||
|
|
6a35fccba3 | ||
|
|
db925450c5 | ||
|
|
90f1fcb25f | ||
|
|
0245058e05 | ||
|
|
5d311b7a20 | ||
|
|
4836854263 | ||
|
|
2a43d929ec | ||
|
|
437bbadd0f | ||
|
|
d181ce5089 | ||
|
|
dab29e1cc0 | ||
|
|
3e6a02c29c | ||
|
|
16b87f3e07 | ||
|
|
fa56bb1c9c | ||
|
|
c7751251ac | ||
|
|
d2fc91f1f0 | ||
|
|
0379aa95dd | ||
|
|
6eb4f7707a | ||
|
|
3ee521b2a5 | ||
|
|
86367b6e33 | ||
|
|
730cf247f8 | ||
|
|
bbd9595352 | ||
|
|
0386e2fd9f | ||
|
|
2a8a61697f | ||
|
|
8c732ab38e | ||
|
|
2912c8b608 | ||
|
|
5ec34e89ca | ||
|
|
645e971b99 | ||
|
|
1764e4b590 | ||
|
|
75d8004d1b | ||
|
|
f653987369 | ||
|
|
6d1ede0b75 | ||
|
|
70eec108fa | ||
|
|
e977743912 | ||
|
|
7dd5331232 | ||
|
|
d368abe867 | ||
|
|
28762f51c5 | ||
|
|
32d4a741f9 | ||
|
|
2f4221da56 | ||
|
|
60d8c0fd5f | ||
|
|
637f777135 | ||
|
|
a1b4c4f362 | ||
|
|
b6b24eef6c | ||
|
|
37dcab4db5 | ||
|
|
b9a769fdc4 | ||
|
|
2b127b6230 | ||
|
|
73ed977811 | ||
|
|
e81f0bb94b | ||
|
|
c3050a33f2 | ||
|
|
ef2710fcc8 | ||
|
|
ad08682610 |
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO media_files\nSET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n maybe_project_type = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n\n maybe_engine_category = ?,\n maybe_animation_type = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n\n checksum_sha2 = ?,\n\n maybe_prompt_token = ?,\n maybe_batch_token = ?,\n\n maybe_title = ?,\n maybe_duration_millis = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n\n creator_set_visibility = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n is_intermediate_system_file = ?,\n is_user_upload = ?,\n\n maybe_generation_provider = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n maybe_origin_model_type = NULL,\n maybe_origin_model_token = NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 30
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "026a3ab39a1ba09a891cebd840ab1c956e1bcedd07006ee34473bd4f1d0ff67a"
|
||||
}
|
||||
Generated
+4
@@ -1348,6 +1348,7 @@ dependencies = [
|
||||
"hostname 0.4.0",
|
||||
"log",
|
||||
"server_environment",
|
||||
"tempfile",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
@@ -3239,6 +3240,7 @@ dependencies = [
|
||||
"ffprobe",
|
||||
"log",
|
||||
"tempdir",
|
||||
"tempfile",
|
||||
"test_utils",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
@@ -7919,10 +7921,12 @@ dependencies = [
|
||||
"easyenv",
|
||||
"enums",
|
||||
"errors",
|
||||
"ffmpeg_utils",
|
||||
"hashing",
|
||||
"jobs_common",
|
||||
"kinovi_web_client",
|
||||
"log",
|
||||
"mimetypes",
|
||||
"mysql_queries",
|
||||
"pager",
|
||||
"rand 0.9.2",
|
||||
|
||||
+6
@@ -6,6 +6,7 @@ use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -79,6 +80,11 @@ pub struct OmniApiVideoGenerateRequest {
|
||||
/// Not all models support this; models that don't simply ignore it.
|
||||
pub bitrate: Option<CommonBitrate>,
|
||||
|
||||
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
|
||||
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
|
||||
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_output_format: Option<CommonVideoOutputFormat>,
|
||||
|
||||
/// The quality to use.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
|
||||
+6
@@ -6,6 +6,7 @@ use enums::common::generation::common_bitrate::CommonBitrate;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -55,6 +56,11 @@ pub struct OmniGenVideoCostAndGenerateRequest {
|
||||
/// Not all models support this; models that don't simply ignore it.
|
||||
pub bitrate: Option<CommonBitrate>,
|
||||
|
||||
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
|
||||
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
|
||||
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_output_format: Option<CommonVideoOutputFormat>,
|
||||
|
||||
/// The quality to use.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
|
||||
+9
@@ -2,6 +2,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
@@ -156,6 +157,12 @@ pub struct OmniGenVideoModelDetails {
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_default: Option<CommonBitrate>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format_options: Option<Vec<CommonVideoOutputFormat>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format_default: Option<CommonVideoOutputFormat>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality_options: Option<Vec<CommonQuality>>,
|
||||
@@ -229,6 +236,8 @@ impl Default for OmniGenVideoModelDetails {
|
||||
resolution_default: None,
|
||||
bitrate_options: None,
|
||||
bitrate_default: None,
|
||||
output_format_options: None,
|
||||
output_format_default: None,
|
||||
quality_options: None,
|
||||
default_quality: None,
|
||||
duration_seconds_min: None,
|
||||
|
||||
+12
@@ -8,6 +8,9 @@ use serde_derive::{Deserialize, Serialize};
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientQuality {
|
||||
Auto,
|
||||
Max,
|
||||
XHigh,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
@@ -18,6 +21,9 @@ pub enum ApiClientQuality {
|
||||
impl From<String> for ApiClientQuality {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"auto" => Self::Auto,
|
||||
"max" => Self::Max,
|
||||
"xhigh" => Self::XHigh,
|
||||
"high" => Self::High,
|
||||
"medium" => Self::Medium,
|
||||
"low" => Self::Low,
|
||||
@@ -29,6 +35,9 @@ impl From<String> for ApiClientQuality {
|
||||
impl From<ApiClientQuality> for String {
|
||||
fn from(value: ApiClientQuality) -> Self {
|
||||
match value {
|
||||
ApiClientQuality::Auto => "auto".to_string(),
|
||||
ApiClientQuality::Max => "max".to_string(),
|
||||
ApiClientQuality::XHigh => "xhigh".to_string(),
|
||||
ApiClientQuality::High => "high".to_string(),
|
||||
ApiClientQuality::Medium => "medium".to_string(),
|
||||
ApiClientQuality::Low => "low".to_string(),
|
||||
@@ -58,6 +67,9 @@ mod tests {
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientQuality::Auto, "auto"),
|
||||
(ApiClientQuality::Max, "max"),
|
||||
(ApiClientQuality::XHigh, "xhigh"),
|
||||
(ApiClientQuality::High, "high"),
|
||||
(ApiClientQuality::Medium, "medium"),
|
||||
(ApiClientQuality::Low, "low"),
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
|
||||
Err(err) => Err(StorytellerError::Api(err)),
|
||||
}
|
||||
}
|
||||
Some(FileExtension::Mp4) => {
|
||||
Some(FileExtension::Mp4 | FileExtension::Mov) => {
|
||||
match upload_video_media_file_from_file(UploadVideoFromFileArgs {
|
||||
api_host,
|
||||
maybe_creds,
|
||||
|
||||
@@ -13,5 +13,6 @@ pub mod router_quality;
|
||||
pub mod router_resolution;
|
||||
pub mod router_splat_model;
|
||||
pub mod router_video_model;
|
||||
pub mod router_video_output_format;
|
||||
pub mod video_list_ref;
|
||||
pub mod video_ref;
|
||||
@@ -1,10 +1,20 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Common quality levels you can specify when enqueuing a generation.
|
||||
/// Not every model will use this; models that don't simply ignore it.
|
||||
/// Not every model will use this; models that don't simply ignore it, and models that only
|
||||
/// offer a subset of tiers clamp to the nearest tier they support.
|
||||
///
|
||||
/// Serialized names mirror the server's `CommonQuality` so the two convert via serde.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterQuality {
|
||||
/// Let the model choose. Models without a native "auto" use their default tier.
|
||||
Auto,
|
||||
/// Highest tier (GPT Image 2.5 `max`).
|
||||
Max,
|
||||
/// Between High and Max (GPT Image 2.5 `xhigh`).
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
|
||||
@@ -97,6 +97,12 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Output video container. Supported by Seedance 2.5 on Kinovi and Artcraft.
|
||||
/// Other models/providers ignore this option.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterVideoOutputFormat {
|
||||
Mp4,
|
||||
Mov,
|
||||
}
|
||||
+5
-2
@@ -151,8 +151,11 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
|
||||
|
||||
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
|
||||
match q {
|
||||
RouterQuality::Low => CommonQualityEnum::Low,
|
||||
RouterQuality::Medium => CommonQualityEnum::Medium,
|
||||
RouterQuality::Auto => CommonQualityEnum::Auto,
|
||||
RouterQuality::Max => CommonQualityEnum::Max,
|
||||
RouterQuality::XHigh => CommonQualityEnum::XHigh,
|
||||
RouterQuality::High => CommonQualityEnum::High,
|
||||
RouterQuality::Medium => CommonQualityEnum::Medium,
|
||||
RouterQuality::Low => CommonQualityEnum::Low,
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let is_square = is_square_or_none(self.aspect_ratio);
|
||||
|
||||
let output_cost_per_image: u64 = match (quality, is_square) {
|
||||
@@ -45,6 +45,8 @@ impl ArtcraftGptImage1CostState {
|
||||
(CommonQualityEnum::Medium, false) => 7,
|
||||
(CommonQualityEnum::High, true) => 17,
|
||||
(CommonQualityEnum::High, false) => 25,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let output_cost = output_cost_per_image * self.num_images as u64;
|
||||
@@ -63,6 +65,20 @@ impl ArtcraftGptImage1CostState {
|
||||
}
|
||||
}
|
||||
|
||||
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
matches!(
|
||||
|
||||
+17
-1
@@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let size = size_bucket(self.aspect_ratio);
|
||||
|
||||
let cost_per_image: u64 = match (quality, size) {
|
||||
@@ -50,6 +50,8 @@ impl ArtcraftGptImage1p5CostState {
|
||||
|
||||
(CommonQualityEnum::High, SizeBucket::Square) => 14,
|
||||
(CommonQualityEnum::High, _) => 20,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
|
||||
@@ -65,6 +67,20 @@ impl ArtcraftGptImage1p5CostState {
|
||||
}
|
||||
}
|
||||
|
||||
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
match aspect_ratio {
|
||||
|
||||
+17
-1
@@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let size = size_bucket(self.aspect_ratio);
|
||||
|
||||
let cost_per_image: u64 = match (quality, size) {
|
||||
@@ -62,6 +62,8 @@ impl ArtcraftGptImage2CostState {
|
||||
| (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15,
|
||||
(CommonQualityEnum::High, SizeBucket::Landscape16x9)
|
||||
| (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
|
||||
@@ -79,6 +81,20 @@ impl ArtcraftGptImage2CostState {
|
||||
|
||||
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
|
||||
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
|
||||
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
match aspect_ratio {
|
||||
|
||||
+58
-20
@@ -8,25 +8,27 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image
|
||||
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
|
||||
/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and
|
||||
/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of
|
||||
/// the same dimensions price identically. Dimensions come from the aspect ratio preset and
|
||||
/// resolution tier the same way the Fal request builder derives them.
|
||||
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[
|
||||
/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5
|
||||
/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored
|
||||
/// landscape-first; portrait orientations of the same dimensions price identically.
|
||||
/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal
|
||||
/// request builder derives them.
|
||||
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[
|
||||
// 1:1
|
||||
(1024, 1024, [ 65, 152, 582]),
|
||||
(2048, 2048, [103, 239, 919]),
|
||||
(2880, 2880, [151, 352, 1357]),
|
||||
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
|
||||
(2048, 2048, [103, 239, 919, 1633, 3672]),
|
||||
(2880, 2880, [151, 352, 1357, 2412, 5426]),
|
||||
// 4:3
|
||||
(1024, 768, [ 46, 105, 400]),
|
||||
(2048, 1536, [ 73, 170, 652]),
|
||||
(3072, 2304, [120, 279, 1073]),
|
||||
(3312, 2480, [134, 311, 1194]),
|
||||
(1024, 768, [ 46, 105, 400, 710, 1597]),
|
||||
(2048, 1536, [ 73, 170, 652, 1159, 2605]),
|
||||
(3072, 2304, [120, 279, 1073, 1906, 4287]),
|
||||
(3312, 2480, [134, 311, 1194, 2122, 4773]),
|
||||
// 16:9
|
||||
(1088, 608, [ 32, 75, 285]),
|
||||
(1920, 1080, [ 49, 114, 436]),
|
||||
(2048, 1152, [ 53, 121, 467]),
|
||||
(3072, 1728, [ 87, 204, 783]),
|
||||
(3840, 2160, [124, 286, 1102]),
|
||||
(1088, 608, [ 32, 75, 285, 506, 1139]),
|
||||
(1920, 1080, [ 49, 114, 436, 775, 1743]),
|
||||
(2048, 1152, [ 53, 121, 467, 830, 1865]),
|
||||
(3072, 1728, [ 87, 204, 783, 1390, 3127]),
|
||||
(3840, 2160, [124, 286, 1102, 1957, 4404]),
|
||||
];
|
||||
|
||||
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
|
||||
@@ -55,7 +57,8 @@ impl ArtcraftGptImage2p5CostInputs {
|
||||
}
|
||||
|
||||
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
|
||||
/// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
|
||||
/// high, and `auto` is priced as high (the documented default the model falls back to).
|
||||
/// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
|
||||
/// matching the Fal defaults the request would fall back to.
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
@@ -91,11 +94,13 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
|
||||
match quality {
|
||||
CommonQualityEnum::Low => prices[0],
|
||||
CommonQualityEnum::Medium => prices[1],
|
||||
CommonQualityEnum::High => prices[2],
|
||||
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
|
||||
CommonQualityEnum::XHigh => prices[3],
|
||||
CommonQualityEnum::Max => prices[4],
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
|
||||
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
|
||||
let long_edge = width.max(height);
|
||||
let short_edge = width.min(height);
|
||||
OUTPUT_IMAGE_PRICE_TABLE.iter()
|
||||
@@ -176,6 +181,18 @@ mod tests {
|
||||
(Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]),
|
||||
];
|
||||
|
||||
// (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image
|
||||
const TEXT_GRID_UPPER_TIERS: &[(Option<Ar>, Option<R>, [u64; 2])] = &[
|
||||
(None, None, [ 8, 16]), // landscape 4:3 default
|
||||
(Some(Ar::Square), None, [11, 24]),
|
||||
(Some(Ar::Square), Some(R::TwoK), [17, 37]),
|
||||
(Some(Ar::Square), Some(R::FourK), [25, 55]),
|
||||
(Some(Ar::WideSixteenByNine), None, [ 8, 18]),
|
||||
(Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]),
|
||||
(Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]),
|
||||
(Some(Ar::Auto), None, [20, 45]),
|
||||
];
|
||||
|
||||
mod text_to_image_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -189,11 +206,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_tier_grid() {
|
||||
for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS {
|
||||
for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) {
|
||||
let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1));
|
||||
assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_defaults_to_high() {
|
||||
assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_prices_as_high() {
|
||||
for &(aspect_ratio, resolution, _) in TEXT_GRID {
|
||||
assert_eq!(
|
||||
cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)),
|
||||
cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)),
|
||||
"{aspect_ratio:?} {resolution:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_per_image_cents() {
|
||||
for &(aspect_ratio, resolution, _) in TEXT_GRID {
|
||||
@@ -265,7 +303,7 @@ mod tests {
|
||||
#[test]
|
||||
fn prices_rise_with_quality_and_size() {
|
||||
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
|
||||
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
|
||||
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
|
||||
}
|
||||
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
|
||||
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));
|
||||
|
||||
+11
-1
@@ -100,11 +100,17 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +343,10 @@ mod tests {
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
||||
+11
-1
@@ -100,11 +100,17 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +343,10 @@ mod tests {
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
||||
+11
-1
@@ -106,11 +106,17 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +368,10 @@ mod tests {
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
||||
+12
-6
@@ -82,13 +82,16 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are
|
||||
/// not reachable through it. Fal's default (`high`) applies when unspecified.
|
||||
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
|
||||
/// unspecified.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
|
||||
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
|
||||
Some(RouterQuality::Auto) => GptImage2p5Quality::Auto,
|
||||
Some(RouterQuality::Max) => GptImage2p5Quality::Max,
|
||||
Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh,
|
||||
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
|
||||
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
|
||||
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,9 +223,12 @@ mod tests {
|
||||
fn quality_maps_exhaustively_for_text_and_edit() {
|
||||
let cases = [
|
||||
(None, "Some(High)"),
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::Auto), "Some(Auto)"),
|
||||
(Some(RouterQuality::Max), "Some(Max)"),
|
||||
(Some(RouterQuality::XHigh), "Some(XHigh)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
];
|
||||
for (quality, expected) in cases {
|
||||
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));
|
||||
|
||||
+3
@@ -53,6 +53,9 @@ mod tests {
|
||||
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
|
||||
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
|
||||
(Some(Q::High), Some(Ar::Square), None, 1, 6),
|
||||
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
|
||||
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
|
||||
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
|
||||
(Some(Q::High), Some(Ar::Square), None, 4, 24),
|
||||
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
|
||||
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
|
||||
|
||||
+3
@@ -53,6 +53,9 @@ mod tests {
|
||||
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
|
||||
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
|
||||
(Some(Q::High), Some(Ar::Square), None, 1, 6),
|
||||
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
|
||||
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
|
||||
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
|
||||
(Some(Q::High), Some(Ar::Square), None, 4, 24),
|
||||
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
|
||||
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
|
||||
|
||||
+6
-2
@@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
|
||||
// Kinovi pricing — pass through whatever the caller asked for, and let
|
||||
// `None` ride Midjourney's server-side default.
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
|
||||
|
||||
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
|
||||
|
||||
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV8Quality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-2
@@ -1,9 +1,12 @@
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::build::build_kinovi_wan_3p0;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::build::build_artcraft_wan_3p0;
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_video_output_format::RouterVideoOutputFormat;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
@@ -125,6 +128,10 @@ pub struct GenerateVideoRequestBuilder {
|
||||
/// Not all models support this; models that don't simply ignore it.
|
||||
pub bitrate: Option<RouterBitrate>,
|
||||
|
||||
/// Output container for Seedance 2.5 on Kinovi and Artcraft.
|
||||
/// None keeps the provider default; unsupported models/providers ignore it.
|
||||
pub maybe_output_format: Option<RouterVideoOutputFormat>,
|
||||
|
||||
/// How many seconds to generate.
|
||||
pub duration_seconds: Option<u16>,
|
||||
|
||||
@@ -137,10 +144,12 @@ pub struct GenerateVideoRequestBuilder {
|
||||
/// so it's best to be explicit.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
|
||||
/// Local pricing/validation metadata (never sent to a provider): total seconds of
|
||||
/// reference video input, summed across all reference videos and rounded
|
||||
/// up to whole seconds per file. Models that bill reference-video input
|
||||
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
|
||||
/// seconds (e.g. Seedance 2.5) add this to the billed duration. Wan uses
|
||||
/// the ceiling of the combined measured duration to validate its output
|
||||
/// budget; reference seconds do not affect Wan pricing.
|
||||
pub total_reference_video_input_seconds: Option<u16>,
|
||||
|
||||
/// If the request is a mismatch with the (model/provider), how to mitigate it.
|
||||
@@ -168,6 +177,7 @@ impl Default for GenerateVideoRequestBuilder {
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
@@ -186,6 +196,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_artcraft_wan_3p0(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
|
||||
@@ -248,6 +259,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
|
||||
// Kinovi
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_kinovi_wan_3p0(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
|
||||
|
||||
+75
-24
@@ -3,10 +3,12 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_output_format::RouterVideoOutputFormat;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
@@ -55,6 +57,13 @@ pub fn build_artcraft_omni_video_request(
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let bitrate = plan_bitrate(builder.bitrate.take());
|
||||
let prompt = builder.prompt.take();
|
||||
let supports_output_format = matches!(model, CommonVideoModelEnum::Seedance2p5 | CommonVideoModelEnum::Seedance2p5Ultra);
|
||||
let maybe_output_format = builder.maybe_output_format
|
||||
.filter(|_| supports_output_format)
|
||||
.map(|format| match format {
|
||||
RouterVideoOutputFormat::Mp4 => CommonVideoOutputFormat::Mp4,
|
||||
RouterVideoOutputFormat::Mov => CommonVideoOutputFormat::Mov,
|
||||
});
|
||||
|
||||
let start_frame = resolve_image_ref(builder.start_frame.take())?;
|
||||
let end_frame = resolve_image_ref(builder.end_frame.take())?;
|
||||
@@ -77,6 +86,7 @@ pub fn build_artcraft_omni_video_request(
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
bitrate,
|
||||
maybe_output_format,
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
@@ -254,30 +264,6 @@ pub fn plan_batch_count(
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
|
||||
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
|
||||
/// mitigation strategy.
|
||||
pub fn plan_mini_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..=8 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the router-facing bitrate into the API `CommonBitrate`.
|
||||
///
|
||||
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
|
||||
@@ -317,6 +303,71 @@ pub fn plan_duration(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod output_format {
|
||||
use super::*;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
#[test]
|
||||
fn seedance_2p5_preserves_output_format_and_audio_over_http() {
|
||||
for model in [RouterVideoModel::Seedance2p5, RouterVideoModel::Seedance2p5Ultra] {
|
||||
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
|
||||
for generate_audio in [None, Some(true), Some(false)] {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
model,
|
||||
maybe_output_format,
|
||||
generate_audio,
|
||||
..Default::default()
|
||||
};
|
||||
let request = match builder.build2().unwrap() {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state.request,
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state.request,
|
||||
other => panic!("Unexpected request: {:?}", other),
|
||||
};
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
let expected = match maybe_output_format {
|
||||
None => None,
|
||||
Some(RouterVideoOutputFormat::Mp4) => Some("mp4"),
|
||||
Some(RouterVideoOutputFormat::Mov) => Some("mov"),
|
||||
};
|
||||
assert_eq!(json.get("output_format").and_then(|v| v.as_str()), expected);
|
||||
if expected.is_none() {
|
||||
assert!(json.get("output_format").is_none());
|
||||
}
|
||||
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded.maybe_output_format, request.maybe_output_format);
|
||||
assert_eq!(decoded.generate_audio, generate_audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_models_ignore_output_format_even_with_error_strategy() {
|
||||
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p5Preview] {
|
||||
for format in [RouterVideoOutputFormat::Mp4, RouterVideoOutputFormat::Mov] {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
model,
|
||||
maybe_output_format: Some(format),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
};
|
||||
let request = match builder.build2().unwrap() {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)) => state.request,
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)) => state.request,
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state)) => state.request,
|
||||
other => panic!("Unexpected request: {:?}", other),
|
||||
};
|
||||
assert!(serde_json::to_value(request).unwrap().get("output_format").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod bitrate_translation {
|
||||
use super::*;
|
||||
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ mod tests {
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
quality: None,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
//! Public ArtCraft batch planning and retail price snapshots for Kinovi models.
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
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::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
const MODELS: [RouterVideoModel; 12] = [
|
||||
RouterVideoModel::Seedance2p5,
|
||||
RouterVideoModel::Seedance2p0,
|
||||
RouterVideoModel::Seedance2p0Fast,
|
||||
RouterVideoModel::Seedance2p0Mini,
|
||||
RouterVideoModel::Seedance2p5Ultra,
|
||||
RouterVideoModel::Seedance2p0BytePlus,
|
||||
RouterVideoModel::Seedance2p0BytePlusFast,
|
||||
RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltra,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraFast,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
RouterVideoModel::HappyHorse1p0,
|
||||
];
|
||||
|
||||
mod batch_planning {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batches_one_through_four_survive_http_serialization() {
|
||||
for model in MODELS {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::ErrorOut,
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
for batch in [None, Some(1), Some(2), Some(3), Some(4)] {
|
||||
let mut builder = builder(model, RouterResolution::SevenTwentyP, batch);
|
||||
builder.request_mismatch_mitigation_strategy = strategy;
|
||||
let request = http_request(builder.build2().unwrap());
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json["video_batch_count"], batch.unwrap_or(1), "{model:?}");
|
||||
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded.video_batch_count, Some(batch.unwrap_or(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_is_rejected_under_every_strategy() {
|
||||
for model in MODELS {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::ErrorOut,
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let mut builder = builder(model, RouterResolution::SevenTwentyP, Some(0));
|
||||
builder.request_mismatch_mitigation_strategy = strategy;
|
||||
assert!(matches!(builder.build2(), Err(ArtcraftRouterError::Client(
|
||||
ClientError::UserRequestedZeroGenerations
|
||||
))), "{model:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches_above_four_follow_the_mitigation_strategy() {
|
||||
for model in MODELS {
|
||||
for batch in [5, 6, 7, 8, u16::MAX] {
|
||||
let builder = builder(model, RouterResolution::SevenTwentyP, Some(batch));
|
||||
assert!(matches!(builder.clone().build2(), Err(ArtcraftRouterError::Client(
|
||||
ClientError::ModelDoesNotSupportOption { field: "video_batch_count", .. }
|
||||
))), "{model:?}, batch {batch}");
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let mut builder = builder.clone();
|
||||
builder.request_mismatch_mitigation_strategy = strategy;
|
||||
let request = http_request(builder.build2().unwrap());
|
||||
assert_eq!(request.video_batch_count, Some(4), "{model:?}, batch {batch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod retail_prices {
|
||||
use super::*;
|
||||
use RouterResolution::{FourEightyP, FourK, SevenTwentyP, TenEightyP};
|
||||
use RouterVideoModel::{HappyHorse1p0, Seedance2p0, Seedance2p0Fast, Seedance2p0Mini, Seedance2p5,
|
||||
Seedance2p5Ultra, Seedance2p0BytePlus, Seedance2p0BytePlusFast, Seedance2p0BytePlusMini, Seedance2p0BytePlusUltra, Seedance2p0BytePlusUltraFast, Seedance2p0BytePlusUltraMini,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn five_second_prices_for_every_resolution_and_batch() {
|
||||
// Literal USD cents / ArtCraft credits for batches [1, 2, 3, 4].
|
||||
// Pin the final rounded totals, not a multiple of an already-rounded price.
|
||||
for (model, resolution, expected) in [
|
||||
(Seedance2p5, FourEightyP, [59, 118, 177, 236]),
|
||||
(Seedance2p5, SevenTwentyP, [134, 268, 401, 535]),
|
||||
(Seedance2p5, TenEightyP, [309, 617, 925, 1233]),
|
||||
(Seedance2p5Ultra, FourEightyP, [70, 140, 209, 279]),
|
||||
(Seedance2p5Ultra, SevenTwentyP, [158, 316, 474, 632]),
|
||||
(Seedance2p5Ultra, TenEightyP, [337, 673, 1009, 1345]),
|
||||
(Seedance2p0, FourEightyP, [39, 78, 117, 155]),
|
||||
(Seedance2p0, SevenTwentyP, [93, 185, 278, 370]),
|
||||
(Seedance2p0, TenEightyP, [233, 466, 699, 933]),
|
||||
(Seedance2p0, FourK, [463, 925, 1388, 1850]),
|
||||
(Seedance2p0Fast, FourEightyP, [36, 71, 107, 143]),
|
||||
(Seedance2p0Fast, SevenTwentyP, [64, 127, 191, 255]),
|
||||
(Seedance2p0Mini, FourEightyP, [18, 35, 52, 69]),
|
||||
(Seedance2p0Mini, SevenTwentyP, [45, 89, 134, 178]),
|
||||
(Seedance2p0BytePlus, FourEightyP, [50, 100, 150, 200]),
|
||||
(Seedance2p0BytePlus, SevenTwentyP, [125, 250, 375, 500]),
|
||||
(Seedance2p0BytePlus, TenEightyP, [250, 500, 750, 1000]),
|
||||
(Seedance2p0BytePlus, FourK, [475, 950, 1425, 1900]),
|
||||
(Seedance2p0BytePlusUltra, FourEightyP, [50, 100, 150, 200]),
|
||||
(Seedance2p0BytePlusUltra, SevenTwentyP, [125, 250, 375, 500]),
|
||||
(Seedance2p0BytePlusUltra, TenEightyP, [250, 500, 750, 1000]),
|
||||
(Seedance2p0BytePlusUltra, FourK, [475, 950, 1425, 1900]),
|
||||
(Seedance2p0BytePlusFast, FourEightyP, [45, 90, 135, 180]),
|
||||
(Seedance2p0BytePlusFast, SevenTwentyP, [100, 200, 300, 400]),
|
||||
(Seedance2p0BytePlusUltraFast, FourEightyP, [45, 90, 135, 180]),
|
||||
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [100, 200, 300, 400]),
|
||||
(Seedance2p0BytePlusMini, FourEightyP, [18, 36, 54, 71]),
|
||||
(Seedance2p0BytePlusMini, SevenTwentyP, [46, 91, 137, 182]),
|
||||
(Seedance2p0BytePlusUltraMini, FourEightyP, [18, 36, 54, 71]),
|
||||
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [46, 91, 137, 182]),
|
||||
(HappyHorse1p0, SevenTwentyP, [85, 171, 256, 342]),
|
||||
(HappyHorse1p0, TenEightyP, [171, 342, 513, 684]),
|
||||
] {
|
||||
assert_batch_prices(builder(model, resolution, None), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_reference_prices_for_every_resolution_and_batch() {
|
||||
// Five output seconds, plus ten measured input seconds for Seedance 2.5.
|
||||
for (model, resolution, expected) in [
|
||||
(Seedance2p5, FourEightyP, [109, 218, 326, 435]),
|
||||
(Seedance2p5, SevenTwentyP, [238, 476, 713, 951]),
|
||||
(Seedance2p5, TenEightyP, [578, 1155, 1733, 2310]),
|
||||
(Seedance2p5Ultra, FourEightyP, [129, 257, 386, 514]),
|
||||
(Seedance2p5Ultra, SevenTwentyP, [281, 562, 843, 1124]),
|
||||
(Seedance2p5Ultra, TenEightyP, [628, 1256, 1884, 2511]),
|
||||
(Seedance2p0, FourEightyP, [45, 89, 133, 177]),
|
||||
(Seedance2p0, SevenTwentyP, [111, 221, 331, 441]),
|
||||
(Seedance2p0, TenEightyP, [256, 511, 767, 1022]),
|
||||
(Seedance2p0, FourK, [569, 1138, 1707, 2276]),
|
||||
(Seedance2p0Fast, FourEightyP, [43, 85, 127, 169]),
|
||||
(Seedance2p0Fast, SevenTwentyP, [78, 156, 234, 312]),
|
||||
// Preserve Mini's existing floating-point round-up behavior.
|
||||
(Seedance2p0Mini, FourEightyP, [22, 44, 66, 88]),
|
||||
(Seedance2p0Mini, SevenTwentyP, [54, 108, 161, 215]),
|
||||
(Seedance2p0BytePlus, FourEightyP, [51, 102, 153, 204]),
|
||||
(Seedance2p0BytePlus, SevenTwentyP, [129, 257, 386, 514]),
|
||||
(Seedance2p0BytePlus, TenEightyP, [289, 578, 867, 1156]),
|
||||
(Seedance2p0BytePlus, FourK, [595, 1190, 1785, 2380]),
|
||||
(Seedance2p0BytePlusUltra, FourEightyP, [51, 102, 153, 204]),
|
||||
(Seedance2p0BytePlusUltra, SevenTwentyP, [129, 257, 386, 514]),
|
||||
(Seedance2p0BytePlusUltra, TenEightyP, [289, 578, 867, 1156]),
|
||||
(Seedance2p0BytePlusUltra, FourK, [595, 1190, 1785, 2380]),
|
||||
(Seedance2p0BytePlusFast, FourEightyP, [49, 97, 145, 193]),
|
||||
(Seedance2p0BytePlusFast, SevenTwentyP, [103, 205, 308, 410]),
|
||||
(Seedance2p0BytePlusUltraFast, FourEightyP, [49, 97, 145, 193]),
|
||||
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [103, 205, 308, 410]),
|
||||
(Seedance2p0BytePlusMini, FourEightyP, [23, 45, 68, 90]),
|
||||
(Seedance2p0BytePlusMini, SevenTwentyP, [55, 110, 165, 219]),
|
||||
(Seedance2p0BytePlusUltraMini, FourEightyP, [23, 45, 68, 90]),
|
||||
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [55, 110, 165, 219]),
|
||||
] {
|
||||
let mut builder = builder(model, resolution, None);
|
||||
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_reference".to_string()),
|
||||
]));
|
||||
builder.total_reference_video_input_seconds = Some(10);
|
||||
assert_batch_prices(builder, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p5_input_duration_clamping_and_fallback_scale_with_batch() {
|
||||
for (model, resolution, minimum, maximum) in [
|
||||
(Seedance2p5, FourEightyP, [66, 131, 196, 261], [254, 507, 761, 1014]),
|
||||
(Seedance2p5, SevenTwentyP, [143, 286, 428, 571], [555, 1110, 1664, 2219]),
|
||||
(Seedance2p5, TenEightyP, [347, 693, 1040, 1386], [1348, 2695, 4043, 5390]),
|
||||
(Seedance2p5Ultra, FourEightyP, [78, 155, 232, 309], [300, 600, 899, 1199]),
|
||||
(Seedance2p5Ultra, SevenTwentyP, [169, 338, 506, 675], [656, 1311, 1967, 2622]),
|
||||
(Seedance2p5Ultra, TenEightyP, [377, 754, 1130, 1507], [1465, 2930, 4394, 5859]),
|
||||
] {
|
||||
for (input_seconds, expected) in [
|
||||
(Some(1), minimum), (Some(4), minimum),
|
||||
(None, maximum), (Some(0), maximum), (Some(30), maximum), (Some(99), maximum),
|
||||
] {
|
||||
let mut builder = builder(model, resolution, None);
|
||||
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_reference".to_string()),
|
||||
]));
|
||||
builder.total_reference_video_input_seconds = input_seconds;
|
||||
assert_batch_prices(builder, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn builder(model: RouterVideoModel, resolution: RouterResolution, batch: Option<u16>) -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
model,
|
||||
resolution: Some(resolution),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: batch,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_batch_prices(builder: GenerateVideoRequestBuilder, expected: [u64; 4]) {
|
||||
for (index, expected_price) in expected.into_iter().enumerate() {
|
||||
let batch = index as u16 + 1;
|
||||
let mut builder = builder.clone();
|
||||
builder.video_batch_count = Some(batch);
|
||||
let context = format!("{:?}, {:?}, batch {batch}", builder.model, builder.resolution);
|
||||
let request = builder.build2().unwrap();
|
||||
let cost = request.estimate_cost().unwrap();
|
||||
assert_eq!(cost.cost_in_credits, Some(expected_price), "{context}");
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(expected_price), "{context}");
|
||||
assert_eq!(http_request(request).video_batch_count, Some(batch), "{context}");
|
||||
}
|
||||
}
|
||||
|
||||
fn http_request(request: VideoGenerationDraftOrRequest) -> OmniGenVideoCostAndGenerateRequest {
|
||||
match request {
|
||||
VideoGenerationDraftOrRequest::Request(request) => match request {
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Mini(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(state) => state.request,
|
||||
other => panic!("Unexpected request: {other:?}"),
|
||||
},
|
||||
other => panic!("Expected ArtCraft request: {other:?}"),
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -2,6 +2,8 @@ pub(crate) mod build_common;
|
||||
pub(crate) mod seedance_common;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
#[cfg(test)]
|
||||
mod kinovi_batch_tests;
|
||||
pub mod grok_imagine_video;
|
||||
pub mod grok_imagine_video_1p5;
|
||||
pub mod flux_3;
|
||||
@@ -43,4 +45,4 @@ pub mod veo_3p1_fast;
|
||||
pub mod veo_3p1_lite;
|
||||
pub mod vidu_q3;
|
||||
pub mod vidu_q3_turbo;
|
||||
|
||||
pub mod wan_3p0;
|
||||
+8
-15
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
|
||||
@@ -87,18 +81,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
fn batch_count_up_to_four_passed_through() {
|
||||
for batch in 1..=4u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
fn batch_count_above_four_clamps_to_four() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-18
@@ -108,18 +108,13 @@ mod tests {
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,23 +123,16 @@ mod tests {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
|
||||
fn batch_four_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
fn batch_above_four_clamps_to_four() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +148,7 @@ mod tests {
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for batch in 1..=4 {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
|
||||
+8
-15
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
|
||||
@@ -87,18 +81,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
fn batch_count_up_to_four_passed_through() {
|
||||
for batch in 1..=4u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
fn batch_count_above_four_clamps_to_four() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-18
@@ -108,18 +108,13 @@ mod tests {
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,23 +123,16 @@ mod tests {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
|
||||
fn batch_four_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
fn batch_above_four_clamps_to_four() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +148,7 @@ mod tests {
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for batch in 1..=4 {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
|
||||
+8
-15
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0Mini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0MiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
|
||||
@@ -87,18 +81,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
fn batch_count_up_to_four_passed_through() {
|
||||
for batch in 1..=4u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
fn batch_count_above_four_clamps_to_four() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-18
@@ -108,18 +108,13 @@ mod tests {
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 89);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 134);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 178);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 223);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 267);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 312);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 356);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,23 +123,16 @@ mod tests {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 35);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 52);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 69);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 87);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 104);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 121);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 138);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 429);
|
||||
fn batch_four_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 215);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
fn batch_above_four_clamps_to_four() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 178);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +148,7 @@ mod tests {
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for batch in 1..=4 {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
|
||||
+8
-29
@@ -43,12 +43,11 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// build_common) and generates a single video per request. Plan both here,
|
||||
// leaving None for the shared builder, then set them after.
|
||||
// build_common). Plan duration here and use the shared 1-4 batch planner.
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds.take();
|
||||
let generate_audio = builder.generate_audio.take();
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
@@ -57,7 +56,7 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
request.generate_audio = generate_audio;
|
||||
|
||||
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)))
|
||||
@@ -85,28 +84,6 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
@@ -151,9 +128,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_is_always_one() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(1));
|
||||
fn batch_counts_one_through_four_are_preserved() {
|
||||
for batch_count in 1..=4 {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch_count));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-12
@@ -8,10 +8,11 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5::request:
|
||||
/// — when reference videos are attached — the total seconds of reference
|
||||
/// video input, which are billed on top of the output duration (at a lower
|
||||
/// per-second rate). The TOTAL input duration clamps to the 4..=30 second
|
||||
/// billing range. No batching.
|
||||
/// billing range. Batch count multiplies the total before rounding.
|
||||
pub struct ArtcraftSeedance2p5CostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_references: bool,
|
||||
pub maybe_total_input_seconds: Option<u16>,
|
||||
}
|
||||
@@ -29,6 +30,7 @@ impl ArtcraftSeedance2p5CostState {
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count: request.request.video_batch_count.unwrap_or(1),
|
||||
has_video_references,
|
||||
maybe_total_input_seconds: request.total_input_seconds,
|
||||
}
|
||||
@@ -43,6 +45,7 @@ impl ArtcraftSeedance2p5CostState {
|
||||
let usd_cents = seedance_2p5_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_references,
|
||||
maybe_total_input_seconds,
|
||||
);
|
||||
@@ -94,11 +97,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn table_1080p() {
|
||||
// 45.85869386 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 184);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 230);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 459);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1376);
|
||||
// 61.62732357 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 247);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 309);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 617);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1849);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -121,16 +124,16 @@ mod tests {
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
|
||||
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
|
||||
// 27.39973680 ¢/s × 40 = 1095.99 → 1096¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
|
||||
// 38.49728574 ¢/s × 40 = 1539.89 → 1540¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_1080p_with_ten_input_seconds() {
|
||||
// 27.39973680 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 411);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 548);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
|
||||
// 38.49728574 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 578);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 770);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+8
-29
@@ -43,12 +43,11 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// build_common) and generates a single video per request. Plan both here,
|
||||
// leaving None for the shared builder, then set them after.
|
||||
// build_common). Plan duration here and use the shared 1-4 batch planner.
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds.take();
|
||||
let generate_audio = builder.generate_audio.take();
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
@@ -57,7 +56,7 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
request.generate_audio = generate_audio;
|
||||
|
||||
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
|
||||
@@ -85,28 +84,6 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
@@ -151,9 +128,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_is_always_one() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(1));
|
||||
fn batch_counts_one_through_four_are_preserved() {
|
||||
for batch_count in 1..=4 {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch_count));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-12
@@ -9,10 +9,11 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::reques
|
||||
/// reference video input, which are billed on top of the output duration (at
|
||||
/// a lower per-second rate). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video. No batching.
|
||||
/// video. Batch count multiplies the total before rounding.
|
||||
pub struct ArtcraftSeedance2p5UltraCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_references: bool,
|
||||
pub maybe_total_input_seconds: Option<u16>,
|
||||
}
|
||||
@@ -30,6 +31,7 @@ impl ArtcraftSeedance2p5UltraCostState {
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count: request.request.video_batch_count.unwrap_or(1),
|
||||
has_video_references,
|
||||
maybe_total_input_seconds: request.total_input_seconds,
|
||||
}
|
||||
@@ -44,6 +46,7 @@ impl ArtcraftSeedance2p5UltraCostState {
|
||||
let usd_cents = seedance_2p5_ultra_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_references,
|
||||
maybe_total_input_seconds,
|
||||
);
|
||||
@@ -95,11 +98,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn table_1080p() {
|
||||
// 50.10486922 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 201);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 251);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 502);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1504);
|
||||
// 67.22980753 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 269);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 337);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 673);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 2017);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -141,16 +144,16 @@ mod tests {
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
|
||||
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
|
||||
// 29.93674947 ¢/s × 40 = 1197.47 → 1198¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
|
||||
// 41.84487580 ¢/s × 40 = 1673.80 → 1674¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_1080p_with_ten_input_seconds() {
|
||||
// 29.93674947 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 450);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 599);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
|
||||
// 41.84487580 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 628);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 837);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+17
-13
@@ -137,14 +137,15 @@ pub fn seedance_2p5_preview_usd_cents(
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
|
||||
/// Seedance 2.5 — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
|
||||
/// Seedance 2.5 — 1080p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 45.85869386;
|
||||
/// Seedance 2.5 — 1080p price, USD cents per second (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 61.62732357;
|
||||
/// Seedance 2.5 — 480p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
|
||||
/// Seedance 2.5 — 720p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
|
||||
/// Seedance 2.5 — 1080p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
|
||||
/// Seedance 2.5 — 1080p price with video references, USD cents per second
|
||||
/// (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 38.49728574;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5.
|
||||
///
|
||||
@@ -154,10 +155,11 @@ const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
|
||||
/// seconds = output duration. With video references, the per-second rate
|
||||
/// drops but the billed seconds are the output duration PLUS the total
|
||||
/// seconds of reference video input (clamped to the 4..=30 second billing
|
||||
/// range). The fractional total is rounded UP to a whole cent. No batching.
|
||||
/// range). Multiply by batch count before rounding UP to a whole cent.
|
||||
pub fn seedance_2p5_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_references: bool,
|
||||
maybe_total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
@@ -192,21 +194,22 @@ pub fn seedance_2p5_usd_cents(
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
|
||||
}
|
||||
|
||||
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
|
||||
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
|
||||
/// Seedance 2.5 Ultra — 1080p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 50.10486922;
|
||||
/// Seedance 2.5 Ultra — 1080p price, USD cents per second (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 67.22980753;
|
||||
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
|
||||
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
|
||||
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.93674947;
|
||||
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per
|
||||
/// second (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 41.84487580;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
|
||||
///
|
||||
@@ -215,11 +218,12 @@ const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.936749
|
||||
/// this fallback is only a failsafe). Without video references, billed
|
||||
/// seconds = output duration. With video references, the per-second rate
|
||||
/// drops but the billed seconds are the output duration PLUS the total
|
||||
/// seconds of reference video input. The fractional total is rounded UP to
|
||||
/// a whole cent. No batching.
|
||||
/// seconds of reference video input. Multiply by batch count before rounding
|
||||
/// UP to a whole cent.
|
||||
pub fn seedance_2p5_ultra_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_references: bool,
|
||||
maybe_total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
@@ -254,7 +258,7 @@ pub fn seedance_2p5_ultra_usd_cents(
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::resolve::{resolve_image_ref, resolve_image_list_ref, resolve_video_list_ref, resolve_audio_list_ref};
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
|
||||
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let builder = normalize_wan_3p0_builder(builder)?;
|
||||
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
|
||||
let aspect = match builder.aspect_ratio.unwrap() {
|
||||
RouterAspectRatio::WideSixteenByNine => CommonAspectRatio::WideSixteenByNine,
|
||||
RouterAspectRatio::TallNineBySixteen => CommonAspectRatio::TallNineBySixteen,
|
||||
RouterAspectRatio::WideFourByThree => CommonAspectRatio::WideFourByThree,
|
||||
RouterAspectRatio::TallThreeByFour => CommonAspectRatio::TallThreeByFour,
|
||||
RouterAspectRatio::Square => CommonAspectRatio::Square,
|
||||
_ => unreachable!("normalized Wan aspect ratio"),
|
||||
};
|
||||
let resolution = match builder.resolution.unwrap() {
|
||||
RouterResolution::FourEightyP => CommonResolution::FourEightyP,
|
||||
RouterResolution::SevenTwentyP => CommonResolution::SevenTwentyP,
|
||||
RouterResolution::TenEightyP => CommonResolution::TenEightyP,
|
||||
_ => unreachable!("normalized Wan resolution"),
|
||||
};
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(if prime { CommonVideoModel::Wan3p0Prime } else { CommonVideoModel::Wan3p0 }),
|
||||
idempotency_token: Some(idempotency_token),
|
||||
prompt: builder.prompt,
|
||||
start_frame_image_media_token: resolve_image_ref(builder.start_frame)?,
|
||||
end_frame_image_media_token: resolve_image_ref(builder.end_frame)?,
|
||||
reference_image_media_tokens: resolve_image_list_ref(builder.reference_images)?,
|
||||
reference_video_media_tokens: resolve_video_list_ref(builder.reference_videos)?,
|
||||
reference_audio_media_tokens: resolve_audio_list_ref(builder.reference_audio)?,
|
||||
resolution: Some(resolution),
|
||||
aspect_ratio: Some(aspect),
|
||||
duration_seconds: builder.duration_seconds,
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: builder.generate_audio,
|
||||
reference_character_tokens: None,
|
||||
negative_prompt: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
quality: None,
|
||||
estimate_only: None,
|
||||
};
|
||||
let state = ArtcraftWan3p0RequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(if prime {
|
||||
VideoGenerationRequest::ArtcraftWan3p0Prime(state)
|
||||
} else {
|
||||
VideoGenerationRequest::ArtcraftWan3p0(state)
|
||||
}))
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// ArtCraft USD cents per output second, in 480p/720p/1080p order.
|
||||
// Pricing snapshot: 2026-09-18. Review alongside the Wan provider pricing
|
||||
// after September 23; see kinovi_web_client/costs_wan_3p0.md.
|
||||
const WAN_CENTS_PER_SECOND: [f64; 3] = [4.82858056, 9.65716111, 19.30948396];
|
||||
const PRIME_CENTS_PER_SECOND: [f64; 3] = [9.12495283, 18.78695219, 37.57390438];
|
||||
|
||||
pub struct ArtcraftWan3p0CostState {
|
||||
pub is_prime: bool,
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftWan3p0CostState {
|
||||
pub fn from_request(state: &ArtcraftWan3p0RequestState) -> Self {
|
||||
Self {
|
||||
is_prime: matches!(state.request.model, Some(CommonVideoModel::Wan3p0Prime)),
|
||||
resolution: state.request.resolution.unwrap_or(CommonResolution::SevenTwentyP),
|
||||
duration_seconds: state.request.duration_seconds.unwrap_or(5),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let rates = if self.is_prime { PRIME_CENTS_PER_SECOND } else { WAN_CENTS_PER_SECOND };
|
||||
let index = match self.resolution {
|
||||
CommonResolution::FourEightyP => 0,
|
||||
CommonResolution::TenEightyP => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let cents = (rates[index] * f64::from(self.duration_seconds)).ceil() as u64;
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cents), cost_in_usd_cents: Some(cents),
|
||||
is_free: false, is_unlimited: false, is_rate_limited: false,
|
||||
has_watermark: false, failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn regular_wan_prices_are_fixed_per_resolution_and_duration() {
|
||||
let expected = [
|
||||
(CommonResolution::FourEightyP, [10, 25, 49]),
|
||||
(CommonResolution::SevenTwentyP, [20, 49, 97]),
|
||||
(CommonResolution::TenEightyP, [39, 97, 194]),
|
||||
];
|
||||
for (resolution, prices) in expected {
|
||||
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
|
||||
let cost = ArtcraftWan3p0CostState {
|
||||
is_prime: false,
|
||||
resolution,
|
||||
duration_seconds: duration,
|
||||
}
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(price));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(price));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prime_wan_prices_are_fixed_per_resolution_and_duration() {
|
||||
let expected = [
|
||||
(CommonResolution::FourEightyP, [19, 46, 92]),
|
||||
(CommonResolution::SevenTwentyP, [38, 94, 188]),
|
||||
(CommonResolution::TenEightyP, [76, 188, 376]),
|
||||
];
|
||||
for (resolution, prices) in expected {
|
||||
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
|
||||
let cost = ArtcraftWan3p0CostState {
|
||||
is_prime: true,
|
||||
resolution,
|
||||
duration_seconds: duration,
|
||||
}
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(price));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(price));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftWan3p0RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftWan3p0RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -134,6 +134,7 @@ fn plan_batch_count(
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(None),
|
||||
2 => Ok(Some(KinoviHappyHorse1p0BatchCount::Two)),
|
||||
3 => Ok(Some(KinoviHappyHorse1p0BatchCount::Three)),
|
||||
4 => Ok(Some(KinoviHappyHorse1p0BatchCount::Four)),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
@@ -143,10 +144,10 @@ fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(if count >= 4 { KinoviHappyHorse1p0BatchCount::Four } else { KinoviHappyHorse1p0BatchCount::Two }))
|
||||
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(if count <= 2 { KinoviHappyHorse1p0BatchCount::Two } else { KinoviHappyHorse1p0BatchCount::Four }))
|
||||
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -177,6 +178,24 @@ mod tests {
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
|
||||
|
||||
for count in 1..=4 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((132.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+1
@@ -6,3 +6,4 @@ pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub(crate) mod resolve;
|
||||
pub(crate) mod upload;
|
||||
pub mod wan_3p0;
|
||||
+27
-4
@@ -47,6 +47,7 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res
|
||||
duration_seconds,
|
||||
bitrate,
|
||||
prompt,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
@@ -152,7 +153,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports batch counts of 1 through 4.
|
||||
// Seedance2p0 supports batch counts of 1 through 8.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -164,7 +165,11 @@ fn plan_batch_count(
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
// 5 and above:
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// 9 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -172,8 +177,8 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -238,6 +243,24 @@ mod tests {
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..kinovi_web_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((60.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+3
@@ -65,6 +65,7 @@ impl KinoviSeedance2p0CostState {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
};
|
||||
|
||||
// Enterprise tier: what generations actually cost us (our discounted
|
||||
@@ -276,6 +277,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
.calculate_enterprise_costs();
|
||||
(costs.total_cost.kinovi_credits.round() as u64, costs.total_cost.usd_cents_rounded_up)
|
||||
@@ -654,6 +656,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -28,6 +28,8 @@ pub struct KinoviSeedance2p0DraftState {
|
||||
pub batch_count: KinoviSeedance2p0BatchCount,
|
||||
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
|
||||
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
|
||||
// Pending types that need to be queried.
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
|
||||
}
|
||||
@@ -96,6 +98,7 @@ impl KinoviSeedance2p0DraftState {
|
||||
character_ids,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: self.bitrate,
|
||||
maybe_generate_audio: self.maybe_generate_audio,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p0RequestState { request })
|
||||
|
||||
+27
-4
@@ -47,6 +47,7 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -
|
||||
duration_seconds,
|
||||
bitrate,
|
||||
prompt,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
@@ -166,7 +167,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0Fast supports batch counts of 1 through 4.
|
||||
// Seedance2p0Fast supports batch counts of 1 through 8.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -178,7 +179,11 @@ fn plan_batch_count(
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
// 5 and above:
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// 9 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -186,8 +191,8 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -254,6 +259,24 @@ mod tests {
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..kinovi_web_fast_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((42.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+2
@@ -65,6 +65,7 @@ impl KinoviSeedance2p0FastCostState {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
};
|
||||
|
||||
// Enterprise tier: what generations actually cost us (our discounted
|
||||
@@ -450,6 +451,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -25,6 +25,7 @@ pub struct KinoviSeedance2p0FastDraftState {
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p0FastBatchCount,
|
||||
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
|
||||
}
|
||||
@@ -93,6 +94,7 @@ impl KinoviSeedance2p0FastDraftState {
|
||||
character_ids,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: self.bitrate,
|
||||
maybe_generate_audio: self.maybe_generate_audio,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p0FastRequestState { request })
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ fn do_build_kinovi_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -
|
||||
duration_seconds,
|
||||
bitrate,
|
||||
prompt,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
|
||||
+2
@@ -65,6 +65,7 @@ impl KinoviSeedance2p0MiniCostState {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
};
|
||||
|
||||
// Enterprise tier: what generations actually cost us (our discounted
|
||||
@@ -380,6 +381,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -25,6 +25,7 @@ pub struct KinoviSeedance2p0MiniDraftState {
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p0MiniBatchCount,
|
||||
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0MiniRemainingItems>,
|
||||
}
|
||||
@@ -93,6 +94,7 @@ impl KinoviSeedance2p0MiniDraftState {
|
||||
character_ids,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: self.bitrate,
|
||||
maybe_generate_audio: self.maybe_generate_audio,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p0MiniRequestState { request })
|
||||
|
||||
+77
-6
@@ -1,12 +1,15 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5BatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p5Bitrate,
|
||||
KinoviSeedance2p5OutputFormat,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_output_format::RouterVideoOutputFormat;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
@@ -59,7 +62,7 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
|
||||
// The planned value below only applies to reference mode.
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.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();
|
||||
|
||||
@@ -80,12 +83,18 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5DraftState {
|
||||
batch_count,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
prompt,
|
||||
total_input_seconds,
|
||||
maybe_bitrate,
|
||||
maybe_output_format: builder.maybe_output_format.map(|format| match format {
|
||||
RouterVideoOutputFormat::Mp4 => KinoviSeedance2p5OutputFormat::Mp4,
|
||||
RouterVideoOutputFormat::Mov => KinoviSeedance2p5OutputFormat::Mov,
|
||||
}),
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
@@ -203,16 +212,23 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
// Seedance 2.5 supports batches of 1–8 videos.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
// Over the maximum of 1.
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// Over the maximum of 8.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -221,7 +237,7 @@ fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -275,6 +291,7 @@ mod tests {
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -311,6 +328,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_options_survive_router_finalization() {
|
||||
let client = RouterClient::KinoviWeb(RouterKinoviWebClient::new(
|
||||
KinoviWebSession::from_cookies_string(String::new()),
|
||||
));
|
||||
let context = VideoGenerationDraftContext { client: Some(&client), ..Default::default() };
|
||||
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p0Mini, RouterVideoModel::Seedance2p5] {
|
||||
for generate_audio in [None, Some(false), Some(true)] {
|
||||
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model, generate_audio, maybe_output_format, ..base_builder()
|
||||
};
|
||||
let VideoGenerationDraftOrRequest::Draft(draft) = builder.build2().unwrap() else {
|
||||
panic!("expected Kinovi draft");
|
||||
};
|
||||
let finalized = draft.finalize(context.clone()).await.unwrap();
|
||||
let actual_audio = match finalized {
|
||||
VideoGenerationRequest::KinoviSeedance2p0(state) => state.request.maybe_generate_audio,
|
||||
VideoGenerationRequest::KinoviSeedance2p0Fast(state) => state.request.maybe_generate_audio,
|
||||
VideoGenerationRequest::KinoviSeedance2p0Mini(state) => state.request.maybe_generate_audio,
|
||||
VideoGenerationRequest::KinoviSeedance2p5(state) => {
|
||||
let actual_format = state.request.maybe_output_format.map(|format| match format {
|
||||
KinoviSeedance2p5OutputFormat::Mp4 => RouterVideoOutputFormat::Mp4,
|
||||
KinoviSeedance2p5OutputFormat::Mov => RouterVideoOutputFormat::Mov,
|
||||
});
|
||||
assert_eq!(actual_format, maybe_output_format);
|
||||
state.request.maybe_generate_audio
|
||||
}
|
||||
_ => panic!("expected a Seedance request"),
|
||||
};
|
||||
assert_eq!(actual_audio, generate_audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let cost = KinoviSeedance2p5CostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((104.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+19
-13
@@ -1,5 +1,5 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
|
||||
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
|
||||
};
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
@@ -18,6 +18,7 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::K
|
||||
pub struct KinoviSeedance2p5CostState {
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p5BatchCount>,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: Option<u8>,
|
||||
}
|
||||
@@ -32,6 +33,7 @@ impl KinoviSeedance2p5CostState {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
has_video_references,
|
||||
total_input_seconds: request.request.total_input_seconds,
|
||||
}
|
||||
@@ -49,6 +51,7 @@ impl KinoviSeedance2p5CostState {
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: Some(draft.batch_count),
|
||||
has_video_references,
|
||||
total_input_seconds: draft.total_input_seconds,
|
||||
}
|
||||
@@ -57,7 +60,7 @@ impl KinoviSeedance2p5CostState {
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Delegate to the kinovi_web_client calculator via a pricing-only
|
||||
// request. Only the modality's video references, the resolution, the
|
||||
// duration, and the input seconds affect the price.
|
||||
// duration, batch count, and the input seconds affect the price.
|
||||
let modality = if self.has_video_references {
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
@@ -89,16 +92,19 @@ impl KinoviSeedance2p5CostState {
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
};
|
||||
|
||||
// Enterprise tier: what generations actually cost us (our discounted
|
||||
// per-model credit rate at our bulk credit purchase rate).
|
||||
let costs = pricing_request.calculate_enterprise_costs();
|
||||
// 2.5 bills whole credits at 480p/720p, so the rounding below is exact
|
||||
// there; 1080p bills fractional credits (103.25/sec) and rounds to the
|
||||
// there; 1080p bills fractional credits (136.23/sec) and rounds to the
|
||||
// nearest credit. The USD cents (the authoritative charge) are rounded
|
||||
// up.
|
||||
let cost_in_credits = costs.kinovi_credits.round() as u64;
|
||||
@@ -123,7 +129,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Credits without video references (26/sec at 480p, 59/sec at 720p,
|
||||
// 103.25/sec at 1080p enterprise) ──
|
||||
// 136.23/sec at 1080p enterprise) ──
|
||||
|
||||
mod credits_without_video_references {
|
||||
use super::*;
|
||||
@@ -143,11 +149,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credits_1080p() {
|
||||
// 103.25/sec enterprise; fractional totals round to the nearest credit
|
||||
// (516.25 → 516, 1032.5 → 1033, 3097.5 → 3098).
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 516);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1033);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 3098);
|
||||
// 136.23/sec enterprise; fractional totals round to the nearest credit
|
||||
// (681.15 → 681, 1362.3 → 1362, 4086.9 → 4087).
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 681);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1362);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 4087);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -162,7 +168,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
|
||||
// 61.69/sec at 1080p enterprise, over output duration + input seconds) ──
|
||||
// 81.4/sec at 1080p enterprise, over output duration + input seconds) ──
|
||||
|
||||
mod credits_with_video_references {
|
||||
use super::*;
|
||||
@@ -171,8 +177,8 @@ mod tests {
|
||||
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40);
|
||||
// 61.69/sec enterprise × 40 = 2467.6 → 2468 credits.
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 2468);
|
||||
// 81.4/sec enterprise × 40 = 3256 credits.
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 3256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -228,7 +234,7 @@ mod tests {
|
||||
has_video_references: bool,
|
||||
total_input_seconds: Option<u8>,
|
||||
) -> KinoviSeedance2p5CostState {
|
||||
KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds }
|
||||
KinoviSeedance2p5CostState { batch_count: None, resolution, duration_seconds, has_video_references, total_input_seconds }
|
||||
}
|
||||
|
||||
fn credits(
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
|
||||
KinoviSeedance2p5OutputResolution,
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
|
||||
KinoviSeedance2p5OutputFormat, KinoviSeedance2p5OutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
@@ -27,12 +27,15 @@ pub struct KinoviSeedance2p5DraftState {
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p5BatchCount,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, summed across
|
||||
/// all reference videos (each rounded up to a whole second).
|
||||
pub total_input_seconds: Option<u8>,
|
||||
|
||||
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
|
||||
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
|
||||
}
|
||||
@@ -111,9 +114,12 @@ impl KinoviSeedance2p5DraftState {
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: Some(self.batch_count),
|
||||
total_input_seconds: self.total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: self.maybe_bitrate,
|
||||
maybe_output_format: self.maybe_output_format,
|
||||
maybe_generate_audio: self.maybe_generate_audio,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5RequestState { request })
|
||||
|
||||
+39
-11
@@ -1,5 +1,6 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
|
||||
KinoviSeedance2p5PreviewAspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5PreviewBatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p5PreviewOutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
@@ -47,7 +48,7 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.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();
|
||||
|
||||
@@ -61,6 +62,7 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5PreviewDraftState {
|
||||
batch_count,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
@@ -177,16 +179,23 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 Preview generates a single video per request (no batching).
|
||||
// Seedance 2.5 Preview supports batches of 1–8 videos.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
// Over the maximum of 1.
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// Over the maximum of 8.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -195,7 +204,7 @@ fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -248,6 +257,24 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..preview_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
|
||||
let cost = KinoviSeedance2p5PreviewCostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((168.52 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
@@ -346,19 +373,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_over_one_downgrades_to_single() {
|
||||
fn batch_over_eight_downgrades_to_eight() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(4),
|
||||
video_batch_count: Some(9),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..preview_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5_preview(builder).is_ok());
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Eight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_over_one_error_out() {
|
||||
fn batch_over_eight_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(4),
|
||||
video_batch_count: Some(9),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..preview_builder()
|
||||
};
|
||||
|
||||
+8
-5
@@ -1,18 +1,18 @@
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewOutputResolution,
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewOutputResolution,
|
||||
};
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState;
|
||||
|
||||
/// Only the resolution and duration matter — Seedance 2.5 Preview has no
|
||||
/// video-reference surcharge and no batching, so references never change the
|
||||
/// price.
|
||||
/// Resolution, duration, and batch count determine the price.
|
||||
/// Seedance 2.5 Preview has no video-reference surcharge.
|
||||
pub struct KinoviSeedance2p5PreviewCostState {
|
||||
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p5PreviewBatchCount>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5PreviewCostState {
|
||||
@@ -20,6 +20,7 @@ impl KinoviSeedance2p5PreviewCostState {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ impl KinoviSeedance2p5PreviewCostState {
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: Some(draft.batch_count),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ impl KinoviSeedance2p5PreviewCostState {
|
||||
let pricing_request = GenerateSeedance2p5PreviewRequest {
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// No impact on price (references never affect 2.5 Preview pricing)
|
||||
prompt: String::new(),
|
||||
@@ -139,7 +142,7 @@ mod tests {
|
||||
// ── Helpers ──
|
||||
|
||||
fn cost_state(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> KinoviSeedance2p5PreviewCostState {
|
||||
KinoviSeedance2p5PreviewCostState { resolution, duration_seconds }
|
||||
KinoviSeedance2p5PreviewCostState { batch_count: None, resolution, duration_seconds }
|
||||
}
|
||||
|
||||
fn credits(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> u64 {
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewAspectRatio,
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewAspectRatio,
|
||||
KinoviSeedance2p5PreviewOutputResolution,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct KinoviSeedance2p5PreviewDraftState {
|
||||
pub aspect_ratio: KinoviSeedance2p5PreviewAspectRatio,
|
||||
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p5PreviewBatchCount,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p5PreviewRemainingItems>,
|
||||
}
|
||||
@@ -67,6 +68,7 @@ impl KinoviSeedance2p5PreviewDraftState {
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: Some(self.batch_count),
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
|
||||
+23
-3
@@ -19,9 +19,7 @@ pub(crate) async fn upload_to_kinovi_web(
|
||||
source_url: &str,
|
||||
maybe_local_path: Option<&Path>,
|
||||
) -> Result<String, ArtcraftRouterError> {
|
||||
let extension = extract_extension_from_url_str(source_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
let extension = upload_extension(source_url, maybe_local_path);
|
||||
|
||||
let file_bytes = match maybe_local_path {
|
||||
Some(local_path) => match std::fs::read(local_path) {
|
||||
@@ -58,3 +56,25 @@ pub(crate) async fn upload_to_kinovi_web(
|
||||
Ok(upload_response.public_url)
|
||||
}
|
||||
|
||||
/// Keep the reference container when uploading, including QuickTime/MOV.
|
||||
fn upload_extension(source_url: &str, maybe_local_path: Option<&Path>) -> String {
|
||||
extract_extension_from_url_str(source_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.or_else(|| maybe_local_path
|
||||
.and_then(Path::extension)
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(str::to_ascii_lowercase))
|
||||
.unwrap_or_else(|| "jpg".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quicktime_upload_keeps_mov_extension() {
|
||||
assert_eq!(upload_extension("https://example.com/reference.MOV?token=test", None), "mov");
|
||||
assert_eq!(upload_extension("https://example.com/reference.mov", Some(Path::new("/tmp/cached.bin"))), "mov");
|
||||
assert_eq!(upload_extension("https://example.com/reference", Some(Path::new("/tmp/reference.MOV"))), "mov");
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
|
||||
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
pub fn build_kinovi_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let builder = normalize_wan_3p0_builder(builder)?;
|
||||
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
|
||||
let draft = KinoviWan3p0DraftState { builder };
|
||||
Ok(VideoGenerationDraftOrRequest::Draft(if prime {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft)
|
||||
} else {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0(draft)
|
||||
}))
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
pub struct KinoviWan3p0CostState {
|
||||
costs: KinoviFractionalGenerationCost,
|
||||
}
|
||||
|
||||
impl KinoviWan3p0CostState {
|
||||
pub fn from_draft(draft: &KinoviWan3p0DraftState) -> Self {
|
||||
// Media does not affect Wan pricing. Build a pricing-only request without
|
||||
// uploading anything, keeping the planned model, duration and resolution.
|
||||
Self::from_request(&draft.with_resolved_media(None, None, None, None, None))
|
||||
}
|
||||
|
||||
pub fn from_request(request: &KinoviWan3p0RequestState) -> Self {
|
||||
Self { costs: request.request.calculate_enterprise_costs() }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(self.costs.kinovi_credits.round() as u64),
|
||||
cost_in_usd_cents: Some(self.costs.usd_cents_rounded_up),
|
||||
is_free: false, is_unlimited: false, is_rate_limited: false,
|
||||
has_watermark: false, failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::GenerateWan3p0TextToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::GenerateWan3p0ImageToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::GenerateWan3p0RefToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::GenerateWan3p0PrimeTextToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::GenerateWan3p0PrimeImageToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::GenerateWan3p0PrimeRefToVideoRequest;
|
||||
use kinovi_web_client::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, video_list_ref_into_urls_or_tokens, resolve_and_upload_list, resolve_and_upload_single};
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::{KinoviWan3p0Request, KinoviWan3p0RequestState};
|
||||
use crate::generate::generate_video::providers::wan_3p0_common::{modality, Wan3p0Modality};
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KinoviWan3p0DraftState {
|
||||
/// Settings are normalized before this draft is constructed.
|
||||
pub(crate) builder: GenerateVideoRequestBuilder,
|
||||
}
|
||||
|
||||
impl KinoviWan3p0DraftState {
|
||||
pub async fn to_request(&self, context: &VideoGenerationDraftContext<'_>) -> Result<KinoviWan3p0RequestState, ArtcraftRouterError> {
|
||||
let client = context.get_kinovi_web_client_ref()?;
|
||||
let session = &client.session;
|
||||
let map = context.media_file_to_artcraft_url_map;
|
||||
let paths = context.predownloaded_media_paths;
|
||||
let builder = &self.builder;
|
||||
let start = resolve_and_upload_single(session, builder.start_frame.clone(), map, paths).await?;
|
||||
let end = resolve_and_upload_single(session, builder.end_frame.clone(), map, paths).await?;
|
||||
let images = resolve_and_upload_list(session, builder.reference_images.clone().map(image_list_ref_into_urls_or_tokens), map, paths).await?;
|
||||
let videos = resolve_and_upload_list(session, builder.reference_videos.clone().map(video_list_ref_into_urls_or_tokens), map, paths).await?;
|
||||
let audio = resolve_and_upload_list(session, builder.reference_audio.clone().map(audio_list_ref_into_urls_or_tokens), map, paths).await?;
|
||||
Ok(self.with_resolved_media(start, end, images, videos, audio))
|
||||
}
|
||||
|
||||
pub(crate) fn with_resolved_media(
|
||||
&self,
|
||||
maybe_start: Option<String>,
|
||||
maybe_end: Option<String>,
|
||||
maybe_images: Option<Vec<String>>,
|
||||
maybe_videos: Option<Vec<String>>,
|
||||
maybe_audio: Option<Vec<String>>,
|
||||
) -> KinoviWan3p0RequestState {
|
||||
let builder = &self.builder;
|
||||
let aspect = match builder.aspect_ratio.unwrap() {
|
||||
RouterAspectRatio::WideSixteenByNine => KinoviWan3p0AspectRatio::Landscape16x9,
|
||||
RouterAspectRatio::TallNineBySixteen => KinoviWan3p0AspectRatio::Portrait9x16,
|
||||
RouterAspectRatio::WideFourByThree => KinoviWan3p0AspectRatio::Landscape4x3,
|
||||
RouterAspectRatio::TallThreeByFour => KinoviWan3p0AspectRatio::Portrait3x4,
|
||||
RouterAspectRatio::Square => KinoviWan3p0AspectRatio::Square1x1,
|
||||
_ => unreachable!("normalized Wan aspect ratio"),
|
||||
};
|
||||
let resolution = match builder.resolution.unwrap() {
|
||||
RouterResolution::FourEightyP => KinoviWan3p0OutputResolution::FourEightyP,
|
||||
RouterResolution::SevenTwentyP => KinoviWan3p0OutputResolution::SevenTwentyP,
|
||||
RouterResolution::TenEightyP => KinoviWan3p0OutputResolution::TenEightyP,
|
||||
_ => unreachable!("normalized Wan resolution"),
|
||||
};
|
||||
let request = match (matches!(builder.model, RouterVideoModel::Wan3p0Prime), modality(builder)) {
|
||||
(false, Wan3p0Modality::Text) => KinoviWan3p0Request::Text(GenerateWan3p0TextToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
..Default::default()
|
||||
}),
|
||||
(false, Wan3p0Modality::Image) => KinoviWan3p0Request::Image(GenerateWan3p0ImageToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_start_frame_url: maybe_start,
|
||||
maybe_end_frame_url: maybe_end,
|
||||
..Default::default()
|
||||
}),
|
||||
(false, Wan3p0Modality::Reference) => KinoviWan3p0Request::Reference(GenerateWan3p0RefToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_reference_image_urls: maybe_images,
|
||||
maybe_reference_video_urls: maybe_videos,
|
||||
maybe_reference_audio_urls: maybe_audio,
|
||||
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
|
||||
..Default::default()
|
||||
}),
|
||||
(true, Wan3p0Modality::Text) => KinoviWan3p0Request::PrimeText(GenerateWan3p0PrimeTextToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
..Default::default()
|
||||
}),
|
||||
(true, Wan3p0Modality::Image) => KinoviWan3p0Request::PrimeImage(GenerateWan3p0PrimeImageToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_start_frame_url: maybe_start,
|
||||
maybe_end_frame_url: maybe_end,
|
||||
..Default::default()
|
||||
}),
|
||||
(true, Wan3p0Modality::Reference) => KinoviWan3p0Request::PrimeReference(GenerateWan3p0PrimeRefToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_reference_image_urls: maybe_images,
|
||||
maybe_reference_video_urls: maybe_videos,
|
||||
maybe_reference_audio_urls: maybe_audio,
|
||||
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
KinoviWan3p0RequestState { request }
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::{generate_wan_3p0_text_to_video, GenerateWan3p0TextToVideoArgs, GenerateWan3p0TextToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::{generate_wan_3p0_image_to_video, GenerateWan3p0ImageToVideoArgs, GenerateWan3p0ImageToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::{generate_wan_3p0_ref_to_video, GenerateWan3p0RefToVideoArgs, GenerateWan3p0RefToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::{generate_wan_3p0_prime_text_to_video, GenerateWan3p0PrimeTextToVideoArgs, GenerateWan3p0PrimeTextToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::{generate_wan_3p0_prime_image_to_video, GenerateWan3p0PrimeImageToVideoArgs, GenerateWan3p0PrimeImageToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::{generate_wan_3p0_prime_ref_to_video, GenerateWan3p0PrimeRefToVideoArgs, GenerateWan3p0PrimeRefToVideoRequest};
|
||||
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KinoviWan3p0RequestState {
|
||||
pub request: KinoviWan3p0Request,
|
||||
}
|
||||
|
||||
/// Provider bindings selected internally from the two public Wan models.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum KinoviWan3p0Request {
|
||||
Text(GenerateWan3p0TextToVideoRequest),
|
||||
Image(GenerateWan3p0ImageToVideoRequest),
|
||||
Reference(GenerateWan3p0RefToVideoRequest),
|
||||
PrimeText(GenerateWan3p0PrimeTextToVideoRequest),
|
||||
PrimeImage(GenerateWan3p0PrimeImageToVideoRequest),
|
||||
PrimeReference(GenerateWan3p0PrimeRefToVideoRequest),
|
||||
}
|
||||
|
||||
impl KinoviWan3p0RequestState {
|
||||
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
let response = match &self.request {
|
||||
KinoviWan3p0Request::Text(request) => generate_wan_3p0_text_to_video(GenerateWan3p0TextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::Image(request) => generate_wan_3p0_image_to_video(GenerateWan3p0ImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::Reference(request) => generate_wan_3p0_ref_to_video(GenerateWan3p0RefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::PrimeText(request) => generate_wan_3p0_prime_text_to_video(GenerateWan3p0PrimeTextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::PrimeImage(request) => generate_wan_3p0_prime_image_to_video(GenerateWan3p0PrimeImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::PrimeReference(request) => generate_wan_3p0_prime_ref_to_video(GenerateWan3p0PrimeRefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
}.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
|
||||
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviWan3p0Request {
|
||||
pub fn calculate_enterprise_costs(&self) -> KinoviFractionalGenerationCost {
|
||||
match self {
|
||||
Self::Text(request) => request.calculate_enterprise_costs(),
|
||||
Self::Image(request) => request.calculate_enterprise_costs(),
|
||||
Self::Reference(request) => request.calculate_enterprise_costs(),
|
||||
Self::PrimeText(request) => request.calculate_enterprise_costs(),
|
||||
Self::PrimeImage(request) => request.calculate_enterprise_costs(),
|
||||
Self::PrimeReference(request) => request.calculate_enterprise_costs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -3,3 +3,4 @@ pub mod fal;
|
||||
pub mod first_party;
|
||||
pub mod grok_api;
|
||||
pub mod kinovi;
|
||||
pub mod wan_3p0_common;
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
//! Request planning shared by the ArtCraft and Kinovi Wan providers.
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Wan3p0Modality {
|
||||
Text,
|
||||
Image,
|
||||
Reference,
|
||||
}
|
||||
|
||||
/// Materialize the same settings for execution and ArtCraft cost estimation.
|
||||
pub fn normalize_wan_3p0_builder(
|
||||
mut builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<GenerateVideoRequestBuilder, ArtcraftRouterError> {
|
||||
let strict = matches!(builder.request_mismatch_mitigation_strategy, RequestMismatchMitigationStrategy::ErrorOut);
|
||||
builder.reference_images = builder.reference_images.filter(|images| match images {
|
||||
ImageListRef::MediaFileTokens(items) => !items.is_empty(),
|
||||
ImageListRef::Urls(items) => !items.is_empty(),
|
||||
});
|
||||
builder.reference_videos = builder.reference_videos.filter(|videos| match videos {
|
||||
VideoListRef::MediaFileTokens(items) => !items.is_empty(),
|
||||
VideoListRef::Urls(items) => !items.is_empty(),
|
||||
});
|
||||
builder.reference_audio = builder.reference_audio.filter(|audio| match audio {
|
||||
AudioListRef::MediaFileTokens(items) => !items.is_empty(),
|
||||
AudioListRef::Urls(items) => !items.is_empty(),
|
||||
});
|
||||
builder.reference_character_tokens = builder.reference_character_tokens.filter(|characters| match characters {
|
||||
CharacterListRef::CharacterTokens(items) => !items.is_empty(),
|
||||
});
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
if has_keyframes && modality(&builder) == Wan3p0Modality::Reference {
|
||||
return Err(unsupported("reference_images", "Wan cannot combine keyframes with reference media"));
|
||||
}
|
||||
if builder.start_frame.is_none() && builder.end_frame.is_some() {
|
||||
if strict {
|
||||
return Err(unsupported("end_frame", "Wan requires a starting frame"));
|
||||
}
|
||||
// Kinovi requires the first keyframe. With adaptation enabled, use the
|
||||
// only supplied image as that keyframe, preserving image-to-video routing.
|
||||
builder.start_frame = builder.end_frame.take();
|
||||
}
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(unsupported("reference_character_tokens", "Wan character references are not supported"));
|
||||
}
|
||||
let image_count = builder.reference_images.as_ref().map_or(0, |images| match images {
|
||||
ImageListRef::MediaFileTokens(items) => items.len(),
|
||||
ImageListRef::Urls(items) => items.len(),
|
||||
});
|
||||
let video_count = builder.reference_videos.as_ref().map_or(0, |videos| match videos {
|
||||
VideoListRef::MediaFileTokens(items) => items.len(),
|
||||
VideoListRef::Urls(items) => items.len(),
|
||||
});
|
||||
let audio_count = builder.reference_audio.as_ref().map_or(0, |audio| match audio {
|
||||
AudioListRef::MediaFileTokens(items) => items.len(),
|
||||
AudioListRef::Urls(items) => items.len(),
|
||||
});
|
||||
for (field, count, limit) in [("reference_images", image_count, 10), ("reference_videos", video_count, 5), ("reference_audio", audio_count, 5)] {
|
||||
if count > limit {
|
||||
return Err(unsupported(field, format!("Wan supports at most {limit} references")));
|
||||
}
|
||||
}
|
||||
let duration = builder.duration_seconds.unwrap_or(5);
|
||||
if strict && !(2..=30).contains(&duration) {
|
||||
return Err(unsupported("duration_seconds", "Wan supports 2 through 30 seconds"));
|
||||
}
|
||||
builder.duration_seconds = Some(duration.clamp(2, 30));
|
||||
match builder.video_batch_count.unwrap_or(1) {
|
||||
0 => return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => {},
|
||||
_ if strict => return Err(unsupported("video_batch_count", "Wan supports one video per request")),
|
||||
_ => {},
|
||||
}
|
||||
builder.video_batch_count = Some(1);
|
||||
builder.aspect_ratio = Some(match builder.aspect_ratio {
|
||||
None | Some(RouterAspectRatio::Auto | RouterAspectRatio::Auto2k | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k | RouterAspectRatio::Wide) => RouterAspectRatio::WideSixteenByNine,
|
||||
Some(RouterAspectRatio::Tall) => RouterAspectRatio::TallNineBySixteen,
|
||||
Some(RouterAspectRatio::SquareHd) => RouterAspectRatio::Square,
|
||||
Some(ratio @ (RouterAspectRatio::WideSixteenByNine | RouterAspectRatio::TallNineBySixteen | RouterAspectRatio::WideFourByThree | RouterAspectRatio::TallThreeByFour | RouterAspectRatio::Square)) => ratio,
|
||||
Some(ratio) if strict => return Err(unsupported("aspect_ratio", format!("{ratio:?}"))),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => RouterAspectRatio::WideSixteenByNine,
|
||||
Some(RouterAspectRatio::WideFiveByFour | RouterAspectRatio::WideThreeByTwo) => RouterAspectRatio::WideFourByThree,
|
||||
Some(RouterAspectRatio::TallFourByFive | RouterAspectRatio::TallTwoByThree) => RouterAspectRatio::TallThreeByFour,
|
||||
Some(RouterAspectRatio::TallNineByTwentyOne) => RouterAspectRatio::TallNineBySixteen,
|
||||
});
|
||||
builder.resolution = Some(match builder.resolution {
|
||||
None => RouterResolution::SevenTwentyP,
|
||||
Some(resolution @ (RouterResolution::FourEightyP | RouterResolution::SevenTwentyP | RouterResolution::TenEightyP)) => resolution,
|
||||
Some(resolution) if strict => return Err(unsupported("resolution", format!("{resolution:?}"))),
|
||||
Some(RouterResolution::HalfK) => match builder.request_mismatch_mitigation_strategy {
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => RouterResolution::SevenTwentyP,
|
||||
_ => RouterResolution::FourEightyP,
|
||||
},
|
||||
Some(RouterResolution::OneK) => match builder.request_mismatch_mitigation_strategy {
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => RouterResolution::SevenTwentyP,
|
||||
_ => RouterResolution::TenEightyP,
|
||||
},
|
||||
Some(_) => RouterResolution::TenEightyP,
|
||||
});
|
||||
if video_count > 0 {
|
||||
// Without measurements, each reference still consumes at least one second.
|
||||
let seconds = builder.total_reference_video_input_seconds.unwrap_or(video_count as u16);
|
||||
if seconds < video_count as u16 || seconds > 15 || seconds + builder.duration_seconds.unwrap() > 30 {
|
||||
return Err(unsupported("duration_seconds", "Wan reference videos must total at most 15s, and reference plus output duration must not exceed 30s"));
|
||||
}
|
||||
}
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
pub fn modality(builder: &GenerateVideoRequestBuilder) -> Wan3p0Modality {
|
||||
if builder.reference_images.is_some() || builder.reference_videos.is_some() || builder.reference_audio.is_some() {
|
||||
Wan3p0Modality::Reference
|
||||
} else if builder.start_frame.is_some() || builder.end_frame.is_some() {
|
||||
Wan3p0Modality::Image
|
||||
} else {
|
||||
Wan3p0Modality::Text
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(field: &'static str, value: impl Into<String>) -> ArtcraftRouterError {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, value: value.into() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
fn builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Wan3p0,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modality_selects_text_keyframe_or_reference_endpoint() {
|
||||
let text = builder();
|
||||
assert_eq!(modality(&text), Wan3p0Modality::Text);
|
||||
|
||||
let keyframe = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())),
|
||||
..text.clone()
|
||||
};
|
||||
assert_eq!(modality(&keyframe), Wan3p0Modality::Image);
|
||||
|
||||
let reference = GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
|
||||
..text
|
||||
};
|
||||
assert_eq!(modality(&reference), Wan3p0Modality::Reference);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_defaults_and_adapts_settings_for_kinovi() {
|
||||
let normalized = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Wan3p0Prime,
|
||||
duration_seconds: Some(99),
|
||||
video_batch_count: Some(4),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
|
||||
resolution: Some(RouterResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(normalized.duration_seconds, Some(30));
|
||||
assert_eq!(normalized.video_batch_count, Some(1));
|
||||
assert!(matches!(normalized.aspect_ratio, Some(RouterAspectRatio::WideSixteenByNine)));
|
||||
assert!(matches!(normalized.resolution, Some(RouterResolution::TenEightyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_rejects_unsupported_shape() {
|
||||
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_budget_uses_measured_total_when_available() {
|
||||
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
duration_seconds: Some(20),
|
||||
total_reference_video_input_seconds: Some(11),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
|
||||
..builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
duration_seconds: Some(20),
|
||||
total_reference_video_input_seconds: Some(10),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
|
||||
..builder()
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
+14
@@ -1,3 +1,5 @@
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
@@ -21,6 +23,8 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
||||
*/
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoGenerationDraftRequest {
|
||||
KinoviWan3p0(KinoviWan3p0DraftState),
|
||||
KinoviWan3p0Prime(KinoviWan3p0DraftState),
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
|
||||
@@ -33,6 +37,8 @@ impl VideoGenerationDraftRequest {
|
||||
|
||||
pub fn get_provider(&self) -> RouterProvider {
|
||||
match self {
|
||||
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
@@ -45,6 +51,8 @@ impl VideoGenerationDraftRequest {
|
||||
/// Return a cost estimate to fulfill the request.
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
|
||||
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()),
|
||||
@@ -58,6 +66,12 @@ impl VideoGenerationDraftRequest {
|
||||
/// This may involve uploading media to the provider.
|
||||
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0(draft) => {
|
||||
Ok(VideoGenerationRequest::KinoviWan3p0(draft.to_request(&draft_context).await?))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft) => {
|
||||
Ok(VideoGenerationRequest::KinoviWan3p0Prime(draft.to_request(&draft_context).await?))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result))
|
||||
|
||||
+28
@@ -1,3 +1,7 @@
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::cost::ArtcraftWan3p0CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -148,6 +152,8 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
|
||||
ArtcraftFlux3(ArtcraftFlux3RequestState),
|
||||
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
|
||||
ArtcraftWan3p0(ArtcraftWan3p0RequestState),
|
||||
ArtcraftWan3p0Prime(ArtcraftWan3p0RequestState),
|
||||
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
|
||||
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
|
||||
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
|
||||
@@ -207,6 +213,8 @@ pub enum VideoGenerationRequest {
|
||||
FalViduQ3Turbo(FalViduQ3TurboRequestState),
|
||||
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
|
||||
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
|
||||
KinoviWan3p0(KinoviWan3p0RequestState),
|
||||
KinoviWan3p0Prime(KinoviWan3p0RequestState),
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
@@ -221,6 +229,8 @@ impl VideoGenerationRequest {
|
||||
match self {
|
||||
Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftWan3p0(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftWan3p0Prime(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft,
|
||||
@@ -282,6 +292,8 @@ impl VideoGenerationRequest {
|
||||
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
|
||||
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
|
||||
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
|
||||
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
@@ -296,6 +308,8 @@ impl VideoGenerationRequest {
|
||||
match self {
|
||||
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
|
||||
VideoGenerationRequest::ArtcraftWan3p0(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftWan3p0Prime(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()),
|
||||
@@ -357,6 +371,8 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
|
||||
VideoGenerationRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_request(state).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()),
|
||||
@@ -378,6 +394,12 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftWan3p0(request) => {
|
||||
request.send(client.get_artcraft_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftWan3p0Prime(request) => {
|
||||
request.send(client.get_artcraft_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -622,6 +644,12 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_grok_api_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviWan3p0(request) => {
|
||||
request.send(client.get_kinovi_web_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviWan3p0Prime(request) => {
|
||||
request.send(client.get_kinovi_web_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
|
||||
@@ -352,6 +352,7 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
duration_seconds: None,
|
||||
video_batch_count: None,
|
||||
generate_audio: None,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Wan 3.0 pricing and September 23 promotion
|
||||
|
||||
Last verified: **2026-09-18**. Recheck after **2026-09-23** (starting September 24).
|
||||
|
||||
The code and tests intentionally use the promotional Wan 3.0 rates below.
|
||||
Prices stay at these values until a reviewed code change; there is no automatic
|
||||
date-based switch. Kinovi advertises 30% off until September 23 without specifying
|
||||
the cutoff time or timezone. Confirm the live prices when updating.
|
||||
[Kinovi promotion and pricing](https://kinovi.ai/models/wan3-text-to-video)
|
||||
|
||||
## Rates currently used
|
||||
|
||||
All values are **Kinovi credits per output second**. Each family's rates apply
|
||||
to text-to-video, image-to-video, and reference-to-video. Reference media does
|
||||
not add billed input seconds. Sources for regular Wan:
|
||||
[text](https://kinovi.ai/models/wan3-text-to-video),
|
||||
[image](https://kinovi.ai/models/wan3-image-to-video),
|
||||
[reference](https://kinovi.ai/models/wan3-ref-to-video).
|
||||
|
||||
| Family | Resolution | Credits/second | Current status |
|
||||
|---------------|------------|----------------|--------------------------------|
|
||||
| Wan 3.0 | 480p | 9.98 | Promotional until Sep 23 |
|
||||
| Wan 3.0 | 720p | 19.96 | Promotional until Sep 23 |
|
||||
| Wan 3.0 | 1080p | 39.91 | Promotional until Sep 23 |
|
||||
| Wan 3.0 Prime | 480p | 18.86 | No advertised Sep 23 promotion |
|
||||
| Wan 3.0 Prime | 720p | 38.83 | No advertised Sep 23 promotion |
|
||||
| Wan 3.0 Prime | 1080p | 77.66 | No advertised Sep 23 promotion |
|
||||
|
||||
Prime sources:
|
||||
[text](https://kinovi.ai/models/wan3-prime-text-to-video),
|
||||
[image](https://kinovi.ai/models/wan3-prime-image-to-video),
|
||||
[reference](https://kinovi.ai/models/wan3-prime-ref-to-video).
|
||||
Prime 480p for 30 seconds is **565.8 credits**; the original supplied reference
|
||||
example's 568.8 was a typo. Recheck Prime independently; the regular Wan
|
||||
promotion ending does not establish a Prime price change.
|
||||
|
||||
The promotional rates already include the discount. Total credits are the
|
||||
selected rate multiplied by output seconds. Both pricing tiers currently use
|
||||
the same credit rates; only their credit purchase rates differ. The client uses
|
||||
192.98 credits/USD for Consumer and 243.16 for Enterprise, defined in
|
||||
[pricing constants](src/pricing/constants.rs).
|
||||
[KinoviPricingTier::cost_from_credits](src/pricing/kinovi_pricing_tier.rs) rounds
|
||||
the total to hundredths of a credit and calculates fractional, floor, and ceiling
|
||||
USD cents. Use those purchase rates for client costs; Kinovi's displayed USD
|
||||
prices use a different conversion.
|
||||
|
||||
## Advertised non-promotional rates to recheck
|
||||
|
||||
These are the crossed-out prices shown alongside the promotion on September 18.
|
||||
They are **reference values for the later update**, not active calculator rates
|
||||
or a guarantee of what Kinovi will charge after the promotion.
|
||||
The same values appear on the regular Wan
|
||||
[text](https://kinovi.ai/models/wan3-text-to-video),
|
||||
[image](https://kinovi.ai/models/wan3-image-to-video), and
|
||||
[reference](https://kinovi.ai/models/wan3-ref-to-video) pricing pages.
|
||||
|
||||
| Resolution | Current promotional credits/second | Advertised non-promotional credits/second |
|
||||
|------------|------------------------------------|-------------------------------------------|
|
||||
| 480p | 9.98 | 14.2571 |
|
||||
| 720p | 19.96 | 28.5143 |
|
||||
| 1080p | 39.91 | 57.0143 |
|
||||
|
||||
## How to update after September 23
|
||||
|
||||
1. Reopen the six model pages above and confirm the promotion ended, the exact
|
||||
credit rates, and total-credit rounding. Check consumer quotes for multiple
|
||||
durations and every resolution; the web client uses the consumer workflow.
|
||||
Record the verification date and evidence in this document.
|
||||
2. Update `WAN_CREDIT_HUNDREDTHS_PER_SECOND` and `Wan3p0Model::calculate_costs`
|
||||
in [wan_3p0.rs](src/generate/video/wan_3p0.rs). The current table is
|
||||
`[998, 1996, 3991]` in 480p/720p/1080p order. **The advertised replacement
|
||||
rates have four decimal places:** the existing hundredths representation
|
||||
cannot preserve them. If confirmed, use a sufficient fixed-point scale
|
||||
(for example 10,000), rename the constants to match their units, and adjust
|
||||
the final divisor. Preserve rate precision until after multiplying by
|
||||
duration; verify provider rounding before changing the shared credit
|
||||
conversion. If both families change scale, rescale the Prime integers while
|
||||
preserving Prime's monetary rates unless a separate price change is confirmed.
|
||||
3. Update the literal expectations in
|
||||
[wan_3p0_tests.rs](src/generate/video/wan_3p0_tests.rs): `WAN_CREDITS`,
|
||||
`WAN_CENTS`, the `199.6` regular `reference_media_tests!` argument (720p/10s),
|
||||
and the `99.8` regular image `check!` argument (720p/5s). The default-price
|
||||
and seed/audio tests already read the literal tables. Keep expected prices
|
||||
independent of production rate constants and assert both pricing tiers.
|
||||
Update Prime expectations only if its verified monetary rates change.
|
||||
4. Update this document and the dated comments beside the production constants
|
||||
and test tables. Keep the promotional snapshot as historical context.
|
||||
5. Run from the repository root:
|
||||
|
||||
```sh
|
||||
cargo test --offline -p kinovi_web_client --lib wan_3p0_tests
|
||||
cargo test --offline -p kinovi_web_client --lib
|
||||
```
|
||||
|
||||
The Wan HTTP tests use a local server and need permission to bind localhost.
|
||||
These commands leave the existing ignored live-generation tests ignored.
|
||||
+66
-4
@@ -49,6 +49,7 @@ pub enum KinoviHappyHorse1p0OutputResolution {
|
||||
pub enum KinoviHappyHorse1p0BatchCount {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
}
|
||||
|
||||
@@ -79,6 +80,7 @@ impl GenerateHappyHorse1p0Request {
|
||||
let batch_multiplier: u64 = match self.batch_count {
|
||||
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Three) => 3,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
|
||||
};
|
||||
|
||||
@@ -130,6 +132,8 @@ pub async fn generate_happy_horse_1p0(
|
||||
character_ids: None,
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
@@ -172,6 +176,7 @@ fn map_batch_count(bc: Option<KinoviHappyHorse1p0BatchCount>) -> KinoviBatchCoun
|
||||
match bc {
|
||||
Some(KinoviHappyHorse1p0BatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
}
|
||||
}
|
||||
@@ -273,11 +278,68 @@ mod tests {
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
|
||||
const BATCHES: [KinoviHappyHorse1p0BatchCount; 4] = [
|
||||
KinoviHappyHorse1p0BatchCount::One,
|
||||
KinoviHappyHorse1p0BatchCount::Two,
|
||||
KinoviHappyHorse1p0BatchCount::Three,
|
||||
KinoviHappyHorse1p0BatchCount::Four,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).calculate_costs().kinovi_credits;
|
||||
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).calculate_costs().kinovi_credits;
|
||||
assert_eq!(base, explicit);
|
||||
fn fixed_five_second_batch_prices() {
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 4.
|
||||
let cases = [
|
||||
(KinoviHappyHorse1p0OutputResolution::SevenTwentyP, [
|
||||
(165, 68), (330, 136), (495, 204), (660, 272),
|
||||
]),
|
||||
(KinoviHappyHorse1p0OutputResolution::TenEightyP, [
|
||||
(330, 136), (660, 272), (990, 408), (1320, 544),
|
||||
]),
|
||||
];
|
||||
for (resolution, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let cost = make_request(5, Some(resolution), Some(batch)).calculate_costs();
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_round_the_total() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::One), 1),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::Two), 2),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::Three), 3),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::Four), 4),
|
||||
];
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
|
||||
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
|
||||
] {
|
||||
for duration in [3, 5, 15] {
|
||||
let single = make_request(duration, resolution, None).calculate_costs();
|
||||
for (batch, count) in batches {
|
||||
let batched = make_request(duration, resolution, batch).calculate_costs();
|
||||
let context = format!("{resolution:?} {duration}s {batch:?}");
|
||||
let expected_credits = single.kinovi_credits * count;
|
||||
assert_eq!(batched.kinovi_credits, expected_credits, "{context}: credits");
|
||||
assert!(
|
||||
(batched.usd_cents_fractional - single.usd_cents_fractional * count as f64).abs() < 1e-8,
|
||||
"{context}: fractional cents",
|
||||
);
|
||||
// Happy Horse currently uses the legacy 243 credits/dollar conversion.
|
||||
let numerator = expected_credits * 100;
|
||||
assert_eq!(batched.usd_cents_rounded_up, numerator.div_ceil(243), "{context}: rounded up");
|
||||
assert_eq!(batched.usd_cents_rounded_down, numerator / 243, "{context}: rounded down");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,6 +38,8 @@ pub struct GenerateSeedance2p0Request {
|
||||
/// Output video bitrate. None defaults to "standard"; `High` requests a
|
||||
/// higher bitrate. Does not affect cost.
|
||||
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
|
||||
/// Whether to generate audio. None leaves the provider default unchanged.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
@@ -66,6 +68,10 @@ pub enum KinoviSeedance2p0BatchCount {
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -164,6 +170,10 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0Request {
|
||||
Some(KinoviSeedance2p0BatchCount::Two) => 2.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Three) => 3.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Four) => 4.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Five) => 5.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Six) => 6.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Seven) => 7.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Eight) => 8.0,
|
||||
};
|
||||
|
||||
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
|
||||
@@ -221,6 +231,8 @@ pub async fn generate_seedance_2p0(
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: map_bitrate(req.bitrate),
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: req.maybe_generate_audio,
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
@@ -270,6 +282,10 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountR
|
||||
Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p0BatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
Some(KinoviSeedance2p0BatchCount::Five) => KinoviBatchCountRaw::Five,
|
||||
Some(KinoviSeedance2p0BatchCount::Six) => KinoviBatchCountRaw::Six,
|
||||
Some(KinoviSeedance2p0BatchCount::Seven) => KinoviBatchCountRaw::Seven,
|
||||
Some(KinoviSeedance2p0BatchCount::Eight) => KinoviBatchCountRaw::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,18 +624,165 @@ mod tests {
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p0BatchCount; 8] = [
|
||||
KinoviSeedance2p0BatchCount::One,
|
||||
KinoviSeedance2p0BatchCount::Two,
|
||||
KinoviSeedance2p0BatchCount::Three,
|
||||
KinoviSeedance2p0BatchCount::Four,
|
||||
KinoviSeedance2p0BatchCount::Five,
|
||||
KinoviSeedance2p0BatchCount::Six,
|
||||
KinoviSeedance2p0BatchCount::Seven,
|
||||
KinoviSeedance2p0BatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_without_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(75.0, 39), (150.0, 78), (225.0, 117), (300.0, 156),
|
||||
(375.0, 195), (450.0, 234), (525.0, 273), (600.0, 311),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(200.0, 104), (400.0, 208), (600.0, 311), (800.0, 415),
|
||||
(1000.0, 519), (1200.0, 622), (1400.0, 726), (1600.0, 830),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
|
||||
(450.0, 234), (900.0, 467), (1350.0, 700), (1800.0, 933),
|
||||
(2250.0, 1166), (2700.0, 1400), (3150.0, 1633), (3600.0, 1866),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
|
||||
(1000.0, 519), (2000.0, 1037), (3000.0, 1555), (4000.0, 2073),
|
||||
(5000.0, 2591), (6000.0, 3110), (7000.0, 3628), (8000.0, 4146),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(75.0, 31), (150.0, 62), (225.0, 93), (300.0, 124),
|
||||
(375.0, 155), (450.0, 186), (525.0, 216), (600.0, 247),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(189.5, 78), (379.0, 156), (568.5, 234), (758.0, 312),
|
||||
(947.5, 390), (1137.0, 468), (1326.5, 546), (1516.0, 624),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
|
||||
(450.0, 186), (900.0, 371), (1350.0, 556), (1800.0, 741),
|
||||
(2250.0, 926), (2700.0, 1111), (3150.0, 1296), (3600.0, 1481),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
|
||||
(1000.0, 412), (2000.0, 823), (3000.0, 1234), (4000.0, 1646),
|
||||
(5000.0, 2057), (6000.0, 2468), (7000.0, 2879), (8000.0, 3291),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_at_both_tiers() {
|
||||
fn fixed_five_second_batch_prices_with_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(95.0, 50), (190.0, 99), (285.0, 148), (380.0, 197),
|
||||
(475.0, 247), (570.0, 296), (665.0, 345), (760.0, 394),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(240.0, 125), (480.0, 249), (720.0, 374), (960.0, 498),
|
||||
(1200.0, 622), (1440.0, 747), (1680.0, 871), (1920.0, 995),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
|
||||
(540.0, 280), (1080.0, 560), (1620.0, 840), (2160.0, 1120),
|
||||
(2700.0, 1400), (3240.0, 1679), (3780.0, 1959), (4320.0, 2239),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
|
||||
(1200.0, 622), (2400.0, 1244), (3600.0, 1866), (4800.0, 2488),
|
||||
(6000.0, 3110), (7200.0, 3731), (8400.0, 4353), (9600.0, 4975),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(95.0, 40), (190.0, 79), (285.0, 118), (380.0, 157),
|
||||
(475.0, 196), (570.0, 235), (665.0, 274), (760.0, 313),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(227.4, 94), (454.8, 188), (682.2, 281), (909.6, 375),
|
||||
(1137.0, 468), (1364.4, 562), (1591.8, 655), (1819.2, 749),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
|
||||
(540.0, 223), (1080.0, 445), (1620.0, 667), (2160.0, 889),
|
||||
(2700.0, 1111), (3240.0, 1333), (3780.0, 1555), (4320.0, 1777),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
|
||||
(1200.0, 494), (2400.0, 988), (3600.0, 1481), (4800.0, 1975),
|
||||
(6000.0, 2468), (7200.0, 2962), (8400.0, 3455), (9600.0, 3949),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let request = with_video_ref(request);
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p0BatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p0BatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p0BatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p0BatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p0BatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p0BatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p0BatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p0BatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
for (batch, multiplier) in [
|
||||
(KinoviSeedance2p0BatchCount::Two, 2.0),
|
||||
(KinoviSeedance2p0BatchCount::Three, 3.0),
|
||||
(KinoviSeedance2p0BatchCount::Four, 4.0),
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p0OutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP),
|
||||
Some(KinoviSeedance2p0OutputResolution::TenEightyP),
|
||||
Some(KinoviSeedance2p0OutputResolution::FourK),
|
||||
] {
|
||||
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
|
||||
for duration in [4, 5, 15] {
|
||||
for references in [
|
||||
None,
|
||||
Some(vec![]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
|
||||
] {
|
||||
let mut request = build_request(duration, resolution, None);
|
||||
request.reference_video_urls = references;
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
|
||||
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
|
||||
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
|
||||
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
|
||||
(None, None) => {},
|
||||
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
|
||||
_ => panic!("{context}: batch count changed surcharge presence"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -728,6 +891,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,6 +964,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -831,6 +996,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -865,6 +1031,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -897,6 +1064,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -933,6 +1101,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -966,6 +1135,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1003,6 +1173,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1038,6 +1209,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("4K image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1076,6 +1248,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1111,6 +1284,7 @@ mod tests {
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1142,6 +1316,7 @@ mod tests {
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
|
||||
+147
-8
@@ -38,6 +38,8 @@ pub struct GenerateSeedance2p0FastRequest {
|
||||
/// Output video bitrate. None defaults to "standard"; `High` requests a
|
||||
/// higher bitrate. Does not affect cost.
|
||||
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
|
||||
/// Whether to generate audio. None leaves the provider default unchanged.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
@@ -64,6 +66,10 @@ pub enum KinoviSeedance2p0FastBatchCount {
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -138,6 +144,10 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0FastRequest {
|
||||
Some(KinoviSeedance2p0FastBatchCount::Two) => 2.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Three) => 3.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Four) => 4.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Five) => 5.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Six) => 6.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Seven) => 7.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Eight) => 8.0,
|
||||
};
|
||||
|
||||
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
|
||||
@@ -195,6 +205,8 @@ pub async fn generate_seedance_2p0_fast(
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: map_bitrate(req.bitrate),
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: req.maybe_generate_audio,
|
||||
};
|
||||
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
@@ -240,6 +252,10 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCo
|
||||
Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Five) => KinoviBatchCountRaw::Five,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Six) => KinoviBatchCountRaw::Six,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Seven) => KinoviBatchCountRaw::Seven,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Eight) => KinoviBatchCountRaw::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,18 +540,131 @@ mod tests {
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p0FastBatchCount; 8] = [
|
||||
KinoviSeedance2p0FastBatchCount::One,
|
||||
KinoviSeedance2p0FastBatchCount::Two,
|
||||
KinoviSeedance2p0FastBatchCount::Three,
|
||||
KinoviSeedance2p0FastBatchCount::Four,
|
||||
KinoviSeedance2p0FastBatchCount::Five,
|
||||
KinoviSeedance2p0FastBatchCount::Six,
|
||||
KinoviSeedance2p0FastBatchCount::Seven,
|
||||
KinoviSeedance2p0FastBatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_without_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(70.0, 37), (140.0, 73), (210.0, 109), (280.0, 146),
|
||||
(350.0, 182), (420.0, 218), (490.0, 254), (560.0, 291),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(140.0, 73), (280.0, 146), (420.0, 218), (560.0, 291),
|
||||
(700.0, 363), (840.0, 436), (980.0, 508), (1120.0, 581),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(52.5, 22), (105.0, 44), (157.5, 65), (210.0, 87),
|
||||
(262.5, 108), (315.0, 130), (367.5, 152), (420.0, 173),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(105.0, 44), (210.0, 87), (315.0, 130), (420.0, 173),
|
||||
(525.0, 216), (630.0, 260), (735.0, 303), (840.0, 346),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_at_both_tiers() {
|
||||
fn fixed_five_second_batch_prices_with_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(90.0, 47), (180.0, 94), (270.0, 140), (360.0, 187),
|
||||
(450.0, 234), (540.0, 280), (630.0, 327), (720.0, 374),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(170.0, 89), (340.0, 177), (510.0, 265), (680.0, 353),
|
||||
(850.0, 441), (1020.0, 529), (1190.0, 617), (1360.0, 705),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(67.5, 28), (135.0, 56), (202.5, 84), (270.0, 112),
|
||||
(337.5, 139), (405.0, 167), (472.5, 195), (540.0, 223),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(127.5, 53), (255.0, 105), (382.5, 158), (510.0, 210),
|
||||
(637.5, 263), (765.0, 315), (892.5, 368), (1020.0, 420),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let request = with_video_ref(request);
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
for (batch, multiplier) in [
|
||||
(KinoviSeedance2p0FastBatchCount::Two, 2.0),
|
||||
(KinoviSeedance2p0FastBatchCount::Three, 3.0),
|
||||
(KinoviSeedance2p0FastBatchCount::Four, 4.0),
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP),
|
||||
] {
|
||||
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
|
||||
for duration in [4, 5, 15] {
|
||||
for references in [
|
||||
None,
|
||||
Some(vec![]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
|
||||
] {
|
||||
let mut request = build_request(duration, resolution, None);
|
||||
request.reference_video_urls = references;
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
|
||||
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
|
||||
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
|
||||
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
|
||||
(None, None) => {},
|
||||
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
|
||||
_ => panic!("{context}: batch count changed surcharge presence"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,6 +777,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,6 +842,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -743,6 +874,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -777,6 +909,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -809,6 +942,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -845,6 +979,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -882,6 +1017,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -919,6 +1055,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -954,6 +1091,7 @@ mod tests {
|
||||
character_ids: Some(vec![MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -985,6 +1123,7 @@ mod tests {
|
||||
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
|
||||
+132
-14
@@ -38,6 +38,8 @@ pub struct GenerateSeedance2p0MiniRequest {
|
||||
/// Output video bitrate. None defaults to "normal"; `High` requests a
|
||||
/// higher bitrate. Does not affect cost.
|
||||
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
|
||||
/// Whether to generate audio. None leaves the provider default unchanged.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
@@ -222,6 +224,8 @@ fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: map_bitrate(req.bitrate),
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: req.maybe_generate_audio,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,24 +514,131 @@ mod tests {
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p0MiniBatchCount; 8] = [
|
||||
KinoviSeedance2p0MiniBatchCount::One,
|
||||
KinoviSeedance2p0MiniBatchCount::Two,
|
||||
KinoviSeedance2p0MiniBatchCount::Three,
|
||||
KinoviSeedance2p0MiniBatchCount::Four,
|
||||
KinoviSeedance2p0MiniBatchCount::Five,
|
||||
KinoviSeedance2p0MiniBatchCount::Six,
|
||||
KinoviSeedance2p0MiniBatchCount::Seven,
|
||||
KinoviSeedance2p0MiniBatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_without_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(37.5, 20), (75.0, 39), (112.5, 59), (150.0, 78),
|
||||
(187.5, 98), (225.0, 117), (262.5, 137), (300.0, 156),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(100.0, 52), (200.0, 104), (300.0, 156), (400.0, 208),
|
||||
(500.0, 260), (600.0, 311), (700.0, 363), (800.0, 415),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(15.0, 7), (30.0, 13), (45.0, 19), (60.0, 25),
|
||||
(75.0, 31), (90.0, 38), (105.0, 44), (120.0, 50),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(40.0, 17), (80.0, 33), (120.0, 50), (160.0, 66),
|
||||
(200.0, 83), (240.0, 99), (280.0, 116), (320.0, 132),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_scales_total_linearly_at_both_tiers() {
|
||||
let by_count = [
|
||||
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
|
||||
fn fixed_five_second_batch_prices_with_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(47.5, 25), (95.0, 50), (142.5, 74), (190.0, 99),
|
||||
(237.5, 124), (285.0, 148), (332.5, 173), (380.0, 197),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(120.0, 63), (240.0, 125), (360.0, 187), (480.0, 249),
|
||||
(600.0, 311), (720.0, 374), (840.0, 436), (960.0, 498),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(19.0, 8), (38.0, 16), (57.0, 24), (76.0, 32),
|
||||
(95.0, 40), (114.0, 47), (133.0, 55), (152.0, 63),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(48.0, 20), (96.0, 40), (144.0, 60), (192.0, 79),
|
||||
(240.0, 99), (288.0, 119), (336.0, 139), (384.0, 158),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let request = with_video_ref(request);
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
for (count, multiplier) in by_count {
|
||||
let credits = build_request(5, None, Some(count)).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
assert_eq!(credits, base * multiplier, "batch {count:?} at {tier:?}");
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP),
|
||||
] {
|
||||
for duration in [4, 5, 15] {
|
||||
for references in [
|
||||
None,
|
||||
Some(vec![]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
|
||||
] {
|
||||
let mut request = build_request(duration, resolution, None);
|
||||
request.reference_video_urls = references;
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
|
||||
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
|
||||
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
|
||||
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
|
||||
(None, None) => {},
|
||||
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
|
||||
_ => panic!("{context}: batch count changed surcharge presence"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,6 +753,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,6 +807,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: Some(KinoviSeedance2p0MiniBitrate::High),
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,6 +882,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -805,6 +919,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -836,6 +951,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -868,6 +984,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -900,6 +1017,7 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, KinoviModelTypeRaw,
|
||||
KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
|
||||
KinoviOutputFormatRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
|
||||
};
|
||||
|
||||
// ── Constants ──
|
||||
@@ -48,6 +48,7 @@ pub struct GenerateSeedance2p5Request {
|
||||
pub output_resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p5BatchCount>,
|
||||
|
||||
/// CALCULATION-ONLY (never sent on the wire): the total seconds of
|
||||
/// reference video input, summed across all reference videos. When video
|
||||
@@ -64,6 +65,12 @@ pub struct GenerateSeedance2p5Request {
|
||||
/// Output video bitrate. None keeps the standard bitrate; `High` requests
|
||||
/// a higher bitrate. This does not select a container or lossless codec.
|
||||
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
|
||||
|
||||
/// Output container. None leaves the provider default unchanged.
|
||||
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
|
||||
|
||||
/// Whether to generate audio. None leaves the provider default unchanged.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Modality ──
|
||||
@@ -124,6 +131,26 @@ pub enum KinoviSeedance2p5Bitrate {
|
||||
High,
|
||||
}
|
||||
|
||||
/// Output container for Seedance 2.5.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p5OutputFormat {
|
||||
Mp4,
|
||||
Mov,
|
||||
}
|
||||
|
||||
/// Number of videos to generate in one request (1–8).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p5BatchCount {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.5 credit pricing (credits per billed second). Without video
|
||||
@@ -134,7 +161,7 @@ pub enum KinoviSeedance2p5Bitrate {
|
||||
// |------------|----------------------|------------------------|
|
||||
// | 480p | 26 | (no discount) |
|
||||
// | 720p | 59 | (no discount) |
|
||||
// | 1080p | 107.55 | 103.25 |
|
||||
// | 1080p | 143.4 | 136.23 |
|
||||
//
|
||||
// With video references, the rate drops but the billed seconds are the
|
||||
// output duration PLUS the total seconds of reference video input
|
||||
@@ -147,11 +174,10 @@ pub enum KinoviSeedance2p5Bitrate {
|
||||
// |------------|----------------------|------------------------|
|
||||
// | 480p | 16 | (no discount) |
|
||||
// | 720p | 35 | (no discount) |
|
||||
// | 1080p | 64.26 | 61.69 |
|
||||
// | 1080p | 85.68 | 81.4 |
|
||||
//
|
||||
// The 1080p enterprise rates were spot-checked against the Kinovi site
|
||||
// (2026-08-17): 4s / 10s / 30s without a reference video showed 413 /
|
||||
// 1,032.5 / 3,097.5 credits. 480p and 720p have no separately negotiated
|
||||
// The 1080p rates were last updated 2026-09-17 (previously 107.55 / 103.25
|
||||
// and 64.26 / 61.69 credits/sec). 480p and 720p have no separately negotiated
|
||||
// enterprise credit rate, so enterprise bills the same credits as consumer
|
||||
// (the tiers still convert to USD at their own purchase rates). Default
|
||||
// resolution (None) is 720p.
|
||||
@@ -167,8 +193,8 @@ const SEEDANCE_2P5_720P: KinoviPricingRate = KinoviPricingRate {
|
||||
maybe_enterprise_credits: None,
|
||||
};
|
||||
const SEEDANCE_2P5_1080P: KinoviPricingRate = KinoviPricingRate {
|
||||
consumer_credits: 107.55,
|
||||
maybe_enterprise_credits: Some(103.25),
|
||||
consumer_credits: 143.4,
|
||||
maybe_enterprise_credits: Some(136.23),
|
||||
};
|
||||
|
||||
/// Per-second credit rates when video references are attached. These REPLACE
|
||||
@@ -183,8 +209,8 @@ const SEEDANCE_2P5_720P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
|
||||
maybe_enterprise_credits: None,
|
||||
};
|
||||
const SEEDANCE_2P5_1080P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
|
||||
consumer_credits: 64.26,
|
||||
maybe_enterprise_credits: Some(61.69),
|
||||
consumer_credits: 85.68,
|
||||
maybe_enterprise_credits: Some(81.4),
|
||||
};
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
|
||||
@@ -221,7 +247,18 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
|
||||
(rate, u64::from(self.duration_seconds))
|
||||
};
|
||||
|
||||
let total_credits = rate.credits(tier) * billed_seconds as f64;
|
||||
let batch_multiplier: f64 = match self.batch_count {
|
||||
None | Some(KinoviSeedance2p5BatchCount::One) => 1.0,
|
||||
Some(KinoviSeedance2p5BatchCount::Two) => 2.0,
|
||||
Some(KinoviSeedance2p5BatchCount::Three) => 3.0,
|
||||
Some(KinoviSeedance2p5BatchCount::Four) => 4.0,
|
||||
Some(KinoviSeedance2p5BatchCount::Five) => 5.0,
|
||||
Some(KinoviSeedance2p5BatchCount::Six) => 6.0,
|
||||
Some(KinoviSeedance2p5BatchCount::Seven) => 7.0,
|
||||
Some(KinoviSeedance2p5BatchCount::Eight) => 8.0,
|
||||
};
|
||||
|
||||
let total_credits = rate.credits(tier) * billed_seconds as f64 * batch_multiplier;
|
||||
tier.cost_from_credits(total_credits)
|
||||
}
|
||||
}
|
||||
@@ -296,7 +333,7 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
|
||||
aspect_ratio,
|
||||
output_resolution: Some(map_output_resolution(req.output_resolution)),
|
||||
duration_seconds: req.duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
batch_count: map_batch_count(req.batch_count),
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
@@ -305,6 +342,11 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: map_bitrate(req.maybe_bitrate),
|
||||
maybe_output_format: req.maybe_output_format.map(|format| match format {
|
||||
KinoviSeedance2p5OutputFormat::Mp4 => KinoviOutputFormatRaw::Mp4,
|
||||
KinoviSeedance2p5OutputFormat::Mov => KinoviOutputFormatRaw::Mov,
|
||||
}),
|
||||
maybe_generate_audio: req.maybe_generate_audio,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +379,19 @@ fn map_output_resolution(res: Option<KinoviSeedance2p5OutputResolution>) -> Kino
|
||||
}
|
||||
}
|
||||
|
||||
fn map_batch_count(bc: Option<KinoviSeedance2p5BatchCount>) -> KinoviBatchCountRaw {
|
||||
match bc {
|
||||
Some(KinoviSeedance2p5BatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviSeedance2p5BatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p5BatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviSeedance2p5BatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
Some(KinoviSeedance2p5BatchCount::Five) => KinoviBatchCountRaw::Five,
|
||||
Some(KinoviSeedance2p5BatchCount::Six) => KinoviBatchCountRaw::Six,
|
||||
Some(KinoviSeedance2p5BatchCount::Seven) => KinoviBatchCountRaw::Seven,
|
||||
Some(KinoviSeedance2p5BatchCount::Eight) => KinoviBatchCountRaw::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -357,7 +412,7 @@ mod tests {
|
||||
// ── No video references: output-duration billing ──
|
||||
//
|
||||
// 480p = 26 credits/sec and 720p = 59 credits/sec at both tiers (no
|
||||
// negotiated enterprise discount); 1080p = 107.55 consumer / 103.25
|
||||
// negotiated enterprise discount); 1080p = 143.4 consumer / 136.23
|
||||
// enterprise credits/sec.
|
||||
|
||||
mod without_video_references {
|
||||
@@ -383,24 +438,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn table_1080p() {
|
||||
// Enterprise 103.25 credits/sec — the 4s / 10s / 30s values were
|
||||
// verified against the Kinovi site (413 / 1,032.5 / 3,097.5).
|
||||
assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 413.0);
|
||||
assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 516.25);
|
||||
assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1032.5);
|
||||
assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 1548.75);
|
||||
assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2065.0);
|
||||
assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 2581.25);
|
||||
assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 3097.5);
|
||||
// Enterprise 136.23 credits/sec (2026-09-17).
|
||||
assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 544.92);
|
||||
assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 681.15);
|
||||
assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1362.3);
|
||||
assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 2043.45);
|
||||
assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2724.6);
|
||||
assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 3405.75);
|
||||
assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 4086.9);
|
||||
|
||||
// Consumer 107.55 credits/sec.
|
||||
assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 430.2);
|
||||
assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 537.75);
|
||||
assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1075.5);
|
||||
assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 1613.25);
|
||||
assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2151.0);
|
||||
assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 2688.75);
|
||||
assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 3226.5);
|
||||
// Consumer 143.4 credits/sec.
|
||||
assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 573.6);
|
||||
assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 717.0);
|
||||
assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1434.0);
|
||||
assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 2151.0);
|
||||
assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2868.0);
|
||||
assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 3585.0);
|
||||
assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 4302.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -438,7 +492,7 @@ mod tests {
|
||||
|
||||
let mut request_1080 = keyframe_request(10);
|
||||
request_1080.output_resolution = Some(KinoviSeedance2p5OutputResolution::TenEightyP);
|
||||
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1032.5);
|
||||
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1362.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -471,7 +525,7 @@ mod tests {
|
||||
// ── With video references: input seconds are billed too ──
|
||||
//
|
||||
// 480p = 16 credits/sec and 720p = 35 credits/sec at both tiers; 1080p
|
||||
// = 64.26 consumer / 61.69 enterprise credits/sec. All over
|
||||
// = 85.68 consumer / 81.4 enterprise credits/sec. All over
|
||||
// (output duration + total_input_seconds) billed seconds.
|
||||
|
||||
mod with_video_references {
|
||||
@@ -523,21 +577,21 @@ mod tests {
|
||||
// seconds: billed seconds = 20 + clamp(input, 4, 30). E.g. a 10s
|
||||
// input video + 20s generation = 30 billed seconds.
|
||||
//
|
||||
// Enterprise 61.69 credits/sec.
|
||||
assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1480.56);
|
||||
assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 1542.25);
|
||||
assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 1850.7);
|
||||
assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2159.15);
|
||||
assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 2467.6);
|
||||
assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 3084.5);
|
||||
// Enterprise 81.4 credits/sec.
|
||||
assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1953.6);
|
||||
assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 2035.0);
|
||||
assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 2442.0);
|
||||
assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2849.0);
|
||||
assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 3256.0);
|
||||
assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 4070.0);
|
||||
|
||||
// Consumer 64.26 credits/sec.
|
||||
assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 1542.24);
|
||||
assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 1606.5);
|
||||
assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 1927.8);
|
||||
assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2249.1);
|
||||
assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 2570.4);
|
||||
assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 3213.0);
|
||||
// Consumer 85.68 credits/sec.
|
||||
assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 2056.32);
|
||||
assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 2142.0);
|
||||
assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 2570.4);
|
||||
assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2998.8);
|
||||
assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 3427.2);
|
||||
assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 4284.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -556,9 +610,9 @@ mod tests {
|
||||
// 30-second maximum so the estimate never undershoots the charge.
|
||||
assert_eq!(video_ref_480(10, None).calculate_consumer_costs().kinovi_credits, 640.0);
|
||||
assert_eq!(video_ref_720(10, None).calculate_consumer_costs().kinovi_credits, 1400.0);
|
||||
// 1080p: 40 billed seconds at 61.69 enterprise / 64.26 consumer.
|
||||
assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 2467.6);
|
||||
assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 2570.4);
|
||||
// 1080p: 40 billed seconds at 81.4 enterprise / 85.68 consumer.
|
||||
assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 3256.0);
|
||||
assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 3427.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -631,47 +685,151 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn enterprise_cents_1080p_4s() {
|
||||
// 413 credits; 41300/243.16 = 169.8470 cents.
|
||||
// 544.92 credits; 54492/243.16 = 224.0994 cents.
|
||||
let cost = t2v_1080(4).calculate_enterprise_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 170);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 169);
|
||||
assert!((cost.usd_cents_fractional - 169.8470).abs() < FLOAT_TOLERANCE);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 225);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 224);
|
||||
assert!((cost.usd_cents_fractional - 224.0994).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_cents_1080p_4s() {
|
||||
// 430.2 credits; 43020/192.98 = 222.9247 cents.
|
||||
// 573.6 credits; 57360/192.98 = 297.2329 cents.
|
||||
let cost = t2v_1080(4).calculate_consumer_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 223);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 222);
|
||||
assert!((cost.usd_cents_fractional - 222.9247).abs() < FLOAT_TOLERANCE);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 298);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 297);
|
||||
assert!((cost.usd_cents_fractional - 297.2329).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_cents_1080p_30s() {
|
||||
// 3097.5 credits; 309750/243.16 = 1273.8526 cents.
|
||||
// 4086.9 credits; 408690/243.16 = 1680.7452 cents.
|
||||
let cost = t2v_1080(30).calculate_enterprise_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 1274);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 1273);
|
||||
assert!((cost.usd_cents_fractional - 1273.8526).abs() < FLOAT_TOLERANCE);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 1681);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 1680);
|
||||
assert!((cost.usd_cents_fractional - 1680.7452).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_cents_1080p_video_ref_30_billed_seconds() {
|
||||
// 61.69 × 30 = 1850.7 credits; 185070/243.16 = 761.1038 cents.
|
||||
// 81.4 × 30 = 2442 credits; 244200/243.16 = 1004.2770 cents.
|
||||
let cost = video_ref_1080(20, Some(10)).calculate_enterprise_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 762);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 761);
|
||||
assert!((cost.usd_cents_fractional - 761.1038).abs() < FLOAT_TOLERANCE);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 1005);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 1004);
|
||||
assert!((cost.usd_cents_fractional - 1004.2770).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_cents_1080p_video_ref_30_billed_seconds() {
|
||||
// 64.26 × 30 = 1927.8 credits; 192780/192.98 = 998.9636 cents.
|
||||
// 85.68 × 30 = 2570.4 credits; 257040/192.98 = 1331.9515 cents.
|
||||
let cost = video_ref_1080(20, Some(10)).calculate_consumer_costs();
|
||||
assert_eq!(cost.usd_cents_rounded_up, 999);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 998);
|
||||
assert!((cost.usd_cents_fractional - 998.9636).abs() < FLOAT_TOLERANCE);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 1332);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 1331);
|
||||
assert!((cost.usd_cents_fractional - 1331.9515).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_price_tests {
|
||||
use super::*;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p5BatchCount; 8] = [
|
||||
KinoviSeedance2p5BatchCount::One,
|
||||
KinoviSeedance2p5BatchCount::Two,
|
||||
KinoviSeedance2p5BatchCount::Three,
|
||||
KinoviSeedance2p5BatchCount::Four,
|
||||
KinoviSeedance2p5BatchCount::Five,
|
||||
KinoviSeedance2p5BatchCount::Six,
|
||||
KinoviSeedance2p5BatchCount::Seven,
|
||||
KinoviSeedance2p5BatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
// Video-reference cases bill 5s output + 10s input per video.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_without_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(130.0, 68), (260.0, 135), (390.0, 203), (520.0, 270),
|
||||
(650.0, 337), (780.0, 405), (910.0, 472), (1040.0, 539),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(295.0, 153), (590.0, 306), (885.0, 459), (1180.0, 612),
|
||||
(1475.0, 765), (1770.0, 918), (2065.0, 1071), (2360.0, 1223),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
|
||||
(717.0, 372), (1434.0, 744), (2151.0, 1115), (2868.0, 1487),
|
||||
(3585.0, 1858), (4302.0, 2230), (5019.0, 2601), (5736.0, 2973),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(130.0, 54), (260.0, 107), (390.0, 161), (520.0, 214),
|
||||
(650.0, 268), (780.0, 321), (910.0, 375), (1040.0, 428),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(295.0, 122), (590.0, 243), (885.0, 364), (1180.0, 486),
|
||||
(1475.0, 607), (1770.0, 728), (2065.0, 850), (2360.0, 971),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
|
||||
(681.15, 281), (1362.30, 561), (2043.45, 841), (2724.60, 1121),
|
||||
(3405.75, 1401), (4086.90, 1681), (4768.05, 1961), (5449.20, 2241),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let mut request = text_to_video_request(5, Some(resolution));
|
||||
request.batch_count = Some(batch);
|
||||
let cost = request.calculate_costs(tier);
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_with_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(240.0, 125), (480.0, 249), (720.0, 374), (960.0, 498),
|
||||
(1200.0, 622), (1440.0, 747), (1680.0, 871), (1920.0, 995),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(525.0, 273), (1050.0, 545), (1575.0, 817), (2100.0, 1089),
|
||||
(2625.0, 1361), (3150.0, 1633), (3675.0, 1905), (4200.0, 2177),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
|
||||
(1285.2, 666), (2570.4, 1332), (3855.6, 1998), (5140.8, 2664),
|
||||
(6426.0, 3330), (7711.2, 3996), (8996.4, 4662), (10281.6, 5328),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(240.0, 99), (480.0, 198), (720.0, 297), (960.0, 395),
|
||||
(1200.0, 494), (1440.0, 593), (1680.0, 691), (1920.0, 790),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(525.0, 216), (1050.0, 432), (1575.0, 648), (2100.0, 864),
|
||||
(2625.0, 1080), (3150.0, 1296), (3675.0, 1512), (4200.0, 1728),
|
||||
]),
|
||||
(KinoviSeedance2p5OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
|
||||
(1221.0, 503), (2442.0, 1005), (3663.0, 1507), (4884.0, 2009),
|
||||
(6105.0, 2511), (7326.0, 3013), (8547.0, 3515), (9768.0, 4018),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let mut request = text_to_video_request(5, Some(resolution));
|
||||
request.batch_count = Some(batch);
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = Some(10);
|
||||
let cost = request.calculate_costs(tier);
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,10 +837,81 @@ mod tests {
|
||||
|
||||
mod relative_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_modality_and_input_duration() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p5BatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p5BatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p5BatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p5BatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p5BatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p5BatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p5BatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p5BatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p5OutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP),
|
||||
Some(KinoviSeedance2p5OutputResolution::TenEightyP),
|
||||
] {
|
||||
for duration in [4, 5, 30] {
|
||||
let text_request = text_to_video_request(duration, resolution);
|
||||
let mut keyframe = keyframe_request(duration);
|
||||
keyframe.output_resolution = resolution;
|
||||
let mut image_audio = text_request.clone();
|
||||
set_reference_urls(
|
||||
&mut image_audio,
|
||||
Some(vec!["https://example.com/image.png".to_string()]),
|
||||
None,
|
||||
Some(vec!["https://example.com/audio.mp3".to_string()]),
|
||||
);
|
||||
let mut empty_videos = text_request.clone();
|
||||
set_reference_urls(&mut empty_videos, None, Some(vec![]), None);
|
||||
let mut requests = vec![
|
||||
("text".to_string(), text_request.clone()),
|
||||
("keyframe".to_string(), keyframe),
|
||||
("image/audio".to_string(), image_audio),
|
||||
("empty videos".to_string(), empty_videos),
|
||||
];
|
||||
// Include unknown/zero inputs, the minimum, ordinary input, the
|
||||
// maximum, and values clamped at either end of the billed range.
|
||||
for input_seconds in [None, Some(0), Some(1), Some(4), Some(10), Some(30), Some(31), Some(u8::MAX)] {
|
||||
let mut request = text_request.clone();
|
||||
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
|
||||
request.total_input_seconds = input_seconds;
|
||||
requests.push((format!("video input={input_seconds:?}"), request));
|
||||
}
|
||||
for (modality, mut request) in requests {
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} {modality}");
|
||||
assert_batch_cost(single, batched, count, tier, &context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_output_and_reference_input_costs() {
|
||||
let mut request = video_ref_480(5, Some(10));
|
||||
request.batch_count = Some(KinoviSeedance2p5BatchCount::Eight);
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1920.0);
|
||||
assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 1920.0);
|
||||
assert!(matches!(to_raw_request(request).batch_count, KinoviBatchCountRaw::Eight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_reference_rate_is_cheaper_per_second() {
|
||||
// 16 < 26, 35 < 59, and 61.69 < 103.25: the with-references rate is
|
||||
// 16 < 26, 35 < 59, and 81.4 < 136.23: the with-references rate is
|
||||
// lower per billed second (the input seconds are where the money
|
||||
// goes). Compare at the minimum billed input (1s clamps to 4) —
|
||||
// unknown or zero input assumes the 30-second maximum, which would
|
||||
@@ -753,6 +982,7 @@ mod tests {
|
||||
output_resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
@@ -765,6 +995,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,6 +1014,7 @@ mod tests {
|
||||
|
||||
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
|
||||
GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: String::new(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
@@ -792,6 +1025,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,6 +1077,7 @@ mod tests {
|
||||
#[test]
|
||||
fn keyframe_maps_frames_and_no_references() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://example.com/start.png".to_string(),
|
||||
@@ -852,6 +1088,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
|
||||
@@ -866,6 +1104,7 @@ mod tests {
|
||||
#[test]
|
||||
fn reference_maps_references_and_no_frames() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "The t-rex @image1 eats the banana".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
@@ -878,10 +1117,14 @@ mod tests {
|
||||
total_input_seconds: Some(7),
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: Some(KinoviSeedance2p5Bitrate::High),
|
||||
maybe_output_format: Some(KinoviSeedance2p5OutputFormat::Mov),
|
||||
maybe_generate_audio: Some(false),
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
|
||||
assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High)));
|
||||
assert!(matches!(raw.maybe_output_format, Some(KinoviOutputFormatRaw::Mov)));
|
||||
assert_eq!(raw.maybe_generate_audio, Some(false));
|
||||
assert!(raw.start_frame_url.is_none());
|
||||
assert!(raw.end_frame_url.is_none());
|
||||
assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1));
|
||||
@@ -892,6 +1135,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolution_1080p_maps_to_raw_1080p() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "A car driving on the beach".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
@@ -904,6 +1148,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::TenEightyP)));
|
||||
@@ -912,6 +1158,7 @@ mod tests {
|
||||
#[test]
|
||||
fn reference_aspect_ratio_defaults_to_16x9() {
|
||||
let raw = to_raw_request(GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
@@ -924,6 +1171,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
});
|
||||
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
|
||||
@@ -942,6 +1191,7 @@ mod tests {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "Lightning hits a building".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
|
||||
@@ -954,6 +1204,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -974,6 +1226,7 @@ mod tests {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "A car driving on the beach".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
|
||||
@@ -986,6 +1239,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1004,6 +1259,7 @@ mod tests {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
|
||||
@@ -1014,6 +1270,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1032,6 +1290,7 @@ mod tests {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "Car drives into the sunset".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Keyframe {
|
||||
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
|
||||
@@ -1042,6 +1301,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
@@ -1060,6 +1321,7 @@ mod tests {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5Request {
|
||||
batch_count: None,
|
||||
prompt: "The t-rex @image1 eats the banana @image2".to_string(),
|
||||
modality: KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::UltraWide21x9),
|
||||
@@ -1075,6 +1337,8 @@ mod tests {
|
||||
total_input_seconds: None,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
|
||||
+149
-2
@@ -28,6 +28,7 @@ pub struct GenerateSeedance2p5PreviewRequest {
|
||||
pub aspect_ratio: Option<KinoviSeedance2p5PreviewAspectRatio>,
|
||||
pub output_resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p5PreviewBatchCount>,
|
||||
/// Reference images, referenced in prompts as @image1, @image2, etc.
|
||||
pub reference_image_urls: Option<Vec<String>>,
|
||||
/// Reference videos, referenced in prompts as @video1, @video2, etc.
|
||||
@@ -58,6 +59,19 @@ pub enum KinoviSeedance2p5PreviewOutputResolution {
|
||||
SevenTwentyP,
|
||||
}
|
||||
|
||||
/// Number of videos to generate in one request (1–8).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p5PreviewBatchCount {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.5 Preview credit pricing (per second of output duration):
|
||||
@@ -97,7 +111,18 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p5PreviewRequest {
|
||||
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => SEEDANCE_2P5_PREVIEW_480P,
|
||||
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_PREVIEW_720P,
|
||||
};
|
||||
let total_credits = rate.credits(tier) * f64::from(self.duration_seconds);
|
||||
let batch_multiplier: f64 = match self.batch_count {
|
||||
None | Some(KinoviSeedance2p5PreviewBatchCount::One) => 1.0,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Two) => 2.0,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Three) => 3.0,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Four) => 4.0,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Five) => 5.0,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Six) => 6.0,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Seven) => 7.0,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Eight) => 8.0,
|
||||
};
|
||||
|
||||
let total_credits = rate.credits(tier) * f64::from(self.duration_seconds) * batch_multiplier;
|
||||
tier.cost_from_credits(total_credits)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +164,7 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
|
||||
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
|
||||
output_resolution: Some(map_output_resolution(req.output_resolution)),
|
||||
duration_seconds: req.duration_seconds,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
batch_count: map_batch_count(req.batch_count),
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: req.reference_image_urls,
|
||||
@@ -148,6 +173,8 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
|
||||
character_ids: None,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +199,19 @@ fn map_output_resolution(res: Option<KinoviSeedance2p5PreviewOutputResolution>)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_batch_count(bc: Option<KinoviSeedance2p5PreviewBatchCount>) -> KinoviBatchCountRaw {
|
||||
match bc {
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Five) => KinoviBatchCountRaw::Five,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Six) => KinoviBatchCountRaw::Six,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Seven) => KinoviBatchCountRaw::Seven,
|
||||
Some(KinoviSeedance2p5PreviewBatchCount::Eight) => KinoviBatchCountRaw::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -505,6 +545,110 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p5PreviewBatchCount; 8] = [
|
||||
KinoviSeedance2p5PreviewBatchCount::One,
|
||||
KinoviSeedance2p5PreviewBatchCount::Two,
|
||||
KinoviSeedance2p5PreviewBatchCount::Three,
|
||||
KinoviSeedance2p5PreviewBatchCount::Four,
|
||||
KinoviSeedance2p5PreviewBatchCount::Five,
|
||||
KinoviSeedance2p5PreviewBatchCount::Six,
|
||||
KinoviSeedance2p5PreviewBatchCount::Seven,
|
||||
KinoviSeedance2p5PreviewBatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_with_and_without_references() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p5PreviewOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(230.75, 120), (461.50, 240), (692.25, 359), (923.00, 479),
|
||||
(1153.75, 598), (1384.50, 718), (1615.25, 838), (1846.00, 957),
|
||||
]),
|
||||
(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(460.15, 239), (920.30, 477), (1380.45, 716), (1840.60, 954),
|
||||
(2300.75, 1193), (2760.90, 1431), (3221.05, 1670), (3681.20, 1908),
|
||||
]),
|
||||
(KinoviSeedance2p5PreviewOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(210.65, 87), (421.30, 174), (631.95, 260), (842.60, 347),
|
||||
(1053.25, 434), (1263.90, 520), (1474.55, 607), (1685.20, 694),
|
||||
]),
|
||||
(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(421.3, 174), (842.6, 347), (1263.9, 520), (1685.2, 694),
|
||||
(2106.5, 867), (2527.8, 1040), (2949.1, 1213), (3370.4, 1387),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for has_references in [false, true] {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let mut request = build_request(5, Some(resolution));
|
||||
request.batch_count = Some(batch);
|
||||
if has_references {
|
||||
request = with_all_reference_types(request);
|
||||
}
|
||||
let cost = request.calculate_costs(tier);
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?} references={has_references}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_with_and_without_references() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p5PreviewBatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP),
|
||||
] {
|
||||
for duration in [4, 5, 30] {
|
||||
for has_references in [false, true] {
|
||||
let mut request = build_request(duration, resolution);
|
||||
if has_references {
|
||||
request = with_all_reference_types(request);
|
||||
}
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={has_references}");
|
||||
assert_batch_cost(single, batched, count, tier, &context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_costs_and_request_count_agree() {
|
||||
let mut request = r480(4);
|
||||
request.batch_count = Some(KinoviSeedance2p5PreviewBatchCount::Eight);
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1476.8);
|
||||
assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 1348.16);
|
||||
assert!(matches!(to_raw_request(request).batch_count, KinoviBatchCountRaw::Eight));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn build_request(
|
||||
@@ -512,6 +656,7 @@ mod tests {
|
||||
output_resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
|
||||
) -> GenerateSeedance2p5PreviewRequest {
|
||||
GenerateSeedance2p5PreviewRequest {
|
||||
batch_count: None,
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
output_resolution,
|
||||
@@ -554,6 +699,7 @@ mod tests {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5PreviewRequest {
|
||||
batch_count: None,
|
||||
prompt: "A man is running from a t-rex".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p5PreviewAspectRatio::Landscape16x9),
|
||||
output_resolution: Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP),
|
||||
@@ -580,6 +726,7 @@ mod tests {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p5PreviewRequest {
|
||||
batch_count: None,
|
||||
prompt: "A corgi and a shiba are playing chess against one another".to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//! Wan 3.0 image-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0ImageToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0ImageToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0ImageToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0ImageToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Required for generation; optional so costs can be estimated without media.
|
||||
pub maybe_start_frame_url: Option<String>,
|
||||
/// Optional final keyframe. Requires a start frame.
|
||||
pub maybe_end_frame_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0ImageToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_start_frame_url: None,
|
||||
maybe_end_frame_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0ImageToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::ImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0ImageToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
|
||||
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
|
||||
}
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::ImageToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_image_to_video(
|
||||
args: GenerateWan3p0ImageToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0ImageToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//! Wan 3.0 Prime image-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeImageToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0PrimeImageToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0PrimeImageToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0PrimeImageToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Required for generation; optional so costs can be estimated without media.
|
||||
pub maybe_start_frame_url: Option<String>,
|
||||
/// Optional final keyframe. Requires a start frame.
|
||||
pub maybe_end_frame_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0PrimeImageToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_start_frame_url: None,
|
||||
maybe_end_frame_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeImageToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::PrimeImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0PrimeImageToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
|
||||
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
|
||||
}
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::PrimeImageToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_prime_image_to_video(
|
||||
args: GenerateWan3p0PrimeImageToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0PrimeImageToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
//! Wan 3.0 Prime ref-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeRefToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0PrimeRefToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0PrimeRefToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0PrimeRefToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
|
||||
pub maybe_reference_image_urls: Option<Vec<String>>,
|
||||
/// Up to 5 videos, each 1-15s, totaling at most 15s.
|
||||
pub maybe_reference_video_urls: Option<Vec<String>>,
|
||||
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
|
||||
/// Actual file durations are validated by Kinovi.
|
||||
pub maybe_reference_audio_urls: Option<Vec<String>>,
|
||||
/// Optional measured TOTAL reference-video duration, used only for validation.
|
||||
/// Reference duration + output duration must not exceed 30s. This metadata
|
||||
/// is not sent or billed. Without it, Kinovi validates the actual durations.
|
||||
pub maybe_total_reference_video_duration_millis: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0PrimeRefToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_reference_image_urls: None,
|
||||
maybe_reference_video_urls: None,
|
||||
maybe_reference_audio_urls: None,
|
||||
maybe_total_reference_video_duration_millis: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeRefToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::PrimeRefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0PrimeRefToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::PrimeRefToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = self.maybe_reference_image_urls;
|
||||
params.video_urls = self.maybe_reference_video_urls;
|
||||
params.audio_urls = self.maybe_reference_audio_urls;
|
||||
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_prime_ref_to_video(
|
||||
args: GenerateWan3p0PrimeRefToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0PrimeRefToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
//! Wan 3.0 Prime text-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeTextToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0PrimeTextToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0PrimeTextToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0PrimeTextToVideoRequest {
|
||||
/// Required prompt, up to 20,000 characters.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0PrimeTextToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeTextToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::PrimeTextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0PrimeTextToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::PrimeTextToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_prime_text_to_video(
|
||||
args: GenerateWan3p0PrimeTextToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0PrimeTextToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
//! Wan 3.0 ref-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0RefToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0RefToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0RefToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0RefToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
|
||||
pub maybe_reference_image_urls: Option<Vec<String>>,
|
||||
/// Up to 5 videos, each 1-15s, totaling at most 15s.
|
||||
pub maybe_reference_video_urls: Option<Vec<String>>,
|
||||
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
|
||||
/// Actual file durations are validated by Kinovi.
|
||||
pub maybe_reference_audio_urls: Option<Vec<String>>,
|
||||
/// Optional measured TOTAL reference-video duration, used only for validation.
|
||||
/// Reference duration + output duration must not exceed 30s. This metadata
|
||||
/// is not sent or billed. Without it, Kinovi validates the actual durations.
|
||||
pub maybe_total_reference_video_duration_millis: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0RefToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_reference_image_urls: None,
|
||||
maybe_reference_video_urls: None,
|
||||
maybe_reference_audio_urls: None,
|
||||
maybe_total_reference_video_duration_millis: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0RefToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::RefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0RefToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::RefToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = self.maybe_reference_image_urls;
|
||||
params.video_urls = self.maybe_reference_video_urls;
|
||||
params.audio_urls = self.maybe_reference_audio_urls;
|
||||
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_ref_to_video(
|
||||
args: GenerateWan3p0RefToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0RefToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
//! Wan 3.0 text-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0TextToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0TextToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0TextToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0TextToVideoRequest {
|
||||
/// Required prompt, up to 20,000 characters.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0TextToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0TextToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::TextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0TextToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::TextToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_text_to_video(
|
||||
args: GenerateWan3p0TextToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0TextToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -4,3 +4,13 @@ pub mod generate_seedance_2p0_fast;
|
||||
pub mod generate_seedance_2p0_mini;
|
||||
pub mod generate_seedance_2p5;
|
||||
pub mod generate_seedance_2p5_preview;
|
||||
pub mod generate_wan_3p0_text_to_video;
|
||||
pub mod generate_wan_3p0_image_to_video;
|
||||
pub mod generate_wan_3p0_ref_to_video;
|
||||
pub mod generate_wan_3p0_prime_text_to_video;
|
||||
pub mod generate_wan_3p0_prime_image_to_video;
|
||||
pub mod generate_wan_3p0_prime_ref_to_video;
|
||||
pub mod wan_3p0;
|
||||
|
||||
#[cfg(test)]
|
||||
mod wan_3p0_tests;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Shared options and wire format for Wan 3.0 and Wan 3.0 Prime.
|
||||
//!
|
||||
//! Schema: https://kinovi.ai/models/wan3-ref-to-video (checked 2026-09-18).
|
||||
//! All modes bill only OUTPUT seconds; reference media does not add a surcharge.
|
||||
//!
|
||||
//! Output accepts any whole-second duration from 2 through 30. Reference
|
||||
//! videos must each be 1-15s and total at most 15s; their total plus output
|
||||
//! duration must not exceed 30s. Thus 10s of video references allows at most
|
||||
//! 20s of output. Audio and image references do not consume this video budget.
|
||||
//! The optional measured reference duration enables local validation; otherwise
|
||||
//! Kinovi checks actual file durations. This client does not probe media URLs.
|
||||
//!
|
||||
//! Pricing sources (all three modalities share each family's rates):
|
||||
//! - https://kinovi.ai/models/wan3-text-to-video
|
||||
//! - https://kinovi.ai/models/wan3-prime-ref-to-video
|
||||
//! Pricing maintenance and the September 23 promotion: `costs_wan_3p0.md`
|
||||
//! at the crate root.
|
||||
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::error::kinovi_web_client_error::KinoviWebClientError;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
|
||||
pub(super) const BUSINESS_TYPE: &str = "wan3-video-generation";
|
||||
pub const MAX_SEED: u32 = 2_147_483_647;
|
||||
pub const MIN_DURATION_SECONDS: u8 = 2;
|
||||
pub const MAX_DURATION_SECONDS: u8 = 30;
|
||||
pub const MAX_REFERENCE_VIDEO_DURATION_MILLIS: u32 = 15_000;
|
||||
|
||||
// Hundredths of a credit per OUTPUT second, as observed 2026-09-18.
|
||||
// Regular Wan intentionally uses the promotional rates (30% off until 2026-09-23).
|
||||
// TODO(2026-09-24): Recheck live pricing using costs_wan_3p0.md at the crate root.
|
||||
// That note records advertised replacement rates, precision needs, and test updates.
|
||||
// Keep these promotional rates until a reviewed update; no automatic date switch.
|
||||
// Prime 480p is 18.86/s, so 30s costs 565.8, not 568.8 credits.
|
||||
// No separate enterprise credit discount has been supplied for these models.
|
||||
const WAN_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [998, 1996, 3991];
|
||||
const PRIME_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [1886, 3883, 7766];
|
||||
|
||||
/// Shared by all six Wan operations. Defaults to 16:9.
|
||||
/// `adaptive` is REST-only for reference-to-video and is not a Studio option.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum KinoviWan3p0AspectRatio {
|
||||
#[default]
|
||||
Landscape16x9,
|
||||
Portrait9x16,
|
||||
Landscape4x3,
|
||||
Portrait3x4,
|
||||
Square1x1,
|
||||
}
|
||||
|
||||
/// Shared by regular and Prime Wan. Defaults to 720p, pinned on the wire.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum KinoviWan3p0OutputResolution {
|
||||
FourEightyP,
|
||||
#[default]
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum Wan3p0Model {
|
||||
TextToVideo,
|
||||
ImageToVideo,
|
||||
RefToVideo,
|
||||
PrimeTextToVideo,
|
||||
PrimeImageToVideo,
|
||||
PrimeRefToVideo,
|
||||
}
|
||||
|
||||
/// Consumer tRPC shape, verified against the 2026-09-18 captures. Images
|
||||
/// use `uploadedUrls`; videos use BOTH `videoUrl` and `videoUrls`.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct Wan3p0ApiParams {
|
||||
model: &'static str,
|
||||
content_mode: &'static str,
|
||||
face_blur_mode: &'static str,
|
||||
prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mode: Option<&'static str>,
|
||||
aspect_ratio: &'static str,
|
||||
duration: String,
|
||||
output_resolution: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
seed: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
audio: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) uploaded_urls: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
video_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) video_urls: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) audio_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Wan3p0Model {
|
||||
pub(super) fn calculate_costs(
|
||||
self,
|
||||
resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
tier: KinoviPricingTier,
|
||||
) -> KinoviFractionalGenerationCost {
|
||||
let rates = match self {
|
||||
Self::TextToVideo | Self::ImageToVideo | Self::RefToVideo => WAN_CREDIT_HUNDREDTHS_PER_SECOND,
|
||||
Self::PrimeTextToVideo | Self::PrimeImageToVideo | Self::PrimeRefToVideo => PRIME_CREDIT_HUNDREDTHS_PER_SECOND,
|
||||
};
|
||||
let index = match resolution.unwrap_or_default() {
|
||||
KinoviWan3p0OutputResolution::FourEightyP => 0,
|
||||
KinoviWan3p0OutputResolution::SevenTwentyP => 1,
|
||||
KinoviWan3p0OutputResolution::TenEightyP => 2,
|
||||
};
|
||||
let credit_hundredths = rates[index] * u64::from(duration_seconds);
|
||||
tier.cost_from_credits(credit_hundredths as f64 / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Wan3p0ApiParams {
|
||||
pub(super) fn new(
|
||||
model: Wan3p0Model,
|
||||
prompt: String,
|
||||
aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
seed: Option<u32>,
|
||||
audio: Option<bool>,
|
||||
) -> Result<Self, KinoviWebError> {
|
||||
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration_seconds) {
|
||||
return Err(invalid_field("duration_seconds", duration_seconds, "must be between 2 and 30"));
|
||||
}
|
||||
if let Some(seed) = seed {
|
||||
if seed > MAX_SEED {
|
||||
return Err(invalid_field("maybe_seed", seed, "must be between 0 and 2147483647"));
|
||||
}
|
||||
}
|
||||
if prompt.chars().count() > 20_000 {
|
||||
return Err(invalid_field("prompt", prompt.chars().count(), "must not exceed 20000 characters"));
|
||||
}
|
||||
|
||||
let (model, mode) = match model {
|
||||
Wan3p0Model::TextToVideo => ("wan3.0-text-to-video", None),
|
||||
Wan3p0Model::ImageToVideo => ("wan3.0-image-to-video", Some("keyframe")),
|
||||
Wan3p0Model::RefToVideo => ("wan3.0-ref-to-video", Some("reference")),
|
||||
Wan3p0Model::PrimeTextToVideo => ("wan3.0-prime-text-to-video", None),
|
||||
Wan3p0Model::PrimeImageToVideo => ("wan3.0-prime-image-to-video", Some("keyframe")),
|
||||
Wan3p0Model::PrimeRefToVideo => ("wan3.0-prime-ref-to-video", Some("reference")),
|
||||
};
|
||||
Ok(Self {
|
||||
model,
|
||||
content_mode: "normal",
|
||||
face_blur_mode: "off",
|
||||
prompt,
|
||||
mode,
|
||||
aspect_ratio: match aspect_ratio.unwrap_or_default() {
|
||||
KinoviWan3p0AspectRatio::Landscape16x9 => "16:9",
|
||||
KinoviWan3p0AspectRatio::Portrait9x16 => "9:16",
|
||||
KinoviWan3p0AspectRatio::Landscape4x3 => "4:3",
|
||||
KinoviWan3p0AspectRatio::Portrait3x4 => "3:4",
|
||||
KinoviWan3p0AspectRatio::Square1x1 => "1:1",
|
||||
},
|
||||
duration: format!("{duration_seconds}s"),
|
||||
output_resolution: match resolution.unwrap_or_default() {
|
||||
KinoviWan3p0OutputResolution::FourEightyP => "480p",
|
||||
KinoviWan3p0OutputResolution::SevenTwentyP => "720p",
|
||||
KinoviWan3p0OutputResolution::TenEightyP => "1080p",
|
||||
},
|
||||
seed,
|
||||
audio,
|
||||
uploaded_urls: None,
|
||||
video_url: None,
|
||||
video_urls: None,
|
||||
audio_urls: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_media(
|
||||
&mut self,
|
||||
duration_seconds: u8,
|
||||
maybe_total_reference_video_duration_millis: Option<u32>,
|
||||
) -> Result<(), KinoviWebError> {
|
||||
self.uploaded_urls = self.uploaded_urls.take().filter(|urls| !urls.is_empty());
|
||||
self.video_urls = self.video_urls.take().filter(|urls| !urls.is_empty());
|
||||
self.audio_urls = self.audio_urls.take().filter(|urls| !urls.is_empty());
|
||||
let image_count = self.uploaded_urls.as_ref().map_or(0, Vec::len);
|
||||
let video_count = self.video_urls.as_ref().map_or(0, Vec::len);
|
||||
let audio_count = self.audio_urls.as_ref().map_or(0, Vec::len);
|
||||
let max_images = if self.mode == Some("keyframe") { 2 } else { 10 };
|
||||
for (field, count, maximum) in [
|
||||
("maybe_reference_image_urls", image_count, max_images),
|
||||
("maybe_reference_video_urls", video_count, 5),
|
||||
("maybe_reference_audio_urls", audio_count, 5),
|
||||
] {
|
||||
if count > maximum {
|
||||
return Err(invalid_field(field, count, format!("at most {maximum} items are supported")));
|
||||
}
|
||||
}
|
||||
if self.mode == Some("keyframe") && image_count == 0 {
|
||||
return Err(invalid_field("maybe_start_frame_url", "", "image-to-video requires a start frame"));
|
||||
}
|
||||
if self.prompt.trim().is_empty() && image_count + video_count + audio_count == 0 {
|
||||
return Err(invalid_field("prompt", "", "a prompt is required when no media is supplied"));
|
||||
}
|
||||
for urls in [&self.uploaded_urls, &self.video_urls, &self.audio_urls].into_iter().flatten() {
|
||||
if urls.iter().any(|url| url.trim().is_empty()) {
|
||||
return Err(invalid_field("media_url", "", "media URLs must not be empty"));
|
||||
}
|
||||
}
|
||||
if video_count > 0 {
|
||||
// Without measured metadata, only enforce the known lower bound:
|
||||
// each reference video must be at least 1s. Kinovi validates actual
|
||||
// media durations; the client does not download/probe URLs here.
|
||||
let minimum_millis = video_count as u32 * 1_000;
|
||||
let input_millis = maybe_total_reference_video_duration_millis.unwrap_or(minimum_millis);
|
||||
if !(minimum_millis..=MAX_REFERENCE_VIDEO_DURATION_MILLIS).contains(&input_millis) {
|
||||
return Err(invalid_field(
|
||||
"maybe_total_reference_video_duration_millis",
|
||||
input_millis,
|
||||
"reference videos must each be 1-15s and total at most 15s",
|
||||
));
|
||||
}
|
||||
if input_millis + u32::from(duration_seconds) * 1_000 > 30_000 {
|
||||
return Err(invalid_field(
|
||||
"duration_seconds",
|
||||
duration_seconds,
|
||||
"reference-video duration plus output duration must not exceed 30s",
|
||||
));
|
||||
}
|
||||
self.video_url = self.video_urls.as_ref().and_then(|urls| urls.first()).cloned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn invalid_field(field: &'static str, value: impl ToString, reason: impl Into<String>) -> KinoviWebError {
|
||||
KinoviWebClientError::InvalidRequestField {
|
||||
field,
|
||||
raw_value: value.to_string(),
|
||||
reason: reason.into(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
#[test]
|
||||
fn regular_and_prime_credit_prices_match_the_promotion() {
|
||||
let cases: &[(Wan3p0Model, KinoviWan3p0OutputResolution, u8, f64)] = &[
|
||||
(Wan3p0Model::TextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 19.96),
|
||||
(Wan3p0Model::ImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 99.8),
|
||||
(Wan3p0Model::RefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 399.1),
|
||||
(Wan3p0Model::PrimeTextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 37.72),
|
||||
(Wan3p0Model::PrimeImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 194.15),
|
||||
(Wan3p0Model::PrimeRefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 776.6),
|
||||
];
|
||||
for (model, resolution, duration, credits) in cases {
|
||||
let enterprise = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Enterprise);
|
||||
let consumer = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Consumer);
|
||||
assert!((enterprise.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
|
||||
assert!((consumer.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
|
||||
assert!(consumer.usd_cents_fractional > enterprise.usd_cents_fractional);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_wan_modalities_scale_linearly_with_duration() {
|
||||
for model in [
|
||||
Wan3p0Model::TextToVideo,
|
||||
Wan3p0Model::ImageToVideo,
|
||||
Wan3p0Model::RefToVideo,
|
||||
Wan3p0Model::PrimeTextToVideo,
|
||||
Wan3p0Model::PrimeImageToVideo,
|
||||
Wan3p0Model::PrimeRefToVideo,
|
||||
] {
|
||||
let two_seconds = model.calculate_costs(
|
||||
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
|
||||
2,
|
||||
KinoviPricingTier::Enterprise,
|
||||
);
|
||||
let ten_seconds = model.calculate_costs(
|
||||
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
|
||||
10,
|
||||
KinoviPricingTier::Enterprise,
|
||||
);
|
||||
assert!((ten_seconds.kinovi_credits - two_seconds.kinovi_credits * 5.0).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"model": "wan3.0-ref-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A corgi eats a watermelon",
|
||||
"mode": "reference",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p"
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"model": "wan3.0-text-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A shiba eats a pie",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p"
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"model": "wan3.0-prime-image-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A lighthouse over the coast",
|
||||
"mode": "keyframe",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p",
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789739927378-438b78db.png"
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"model": "wan3.0-ref-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "The Image 2 is on the beach Video 1 . There's a lighthouse Image 1 . The shiba says Audio 1",
|
||||
"mode": "reference",
|
||||
"aspectRatio": "4:3",
|
||||
"duration": "5s",
|
||||
"outputResolution": "1080p",
|
||||
"videoUrl": "https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4",
|
||||
"videoUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4"
|
||||
],
|
||||
"audioUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740228370-3f5d9623.wav"
|
||||
],
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740059548-4149c254.png",
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740077221-9702e67d.png"
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"model": "wan3.0-prime-ref-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "There's a shop full of lanterns. the camera orbits.",
|
||||
"mode": "reference",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "10s",
|
||||
"outputResolution": "480p",
|
||||
"seed": 2028741236,
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740399001-2bb62f21.jpg"
|
||||
]
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"model": "wan3.0-prime-text-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A bowl of soup is knocked over by a knight",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"model": "wan3.0-prime-image-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "The lanterns blow in the breeze above the water",
|
||||
"mode": "keyframe",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "4s",
|
||||
"outputResolution": "1080p",
|
||||
"seed": 1878107760,
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740716787-bf6a26f8.jpg",
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740722438-0101d158.jpg"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::generate_wan_3p0_image_to_video::*;
|
||||
use super::generate_wan_3p0_prime_image_to_video::*;
|
||||
use super::generate_wan_3p0_prime_ref_to_video::*;
|
||||
use super::generate_wan_3p0_prime_text_to_video::*;
|
||||
use super::generate_wan_3p0_ref_to_video::*;
|
||||
use super::generate_wan_3p0_text_to_video::*;
|
||||
use super::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution, Wan3p0ApiParams};
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_client_error::KinoviWebClientError;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
|
||||
const RESOLUTIONS: [KinoviWan3p0OutputResolution; 3] = [
|
||||
KinoviWan3p0OutputResolution::FourEightyP,
|
||||
KinoviWan3p0OutputResolution::SevenTwentyP,
|
||||
KinoviWan3p0OutputResolution::TenEightyP,
|
||||
];
|
||||
|
||||
// Literal price snapshots from the supplied observations and Kinovi's model
|
||||
// pages, checked 2026-09-18. Keep independent of the calculator's rate tables.
|
||||
// Columns are 480p, 720p, 1080p; all three modalities have the same rates.
|
||||
// WAN_CREDITS and WAN_CENTS intentionally assert promotional prices until a
|
||||
// reviewed update after 2026-09-23. See costs_wan_3p0.md at the crate root for
|
||||
// advertised replacement rates and all expectations that need to change.
|
||||
const WAN_CREDITS: [(u8, [f64; 3]); 9] = [
|
||||
(2, [19.96, 39.92, 79.82]),
|
||||
(5, [49.9, 99.8, 199.55]),
|
||||
(8, [79.84, 159.68, 319.28]),
|
||||
(10, [99.8, 199.6, 399.1]),
|
||||
(15, [149.7, 299.4, 598.65]),
|
||||
(20, [199.6, 399.2, 798.2]),
|
||||
(22, [219.56, 439.12, 878.02]),
|
||||
(25, [249.5, 499.0, 997.75]),
|
||||
(30, [299.4, 598.8, 1197.3]),
|
||||
];
|
||||
const PRIME_CREDITS: [(u8, [f64; 3]); 9] = [
|
||||
(2, [37.72, 77.66, 155.32]),
|
||||
(5, [94.3, 194.15, 388.3]),
|
||||
(8, [150.88, 310.64, 621.28]),
|
||||
(10, [188.6, 388.3, 776.6]),
|
||||
(15, [282.9, 582.45, 1164.9]),
|
||||
(20, [377.2, 776.6, 1553.2]),
|
||||
(22, [414.92, 854.26, 1708.52]),
|
||||
(25, [471.5, 970.75, 1941.5]),
|
||||
// The supplied Prime reference 480p/30s figure said 568.8; docs say 565.8.
|
||||
(30, [565.8, 1164.9, 2329.8]),
|
||||
];
|
||||
// Thirty-second USD cents, (rounded down, rounded up), consumer then enterprise.
|
||||
const WAN_CENTS: [[(u64, u64); 3]; 2] = [[(155, 156), (310, 311), (620, 621)], [(123, 124), (246, 247), (492, 493)]];
|
||||
const PRIME_CENTS: [[(u64, u64); 3]; 2] =
|
||||
[[(293, 294), (603, 604), (1207, 1208)], [(232, 233), (479, 480), (958, 959)]];
|
||||
// Only sanitized apiParams are included; the original captures contain cookies.
|
||||
const CAPTURES: [&str; 7] = [
|
||||
include_str!("wan_3p0_test_data/01_wan_ref_to_video_without_ref.json"),
|
||||
include_str!("wan_3p0_test_data/02_wan_text_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/03_wan_image_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/04_wan_ref_to_video_full.json"),
|
||||
include_str!("wan_3p0_test_data/05_wan_prime_ref_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/06_wan_prime_text_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/07_wan_prime_image_to_video.json"),
|
||||
];
|
||||
|
||||
macro_rules! operation_tests {
|
||||
($module:ident, $request:ident, $args:ident, $generate:ident, $model:literal,
|
||||
$credits:ident, $cents:ident, {$($field:ident: $value:expr),* $(,)?}) => {
|
||||
mod $module {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_credit_prices_at_every_resolution_and_observed_duration() {
|
||||
for tier in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise] {
|
||||
for (duration, expected) in $credits {
|
||||
for (resolution, credits) in RESOLUTIONS.into_iter().zip(expected) {
|
||||
// Cost estimates work before media has been uploaded.
|
||||
let request = $request {
|
||||
duration_seconds: duration,
|
||||
maybe_output_resolution: Some(resolution),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(request.calculate_costs(tier).kinovi_credits, credits,
|
||||
"{tier:?}, {resolution:?}, {duration}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_usd_cent_prices_for_both_tiers() {
|
||||
for (tier, expected) in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise]
|
||||
.into_iter().zip($cents)
|
||||
{
|
||||
for (resolution, (down, up)) in RESOLUTIONS.into_iter().zip(expected) {
|
||||
let request = $request {
|
||||
duration_seconds: 30,
|
||||
maybe_output_resolution: Some(resolution),
|
||||
..Default::default()
|
||||
};
|
||||
let cost = request.calculate_costs(tier);
|
||||
assert_eq!((cost.usd_cents_rounded_down, cost.usd_cents_rounded_up), (down, up));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_priced_resolution() {
|
||||
let request = valid_request();
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["model"], $model);
|
||||
assert_eq!(params["duration"], "5s");
|
||||
assert_eq!(params["outputResolution"], "720p");
|
||||
assert_eq!(params["aspectRatio"], "16:9");
|
||||
assert!(params.get("seed").is_none());
|
||||
assert!(params.get("audio").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_whole_second_durations_and_aspect_ratios_serialize() {
|
||||
for duration in 2..=30 {
|
||||
let mut request = valid_request();
|
||||
request.duration_seconds = duration;
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["duration"], format!("{duration}s"));
|
||||
}
|
||||
for (maybe_aspect_ratio, expected) in [
|
||||
(KinoviWan3p0AspectRatio::Landscape16x9, "16:9"),
|
||||
(KinoviWan3p0AspectRatio::Portrait9x16, "9:16"),
|
||||
(KinoviWan3p0AspectRatio::Landscape4x3, "4:3"),
|
||||
(KinoviWan3p0AspectRatio::Portrait3x4, "3:4"),
|
||||
(KinoviWan3p0AspectRatio::Square1x1, "1:1"),
|
||||
] {
|
||||
let mut request = valid_request();
|
||||
request.maybe_aspect_ratio = Some(maybe_aspect_ratio);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["aspectRatio"], expected);
|
||||
}
|
||||
for (resolution, expected) in RESOLUTIONS.into_iter().zip(["480p", "720p", "1080p"]) {
|
||||
let mut request = valid_request();
|
||||
request.maybe_output_resolution = Some(resolution);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["outputResolution"], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_and_audio_flags_preserve_values_without_changing_price() {
|
||||
for (seed, audio) in [(0, false), (2_147_483_647, true)] {
|
||||
let mut request = valid_request();
|
||||
request.maybe_seed = Some(seed);
|
||||
request.maybe_generate_audio = Some(audio);
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["seed"], seed);
|
||||
assert_eq!(params["audio"], audio);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_duration_seed_and_prompt_are_rejected() {
|
||||
for duration in [0, 1, 31, 255] {
|
||||
let mut request = valid_request();
|
||||
request.duration_seconds = duration;
|
||||
assert_invalid(request.build_api_params(), "duration_seconds");
|
||||
}
|
||||
for seed in [2_147_483_648, u32::MAX] {
|
||||
let mut request = valid_request();
|
||||
request.maybe_seed = Some(seed);
|
||||
assert_invalid(request.build_api_params(), "maybe_seed");
|
||||
}
|
||||
let mut request = valid_request();
|
||||
request.prompt = "猫".repeat(20_000);
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
request.prompt.push('猫');
|
||||
assert_invalid(request.build_api_params(), "prompt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_generate_sends_workflow_request_and_returns_task_ids() {
|
||||
let (host, server) = stub_server().await;
|
||||
let session = KinoviWebSession::from_cookies_string("test-session=local".to_owned());
|
||||
let request = valid_request();
|
||||
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
|
||||
let result = timeout(Duration::from_secs(10), $generate($args {
|
||||
request,
|
||||
session: &session,
|
||||
maybe_host_override: Some(host),
|
||||
})).await.unwrap().unwrap();
|
||||
assert_eq!(result.task_id, "wan-task");
|
||||
assert_eq!(result.order_id, "wan-order");
|
||||
assert_eq!(result.task_ids, Some(vec!["wan-task".to_owned()]));
|
||||
assert_eq!(result.order_ids, Some(vec!["wan-order".to_owned()]));
|
||||
let (headers, body) = timeout(Duration::from_secs(10), server).await.unwrap().unwrap();
|
||||
assert!(headers.starts_with("POST /api/trpc/workflow.runTask?batch=1 HTTP/1.1\r\n"));
|
||||
assert_eq!(body, json!({"0": {"json": {
|
||||
"businessType": "wan3-video-generation", "apiParams": params,
|
||||
}}}));
|
||||
}
|
||||
|
||||
fn valid_request() -> $request {
|
||||
$request {
|
||||
prompt: "A lighthouse on the coast".to_owned(),
|
||||
$($field: $value,)*
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
operation_tests!(
|
||||
text,
|
||||
GenerateWan3p0TextToVideoRequest,
|
||||
GenerateWan3p0TextToVideoArgs,
|
||||
generate_wan_3p0_text_to_video,
|
||||
"wan3.0-text-to-video",
|
||||
WAN_CREDITS,
|
||||
WAN_CENTS,
|
||||
{}
|
||||
);
|
||||
operation_tests!(image, GenerateWan3p0ImageToVideoRequest, GenerateWan3p0ImageToVideoArgs,
|
||||
generate_wan_3p0_image_to_video, "wan3.0-image-to-video", WAN_CREDITS, WAN_CENTS,
|
||||
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
|
||||
operation_tests!(
|
||||
reference,
|
||||
GenerateWan3p0RefToVideoRequest,
|
||||
GenerateWan3p0RefToVideoArgs,
|
||||
generate_wan_3p0_ref_to_video,
|
||||
"wan3.0-ref-to-video",
|
||||
WAN_CREDITS,
|
||||
WAN_CENTS,
|
||||
{}
|
||||
);
|
||||
operation_tests!(
|
||||
prime_text,
|
||||
GenerateWan3p0PrimeTextToVideoRequest,
|
||||
GenerateWan3p0PrimeTextToVideoArgs,
|
||||
generate_wan_3p0_prime_text_to_video,
|
||||
"wan3.0-prime-text-to-video",
|
||||
PRIME_CREDITS,
|
||||
PRIME_CENTS,
|
||||
{}
|
||||
);
|
||||
operation_tests!(prime_image, GenerateWan3p0PrimeImageToVideoRequest, GenerateWan3p0PrimeImageToVideoArgs,
|
||||
generate_wan_3p0_prime_image_to_video, "wan3.0-prime-image-to-video", PRIME_CREDITS, PRIME_CENTS,
|
||||
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
|
||||
operation_tests!(
|
||||
prime_reference,
|
||||
GenerateWan3p0PrimeRefToVideoRequest,
|
||||
GenerateWan3p0PrimeRefToVideoArgs,
|
||||
generate_wan_3p0_prime_ref_to_video,
|
||||
"wan3.0-prime-ref-to-video",
|
||||
PRIME_CREDITS,
|
||||
PRIME_CENTS,
|
||||
{}
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn payloads_match_all_seven_consumer_captures() {
|
||||
for fixture in CAPTURES {
|
||||
let expected: Value = serde_json::from_str(fixture).unwrap();
|
||||
assert_eq!(params_from_capture(&expected), expected);
|
||||
}
|
||||
// Capture 03's filename says Wan image, but its actual model is Prime.
|
||||
// Exercise regular image too, using the same documented keyframe shape.
|
||||
let mut regular_image: Value = serde_json::from_str(CAPTURES[2]).unwrap();
|
||||
regular_image["model"] = json!("wan3.0-image-to-video");
|
||||
assert_eq!(params_from_capture(®ular_image), regular_image);
|
||||
}
|
||||
|
||||
macro_rules! reference_media_tests {
|
||||
($module:ident, $request:ident, $credits:literal) => {
|
||||
mod $module {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn references_are_optional_and_empty_lists_are_omitted() {
|
||||
let request = $request {
|
||||
prompt: "A ship at sea".to_owned(),
|
||||
duration_seconds: 30,
|
||||
maybe_reference_image_urls: Some(vec![]),
|
||||
maybe_reference_video_urls: Some(vec![]),
|
||||
maybe_reference_audio_urls: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
for field in ["uploadedUrls", "videoUrl", "videoUrls", "audioUrls"] {
|
||||
assert!(params.get(field).is_none(), "{field}");
|
||||
}
|
||||
assert_invalid($request::default().build_api_params(), "prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_duration_and_output_share_thirty_seconds() {
|
||||
let request = $request {
|
||||
duration_seconds: 20,
|
||||
maybe_reference_video_urls: Some(urls(1)),
|
||||
maybe_total_reference_video_duration_millis: Some(10_000),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
for millis in [0, 999, 10_001, 15_001, u32::MAX] {
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_total_reference_video_duration_millis = Some(millis);
|
||||
assert!(invalid.build_api_params().is_err(), "{millis}ms");
|
||||
}
|
||||
let mut request = request;
|
||||
request.duration_seconds = 15;
|
||||
request.maybe_total_reference_video_duration_millis = Some(15_000);
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
request.duration_seconds = 16;
|
||||
assert_invalid(request.clone().build_api_params(), "duration_seconds");
|
||||
request.maybe_total_reference_video_duration_millis = None;
|
||||
request.duration_seconds = 29;
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
request.duration_seconds = 30;
|
||||
assert_invalid(request.build_api_params(), "duration_seconds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_types_have_independent_limits_and_no_surcharge() {
|
||||
let request = $request {
|
||||
duration_seconds: 10,
|
||||
maybe_reference_image_urls: Some(urls(10)),
|
||||
maybe_reference_video_urls: Some(urls(5)),
|
||||
maybe_reference_audio_urls: Some(urls(5)),
|
||||
maybe_total_reference_video_duration_millis: Some(15_000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
|
||||
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["uploadedUrls"], json!(urls(10)));
|
||||
assert_eq!(params["videoUrls"], json!(urls(5)));
|
||||
assert_eq!(params["videoUrl"], urls(1)[0]);
|
||||
assert_eq!(params["audioUrls"], json!(urls(5)));
|
||||
assert!(params.get("maybeTotalReferenceVideoDurationMillis").is_none());
|
||||
assert!(params.get("imageUrls").is_none());
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_reference_image_urls = Some(urls(11));
|
||||
assert_invalid(invalid.build_api_params(), "maybe_reference_image_urls");
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_reference_video_urls = Some(urls(6));
|
||||
assert_invalid(invalid.build_api_params(), "maybe_reference_video_urls");
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_reference_audio_urls = Some(urls(6));
|
||||
assert_invalid(invalid.build_api_params(), "maybe_reference_audio_urls");
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_total_reference_video_duration_millis = Some(4_999);
|
||||
assert_invalid(invalid.build_api_params(), "maybe_total_reference_video_duration_millis");
|
||||
let mut invalid = request;
|
||||
invalid.maybe_reference_audio_urls = Some(vec![" ".to_owned()]);
|
||||
assert_invalid(invalid.build_api_params(), "media_url");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reference_media_tests!(reference_media, GenerateWan3p0RefToVideoRequest, 199.6);
|
||||
reference_media_tests!(prime_reference_media, GenerateWan3p0PrimeRefToVideoRequest, 388.3);
|
||||
|
||||
#[test]
|
||||
fn keyframes_require_a_start_image_and_preserve_start_end_order() {
|
||||
macro_rules! check {
|
||||
($request:ident, $credits:literal) => {
|
||||
let mut request = $request {
|
||||
prompt: "Waves".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_invalid(request.clone().build_api_params(), "maybe_start_frame_url");
|
||||
request.maybe_end_frame_url = Some("https://example.com/end.png".to_owned());
|
||||
assert_invalid(request.clone().build_api_params(), "maybe_end_frame_url");
|
||||
request.maybe_start_frame_url = Some("https://example.com/start.png".to_owned());
|
||||
request.prompt.clear();
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["uploadedUrls"], json!(["https://example.com/start.png", "https://example.com/end.png",]));
|
||||
assert!(params.get("imageUrls").is_none());
|
||||
};
|
||||
}
|
||||
check!(GenerateWan3p0ImageToVideoRequest, 99.8);
|
||||
check!(GenerateWan3p0PrimeImageToVideoRequest, 194.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_operations_require_a_prompt() {
|
||||
assert_invalid(GenerateWan3p0TextToVideoRequest::default().build_api_params(), "prompt");
|
||||
assert_invalid(GenerateWan3p0PrimeTextToVideoRequest::default().build_api_params(), "prompt");
|
||||
}
|
||||
|
||||
fn params_from_capture(value: &Value) -> Value {
|
||||
macro_rules! build {
|
||||
($request:ident, {$($field:ident: $extra:expr),* $(,)?}) => {
|
||||
$request {
|
||||
prompt: value["prompt"].as_str().unwrap().to_owned(),
|
||||
maybe_aspect_ratio: Some(match value["aspectRatio"].as_str().unwrap() {
|
||||
"16:9" => KinoviWan3p0AspectRatio::Landscape16x9,
|
||||
"4:3" => KinoviWan3p0AspectRatio::Landscape4x3,
|
||||
other => panic!("unexpected fixture aspect ratio: {other}"),
|
||||
}),
|
||||
maybe_output_resolution: Some(match value["outputResolution"].as_str().unwrap() {
|
||||
"480p" => KinoviWan3p0OutputResolution::FourEightyP,
|
||||
"720p" => KinoviWan3p0OutputResolution::SevenTwentyP,
|
||||
"1080p" => KinoviWan3p0OutputResolution::TenEightyP,
|
||||
other => panic!("unexpected fixture resolution: {other}"),
|
||||
}),
|
||||
duration_seconds: value["duration"].as_str().unwrap().trim_end_matches('s').parse().unwrap(),
|
||||
maybe_seed: value["seed"].as_u64().map(|seed| seed as u32),
|
||||
$($field: $extra,)*
|
||||
..Default::default()
|
||||
}.build_api_params().unwrap()
|
||||
};
|
||||
}
|
||||
macro_rules! image {
|
||||
($request:ident) => {
|
||||
build!($request, {
|
||||
maybe_start_frame_url: value["uploadedUrls"][0].as_str().map(str::to_owned),
|
||||
maybe_end_frame_url: value["uploadedUrls"][1].as_str().map(str::to_owned),
|
||||
})
|
||||
};
|
||||
}
|
||||
macro_rules! reference {
|
||||
($request:ident) => {
|
||||
build!($request, {
|
||||
maybe_reference_image_urls: capture_urls(value, "uploadedUrls"),
|
||||
maybe_reference_video_urls: capture_urls(value, "videoUrls"),
|
||||
maybe_reference_audio_urls: capture_urls(value, "audioUrls"),
|
||||
})
|
||||
};
|
||||
}
|
||||
let params = match value["model"].as_str().unwrap() {
|
||||
"wan3.0-text-to-video" => build!(GenerateWan3p0TextToVideoRequest, {}),
|
||||
"wan3.0-image-to-video" => image!(GenerateWan3p0ImageToVideoRequest),
|
||||
"wan3.0-ref-to-video" => reference!(GenerateWan3p0RefToVideoRequest),
|
||||
"wan3.0-prime-text-to-video" => build!(GenerateWan3p0PrimeTextToVideoRequest, {}),
|
||||
"wan3.0-prime-image-to-video" => image!(GenerateWan3p0PrimeImageToVideoRequest),
|
||||
"wan3.0-prime-ref-to-video" => reference!(GenerateWan3p0PrimeRefToVideoRequest),
|
||||
other => panic!("unexpected fixture model: {other}"),
|
||||
};
|
||||
serde_json::to_value(params).unwrap()
|
||||
}
|
||||
|
||||
async fn stub_server() -> (KinoviHost, JoinHandle<(String, Value)>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let (headers, body_start, body_len) = loop {
|
||||
let mut chunk = [0; 4096];
|
||||
let length = stream.read(&mut chunk).await.unwrap();
|
||||
assert_ne!(length, 0, "request ended before headers");
|
||||
bytes.extend_from_slice(&chunk[..length]);
|
||||
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8(bytes[..end + 4].to_vec()).unwrap();
|
||||
let body_len: usize = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap();
|
||||
break (headers, end + 4, body_len);
|
||||
}
|
||||
};
|
||||
while bytes.len() < body_start + body_len {
|
||||
let mut chunk = [0; 4096];
|
||||
let length = stream.read(&mut chunk).await.unwrap();
|
||||
assert_ne!(length, 0, "request ended before body");
|
||||
bytes.extend_from_slice(&chunk[..length]);
|
||||
}
|
||||
let body = serde_json::from_slice(&bytes[body_start..body_start + body_len]).unwrap();
|
||||
let response = json!([{"result": {"data": {"json": {
|
||||
"taskId": "wan-task", "orderId": "wan-order",
|
||||
"taskIds": ["wan-task"], "orderIds": ["wan-order"], "violationWarning": false,
|
||||
}}}}])
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response.len(),
|
||||
response,
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
(headers, body)
|
||||
});
|
||||
(
|
||||
KinoviHost::CustomHost {
|
||||
api_host: address.clone(),
|
||||
cdn_host: address,
|
||||
},
|
||||
server,
|
||||
)
|
||||
}
|
||||
|
||||
fn capture_urls(value: &Value, field: &str) -> Option<Vec<String>> {
|
||||
value[field]
|
||||
.as_array()
|
||||
.map(|urls| urls.iter().map(|url| url.as_str().unwrap().to_owned()).collect())
|
||||
}
|
||||
|
||||
fn urls(count: usize) -> Vec<String> {
|
||||
(0..count).map(|index| format!("https://example.com/reference-{index}")).collect()
|
||||
}
|
||||
|
||||
fn assert_invalid(result: Result<Wan3p0ApiParams, KinoviWebError>, field: &str) {
|
||||
match result.unwrap_err() {
|
||||
KinoviWebError::Client(KinoviWebClientError::InvalidRequestField {
|
||||
field: actual,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(actual, field);
|
||||
},
|
||||
other => panic!("expected invalid {field}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,10 @@ pub (super) struct ApiParams {
|
||||
pub batch_count: Option<u8>,
|
||||
#[serde(rename = "bitrate_mode", skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_mode: Option<&'static str>,
|
||||
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_output_format: Option<&'static str>,
|
||||
#[serde(rename = "generate_audio", skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
|
||||
+152
@@ -74,6 +74,12 @@ pub struct WorkflowRunTaskRequest {
|
||||
/// Output video bitrate. None defaults to "standard" (the field is omitted);
|
||||
/// `High` sends `bitrate_mode: "high"`. Does not affect cost.
|
||||
pub bitrate: Option<KinoviBitrateRaw>,
|
||||
|
||||
/// Output container, supported only by Seedance 2.5. None omits the field.
|
||||
pub maybe_output_format: Option<KinoviOutputFormatRaw>,
|
||||
|
||||
/// Audio generation for Seedance 2.0 and 2.5. None keeps the provider default.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkflowRunTaskRequest {
|
||||
@@ -93,6 +99,8 @@ impl std::fmt::Debug for WorkflowRunTaskRequest {
|
||||
.field("output_resolution", &self.output_resolution)
|
||||
.field("use_face_blur_hack", &self.use_face_blur_hack)
|
||||
.field("bitrate", &self.bitrate)
|
||||
.field("maybe_output_format", &self.maybe_output_format)
|
||||
.field("maybe_generate_audio", &self.maybe_generate_audio)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -108,6 +116,22 @@ impl std::fmt::Debug for WorkflowRunTaskArgs<'_> {
|
||||
|
||||
// --- Public enums ---
|
||||
|
||||
/// Output container for Seedance 2.5.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviOutputFormatRaw {
|
||||
Mp4,
|
||||
Mov,
|
||||
}
|
||||
|
||||
impl KinoviOutputFormatRaw {
|
||||
fn as_api_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mp4 => "mp4",
|
||||
Self::Mov => "mov",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Video resolution / aspect ratio.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviAspectRatioRaw {
|
||||
@@ -589,6 +613,15 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
audio_urls,
|
||||
batch_count,
|
||||
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
|
||||
maybe_output_format: match req.model_type {
|
||||
KinoviModelTypeRaw::Seedance2p5 => req.maybe_output_format.map(KinoviOutputFormatRaw::as_api_str),
|
||||
_ => None,
|
||||
},
|
||||
maybe_generate_audio: match req.model_type {
|
||||
KinoviModelTypeRaw::Seedance2Pro | KinoviModelTypeRaw::Seedance2Fast
|
||||
| KinoviModelTypeRaw::Seedance2Mini | KinoviModelTypeRaw::Seedance2p5 => req.maybe_generate_audio,
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -650,6 +683,8 @@ mod tests {
|
||||
audio_urls: None,
|
||||
batch_count: None,
|
||||
bitrate_mode,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -701,6 +736,8 @@ mod tests {
|
||||
audio_urls: None,
|
||||
batch_count: None,
|
||||
bitrate_mode: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -735,6 +772,8 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,6 +816,30 @@ mod tests {
|
||||
assert!(json.contains(r#""batchCount":8"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_serializes_for_all_video_models() {
|
||||
for model in [
|
||||
KinoviModelTypeRaw::Seedance2Pro,
|
||||
KinoviModelTypeRaw::Seedance2Fast,
|
||||
KinoviModelTypeRaw::Seedance2Mini,
|
||||
KinoviModelTypeRaw::Seedance2p5,
|
||||
KinoviModelTypeRaw::Seedance2p5Preview,
|
||||
KinoviModelTypeRaw::HappyHorse1p0,
|
||||
] {
|
||||
let batches = match model {
|
||||
KinoviModelTypeRaw::HappyHorse1p0 => [(KinoviBatchCountRaw::One, None), (KinoviBatchCountRaw::Three, Some(3))],
|
||||
_ => [(KinoviBatchCountRaw::One, None), (KinoviBatchCountRaw::Eight, Some(8))],
|
||||
};
|
||||
for (batch, expected) in batches {
|
||||
let mut request = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, batch);
|
||||
request.model_type = model;
|
||||
let body = serde_json::to_value(build_batch_request(request)).unwrap();
|
||||
let params = &body["0"]["json"]["apiParams"];
|
||||
assert_eq!(params.get("batchCount"), expected.map(serde_json::Value::from).as_ref(), "{model:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_mini_still_uses_resolution_field() {
|
||||
let mut req = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, KinoviBatchCountRaw::One);
|
||||
@@ -842,6 +905,8 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +1012,51 @@ mod tests {
|
||||
mod seedance_2p5_shape_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn optional_output_settings_preserve_omission_and_explicit_values() {
|
||||
for (format, expected) in [(None, None), (Some(KinoviOutputFormatRaw::Mp4), Some("mp4")), (Some(KinoviOutputFormatRaw::Mov), Some("mov"))] {
|
||||
for audio in [None, Some(false), Some(true)] {
|
||||
let mut request = base_request("A boat on a lake", 4);
|
||||
request.maybe_output_format = format;
|
||||
request.maybe_generate_audio = audio;
|
||||
let body = serde_json::to_value(build_batch_request(request)).unwrap();
|
||||
let params = &body["0"]["json"]["apiParams"];
|
||||
assert_eq!(params.get("output_format"), expected.map(serde_json::Value::from).as_ref());
|
||||
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_setting_applies_to_seedance_2p0_without_output_format() {
|
||||
for model in [KinoviModelTypeRaw::Seedance2Pro, KinoviModelTypeRaw::Seedance2Fast, KinoviModelTypeRaw::Seedance2Mini] {
|
||||
for audio in [None, Some(false), Some(true)] {
|
||||
let mut request = base_request("A boat on a lake", 4);
|
||||
request.model_type = model;
|
||||
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
|
||||
request.maybe_generate_audio = audio;
|
||||
let body = serde_json::to_value(build_batch_request(request)).unwrap();
|
||||
let params = &body["0"]["json"]["apiParams"];
|
||||
assert!(params.get("output_format").is_none());
|
||||
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_models_omit_unsupported_output_settings() {
|
||||
for model in [KinoviModelTypeRaw::HappyHorse1p0, KinoviModelTypeRaw::Seedance2p5Preview] {
|
||||
let mut request = base_request("A boat on a lake", 4);
|
||||
request.model_type = model;
|
||||
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
|
||||
request.maybe_generate_audio = Some(false);
|
||||
let body = serde_json::to_value(build_batch_request(request)).unwrap();
|
||||
let params = &body["0"]["json"]["apiParams"];
|
||||
assert!(params.get("output_format").is_none());
|
||||
assert!(params.get("generate_audio").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2p5,
|
||||
@@ -963,6 +1073,8 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,6 +1227,8 @@ mod tests {
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,6 +1319,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1239,6 +1355,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1275,6 +1393,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1310,6 +1430,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1347,6 +1469,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1401,6 +1525,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1454,6 +1580,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1507,6 +1635,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1569,6 +1699,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1624,6 +1756,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1664,6 +1798,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1698,6 +1834,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1734,6 +1872,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1771,6 +1911,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1809,6 +1951,8 @@ mod tests {
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1861,6 +2005,8 @@ mod tests {
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
output_resolution: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1913,6 +2059,8 @@ mod tests {
|
||||
use_face_blur_hack: Some(false),
|
||||
bitrate: None,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
@@ -1957,6 +2105,8 @@ mod tests {
|
||||
output_resolution,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2063,6 +2213,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::FourK),
|
||||
maybe_output_format: None,
|
||||
maybe_generate_audio: None,
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-8;
|
||||
|
||||
/// Check scaling of credits and fractional cents, then independently compute
|
||||
/// whole cents from the complete batch using integer arithmetic. Multiplying
|
||||
/// a single video's already-rounded cents can overcharge a batch.
|
||||
pub(crate) fn assert_batch_cost(
|
||||
single: KinoviFractionalGenerationCost,
|
||||
batched: KinoviFractionalGenerationCost,
|
||||
count: u64,
|
||||
tier: KinoviPricingTier,
|
||||
context: &str,
|
||||
) {
|
||||
let expected_credit_hundredths = (single.kinovi_credits * 100.0).round() as u64 * count;
|
||||
let expected_credits = expected_credit_hundredths as f64 / 100.0;
|
||||
let expected_fractional_cents = single.usd_cents_fractional * count as f64;
|
||||
assert_eq!(batched.kinovi_credits, expected_credits, "{context}: credits");
|
||||
assert!(
|
||||
(batched.usd_cents_fractional - expected_fractional_cents).abs() < FLOAT_TOLERANCE,
|
||||
"{context}: fractional cents: {} != {expected_fractional_cents}",
|
||||
batched.usd_cents_fractional,
|
||||
);
|
||||
|
||||
// Credit purchase rates expressed in hundredths of a credit per dollar.
|
||||
let credits_per_dollar_hundredths = match tier {
|
||||
KinoviPricingTier::Consumer => 19_298,
|
||||
KinoviPricingTier::Enterprise => 24_316,
|
||||
};
|
||||
let numerator = expected_credit_hundredths * 100;
|
||||
assert_eq!(batched.usd_cents_rounded_up, numerator.div_ceil(credits_per_dollar_hundredths), "{context}: rounded up");
|
||||
assert_eq!(batched.usd_cents_rounded_down, numerator / credits_per_dollar_hundredths, "{context}: rounded down");
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
#[cfg(test)]
|
||||
pub (crate) mod assert_batch_cost;
|
||||
|
||||
#[cfg(test)]
|
||||
pub (crate) mod get_test_cookies;
|
||||
|
||||
|
||||
Loaded 100 of 433 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user