Compare commits

...
8 Commits
Author SHA1 Message Date
Brandon Thomas cfa8264642 debug tool 2026-09-18 18:54:10 -04:00
Brandon Thomas fcb567ec3c Merge pull request #25 from storytold/gpt-image-qualities
gpt image 2.5: new qualities
2026-09-18 14:13:57 -04:00
Brandon Thomas 734b757ef9 gpt image 2.5: new qualities 2026-09-18 14:12:34 -04:00
Brandon Thomas daf401c635 Merge pull request #24 from storytold/bt-regroup
group Wan video differently
2026-09-18 14:00:04 -04:00
Brandon Thomas c0207c61ba group Wan video differently 2026-09-18 13:59:01 -04:00
Brandon Thomas 0a7b7ae2ac Merge pull request #23 from storytold/bt-wan-pt2
wan 3.0 + prime
2026-09-18 13:45:58 -04:00
Brandon Thomas 92bc1e24c4 wan 3.0 + prime 2026-09-18 13:40:50 -04:00
Brandon Thomas 6dd7a88a12 Merge pull request #22 from storytold/bt-wan
wan 3.0
2026-09-18 12:41:18 -04:00
66 changed files with 1620 additions and 357 deletions

No files matched your search

@@ -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,
@@ -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,
}
}
@@ -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!(
@@ -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 {
@@ -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 {
@@ -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));
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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() }));
@@ -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),
@@ -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),
@@ -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),
}
}
@@ -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),
}
}
@@ -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),
}
}
@@ -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),
@@ -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;
@@ -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)
}))
}
@@ -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));
}
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -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
}
}
@@ -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;
@@ -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)
}))
}
@@ -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,
}
}
}
@@ -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 }
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -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(),
}
}
}
@@ -3,3 +3,4 @@ pub mod fal;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
pub mod wan_3p0_common;
@@ -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());
}
}
@@ -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))
@@ -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
@@ -243,3 +243,53 @@ pub(super) fn invalid_field(field: &'static str, value: impl ToString, reason: i
}
.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);
}
}
}
+17 -1
View File
@@ -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())
})
}
}
@@ -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,
@@ -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;
@@ -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(())
}
@@ -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
@@ -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;
@@ -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,
@@ -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,
}
@@ -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,
}
}
@@ -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,
@@ -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;
@@ -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,
@@ -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 ==================== //
@@ -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);
}
}
}
@@ -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,
+83 -205
View File
File diff suppressed because it is too large. Load diff