Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfa8264642 | ||
|
|
fcb567ec3c | ||
|
|
734b757ef9 | ||
|
|
daf401c635 | ||
|
|
c0207c61ba | ||
|
|
0a7b7ae2ac | ||
|
|
92bc1e24c4 | ||
|
|
6dd7a88a12 | ||
|
|
d5a2b36525 | ||
|
|
e3c553ff2c | ||
|
|
094d33e405 |
No files matched your search
+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,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,
|
||||
|
||||
|
||||
+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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -1,3 +1,5 @@
|
||||
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;
|
||||
@@ -142,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.
|
||||
@@ -192,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),
|
||||
@@ -254,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),
|
||||
|
||||
+1
@@ -45,3 +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;
|
||||
+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
|
||||
}
|
||||
}
|
||||
+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;
|
||||
+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
|
||||
|
||||
@@ -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.
|
||||
+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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,25 @@ cargo run -p support-tool -- kinovi_web generate_video --prompt "A corgi at the
|
||||
|
||||
Requires a `.env-support-tool-secrets` file (or env vars) with:
|
||||
|
||||
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/KinoviWeb API auth
|
||||
- `SEEDANCE2PRO_VOLCENGINE_COOKIES` — Volcengine cookies (legacy fallback: `SEEDANCE2PRO_COOKIES`)
|
||||
- `SEEDANCE2PRO_BYTEPLUS_COOKIES` — BytePlus cookies (legacy fallback: `SEEDANCE2PRO_ALT_COOKIES`)
|
||||
- `SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES` — BytePlus Ultra cookies
|
||||
- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`)
|
||||
|
||||
All `kinovi_web` commands (also available as `kinovi` / `seedance2pro`) accept
|
||||
`--kinovi-account volcengine|byteplus|byteplusultra`, defaulting to `volcengine`.
|
||||
Use `--cookies-env ENV_VAR_NAME` to explicitly override that account's cookie
|
||||
configuration. The override has no default; when omitted, only the selected
|
||||
account's variables and its legacy fallback are checked. Either flag can appear
|
||||
before or after the subcommand. CSV account labels default to the resolved cookie
|
||||
env var name; `--account` still overrides the output label.
|
||||
|
||||
```bash
|
||||
support-tool kinovi_web account_info --kinovi-account byteplus
|
||||
support-tool seedance2pro find_job --token <order_id> --kinovi-account byteplusultra
|
||||
support-tool kinovi_web account_info --kinovi-account byteplus --cookies-env CUSTOM_COOKIES
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use clap::ValueEnum;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
|
||||
#[value(rename_all = "lowercase")]
|
||||
pub enum KinoviAccount {
|
||||
Volcengine,
|
||||
BytePlus,
|
||||
BytePlusUltra,
|
||||
}
|
||||
|
||||
impl KinoviAccount {
|
||||
/// Cookie configuration and legacy fallbacks used by seedance2-pro-job.
|
||||
pub fn cookie_env_vars(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Volcengine => &["SEEDANCE2PRO_VOLCENGINE_COOKIES", "SEEDANCE2PRO_COOKIES"],
|
||||
Self::BytePlus => &["SEEDANCE2PRO_BYTEPLUS_COOKIES", "SEEDANCE2PRO_ALT_COOKIES"],
|
||||
Self::BytePlusUltra => &["SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::anyhow;
|
||||
use clap::Subcommand;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use super::account::KinoviAccount;
|
||||
use super::state::KinoviWebState;
|
||||
use super::subcommands;
|
||||
|
||||
@@ -16,6 +16,20 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
|
||||
"generate_video",
|
||||
];
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct KinoviWebArgs {
|
||||
/// Kinovi account whose cookie configuration to use.
|
||||
#[arg(long, global = true, value_enum, ignore_case = true, default_value = "volcengine")]
|
||||
pub kinovi_account: KinoviAccount,
|
||||
|
||||
/// Read cookies from this env var, overriding --kinovi-account only when supplied.
|
||||
#[arg(long = "cookies-env", global = true, value_name = "ENV_VAR_NAME")]
|
||||
pub maybe_cookies_env: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: KinoviWebCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(rename_all = "snake_case")]
|
||||
pub enum KinoviWebCommand {
|
||||
@@ -44,13 +58,10 @@ pub enum KinoviWebCommand {
|
||||
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
|
||||
}
|
||||
|
||||
pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
|
||||
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
|
||||
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
|
||||
pub async fn run(args: KinoviWebArgs) -> anyhow::Result<()> {
|
||||
let state = KinoviWebState::from_env(args.kinovi_account, args.maybe_cookies_env.as_deref())?;
|
||||
|
||||
let state = KinoviWebState { cookies };
|
||||
|
||||
match command {
|
||||
match args.command {
|
||||
KinoviWebCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
|
||||
KinoviWebCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
|
||||
KinoviWebCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
|
||||
@@ -60,3 +71,85 @@ pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
|
||||
KinoviWebCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::{error::ErrorKind, Parser};
|
||||
|
||||
use super::*;
|
||||
use crate::commands::run::{all_canonical_names, Cli, TopLevelCommand};
|
||||
use crate::utils::normalize_subcommands::normalize_subcommand_args;
|
||||
|
||||
const COMMANDS: &[&[&str]] = &[
|
||||
&["account_info"],
|
||||
&["audit_credits", "--out", "/tmp/credits.csv"],
|
||||
&["audit_orders", "--out", "/tmp/orders.csv"],
|
||||
&["audit_payments", "--out", "/tmp/payments.csv"],
|
||||
&["find_job", "--token", "test-order"],
|
||||
&["failed_job_histogram"],
|
||||
&["generate_video", "--prompt", "A corgi at the lake"],
|
||||
];
|
||||
|
||||
mod cli_parsing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_command_defaults_to_volcengine_without_a_cookie_override() {
|
||||
for command in COMMANDS {
|
||||
let args = parse_kinovi(command);
|
||||
assert_eq!(args.kinovi_account, KinoviAccount::Volcengine);
|
||||
assert!(args.maybe_cookies_env.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_accepts_all_accounts_and_cookie_overrides_before_or_after_subcommand() {
|
||||
for (value, account) in [
|
||||
("volcengine", KinoviAccount::Volcengine),
|
||||
("byteplus", KinoviAccount::BytePlus),
|
||||
("byteplusultra", KinoviAccount::BytePlusUltra),
|
||||
] {
|
||||
for command in COMMANDS {
|
||||
let flags = ["--kinovi-account", value, "--cookies-env", "CUSTOM_COOKIES"];
|
||||
for args in [
|
||||
[flags.as_slice(), *command].concat(),
|
||||
[*command, flags.as_slice()].concat(),
|
||||
] {
|
||||
let args = parse_kinovi(&args);
|
||||
assert_eq!(args.kinovi_account, account);
|
||||
assert_eq!(args.maybe_cookies_env.as_deref(), Some("CUSTOM_COOKIES"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_and_underscore_normalization_preserve_account_selection() {
|
||||
for alias in ["kinovi_web", "kinoviweb", "kinovi", "seedance2pro", "seedance2_pro"] {
|
||||
let args = ["support-tool", alias, "accountinfo", "--kinovi-account", "BYTEPLUSULTRA"];
|
||||
let args = normalize_subcommand_args(args.map(String::from), &all_canonical_names());
|
||||
let cli = Cli::try_parse_from(args).unwrap();
|
||||
let TopLevelCommand::KinoviWeb(args) = cli.command else {
|
||||
panic!("expected Kinovi command");
|
||||
};
|
||||
assert_eq!(args.kinovi_account, KinoviAccount::BytePlusUltra);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_accounts() {
|
||||
let err = Cli::try_parse_from([
|
||||
"support-tool", "kinovi_web", "account_info", "--kinovi-account", "unknown",
|
||||
]).err().expect("unknown accounts should be rejected");
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidValue);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kinovi(args: &[&str]) -> KinoviWebArgs {
|
||||
let cli = Cli::try_parse_from([&["support-tool", "kinovi_web"], args].concat()).unwrap();
|
||||
match cli.command {
|
||||
TopLevelCommand::KinoviWeb(args) => args,
|
||||
_ => panic!("expected Kinovi command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod account;
|
||||
pub mod dispatch;
|
||||
mod state;
|
||||
mod subcommands;
|
||||
|
||||
pub use dispatch::KinoviWebCommand;
|
||||
pub use dispatch::KinoviWebArgs;
|
||||
pub use dispatch::run;
|
||||
@@ -1,3 +1,131 @@
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::account::KinoviAccount;
|
||||
|
||||
pub struct KinoviWebState {
|
||||
pub cookies: String,
|
||||
pub cookies_env: String,
|
||||
}
|
||||
|
||||
impl KinoviWebState {
|
||||
pub fn from_env(account: KinoviAccount, maybe_cookies_env: Option<&str>) -> anyhow::Result<Self> {
|
||||
Self::from_env_lookup(account, maybe_cookies_env, easyenv::get_env_string_optional)
|
||||
}
|
||||
|
||||
fn from_env_lookup(
|
||||
account: KinoviAccount,
|
||||
maybe_cookies_env: Option<&str>,
|
||||
mut lookup: impl FnMut(&str) -> Option<String>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// An explicit override must resolve on its own, without requiring or falling
|
||||
// back to credentials for the selected account.
|
||||
let override_vars;
|
||||
let cookie_env_vars = match maybe_cookies_env {
|
||||
Some(var) => {
|
||||
override_vars = [var];
|
||||
&override_vars[..]
|
||||
},
|
||||
None => account.cookie_env_vars(),
|
||||
};
|
||||
|
||||
for &var in cookie_env_vars {
|
||||
if let Some(cookies) = lookup(var) {
|
||||
return Ok(Self {
|
||||
cookies,
|
||||
cookies_env: var.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Missing Kinovi cookies: set {}", cookie_env_vars.join(" or ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COOKIES: &[(&str, &str)] = &[
|
||||
("SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
|
||||
("SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
|
||||
("SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
|
||||
("SEEDANCE2PRO_COOKIES", "legacy-cookies"),
|
||||
("SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
|
||||
("CUSTOM_COOKIES", "override-cookies"),
|
||||
];
|
||||
|
||||
mod account_selection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selects_each_account_even_when_legacy_cookies_are_set() {
|
||||
for (account, var, cookies) in [
|
||||
(KinoviAccount::Volcengine, "SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
|
||||
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
|
||||
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
|
||||
] {
|
||||
for env in [COOKIES, &[(var, cookies)]] {
|
||||
let state = resolve(account, None, env).unwrap();
|
||||
assert_eq!(state.cookies, cookies);
|
||||
assert_eq!(state.cookies_env, var);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_matching_legacy_variable() {
|
||||
let env = &COOKIES[3..5];
|
||||
for (account, var, cookies) in [
|
||||
(KinoviAccount::Volcengine, "SEEDANCE2PRO_COOKIES", "legacy-cookies"),
|
||||
(KinoviAccount::BytePlus, "SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
|
||||
] {
|
||||
let state = resolve(account, None, env).unwrap();
|
||||
assert_eq!(state.cookies, cookies);
|
||||
assert_eq!(state.cookies_env, var);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_account_cookies_never_use_another_accounts_credentials() {
|
||||
for (account, expected_var) in [
|
||||
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES"),
|
||||
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"),
|
||||
] {
|
||||
let err = resolve(account, None, &COOKIES[3..4]).err().expect("account cookies are missing");
|
||||
assert!(err.to_string().contains(expected_var));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod cookie_overrides {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_override_wins_with_or_without_account_cookies() {
|
||||
for account in [KinoviAccount::Volcengine, KinoviAccount::BytePlus, KinoviAccount::BytePlusUltra] {
|
||||
for env in [COOKIES, &COOKIES[5..]] {
|
||||
let state = resolve(account, Some("CUSTOM_COOKIES"), env).unwrap();
|
||||
assert_eq!(state.cookies, "override-cookies");
|
||||
assert_eq!(state.cookies_env, "CUSTOM_COOKIES");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_explicit_override_errors_instead_of_using_account_cookies() {
|
||||
let err = resolve(KinoviAccount::Volcengine, Some("MISSING_COOKIES"), COOKIES)
|
||||
.err().expect("explicit override must exist");
|
||||
assert_eq!(err.to_string(), "Missing Kinovi cookies: set MISSING_COOKIES");
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
account: KinoviAccount,
|
||||
maybe_override: Option<&str>,
|
||||
env: &[(&str, &str)],
|
||||
) -> anyhow::Result<KinoviWebState> {
|
||||
KinoviWebState::from_env_lookup(account, maybe_override, |var| {
|
||||
env.iter().find(|(name, _)| *name == var).map(|(_, value)| value.to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-16
@@ -11,28 +11,14 @@ use super::super::state::KinoviWebState;
|
||||
/// single CSV line: account,email,credits,available_credits.
|
||||
#[derive(Args)]
|
||||
pub struct AccountInfoArgs {
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label for the output line. Defaults to the cookies env var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AccountInfoArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let details = get_user_auth_details(GetUserAuthDetailsArgs {
|
||||
session: &session,
|
||||
|
||||
+3
-17
@@ -44,10 +44,6 @@ pub struct AuditCreditsArgs {
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
@@ -55,18 +51,8 @@ pub struct AuditCreditsArgs {
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
@@ -101,7 +87,7 @@ pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Resu
|
||||
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
|
||||
|
||||
for entry in &page.entries {
|
||||
write_entry_row(&mut out, &account_label, entry)?;
|
||||
write_entry_row(&mut out, account_label, entry)?;
|
||||
rows += 1;
|
||||
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
|
||||
tally.0 += 1;
|
||||
|
||||
+5
-21
@@ -43,31 +43,15 @@ pub struct AuditOrdersArgs {
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
|
||||
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
|
||||
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name (or "SEEDANCE2PRO_COOKIES").
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
@@ -106,7 +90,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
|
||||
stats.skipped_before_window += 1;
|
||||
continue;
|
||||
}
|
||||
write_order_row(&mut out, &account_label, order)?;
|
||||
write_order_row(&mut out, account_label, order)?;
|
||||
stats.record(order);
|
||||
}
|
||||
out.flush()?;
|
||||
@@ -133,7 +117,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
|
||||
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
|
||||
}
|
||||
|
||||
stats.print_summary(&account_label, cursor);
|
||||
stats.print_summary(account_label, cursor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+3
-17
@@ -29,10 +29,6 @@ pub struct AuditPaymentsArgs {
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
@@ -40,18 +36,8 @@ pub struct AuditPaymentsArgs {
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
@@ -82,7 +68,7 @@ pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Res
|
||||
}
|
||||
|
||||
for payment in &page.payments {
|
||||
write_payment_row(&mut out, &account_label, payment)?;
|
||||
write_payment_row(&mut out, account_label, payment)?;
|
||||
rows += 1;
|
||||
total_amount += payment.amount_usd;
|
||||
total_credits += payment.credits_earned;
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::kinovi_web;
|
||||
/// All canonical subcommand names across all modules.
|
||||
/// Used by the underscore-insensitive arg normalizer.
|
||||
pub fn all_canonical_names() -> Vec<&'static str> {
|
||||
let mut names: Vec<&str> = vec!["kinovi_web", "artcraft"];
|
||||
let mut names: Vec<&str> = vec!["kinovi_web", "kinovi", "seedance2pro", "artcraft"];
|
||||
names.extend_from_slice(kinovi_web::dispatch::SUBCOMMAND_NAMES);
|
||||
names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES);
|
||||
names
|
||||
@@ -23,10 +23,8 @@ pub struct Cli {
|
||||
#[command(rename_all = "snake_case")]
|
||||
pub enum TopLevelCommand {
|
||||
/// Seedance2 Pro support commands (direct Kinovi API)
|
||||
KinoviWeb {
|
||||
#[command(subcommand)]
|
||||
command: kinovi_web::KinoviWebCommand,
|
||||
},
|
||||
#[command(visible_aliases = ["kinovi", "seedance2pro"])]
|
||||
KinoviWeb(kinovi_web::KinoviWebArgs),
|
||||
|
||||
/// ArtCraft support commands (omni API)
|
||||
Artcraft(artcraft::dispatch::ArtcraftArgs),
|
||||
@@ -34,8 +32,8 @@ pub enum TopLevelCommand {
|
||||
|
||||
pub async fn run(cli: Cli) -> anyhow::Result<()> {
|
||||
match cli.command {
|
||||
TopLevelCommand::KinoviWeb { command } => {
|
||||
kinovi_web::run(command).await
|
||||
TopLevelCommand::KinoviWeb(args) => {
|
||||
kinovi_web::run(args).await
|
||||
}
|
||||
TopLevelCommand::Artcraft(args) => {
|
||||
artcraft::dispatch(args).await
|
||||
|
||||
+20
@@ -192,6 +192,12 @@ pub enum InferenceModelType {
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
@@ -394,6 +400,8 @@ impl InferenceModelType {
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Wan3p0 => "wan_3p0",
|
||||
Self::Wan3p0Prime => "wan_3p0_prime",
|
||||
Self::MinimaxH3 => "minimax_h3",
|
||||
Self::MinimaxH3Turbo => "minimax_h3_turbo",
|
||||
Self::MinimaxH3Ultra => "minimax_h3_ultra",
|
||||
@@ -534,6 +542,8 @@ impl InferenceModelType {
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"wan_3p0" => Ok(Self::Wan3p0),
|
||||
"wan_3p0_prime" => Ok(Self::Wan3p0Prime),
|
||||
"minimax_h3" => Ok(Self::MinimaxH3),
|
||||
"minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo),
|
||||
"minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra),
|
||||
@@ -678,6 +688,8 @@ impl InferenceModelType {
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Wan3p0,
|
||||
Self::Wan3p0Prime,
|
||||
Self::MinimaxH3,
|
||||
Self::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra,
|
||||
@@ -805,6 +817,8 @@ impl InferenceModelType {
|
||||
CommonModelType::Kling3p0Standard => Self::Kling3p0Standard,
|
||||
CommonModelType::Kling3p0Pro => Self::Kling3p0Pro,
|
||||
CommonModelType::HappyHorse1p0 => Self::HappyHorse1p0,
|
||||
CommonModelType::Wan3p0 => Self::Wan3p0,
|
||||
CommonModelType::Wan3p0Prime => Self::Wan3p0Prime,
|
||||
CommonModelType::MinimaxH3 => Self::MinimaxH3,
|
||||
CommonModelType::MinimaxH3Turbo => Self::MinimaxH3Turbo,
|
||||
CommonModelType::MinimaxH3Ultra => Self::MinimaxH3Ultra,
|
||||
@@ -954,6 +968,8 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(InferenceModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(InferenceModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(InferenceModelType::Wan3p0, "wan_3p0");
|
||||
assert_serialization(InferenceModelType::Wan3p0Prime, "wan_3p0_prime");
|
||||
assert_serialization(InferenceModelType::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(InferenceModelType::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(InferenceModelType::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
@@ -1090,6 +1106,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
|
||||
assert_eq!(InferenceModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(InferenceModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(InferenceModelType::Wan3p0.to_str(), "wan_3p0");
|
||||
assert_eq!(InferenceModelType::Wan3p0Prime.to_str(), "wan_3p0_prime");
|
||||
assert_eq!(InferenceModelType::MinimaxH3.to_str(), "minimax_h3");
|
||||
assert_eq!(InferenceModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo");
|
||||
assert_eq!(InferenceModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra");
|
||||
@@ -1228,6 +1246,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("kling_3p0_standard").unwrap(), InferenceModelType::Kling3p0Standard);
|
||||
assert_eq!(InferenceModelType::from_str("kling_3p0_pro").unwrap(), InferenceModelType::Kling3p0Pro);
|
||||
assert_eq!(InferenceModelType::from_str("happy_horse_1p0").unwrap(), InferenceModelType::HappyHorse1p0);
|
||||
assert_eq!(InferenceModelType::from_str("wan_3p0").unwrap(), InferenceModelType::Wan3p0);
|
||||
assert_eq!(InferenceModelType::from_str("wan_3p0_prime").unwrap(), InferenceModelType::Wan3p0Prime);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3").unwrap(), InferenceModelType::MinimaxH3);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3_turbo").unwrap(), InferenceModelType::MinimaxH3Turbo);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3_ultra").unwrap(), InferenceModelType::MinimaxH3Ultra);
|
||||
|
||||
@@ -163,6 +163,12 @@ pub enum CommonModelType {
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
@@ -349,6 +355,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Wan3p0 => "wan_3p0",
|
||||
Self::Wan3p0Prime => "wan_3p0_prime",
|
||||
Self::MinimaxH3 => "minimax_h3",
|
||||
Self::MinimaxH3Turbo => "minimax_h3_turbo",
|
||||
Self::MinimaxH3Ultra => "minimax_h3_ultra",
|
||||
@@ -472,6 +480,8 @@ impl CommonModelType {
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"wan_3p0" => Ok(Self::Wan3p0),
|
||||
"wan_3p0_prime" => Ok(Self::Wan3p0Prime),
|
||||
"minimax_h3" => Ok(Self::MinimaxH3),
|
||||
"minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo),
|
||||
"minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra),
|
||||
@@ -599,6 +609,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Wan3p0,
|
||||
Self::Wan3p0Prime,
|
||||
Self::MinimaxH3,
|
||||
Self::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra,
|
||||
@@ -723,6 +735,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Standard => CommonModelClass::Video,
|
||||
Self::Kling3p0Pro => CommonModelClass::Video,
|
||||
Self::HappyHorse1p0 => CommonModelClass::Video,
|
||||
Self::Wan3p0 => CommonModelClass::Video,
|
||||
Self::Wan3p0Prime => CommonModelClass::Video,
|
||||
Self::MinimaxH3 => CommonModelClass::Video,
|
||||
Self::MinimaxH3Turbo => CommonModelClass::Video,
|
||||
Self::MinimaxH3Ultra => CommonModelClass::Video,
|
||||
@@ -856,6 +870,8 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonModelType::Wan3p0, "wan_3p0");
|
||||
assert_serialization(CommonModelType::Wan3p0Prime, "wan_3p0_prime");
|
||||
assert_serialization(CommonModelType::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(CommonModelType::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(CommonModelType::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
@@ -974,6 +990,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
|
||||
assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(CommonModelType::Wan3p0.to_str(), "wan_3p0");
|
||||
assert_eq!(CommonModelType::Wan3p0Prime.to_str(), "wan_3p0_prime");
|
||||
assert_eq!(CommonModelType::MinimaxH3.to_str(), "minimax_h3");
|
||||
assert_eq!(CommonModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo");
|
||||
assert_eq!(CommonModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra");
|
||||
@@ -1094,6 +1112,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("kling_3p0_standard").unwrap(), CommonModelType::Kling3p0Standard);
|
||||
assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro);
|
||||
assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0);
|
||||
assert_eq!(CommonModelType::from_str("wan_3p0").unwrap(), CommonModelType::Wan3p0);
|
||||
assert_eq!(CommonModelType::from_str("wan_3p0_prime").unwrap(), CommonModelType::Wan3p0Prime);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3").unwrap(), CommonModelType::MinimaxH3);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3_turbo").unwrap(), CommonModelType::MinimaxH3Turbo);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3_ultra").unwrap(), CommonModelType::MinimaxH3Ultra);
|
||||
@@ -1158,7 +1178,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 108);
|
||||
assert_eq!(variants.len(), 110);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -1214,6 +1234,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Standard));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Wan3p0));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Wan3p0Prime));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Turbo));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Ultra));
|
||||
|
||||
@@ -13,11 +13,22 @@ pub const MAX_LENGTH: usize = 16;
|
||||
|
||||
/// Common quality levels for generation.
|
||||
///
|
||||
/// Declared from "let the model decide" through the highest tier down to the lowest, so the
|
||||
/// derived `Ord` (and `all_variants()`) reads top-down. Not every model supports every tier;
|
||||
/// the router clamps unsupported tiers to the nearest one the model offers.
|
||||
///
|
||||
/// NB: Keep the max serialized length to 16 characters.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonQuality {
|
||||
/// Let the model choose. Models without a native "auto" treat this as 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,
|
||||
@@ -30,6 +41,9 @@ impl_mysql_from_row!(CommonQuality);
|
||||
impl CommonQuality {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Max => "max",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::High => "high",
|
||||
Self::Medium => "medium",
|
||||
Self::Low => "low",
|
||||
@@ -38,6 +52,9 @@ impl CommonQuality {
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, EnumError> {
|
||||
match value {
|
||||
"auto" => Ok(Self::Auto),
|
||||
"max" => Ok(Self::Max),
|
||||
"xhigh" => Ok(Self::XHigh),
|
||||
"high" => Ok(Self::High),
|
||||
"medium" => Ok(Self::Medium),
|
||||
"low" => Ok(Self::Low),
|
||||
@@ -47,6 +64,9 @@ impl CommonQuality {
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::Auto,
|
||||
Self::Max,
|
||||
Self::XHigh,
|
||||
Self::High,
|
||||
Self::Medium,
|
||||
Self::Low,
|
||||
@@ -66,6 +86,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(CommonQuality::Auto, "auto");
|
||||
assert_serialization(CommonQuality::Max, "max");
|
||||
assert_serialization(CommonQuality::XHigh, "xhigh");
|
||||
assert_serialization(CommonQuality::High, "high");
|
||||
assert_serialization(CommonQuality::Medium, "medium");
|
||||
assert_serialization(CommonQuality::Low, "low");
|
||||
@@ -73,6 +96,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(CommonQuality::Auto.to_str(), "auto");
|
||||
assert_eq!(CommonQuality::Max.to_str(), "max");
|
||||
assert_eq!(CommonQuality::XHigh.to_str(), "xhigh");
|
||||
assert_eq!(CommonQuality::High.to_str(), "high");
|
||||
assert_eq!(CommonQuality::Medium.to_str(), "medium");
|
||||
assert_eq!(CommonQuality::Low.to_str(), "low");
|
||||
@@ -80,6 +106,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn from_str() {
|
||||
assert_eq!(CommonQuality::from_str("auto").unwrap(), CommonQuality::Auto);
|
||||
assert_eq!(CommonQuality::from_str("max").unwrap(), CommonQuality::Max);
|
||||
assert_eq!(CommonQuality::from_str("xhigh").unwrap(), CommonQuality::XHigh);
|
||||
assert_eq!(CommonQuality::from_str("high").unwrap(), CommonQuality::High);
|
||||
assert_eq!(CommonQuality::from_str("medium").unwrap(), CommonQuality::Medium);
|
||||
assert_eq!(CommonQuality::from_str("low").unwrap(), CommonQuality::Low);
|
||||
@@ -99,7 +128,10 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonQuality::all_variants();
|
||||
assert_eq!(variants.len(), 3);
|
||||
assert_eq!(variants.len(), 6);
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Auto));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Max));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::XHigh));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::High));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Medium));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Low));
|
||||
|
||||
@@ -51,6 +51,12 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
|
||||
@@ -168,6 +174,8 @@ impl CommonVideoModel {
|
||||
Self::Kling3p0Standard => CommonModelType::Kling3p0Standard,
|
||||
Self::Kling3p0Pro => CommonModelType::Kling3p0Pro,
|
||||
Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0,
|
||||
Self::Wan3p0 => CommonModelType::Wan3p0,
|
||||
Self::Wan3p0Prime => CommonModelType::Wan3p0Prime,
|
||||
Self::MinimaxH3 => CommonModelType::MinimaxH3,
|
||||
Self::MinimaxH3Turbo => CommonModelType::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra => CommonModelType::MinimaxH3Ultra,
|
||||
@@ -224,6 +232,8 @@ mod tests {
|
||||
assert_serialization(CommonVideoModel::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonVideoModel::Wan3p0, "wan_3p0");
|
||||
assert_serialization(CommonVideoModel::Wan3p0Prime, "wan_3p0_prime");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
@@ -273,6 +283,8 @@ mod tests {
|
||||
("kling_3p0_standard", CommonVideoModel::Kling3p0Standard),
|
||||
("kling_3p0_pro", CommonVideoModel::Kling3p0Pro),
|
||||
("happy_horse_1p0", CommonVideoModel::HappyHorse1p0),
|
||||
("wan_3p0", CommonVideoModel::Wan3p0),
|
||||
("wan_3p0_prime", CommonVideoModel::Wan3p0Prime),
|
||||
("minimax_h3", CommonVideoModel::MinimaxH3),
|
||||
("minimax_h3_turbo", CommonVideoModel::MinimaxH3Turbo),
|
||||
("minimax_h3_ultra", CommonVideoModel::MinimaxH3Ultra),
|
||||
@@ -329,6 +341,8 @@ mod tests {
|
||||
CommonVideoModel::Kling3p0Standard,
|
||||
CommonVideoModel::Kling3p0Pro,
|
||||
CommonVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::Wan3p0,
|
||||
CommonVideoModel::Wan3p0Prime,
|
||||
CommonVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo,
|
||||
CommonVideoModel::MinimaxH3Ultra,
|
||||
@@ -384,6 +398,8 @@ mod tests {
|
||||
(CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard),
|
||||
(CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro),
|
||||
(CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0),
|
||||
(CommonVideoModel::Wan3p0, CommonModelType::Wan3p0),
|
||||
(CommonVideoModel::Wan3p0Prime, CommonModelType::Wan3p0Prime),
|
||||
(CommonVideoModel::MinimaxH3, CommonModelType::MinimaxH3),
|
||||
(CommonVideoModel::MinimaxH3Turbo, CommonModelType::MinimaxH3Turbo),
|
||||
(CommonVideoModel::MinimaxH3Ultra, CommonModelType::MinimaxH3Ultra),
|
||||
|
||||
@@ -384,6 +384,9 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
quality_options: Some(vec![
|
||||
CommonQuality::Auto,
|
||||
CommonQuality::Max,
|
||||
CommonQuality::XHigh,
|
||||
CommonQuality::High,
|
||||
CommonQuality::Medium,
|
||||
CommonQuality::Low,
|
||||
@@ -422,6 +425,9 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
quality_options: Some(vec![
|
||||
CommonQuality::Auto,
|
||||
CommonQuality::Max,
|
||||
CommonQuality::XHigh,
|
||||
CommonQuality::High,
|
||||
CommonQuality::Medium,
|
||||
CommonQuality::Low,
|
||||
@@ -673,7 +679,17 @@ mod tests {
|
||||
assert_eq!(details.text_prompt_supported, Some(true));
|
||||
assert_eq!(details.image_refs_supported, Some(true));
|
||||
assert_eq!(details.default_quality, Some(CommonQuality::High));
|
||||
assert_eq!(details.quality_options.as_ref().map(|q| q.len()), Some(3));
|
||||
assert_eq!(
|
||||
details.quality_options.as_deref(),
|
||||
Some(&[
|
||||
CommonQuality::Auto,
|
||||
CommonQuality::Max,
|
||||
CommonQuality::XHigh,
|
||||
CommonQuality::High,
|
||||
CommonQuality::Medium,
|
||||
CommonQuality::Low,
|
||||
][..]),
|
||||
);
|
||||
assert_eq!(details.resolution_default, Some(CommonResolution::OneK));
|
||||
assert_eq!(details.resolution_options.as_ref().map(|r| r.len()), Some(4));
|
||||
assert_eq!(details.aspect_ratio_default_when_editing, Some(CommonAspectRatio::Auto));
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod seedance_2p5_video_models;
|
||||
pub mod sora_video_models;
|
||||
pub mod veo_video_models;
|
||||
pub mod vidu_video_models;
|
||||
pub mod wan_video_models;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::OmniGenVideoModelDetails;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
|
||||
pub fn wan_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
[(CommonVideoModel::Wan3p0, "Wan 3.0"), (CommonVideoModel::Wan3p0Prime, "Wan 3.0 Prime")]
|
||||
.iter().copied()
|
||||
.map(|(model, name)| OmniGenVideoModelDetails {
|
||||
model,
|
||||
model_creator: Some(ModelCreator::Alibaba),
|
||||
full_name: Some(name.to_owned()),
|
||||
extra_info: Some("Reference videos and output must fit within 30 seconds combined. Keyframes and reference media are separate modes.".to_owned()),
|
||||
text_to_video_supported: Some(true),
|
||||
text_prompt_supported: Some(true),
|
||||
text_prompt_max_length: Some(20_000),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(10),
|
||||
video_references_supported: Some(true),
|
||||
video_references_max: Some(5),
|
||||
video_references_max_total_duration_seconds: Some(15),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(5),
|
||||
audio_references_max_total_duration_seconds: Some(15),
|
||||
show_generate_with_sound_toggle: Some(true),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::TallNineBySixteen,
|
||||
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::Square,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: Some(vec![CommonResolution::FourEightyP, CommonResolution::SevenTwentyP, CommonResolution::TenEightyP]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
batch_size_options: Some(vec![1]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(2),
|
||||
duration_seconds_max: Some(30),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
}).collect()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::by_type::wan_video_models::wan_video_models;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::{OmniGenVideoModelDetails, OmniGenVideoModelProviderDetails, OmniGenVideoModelsResponse, OmniGenVideoProviderModelDetails};
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -41,6 +42,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
models.extend(sora_video_models());
|
||||
models.extend(veo_video_models());
|
||||
models.extend(vidu_video_models());
|
||||
models.extend(wan_video_models());
|
||||
|
||||
models
|
||||
}
|
||||
@@ -51,6 +53,8 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
|
||||
providers.push(OmniGenVideoModelProviderDetails {
|
||||
provider: GenerationProvider::Artcraft,
|
||||
models: vec![
|
||||
OmniGenVideoProviderModelDetails { model: CommonVideoModel::Wan3p0, overrides: None },
|
||||
OmniGenVideoProviderModelDetails { model: CommonVideoModel::Wan3p0Prime, overrides: None },
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::Seedance1p5Pro,
|
||||
overrides: None,
|
||||
|
||||
+15
-6
@@ -1,4 +1,4 @@
|
||||
//! Download + ffprobe reference videos for input-seconds billing.
|
||||
//! Download + ffprobe reference videos for billing and duration validation.
|
||||
//!
|
||||
//! Models that bill reference-video input seconds (e.g. Seedance 2.5) need
|
||||
//! ACCURATE durations. The `media_files` row's stored duration isn't always
|
||||
@@ -25,7 +25,9 @@
|
||||
//! The TOTAL is what gets billed; the router clamps it to the model's
|
||||
//! billing range (e.g. 4..=30 seconds for Seedance 2.5).
|
||||
//!
|
||||
//! Probing NEVER fails a generation: a file whose download or ffprobe fails
|
||||
//! This helper returns partial results when a download or ffprobe fails.
|
||||
//! Seedance billing uses a fallback; Wan rejects unmeasurable references.
|
||||
//! For input-seconds billing, a file whose download or ffprobe fails
|
||||
//! is billed at the worst-case [`MAX_BILLED_INPUT_SECONDS`] instead (and is
|
||||
//! not kept on disk — the provider upload re-downloads it itself).
|
||||
|
||||
@@ -62,6 +64,10 @@ pub struct ProbedReferenceVideos {
|
||||
/// counted once per reference (duplicates bill per reference).
|
||||
pub total_input_seconds: u16,
|
||||
|
||||
/// Actual durations in request order, preserving duplicates. None means
|
||||
/// download/probe failed; duration validation must not use billing fallbacks.
|
||||
pub durations_millis: Vec<Option<u64>>,
|
||||
|
||||
/// Source CDN URL → local temp file path, one entry per unique file.
|
||||
/// Pass to the router as `predownloaded_media_paths` so uploads reuse the
|
||||
/// already-downloaded bytes.
|
||||
@@ -173,35 +179,38 @@ pub async fn download_and_probe_reference_videos(
|
||||
let mut temp_files: Vec<NamedTempFile> = Vec::new();
|
||||
let mut local_paths_by_url: HashMap<String, PathBuf> = HashMap::new();
|
||||
let mut seconds_by_url: HashMap<String, u64> = HashMap::new();
|
||||
let mut durations_by_url: HashMap<String, Option<u64>> = HashMap::new();
|
||||
|
||||
for source in sources {
|
||||
if seconds_by_url.contains_key(&source.cdn_url) {
|
||||
continue; // Already downloaded and probed this file.
|
||||
}
|
||||
|
||||
let billed_seconds = match download_and_probe_one(source).await {
|
||||
let (billed_seconds, maybe_duration_millis) = match download_and_probe_one(source).await {
|
||||
Ok((temp_file, duration_millis)) => {
|
||||
info!("Probed reference video {} at {}ms", source.media_token, duration_millis);
|
||||
local_paths_by_url.insert(source.cdn_url.clone(), temp_file.path().to_path_buf());
|
||||
temp_files.push(temp_file);
|
||||
duration_millis.div_ceil(1_000)
|
||||
(duration_millis.div_ceil(1_000), Some(duration_millis))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to download/probe reference video {}; billing the {}s worst case: {:?}",
|
||||
"Failed to download/probe reference video {}; using the {}s billing fallback: {:?}",
|
||||
source.media_token, MAX_BILLED_INPUT_SECONDS, err,
|
||||
);
|
||||
u64::from(MAX_BILLED_INPUT_SECONDS)
|
||||
(u64::from(MAX_BILLED_INPUT_SECONDS), None)
|
||||
}
|
||||
};
|
||||
|
||||
seconds_by_url.insert(source.cdn_url.clone(), billed_seconds);
|
||||
durations_by_url.insert(source.cdn_url.clone(), maybe_duration_millis);
|
||||
}
|
||||
|
||||
let total_input_seconds = total_billed_seconds(sources, &seconds_by_url);
|
||||
|
||||
ProbedReferenceVideos {
|
||||
total_input_seconds,
|
||||
durations_millis: sources.iter().map(|source| durations_by_url[&source.cdn_url]).collect(),
|
||||
local_paths_by_url,
|
||||
_temp_files: temp_files,
|
||||
}
|
||||
|
||||
+7
-4
@@ -192,10 +192,13 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_quality(res: CommonQuality) -> RouterQuality {
|
||||
match res {
|
||||
CommonQuality::Low => RouterQuality::Low,
|
||||
CommonQuality::Medium => RouterQuality::Medium,
|
||||
fn map_quality(quality: CommonQuality) -> RouterQuality {
|
||||
match quality {
|
||||
CommonQuality::Auto => RouterQuality::Auto,
|
||||
CommonQuality::Max => RouterQuality::Max,
|
||||
CommonQuality::XHigh => RouterQuality::XHigh,
|
||||
CommonQuality::High => RouterQuality::High,
|
||||
CommonQuality::Medium => RouterQuality::Medium,
|
||||
CommonQuality::Low => RouterQuality::Low,
|
||||
}
|
||||
}
|
||||
+2
@@ -165,6 +165,8 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
|
||||
CommonVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
|
||||
CommonVideoModel::Seedance2p5Ultra => RouterVideoModel::Seedance2p5Ultra,
|
||||
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::Wan3p0 => RouterVideoModel::Wan3p0,
|
||||
CommonVideoModel::Wan3p0Prime => RouterVideoModel::Wan3p0Prime,
|
||||
CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo => RouterVideoModel::MinimaxH3Turbo,
|
||||
CommonVideoModel::MinimaxH3Ultra => RouterVideoModel::MinimaxH3Ultra,
|
||||
|
||||
+1
@@ -11,3 +11,4 @@ pub mod insert_db_job;
|
||||
// `pub(crate)` so other modules' database tests can reuse `support::TestHarness`.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
mod wan_3p0_reference_duration;
|
||||
+1
@@ -68,6 +68,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
let router_builder = router_builder.clone();
|
||||
|
||||
let provider = match router_builder.model {
|
||||
RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::HappyHorse1p0 => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0 => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0Fast => RouterProvider::KinoviWeb,
|
||||
|
||||
+26
-8
@@ -9,6 +9,8 @@
|
||||
//! idempotency, media/character resolution, reference-video probing,
|
||||
//! billing, provider dispatch, and job/prompt record writing.
|
||||
|
||||
use artcraft_router::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
|
||||
use super::wan_3p0_reference_duration::validate_wan_3p0_reference_duration;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -154,16 +156,24 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let is_wan = matches!(request.model, Some(CommonVideoModel::Wan3p0 | CommonVideoModel::Wan3p0Prime));
|
||||
let mut router_builder = hydrate_to_router_request(request)?;
|
||||
if is_wan {
|
||||
router_builder = normalize_wan_3p0_builder(router_builder).map_err(|error| {
|
||||
CommonWebError::BadInputWithSimpleMessage(error.to_string())
|
||||
})?;
|
||||
}
|
||||
|
||||
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
|
||||
|
||||
// Seedance 2.5 bills reference-video input seconds on top of the output
|
||||
// duration. Stored durations can't be trusted for billing, so every
|
||||
// Seedance 2.5 bills reference-video input seconds; Wan needs them for
|
||||
// its combined input/output limit. Stored durations cannot be trusted, so every
|
||||
// reference video is downloaded and ffprobed; the downloaded files are
|
||||
// kept and handed to the pipeline so the Kinovi upload reuses them
|
||||
// instead of downloading the same bytes twice.
|
||||
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
|
||||
|
||||
if matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
|
||||
if is_wan || matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
|
||||
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
|
||||
let video_sources = fetch_reference_video_sources(
|
||||
video_tokens,
|
||||
@@ -174,10 +184,17 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
).await?;
|
||||
|
||||
// Downloads + ffprobe are slow — release the pool slot across them,
|
||||
// then re-acquire for billing. Probing never fails the generation:
|
||||
// unmeasurable files bill at the 30-second worst case.
|
||||
// then re-acquire for billing. Seedance uses a billing fallback for
|
||||
// unmeasurable files; Wan requires a successful measurement.
|
||||
drop(mysql_connection);
|
||||
let probed = download_and_probe_reference_videos(&video_sources).await;
|
||||
if is_wan {
|
||||
router_builder.total_reference_video_input_seconds = Some(
|
||||
validate_wan_3p0_reference_duration(
|
||||
router_builder.duration_seconds.unwrap_or(5), &probed.durations_millis,
|
||||
)?,
|
||||
);
|
||||
}
|
||||
mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
maybe_probed_reference_videos = Some(probed);
|
||||
@@ -186,9 +203,10 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
|
||||
// ==================== HYDRATE ROUTER REQUEST ==================== //
|
||||
|
||||
let mut router_builder = hydrate_to_router_request(&request)?;
|
||||
router_builder.total_reference_video_input_seconds =
|
||||
maybe_probed_reference_videos.as_ref().map(|probed| probed.total_input_seconds);
|
||||
if !is_wan {
|
||||
router_builder.total_reference_video_input_seconds =
|
||||
maybe_probed_reference_videos.as_ref().map(|probed| probed.total_input_seconds);
|
||||
}
|
||||
|
||||
// ==================== PIPELINE DISPATCH ==================== //
|
||||
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// Check measured video references before billing or submitting either Wan model.
|
||||
/// Images and audio never enter this duration budget. Return the ceiling of the
|
||||
/// combined duration for the router's local validation metadata.
|
||||
pub(super) fn validate_wan_3p0_reference_duration(
|
||||
output_seconds: u16,
|
||||
durations_millis: &[Option<u64>],
|
||||
) -> Result<u16, CommonWebError> {
|
||||
let mut total_millis = 0;
|
||||
for duration in durations_millis {
|
||||
let millis = duration.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
|
||||
"Could not determine a Wan reference video's duration".to_owned(),
|
||||
))?;
|
||||
if !(1_000..=15_000).contains(&millis) {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"Each Wan reference video must be between 1 and 15 seconds".to_owned(),
|
||||
));
|
||||
}
|
||||
total_millis += millis;
|
||||
if total_millis > 15_000 {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"Wan reference videos must total at most 15 seconds".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if total_millis + u64::from(output_seconds) * 1_000 > 30_000 {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"Wan reference video duration plus output duration must not exceed 30 seconds".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(total_millis.div_ceil(1_000) as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::{http::StatusCode, ResponseError};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_boundaries_and_fractional_totals_are_accepted() {
|
||||
assert_eq!(validate_wan_3p0_reference_duration(30, &[]).unwrap(), 0);
|
||||
assert_eq!(validate_wan_3p0_reference_duration(20, &[Some(10_000)]).unwrap(), 10);
|
||||
assert_eq!(validate_wan_3p0_reference_duration(15, &[Some(15_000)]).unwrap(), 15);
|
||||
// Rounding each file first would reject this valid 14.2s total.
|
||||
assert_eq!(validate_wan_3p0_reference_duration(15, &[Some(6_100), Some(8_100)]).unwrap(), 15);
|
||||
assert_eq!(validate_wan_3p0_reference_duration(27, &[Some(1_500), Some(1_500)]).unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_video_durations_return_http_400() {
|
||||
for (output, references) in [
|
||||
(20, vec![Some(10_001)]),
|
||||
(30, vec![Some(1_000)]),
|
||||
(16, vec![Some(15_000)]),
|
||||
(2, vec![Some(8_000), Some(8_000)]),
|
||||
(2, vec![Some(999)]),
|
||||
(2, vec![Some(15_001)]),
|
||||
(2, vec![Some(u64::MAX)]),
|
||||
(5, vec![None]),
|
||||
] {
|
||||
let error = validate_wan_3p0_reference_duration(output, &references).unwrap_err();
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -26,6 +26,13 @@ pub(crate) fn validate_kinovi_batch_count(
|
||||
"video_batch_count must be between 1 and 4 for this model".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(model, Some(CommonVideoModel::Wan3p0 | CommonVideoModel::Wan3p0Prime))
|
||||
&& video_batch_count.unwrap_or(1) != 1
|
||||
{
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"video_batch_count must be 1 for Wan 3.0 models".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -55,6 +62,8 @@ mod tests {
|
||||
CommonVideoModel::HappyHorse1p0,
|
||||
];
|
||||
|
||||
const WAN_MODELS: [CommonVideoModel; 2] = [CommonVideoModel::Wan3p0, CommonVideoModel::Wan3p0Prime];
|
||||
|
||||
#[test]
|
||||
fn both_endpoints_accept_batches_one_through_four_and_default() {
|
||||
for model in MODELS {
|
||||
@@ -78,6 +87,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wan_accepts_only_a_single_output() {
|
||||
for model in WAN_MODELS {
|
||||
for batch in [None, Some(1)] {
|
||||
for result in validate_both(model, batch) {
|
||||
assert!(result.is_ok(), "{:?}, batch {:?}: {:?}", model, batch, result);
|
||||
}
|
||||
}
|
||||
for batch in [0, 2, 3, 4, 8, u16::MAX] {
|
||||
for result in validate_both(model, Some(batch)) {
|
||||
assert!(result.is_err(), "{:?}, batch {}", model, batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_models_keep_their_existing_validation() {
|
||||
for model in [
|
||||
|
||||
@@ -10,6 +10,9 @@ interface QualityPickerProps {
|
||||
}
|
||||
|
||||
const QUALITY_LABELS: Record<string, string> = {
|
||||
auto: "Auto",
|
||||
max: "Max",
|
||||
xhigh: "Extra High",
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
|
||||
@@ -24,6 +24,7 @@ export const MODEL_ID_PREFIX_CREATORS: Array<[string, ModelCreator]> = [
|
||||
["vidu", ModelCreator.Vidu],
|
||||
["recraft", ModelCreator.Recraft],
|
||||
["happy_horse", ModelCreator.Alibaba],
|
||||
["wan", ModelCreator.Alibaba],
|
||||
["minimax", ModelCreator.Hailuo],
|
||||
["qwen", ModelCreator.Alibaba],
|
||||
["suno", ModelCreator.Suno],
|
||||
|
||||
@@ -13,6 +13,8 @@ const MODEL_ID_PREFIX_FAMILIES: Array<[string, string]> = [
|
||||
["minimax", "MiniMax"],
|
||||
["hailuo", "MiniMax"],
|
||||
["happy_horse", "Happy Horse"],
|
||||
// Wan (Alibaba wanvideo) — grouped under its own name, not "Alibaba".
|
||||
["wan", "Wan"],
|
||||
["vidu", "Vidu"],
|
||||
// ── Image ── (flux also covers the Flux 3 video model)
|
||||
["flux", "Flux"],
|
||||
@@ -50,6 +52,7 @@ export const FAMILY_ORDER: string[] = [
|
||||
"Grok",
|
||||
"Vidu",
|
||||
"Happy Horse",
|
||||
"Wan",
|
||||
// Image families.
|
||||
"Nano Banana",
|
||||
"Seedream",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
/// Not all models have each of these qualities,
|
||||
/// but Tauri will interpolate if a mistake is sent.
|
||||
export enum CommonQuality {
|
||||
Auto = "auto",
|
||||
Max = "max",
|
||||
XHigh = "xhigh",
|
||||
High = "high",
|
||||
Medium = "medium",
|
||||
Low = "low",
|
||||
|
||||
@@ -415,6 +415,9 @@ export const IMAGE_MODELS: ImageModel[] = [
|
||||
],
|
||||
defaultQuality: CommonQuality.High,
|
||||
qualityOptions: [
|
||||
CommonQuality.Auto,
|
||||
CommonQuality.Max,
|
||||
CommonQuality.XHigh,
|
||||
CommonQuality.High,
|
||||
CommonQuality.Medium,
|
||||
CommonQuality.Low,
|
||||
@@ -450,6 +453,9 @@ export const IMAGE_MODELS: ImageModel[] = [
|
||||
],
|
||||
defaultQuality: CommonQuality.High,
|
||||
qualityOptions: [
|
||||
CommonQuality.Auto,
|
||||
CommonQuality.Max,
|
||||
CommonQuality.XHigh,
|
||||
CommonQuality.High,
|
||||
CommonQuality.Medium,
|
||||
CommonQuality.Low,
|
||||
|
||||
Generated
+83
-205
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user